diff --git a/.agents/skills/dev-guide/SKILL.md b/.agents/skills/dev-guide/SKILL.md index e3078237d..a547cde26 100644 --- a/.agents/skills/dev-guide/SKILL.md +++ b/.agents/skills/dev-guide/SKILL.md @@ -16,6 +16,7 @@ description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关 | 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) | | Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) | | HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) | +| 开发独立网关插件 | [plugin-dev](../plugin-dev/SKILL.md):公开 SDK、能力选择、示例、打包与验证 | | 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) | | 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 | | 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) | diff --git a/.agents/skills/plugin-dev/SKILL.md b/.agents/skills/plugin-dev/SKILL.md new file mode 100644 index 000000000..b0534633e --- /dev/null +++ b/.agents/skills/plugin-dev/SKILL.md @@ -0,0 +1,79 @@ +--- +name: plugin-dev +description: 开发 Codex Proxy RS 网关插件,包括选择扩展能力、编写 plugin.json、实现公开 SDK 处理器、管理页面、打包与本地验证。用于新建插件、修改插件或排查插件接入;不用于 OpenAI Codex 的 .codex-plugin 插件、Vue 插件或仅修改宿主插件管理功能。 +--- + +# Codex Proxy 插件开发 + +面向插件作者。插件在独立项目中实现,通过公开 SDK 与宿主通信,不向宿主业务模块添加分支。 +仅修改宿主安装器、Runtime 或管理端时,使用仓库开发指南,不按本技能创建插件工程。 + +## 先建立正确的模型 + +```text +选择能力 → 作者清单 + SDK 处理器 → 构建二进制/页面 → cpr-plugin package + ↓ +实际业务验证 ← 查看权限并安装、自动准备配置 ← tar.gz + sha256 +``` + +- `contributes` 声明提供什么,`permissions` 声明访问域;安装接受记录派生授权,`bindings` 只决定功能挂载和请求匹配 +- 安装包、运行配置和已启用功能不是同一件事;安装成功不能代替功能验证 +- `trustedProcess` 与宿主使用相同系统身份,不是操作系统沙箱 + +## 开始工作 + +1. 确认插件用途、已有工程/输出目录、目标宿主版本与平台。已有项目沿用其结构;没有指定工程时先确认目录,不把插件源码塞进宿主 workspace +2. 先读 [SDK 入口](../../../backend/crates/gateway-plugin/sdk/README.md) 和[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md),再按下表只选本次需要的能力章节 +3. 核对目标版本的 [SDK Cargo.toml](../../../backend/crates/gateway-plugin/sdk/Cargo.toml) 与[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)。当前源码支持某能力,不代表较旧宿主支持;清单版本、通信版本、能力版本也不能混用 +4. 需要构建、联调或交付时读取 [构建与验证](references/development.md),先检查实际依赖和脚本,不假设包已公开发布 + +本技能相对链接以各文件所在目录解析;主仓根目录是本文件所在目录向上三级。插件工程或同级示例不在磁盘上时,请用户提供位置或使用选定版本的公开资料,不猜开发机绝对路径,也不自动克隆或创建远程仓库。 + +## 按任务选择能力 + +| 用户要做什么 | 优先使用 | 需要读取的合同 | +| --- | --- | --- | +| 改写请求/响应、处理流式内容、协议转换 | `middleware` | [洋葱中间件](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#洋葱中间件) | +| 接入新的模型服务与账号 | `executor`,按需组合 `models`、`authentication`、`quota`、`account_management`、`billing`、`request_profile` | [Provider](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#provider)及 `call::provider` / `call::auth` | +| 改变模型选择或账号排序 | `model_router` / `scheduler` | [策略类型](../../../backend/crates/gateway-plugin/sdk/src/call/policy/mod.rs),宿主继续复验范围、资格和租约 | +| 统计成功失败、用量或观察 WS 帧 | `request_lifecycle` / `usage` / `web_socket_observer` | [路由、调度与观察](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#路由调度与观察),观察不改写业务结果 | +| 接受外部客户端身份 | `frontend_authentication` | [数据面入口认证](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证),映射现有 Client Key,不接管后台登录 | +| 增加插件页面与管理操作 | `management` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) | +| 提供终端命令 | `command_line` | [管理页面、公开入口与 CLI](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli) | +| 自动刷新凭据、额度、模型或画像资料 | `maintenance` 及对应 Provider 能力 | [Provider](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#provider),不是任意 cron 或常驻任务入口 | + +只声明实现了的能力。Provider 专属能力需要注册 Provider,并具有 `executor`;普通中间件或页面不需要虚构 Provider。 +网络、账号、模型调用和私有状态属于宿主回调与授权,不重复声明为顶层能力。 + +## 实现时的关键选择 + +### 清单与处理器 + +- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果 +- `contributes` 每种能力最多一项,完整扩展项 ID 属于自己的插件命名空间;运行注册必须与作者清单一致 +- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;Provider 可用业务组简写,CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配 +- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭 +- 优先用 `PluginBuilder::from_json` 组合 `.middleware`、`.management`、`.command_line` 与 `.on(methods::..., handler)`,由构建器生成注册并检查处理器;单一中间件也可用 `MiddlewarePlugin` +- stdout 是二进制协议通道,不能用 `println!` 输出诊断。使用基础设施 `host.log`,不输出 secret 或完整请求 + +### 中间件与宿主服务 + +- `request` 包裹整个逻辑请求,`attempt` 在每次选定账号的尝试中执行;根据作用范围选择,避免重试时重复副作用 +- `next` 只能消费一次。使用 SDK 的正文保留与流式映射能力,不自行拼一套 SSE/取消/流控机制;请求、响应及策略处理使用 `requests` 域 +- 持久化状态使用已声明命名空间的 `host.state.*`,账号读写使用 `host.auth.*`,不直接访问宿主数据库。见[账号与凭据](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#账号与凭据)和[状态、日志与迁移](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#状态日志与迁移) +- 权限只声明 `network`、`models`、`accounts`、`requests`、`public_endpoints`;日志和自身状态无需授权项。安装器不配置 Key/账号白名单,资源选择属于插件业务 +- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用) +- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖 + +### 只有需要页面时才创建前端 + +- 优先参考独立示例仓库的 `examples/workbench/frontend/src/api/`:业务路由放在 `modules/`,`request.ts` 封装公开宿主桥;按需使用 `@codex-proxy/ui` 包出口,不跨仓导入宿主或 UI 的内部源码 +- Vue 页面保留 SFC,逻辑、脚本和配置使用 TypeScript;只实现业务内容,标题和副标题由 `ManagementPage` 交给宿主显示 +- 隔离页面通过 `window.codexProxyPlugin.request` 调用已注册管理路由;`models.responses` 通过普通请求链交付 JSON/SSE,支持取消且不暴露 Key 明文。不直接 `fetch` 宿主、不读取管理 Cookie 或借用宿主 Vue 实例 +- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端 +- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本 + +## 交付 + +按[构建与验证](references/development.md#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。 +开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。 diff --git a/.agents/skills/plugin-dev/agents/openai.yaml b/.agents/skills/plugin-dev/agents/openai.yaml new file mode 100644 index 000000000..44689d37b --- /dev/null +++ b/.agents/skills/plugin-dev/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Codex Proxy 插件开发" + short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南" + default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。" diff --git a/.agents/skills/plugin-dev/references/development.md b/.agents/skills/plugin-dev/references/development.md new file mode 100644 index 000000000..6a2c7dd30 --- /dev/null +++ b/.agents/skills/plugin-dev/references/development.md @@ -0,0 +1,98 @@ +# 构建、联调与验证 + +仅在准备工程、构建或验证插件时读取。这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册。 + +## 工程与依赖 + +| 位置 | 用途 | +| --- | --- | +| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 | +| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 | +| 独立 `codex-proxy-plugins/examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 | +| 独立 `codex-proxy-ui/` | 可选的 Vue UI 库、组件文档与 playground | + +先检查用户给定工程,或主仓父目录下的同级仓库是否存在;读取实际仓库约定、README、`Cargo.toml`、`package.json` 与锁文件。 +只需中间件时不复制示例的管理 API、页面和相关权限;Provider、CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。 + +本地源码联调可以使用 Cargo 路径依赖和 pnpm 的包级链接/override;路径必须真实可解析,并记录在消费方依赖配置中。 +这不等于允许从业务源码跨仓 `import .../src/...`,也不能证明第三方能独立安装。 + +对外交付前核实 UI/SDK 的可用版本和安装来源:仅引用确实存在的发布版本,或包含所需合同的固定 Git commit; +不要把浮动 `main` 当版本,也不要自动发布依赖以补齐条件。本地 override 尚未移除时,明确说明独立安装缺口。 +所用工具链以目标项目的配置为准,不因本机安装了更新版本而顺手升级。 + +## 复用示例 + +找到 `codex-proxy-plugins` 后先读根目录与 `examples/workbench/README.md`。 +该仓库使用 `bash scripts/package` 构建页面、Rust 二进制并打包;前端依赖和工具配置位于示例的 `frontend/`,根目录不维护 Node 工程。执行前核对脚本适用的插件路径和目标平台。 + +| 任务 | 示例入口 | +| --- | --- | +| 身份、声明与资源 | `examples/workbench/plugin.json` | +| 会话启动与处理器组合 | `examples/workbench/backend/src/main.rs`、`app.rs` | +| 管理接口与 Provider | `examples/workbench/backend/src/management/`、`provider/` | +| 页面与主题 | `examples/workbench/frontend/src/` | +| 管理接口调用 | `examples/workbench/frontend/src/api/modules/` | +| 包内静态资源构建 | `examples/workbench/frontend/vite.config.ts` | + +若派生新插件,同时修改包名、二进制名、插件身份、扩展项 ID、注册描述和构建目标,不只改展示名称。 +保留当前项目确实需要的依赖、资源和权限;不要把示例中的全请求范围直接用到共享环境。 + +## 构建与打包 + +先运行目标工程已有的测试和构建入口。Rust 检查示意如下,路径、工具链和 target 按实际工程替换: + +```bash +cargo fmt --manifest-path /path/to/plugin/Cargo.toml -- --check +cargo clippy --manifest-path /path/to/plugin/Cargo.toml --all-targets --all-features --locked -- -D warnings +cargo test --manifest-path /path/to/plugin/Cargo.toml --locked +cargo build --manifest-path /path/to/plugin/Cargo.toml --release --locked --target x86_64-unknown-linux-gnu +``` + +新工程尚无锁文件时先生成并检查锁文件;不要把缺少锁文件的 `--locked` 失败当作源码问题。 +有页面时另跑该前端项目的检查与生产构建,确认产物路径、资源清单和 MIME 类型相符;无页面的插件无需 Node 项目。 + +优先使用已有 `cpr-plugin`。需要本地构建工具时,在主仓根目录运行以下命令,把 `--help` 换成实际参数即可,不必全局安装: + +```bash +cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked -- package --help +``` + +直接打包的示意命令: + +```bash +cpr-plugin package \ + --manifest /path/to/plugin/plugin.json \ + --binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \ + --target x86_64-unknown-linux-gnu \ + --resource-map web=frontend/dist \ + --output-dir /path/to/plugin/dist +``` + +没有 `web` 资源时去掉 `--resource-map`。映射源相对于作者清单目录,而非命令执行目录;资源必须留在插件工程内。 +目标平台与二进制必须匹配,不能只改 target 参数伪装交叉编译。支持平台以 [CLI 文档](../../../../backend/apps/plugin-cli/README.md)及实际 `--help` 为准。 + +输出为 `.tar.gz` 与 `.sha256`,不是源码 zip、npm 包或页面目录。归档根目录应有生成后的 `plugin.json`、可执行文件和声明资源。 +本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。 + +## 验证与交付 + +按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。 +只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。 +需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。 + +按实际实现选择验证,不为不存在的能力增加测试任务: + +| 实现范围 | 应验证的行为 | +| --- | --- | +| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 | +| Provider | 导入或登录、模型发现、实际执行、错误及用量;仅对声明支持的协议、资料、额度等操作验收 | +| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 | +| 入口认证/CLI/维护 | 对应身份和账号范围、缺少授权、取消与错误;不把帮助输出、注册成功或一次健康探测当完整验收 | +| 持久状态或升级 | 读写版本冲突、升级兼容性、迁移失败与停用边界;不覆盖并发配置或把回滚版本误当恢复历史数据 | + +真实请求按用户选择的客户端、模型和测试预算执行;没有这些能力或授权时列为缺口,不模拟成功。 +临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。 + +交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。 +只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 63f699982..82e184a78 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,10 @@ version: 2 +# 标签由维护者手动添加,各生态关闭默认打标,避免重新创建旧标签。 updates: - package-ecosystem: github-actions directory: / + labels: [] schedule: interval: weekly day: monday @@ -16,6 +18,7 @@ updates: - package-ecosystem: cargo directory: /backend + labels: [] schedule: interval: weekly day: monday @@ -49,7 +52,8 @@ updates: - patch - package-ecosystem: npm - directory: /frontend + directory: / + labels: [] schedule: interval: weekly day: monday @@ -65,6 +69,7 @@ updates: - package-ecosystem: docker directory: /deploy + labels: [] schedule: interval: weekly day: monday diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index abd4963ce..3385f89eb 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -161,7 +161,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: - version: 11.7.0 + package_json_file: frontend/package.json - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c52c38c4d..616fd73e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -318,6 +318,7 @@ jobs: env: VERSION: ${{ needs.release-info.outputs.version }} DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }} + GIT_SHA: ${{ needs.release-info.outputs.git_sha }} run: | set -euo pipefail ruby <<'RUBY' > release-platforms.json @@ -364,6 +365,8 @@ jobs: compose = compose.replace(default_image, versioned_image) (release_dir / "compose.yaml").write_text(compose) assets = [release_dir / config_template.name, release_dir / "compose.yaml"] + shutil.rmtree(package_dir, ignore_errors=True) + release_packages = [] for platform in platforms: platform_name = f"{platform['os']}-{platform['arch']}" @@ -372,8 +375,7 @@ jobs: if not binary_src.is_file(): raise SystemExit(f"Missing binary artifact: {binary_src}") - shutil.rmtree(package_dir, ignore_errors=True) - package_root = package_dir / APP_NAME + package_root = package_dir / platform_name / APP_NAME web_dist = package_root / "web" / "dist" web_dist.mkdir(parents=True) @@ -387,11 +389,37 @@ jobs: archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}" archive_path = release_dir / archive_name - if platform["archive"] == "tar.gz": - with tarfile.open(archive_path, "w:gz") as archive: - archive.add(package_root, arcname=APP_NAME) - else: + if platform["archive"] != "tar.gz": raise SystemExit(f"Unsupported archive format: {platform['archive']}") + release_packages.append((platform, package_root, archive_path)) + + plugin_host_path = pathlib.Path("backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json") + plugin_host = json.loads(plugin_host_path.read_text()) + if plugin_host.get("schema_version") != 1: + raise SystemExit(f"Invalid plugin host compatibility declaration: {plugin_host_path}") + + plugin_manifest_path = release_dir / "plugin-release-manifest.json" + plugin_manifest_path.write_text(json.dumps({ + "schema_version": 1, + "sealed": True, + "gateway_version": version, + "gateway_git_sha": os.environ["GIT_SHA"], + "plugin_host": plugin_host, + # 示例独立发版;当前不内置插件,仍保留更新与回滚所需的封口清单。 + "plugins": [], + }, indent=2, sort_keys=True) + "\n") + assets.append(plugin_manifest_path) + + # 保留运行时清单目录合同,宿主包不再携带教学示例。 + for platform, package_root, archive_path in release_packages: + official_dir = package_root / "plugins" / "official" + official_dir.mkdir(parents=True) + bundled_manifest = official_dir / plugin_manifest_path.name + shutil.copy2(plugin_manifest_path, bundled_manifest) + bundled_manifest.chmod(0o444) + official_dir.chmod(0o555) + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(package_root, arcname=APP_NAME) assets.append(archive_path) checksum_path = release_dir / "checksums.txt" @@ -415,7 +443,7 @@ jobs: build-docker-image: name: build-docker-image (${{ matrix.dockerPlatform }}) - needs: [release-info, quality, build-docker-binary] + needs: [release-info, quality, build-docker-binary, package-assets] strategy: fail-fast: false matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }} @@ -448,6 +476,12 @@ jobs: name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }} + - name: Download sealed release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-assets + path: dist/release-assets + - name: Prepare Docker prebuilt assets shell: bash env: @@ -457,6 +491,7 @@ jobs: run: | set -euo pipefail python3 - <<'PY' + import json import shutil import os import pathlib @@ -472,6 +507,36 @@ jobs: binary_dest = target_dir / app_name shutil.copy2(binary_src, binary_dest) binary_dest.chmod(0o755) + + release_dir = pathlib.Path("dist/release-assets") + manifest_path = release_dir / "plugin-release-manifest.json" + manifest = json.loads(manifest_path.read_text()) + if manifest.get("schema_version") != 1 or manifest.get("sealed") is not True: + raise SystemExit("Invalid sealed plugin release manifest") + official_dir = target_dir / "plugins" / "official" + official_dir.mkdir(parents=True) + bundled_manifest = official_dir / manifest_path.name + shutil.copy2(manifest_path, bundled_manifest) + bundled_manifest.chmod(0o444) + selected = 0 + for plugin in manifest.get("plugins", []): + artifacts = [ + artifact for artifact in plugin.get("artifacts", []) + if artifact.get("release_os") == os.environ["MATRIX_OS"] + and artifact.get("release_architecture") == os.environ["MATRIX_ARCH"] + ] + if len(artifacts) != 1: + raise SystemExit(f"Official plugin platform selection is not unique: {plugin.get('id')}") + archive = release_dir / artifacts[0]["archive"] + if not archive.is_file(): + raise SystemExit(f"Missing official plugin archive: {archive.name}") + bundled_archive = official_dir / archive.name + shutil.copy2(archive, bundled_archive) + bundled_archive.chmod(0o444) + selected += 1 + if selected != len(manifest.get("plugins", [])): + raise SystemExit("Official plugin release bundle is incomplete") + official_dir.chmod(0o555) PY - name: Setup Docker Buildx @@ -684,6 +749,9 @@ jobs: **手动下载:** 请从下方附件中下载适合当前平台的归档包。 + 附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。 + 教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。 + 发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。 ## 📚 文档 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 44486fc12..acc317558 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -38,9 +38,9 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit --version 0.22.2 --locked - - name: Run cargo audit - working-directory: backend - run: cargo audit + - name: Run Cargo audits + run: | + cargo audit --file backend/Cargo.lock frontend-security: runs-on: ubuntu-latest @@ -54,7 +54,7 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: - version: 11.7.0 + package_json_file: frontend/package.json - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.gitignore b/.gitignore index ea196bb17..58d9bc8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ /frontend/node_modules/ /frontend/dist/ /frontend/.vite/ -/.pnpm-store/ /frontend/.pnpm-store/ +/frontend/.pnpm-cache/ *.tsbuildinfo # Release artifacts @@ -20,6 +20,9 @@ # Local configuration and secrets /deploy/config.yaml +.env +.env.* +!.env.example # Editors and operating systems /.idea/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e3f77720..cddffb5ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,7 @@ AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下 | 技能 | 用途 | 调用示例 | | --- | --- | --- | | [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` | +| [plugin-dev](.agents/skills/plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$plugin-dev 开发一个添加响应头的插件` | | [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` | | [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` | | [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` | @@ -111,7 +112,7 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或 AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。 -开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。 +开发前查找同类页面及同级 `codex-proxy-ui/src/components` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。 列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。 @@ -131,6 +132,16 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方 | 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 | | 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 | +UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。 + +pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级标签时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。 + +```bash +pnpm --dir frontend install --frozen-lockfile +``` + +需要修改组件库时,按 [UI 源码联调指南](https://github.com/zyycn/codex-proxy-ui/blob/main/playground/guide/development.md) 建立临时本地链接;提交前恢复发布依赖并核对锁文件,不能将开发机目录写入发行依赖。独立插件的 SDK 固定到包含所需合同的 Git 提交,不依赖浮动分支。 + 前端从仓库根目录执行: ```bash diff --git a/README.md b/README.md index 4d8dffeab..6ca37288a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ > [!NOTE] > 本项目提供 Responses API,不支持 `/v1/chat/completions`,接入前请确认客户端支持 Responses 协议 +## 功能 + +| 场景 | 能力 | +| --- | --- | +| 客户端接入 | Responses HTTP / SSE / WebSocket、Images、模型目录与 standalone Search | +| 多账号与路由 | OpenAI、xAI 及插件 Provider,账号分组、模型范围、出站代理与安全重试 | +| 使用与管理 | 管理端、Key 自助用量页、费用限额、请求记录与错误诊断 | +| 插件扩展 | 制品访问域确认、自动默认配置,中间件、管理页面和其他声明能力 | +| 运维 | 单副本部署,PostgreSQL / Redis、S3/R2 备份与同一大版本内在线更新 | + ## 快速预览 无需部署,打开 [快速预览服务](https://codex-proxy-rs.ainz.cc) 即可体验管理端的系统概览、账号分组、代理管理与用量统计 @@ -99,7 +109,7 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` | Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 | | API Key | 管理端创建的客户端密钥 | -可用模型以该密钥查询到的模型列表为准: +查询该密钥可见的模型目录: ```bash curl http://127.0.0.1:8080/v1/models \ @@ -108,14 +118,13 @@ curl http://127.0.0.1:8080/v1/models \ ## 文档 -- [客户端接入与生图](deploy/README.md#客户端配置) -- [部署、备份与恢复](deploy/README.md) -- [API 参考](docs/api.md) -- [模型定价与手动同步](docs/api.md#模型定价) -- [系统架构](docs/architecture.md) -- [管理端主题](docs/theme.md) -- [数据库迁移](backend/migrations/README.md) -- [贡献与审查](CONTRIBUTING.md) +| 任务 | 文档 | +| --- | --- | +| 部署与使用 | [部署、备份与恢复](deploy/README.md) · [客户端接入与生图](deploy/README.md#客户端配置) | +| 接口集成 | [API 参考](docs/api.md) · [模型定价](docs/api.md#模型定价) | +| 使用插件 | [安装、配置与使用](docs/plugins.md) | +| 开发插件 | [SDK 与合同](backend/crates/gateway-plugin/sdk/README.md) · [打包工具](backend/apps/plugin-cli/README.md) | +| 开发宿主 | [贡献与验证](CONTRIBUTING.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) | ## 社区 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d6574065f..9ffdc73f9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -32,6 +46,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -588,6 +652,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.13.0" @@ -624,18 +703,42 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -712,6 +815,46 @@ dependencies = [ "serde", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmake" version = "0.1.58" @@ -727,6 +870,21 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "codex-proxy-plugin-cli" +version = "0.1.0" +dependencies = [ + "clap", + "flate2", + "gateway-plugin-sdk", + "hex", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "thiserror", +] + [[package]] name = "codex-proxy-rs" version = "0.0.0" @@ -736,6 +894,7 @@ dependencies = [ "gateway-api", "gateway-core", "gateway-host", + "gateway-plugin-runtime", "gateway-store", "provider-openai", "provider-xai", @@ -748,6 +907,18 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.8" @@ -1181,6 +1352,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1236,12 +1416,32 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.14.0" @@ -1275,10 +1475,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.12.0" @@ -1326,6 +1537,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + [[package]] name = "fs2" version = "0.4.3" @@ -1464,6 +1685,7 @@ dependencies = [ "hex", "rand_core 0.6.4", "secrecy", + "semver", "serde", "serde_json", "sha2 0.11.0", @@ -1489,6 +1711,7 @@ dependencies = [ "gateway-admin", "gateway-core", "gateway-protocol", + "headers", "secrecy", "serde", "serde_json", @@ -1530,6 +1753,8 @@ version = "0.0.0" dependencies = [ "async-trait", "axum", + "base64 0.23.1", + "bytes", "chrono", "config", "filetime", @@ -1538,8 +1763,18 @@ dependencies = [ "gateway-admin", "gateway-core", "hex", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "percent-encoding", "reqwest", + "rustls", + "rustls-native-certs", "scraper", + "secrecy", "semver", "serde", "serde_ignored", @@ -1549,12 +1784,61 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "tokio-rustls", + "tower-service", "tracing", "tracing-appender", "tracing-subscriber", "wiremock", ] +[[package]] +name = "gateway-plugin-runtime" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.23.1", + "bytes", + "chrono", + "flate2", + "futures", + "gateway-admin", + "gateway-core", + "gateway-host", + "gateway-plugin-sdk", + "gateway-protocol", + "gateway-store", + "hex", + "image", + "jsonschema", + "roxmltree", + "secrecy", + "semver", + "serde", + "serde_json", + "sha2 0.11.0", + "sqlx", + "tar", + "tempfile", + "thiserror", + "tokio", + "tracing", + "url", + "uuid", + "wiremock", +] + +[[package]] +name = "gateway-plugin-sdk" +version = "0.4.0" +dependencies = [ + "semver", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "gateway-protocol" version = "0.0.0" @@ -1634,9 +1918,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1653,6 +1939,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "group" version = "0.14.0" @@ -2075,6 +2371,34 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2091,6 +2415,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2118,6 +2448,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a806f80c1f5560431009ce5ec29b59d38f950e0cd7db5f1b8925e6d8104e21" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb862addfa7782108933abcf842fe25334523a9df5b89ea4d9620e3dd7b42181" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a05cd404c5ff6e2731dbbf7e290a27417750acfb59e329560a1d0af384c93fb0" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + [[package]] name = "jsonwebtoken" version = "11.1.0" @@ -2257,6 +2640,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -2273,6 +2662,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2294,6 +2693,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2326,6 +2735,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2341,6 +2789,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2366,6 +2835,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.75" @@ -2657,6 +3132,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2825,6 +3313,18 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.11" @@ -3011,6 +3511,43 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "referencing" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b4a92fac7e28c27de3ad26df2ecb9e652f227b258e845af034052e5b22c96c" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.0" @@ -3025,9 +3562,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3332,6 +3869,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -3632,6 +4173,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", "webpki-roots", ] @@ -3699,6 +4241,7 @@ dependencies = [ "sqlx-core", "thiserror", "tracing", + "uuid", ] [[package]] @@ -3734,6 +4277,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -3760,6 +4304,7 @@ dependencies = [ "thiserror", "tracing", "url", + "uuid", ] [[package]] @@ -3803,6 +4348,33 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4321,6 +4893,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -4384,6 +4962,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.26.1" @@ -4395,6 +4979,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4552,6 +5146,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "2.1.2" @@ -4976,9 +5576,9 @@ checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" @@ -5007,3 +5607,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ce26929a5..c064e78ef 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,10 +1,13 @@ [workspace] members = [ "apps/gateway", + "apps/plugin-cli", "crates/gateway-admin", "crates/gateway-api", "crates/gateway-core", "crates/gateway-host", + "crates/gateway-plugin/runtime", + "crates/gateway-plugin/sdk", "crates/gateway-protocol", "crates/gateway-store", "crates/providers/openai", diff --git a/backend/apps/gateway/Cargo.toml b/backend/apps/gateway/Cargo.toml index fdfca651c..305e739e3 100644 --- a/backend/apps/gateway/Cargo.toml +++ b/backend/apps/gateway/Cargo.toml @@ -16,6 +16,7 @@ gateway-admin = { path = "../../crates/gateway-admin" } gateway-api = { path = "../../crates/gateway-api" } gateway-core = { path = "../../crates/gateway-core" } gateway-host = { path = "../../crates/gateway-host" } +gateway-plugin-runtime = { path = "../../crates/gateway-plugin/runtime" } gateway-store = { path = "../../crates/gateway-store" } provider-openai = { path = "../../crates/providers/openai" } provider-xai = { path = "../../crates/providers/xai" } diff --git a/backend/apps/gateway/src/bootstrap.rs b/backend/apps/gateway/src/bootstrap.rs index 5bd39b8d4..133119cec 100644 --- a/backend/apps/gateway/src/bootstrap.rs +++ b/backend/apps/gateway/src/bootstrap.rs @@ -52,6 +52,47 @@ impl LoadableConfig for GatewayConfig { /// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。 pub async fn run() -> Result<(), BootstrapError> { + launch(None).await.map(|_| ()) +} + +/// 这里只接收插件命名空间内的参数,不转交宿主配置或其他启动 secret。 +pub struct PluginCommand { + pub instance_id: Option, + pub name: Option, + pub arguments: Vec, +} + +impl PluginCommand { + fn is_help(&self) -> bool { + self.name.is_none() + || self + .arguments + .iter() + .any(|value| matches!(value.as_str(), "--help" | "-h")) + } +} + +pub async fn plugin_command( + command: PluginCommand, +) -> Result { + launch(Some(command)).await +} + +async fn launch( + command: Option, +) -> Result { + macro_rules! or_shutdown { + ($runtime:expr, $result:expr) => { + match $result { + Ok(value) => value, + Err(error) => { + $runtime.shutdown().await; + return Err(error.into()); + } + } + }; + } + let config = gateway_host::load_config::()?; let GatewayConfig { schema_version: _, @@ -63,47 +104,234 @@ pub async fn run() -> Result<(), BootstrapError> { openai, } = config; - let host = gateway_host::initialize(host).await?; + let plugin_cache = host.runtime_data_dir().join("plugins"); + let host = if command.is_some() { + gateway_host::initialize_command_line(host).await? + } else { + gateway_host::initialize(host).await? + }; host.report_startup_ready("Host"); - let mut store = gateway_store::initialize(store).await?; + let help = command.as_ref().is_some_and(PluginCommand::is_help); + let mut store = if help { + gateway_store::initialize_read_only(store).await? + } else if command.is_some() { + gateway_store::initialize_command_line(store).await? + } else { + gateway_store::initialize(store).await? + }; host.report_startup_ready("Store"); + let version = host + .system_operations() + .version() + .await + .map_err(|_| ConfigError::InvalidField("host.version"))? + .version; + let plugin_http = std::sync::Arc::new(gateway_host::outbound::HttpClient::new()?); + if let Some(command) = command.as_ref().filter(|_| help) { + // 帮助在原生 Provider 初始化前返回,避免写默认画像、发现模型或初始化管理员。 + let runtime = prepare_plugins( + &store, + plugin_cache, + &version, + ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]) + .map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?, + plugin_http.clone(), + )?; + let commands = or_shutdown!(runtime, runtime.prepare_command_line().await); + let stdout = commands.help(command.instance_id.as_deref(), command.name.as_deref()); + commands.shutdown().await; + runtime.shutdown().await; + return Ok(gateway_plugin_runtime::PluginCommandOutput { + stdout: stdout?, + stderr: String::new(), + exit_code: 0, + saved_accounts: 0, + }); + } let provider_ports = store.provider_ports(); let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?; host.report_startup_ready("OpenAI Provider"); let mut xai = provider_xai::initialize(provider_ports).await?; host.report_startup_ready("xAI Provider"); let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?; - let mut core = gateway_core::initialize(store.core_ports(), providers).await?; + let plugin_runtime = prepare_plugins( + &store, + plugin_cache, + &version, + providers, + gateway_admin::ports::provider::ProviderAdminRegistry::new([ + openai.admin_provider(), + xai.admin_provider(), + ]) + .map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?, + plugin_http.clone(), + )?; + if let Some(command) = command { + let instance_id = or_shutdown!( + plugin_runtime, + command + .instance_id + .as_deref() + .ok_or_else(|| gateway_admin::model::AdminError::invalid("插件实例 ID 缺失")) + ); + let name = or_shutdown!( + plugin_runtime, + command + .name + .as_deref() + .ok_or_else(|| gateway_admin::model::AdminError::invalid("插件命令名缺失")) + ); + let core = gateway_core::prepare_command_plane( + store.core_ports(), + plugin_runtime.provider_registry(), + Some(plugin_runtime.clone()), + Some(plugin_runtime.observer_registry()), + Some(plugin_runtime.policy_registry()), + Some(plugin_runtime.middleware_registry()), + ); + let accounts = gateway_admin::initialize_plugin_accounts( + plugin_runtime.admin_registry(core.snapshots()), + store.admin_ports().accounts(), + core.snapshot_control(), + ); + or_shutdown!(plugin_runtime, plugin_runtime.bind_account_ports(&accounts)); + let keys = gateway_admin::initialize_plugin_client_keys( + plugin_runtime.admin_registry(core.snapshots()), + store.admin_ports().client_keys(), + core.snapshot_control(), + ); + or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys)); + let core = or_shutdown!(plugin_runtime, core.activate().await); + let nested_models = core.nested_model_execution_port(); + let affinity_lookup = core.affinity_lookup_port(); + or_shutdown!( + plugin_runtime, + plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup) + ); + let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await); + or_shutdown!(plugin_runtime, store.start_command_line_writes()); + // CLI 不恢复主进程的准入状态,不构造 API、不初始化管理员或启动 Worker。 + let result = commands + .execute_cancellable(instance_id, name, &command.arguments, &host.cancellation()) + .await; + commands.shutdown().await; + plugin_runtime.shutdown().await; + store.shutdown_command_line_writes().await?; + return Ok(result?); + } + let core = gateway_core::prepare( + store.core_ports(), + plugin_runtime.provider_registry(), + Some(plugin_runtime.clone()), + Some(plugin_runtime.observer_registry()), + Some(plugin_runtime.policy_registry()), + Some(plugin_runtime.middleware_registry()), + Some(plugin_runtime.frontend_authentication_registry()), + ); + let admin_providers = plugin_runtime.admin_registry(core.snapshots()); + let plugin_accounts = gateway_admin::initialize_plugin_accounts( + admin_providers.clone(), + store.admin_ports().accounts(), + core.snapshot_control(), + ); + or_shutdown!( + plugin_runtime, + plugin_runtime.bind_account_ports(&plugin_accounts) + ); + let plugin_keys = gateway_admin::initialize_plugin_client_keys( + admin_providers.clone(), + store.admin_ports().client_keys(), + core.snapshot_control(), + ); + or_shutdown!( + plugin_runtime, + plugin_runtime.bind_client_key_ports(&plugin_keys) + ); + let mut core = or_shutdown!(plugin_runtime, core.activate().await); + let nested_models = core.nested_model_execution_port(); + let affinity_lookup = core.affinity_lookup_port(); + or_shutdown!( + plugin_runtime, + plugin_runtime.bind_model_ports(&nested_models, &affinity_lookup) + ); host.report_startup_ready("Core"); - let mut admin = gateway_admin::initialize( - admin, - client, - store.admin_ports(), - gateway_admin::AdminRuntimePorts { - pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing), - providers: vec![openai.admin_provider(), xai.admin_provider()], - snapshot: core.snapshot_control(), - account_probe: core.account_probe(), - proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca), - client_distribution: host.client_distribution_resolver(), - system: host.system_operations(), - client_key_verifier: core.client_key_verifier(), - }, - ) - .await?; + let host_version = or_shutdown!( + plugin_runtime, + version + .trim_start_matches('v') + .parse() + .map_err(|_| ConfigError::InvalidField("host.version")) + ); + let plugin_inspector = std::sync::Arc::new(gateway_plugin_runtime::PackageInspector::new( + gateway_plugin_runtime::PackageLimits::default(), + host_version, + )); + let plugin_distribution = std::sync::Arc::new(or_shutdown!( + plugin_runtime, + gateway_host::plugin_distribution::HttpPluginDistribution::new(plugin_http) + )); + let mut admin = or_shutdown!( + plugin_runtime, + gateway_admin::initialize_with_plugin_accounts( + admin, + client, + store.admin_ports(), + gateway_admin::AdminRuntimePorts { + providers: admin_providers, + plugin_preparation: plugin_runtime.clone(), + plugin_management: plugin_runtime.clone(), + published_snapshot: core.snapshots(), + plugin_inspector, + plugin_distribution, + pricing_source: std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing), + snapshot: core.snapshot_control(), + account_probe: core.account_probe(), + proxy_probe: host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca), + client_distribution: host.client_distribution_resolver(), + system: host.system_operations(), + client_key_verifier: core.client_key_verifier(), + }, + plugin_accounts, + ) + .await + ); + let official_files = host.official_plugin_release_files(); + let official_identity = host.official_plugin_release_identity(); + let official_import = or_shutdown!( + plugin_runtime, + admin + .services() + .plugins() + .import_official_release( + official_files.as_ref(), + &official_identity, + &gateway_admin::model::MutationContext { + actor: gateway_admin::model::MutationActor::System, + request_id: "startup-official-plugin-import".to_owned(), + }, + ) + .await + ); + if official_import.artifacts > 0 { + host.report_startup_ready("Official plugins"); + } host.report_startup_ready("Admin"); let mut probes = store.health_probes(); probes.extend(core.health_probes()); probes.push(host.logging_health_probe()); - let api = gateway_api::initialize( - api, - core.execution_service(), - admin.services(), - probes, - host.worker_health(), - host.connection_lifecycle(), - )?; + let api = or_shutdown!( + plugin_runtime, + gateway_api::initialize( + api, + core.execution_service(), + admin.services(), + probes, + host.worker_health(), + host.connection_lifecycle(), + ) + ); host.report_startup_ready("API"); let mut plan = store.take_worker_contributions(); @@ -111,22 +339,76 @@ pub async fn run() -> Result<(), BootstrapError> { plan.extend(openai.take_worker_contributions()); plan.extend(xai.take_worker_contributions()); plan.extend(admin.take_worker_contributions()); - host.start_workers(plan, store.worker_leader_lease())?; + plan.extend(or_shutdown!( + plugin_runtime, + plugin_runtime.worker_contributions(core.snapshots()) + )); + or_shutdown!( + plugin_runtime, + host.start_workers(plan, store.worker_leader_lease()) + ); host.report_startup_ready("Workers"); - host.serve(api.router()).await?; - Ok(()) + let served = host.serve(api.router()).await; + plugin_runtime.shutdown().await; + served?; + Ok(gateway_plugin_runtime::PluginCommandOutput { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + saved_accounts: 0, + }) +} + +fn prepare_plugins( + store: &gateway_store::StoreBundle, + plugin_cache: std::path::PathBuf, + version: &str, + providers: ProviderRegistry, + admin_providers: gateway_admin::ports::provider::ProviderAdminRegistry, + http: std::sync::Arc, +) -> Result, BootstrapError> { + Ok(std::sync::Arc::new( + gateway_plugin_runtime::PluginRuntime::new( + store.admin_ports().plugins(), + store.admin_ports().plugin_state(), + gateway_plugin_runtime::PluginRuntimeConfig { + cache_directory: plugin_cache, + host_version: version + .trim_start_matches('v') + .parse() + .map_err(|_| ConfigError::InvalidField("host.version"))?, + package_limits: gateway_plugin_runtime::PackageLimits::default(), + rpc_limits: gateway_plugin_runtime::RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + providers, + admin_providers, + http, + std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()), + ) + .with_provider_ports(store.provider_ports()), + )) } /// 组合根只保留包级错误分类,不展开内部实现或敏感配置。 #[derive(Debug, thiserror::Error)] pub enum BootstrapError { + #[error(transparent)] + Worker(#[from] gateway_core::task::WorkerDefinitionError), + #[error(transparent)] + PluginCommand(#[from] gateway_plugin_runtime::PluginCommandError), #[error(transparent)] Config(#[from] gateway_host::ConfigError), #[error(transparent)] Host(#[from] gateway_host::HostError), #[error(transparent)] + Outbound(#[from] gateway_host::outbound::HttpError), + #[error(transparent)] Store(#[from] gateway_store::StoreError), #[error(transparent)] + CommandStoreDrain(#[from] gateway_store::CommandStoreDrainError), + #[error(transparent)] OpenAi(#[from] provider_openai::OpenAiInitializeError), #[error(transparent)] Xai(#[from] provider_xai::XaiInitializeError), diff --git a/backend/apps/gateway/src/main.rs b/backend/apps/gateway/src/main.rs index 2b08080d4..bc9d02859 100644 --- a/backend/apps/gateway/src/main.rs +++ b/backend/apps/gateway/src/main.rs @@ -1,19 +1,84 @@ -use std::{env, error::Error, io}; +use std::{ + env, + error::Error, + io::{self, Write}, + process::ExitCode, +}; use provider_openai::ensure_rustls_provider; -fn main() -> Result<(), Box> { +fn main() -> ExitCode { + match run() { + Ok(code) => code, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result> { ensure_rustls_provider(); let mut arguments = env::args_os().skip(1); - match arguments.next().as_deref().and_then(|value| value.to_str()) { + let command = arguments + .next() + .map(|value| { + value + .into_string() + .map_err(|_| invalid_cli("command must be UTF-8")) + }) + .transpose()?; + match command.as_deref() { None | Some("serve") => { reject_extra_arguments(arguments)?; runtime()?.block_on(codex_proxy_rs::bootstrap::run())?; - Ok(()) + Ok(ExitCode::SUCCESS) } - Some(command) => { - Err(invalid_cli(&format!("unknown command {command:?}; expected serve")).into()) + Some("help" | "--help" | "-h") => { + reject_extra_arguments(arguments)?; + println!( + "Usage: codex-proxy-rs [serve]\n codex-proxy-rs plugin [options]\n\n --help Show help without loading configuration\n --version Show version\n plugin --help List enabled plugin commands without starting HTTP" + ); + Ok(ExitCode::SUCCESS) } + Some("--version" | "-V") => { + reject_extra_arguments(arguments)?; + println!( + "{}", + option_env!("CPR_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) + ); + Ok(ExitCode::SUCCESS) + } + Some("plugin") => { + let arguments = arguments + .map(|value| { + value + .into_string() + .map_err(|_| invalid_cli("plugin arguments must be UTF-8")) + }) + .collect::, _>>()?; + let mut arguments = arguments.into_iter(); + let instance_id = arguments + .next() + .filter(|value| !matches!(value.as_str(), "--help" | "-h")); + let name = arguments + .next() + .filter(|value| !matches!(value.as_str(), "--help" | "-h")); + if instance_id.is_none() && name.is_some() { + return Err(invalid_cli("unexpected plugin help arguments").into()); + } + let output = runtime()?.block_on(codex_proxy_rs::bootstrap::plugin_command( + codex_proxy_rs::bootstrap::PluginCommand { + instance_id, + name, + arguments: arguments.collect(), + }, + ))?; + io::stdout().lock().write_all(output.stdout.as_bytes())?; + io::stderr().lock().write_all(output.stderr.as_bytes())?; + Ok(ExitCode::from(output.exit_code)) + } + Some(_) => Err(invalid_cli("unknown command; expected serve, plugin, or --help").into()), } } diff --git a/backend/apps/gateway/tests/architecture.rs b/backend/apps/gateway/tests/architecture.rs index 55c70dce4..ab29c06c9 100644 --- a/backend/apps/gateway/tests/architecture.rs +++ b/backend/apps/gateway/tests/architecture.rs @@ -11,10 +11,13 @@ use syn::{Item, visit::Visit}; /// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。 pub(super) const WORKSPACE_MEMBERS: &[&str] = &[ "apps/gateway", + "apps/plugin-cli", "crates/gateway-admin", "crates/gateway-api", "crates/gateway-core", "crates/gateway-host", + "crates/gateway-plugin/runtime", + "crates/gateway-plugin/sdk", "crates/gateway-protocol", "crates/gateway-store", "crates/providers/openai", @@ -149,6 +152,13 @@ fn gateway_protocol_has_no_workspace_dependencies() { } } +#[test] +fn plugin_sdk_has_no_workspace_dependencies() { + for name in dependency_names("crates/gateway-plugin/sdk") { + assert!(!name.starts_with("gateway-") && !name.starts_with("provider-")); + } +} + #[test] fn provider_crates_do_not_depend_on_each_other() { assert_no_dependency("crates/providers/openai", &["provider-xai"]); @@ -166,25 +176,34 @@ fn gateway_admin_stays_free_of_infrastructure_dependencies() { /// workspace 包名到冻结成员路径的映射。 const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[ ("codex-proxy-rs", "apps/gateway"), + ("codex-proxy-plugin-cli", "apps/plugin-cli"), ("gateway-admin", "crates/gateway-admin"), ("gateway-api", "crates/gateway-api"), ("gateway-core", "crates/gateway-core"), ("gateway-host", "crates/gateway-host"), + ("gateway-plugin-runtime", "crates/gateway-plugin/runtime"), + ("gateway-plugin-sdk", "crates/gateway-plugin/sdk"), ("gateway-protocol", "crates/gateway-protocol"), ("gateway-store", "crates/gateway-store"), ("provider-openai", "crates/providers/openai"), ("provider-xai", "crates/providers/xai"), ]; -/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。 +/// SDK 与 Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。 const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[ + ("crates/gateway-plugin/sdk", &["call", "client"]), + ("crates/gateway-plugin/runtime", &[]), ("crates/gateway-api", &["admin", "auth", "openai"]), ( "crates/gateway-host", &[ "client_distribution", "config", + "outbound", + "official_plugins", "pricing", + "plugin_distribution", + "process", "proxy_probe", "serve", "system_update", @@ -207,10 +226,12 @@ const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[ /// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。 const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[ + ("codex-proxy-plugin-cli", "gateway-plugin-sdk"), ("codex-proxy-rs", "gateway-admin"), ("codex-proxy-rs", "gateway-api"), ("codex-proxy-rs", "gateway-core"), ("codex-proxy-rs", "gateway-host"), + ("codex-proxy-rs", "gateway-plugin-runtime"), ("codex-proxy-rs", "gateway-store"), ("codex-proxy-rs", "provider-openai"), ("codex-proxy-rs", "provider-xai"), @@ -220,6 +241,11 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[ ("gateway-api", "gateway-protocol"), ("gateway-host", "gateway-admin"), ("gateway-host", "gateway-core"), + ("gateway-plugin-runtime", "gateway-host"), + ("gateway-plugin-runtime", "gateway-core"), + ("gateway-plugin-runtime", "gateway-admin"), + ("gateway-plugin-runtime", "gateway-plugin-sdk"), + ("gateway-plugin-runtime", "gateway-protocol"), ("gateway-store", "gateway-admin"), ("gateway-store", "gateway-core"), ("provider-openai", "gateway-admin"), @@ -349,13 +375,18 @@ fn production_sources_do_not_host_tests() { #[test] fn workspace_modules_follow_conventional_file_layout() { + let targets = test_source_roots(); for member in WORKSPACE_MEMBERS { let member_root = backend_root().join(member); assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]); let tests = member_root.join("tests"); if tests.is_dir() { - assert_module_tree(&tests, &["main.rs"]); + let roots = targets.get(&tests).expect("Cargo test source roots"); + assert_module_tree( + &tests, + &roots.iter().map(String::as_str).collect::>(), + ); } } } @@ -368,6 +399,7 @@ enum ModuleLayout { #[test] fn integration_tests_mirror_production_module_tree() { + let targets = test_source_roots(); for member in WORKSPACE_MEMBERS { let member_root = backend_root().join(member); let tests_root = member_root.join("tests"); @@ -376,9 +408,13 @@ fn integration_tests_mirror_production_module_tree() { } let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]); - let tests = module_layouts(&tests_root, &["main.rs"]); + let roots = targets.get(&tests_root).expect("Cargo test source roots"); + let tests = module_layouts( + &tests_root, + &roots.iter().map(String::as_str).collect::>(), + ); for (module, test_layout) in tests { - if module == Path::new("support") { + if module.starts_with("support") { continue; } @@ -408,6 +444,28 @@ fn integration_tests_mirror_production_module_tree() { } } +/// Rust 子进程 fixture 是 Cargo 的独立 crate 根,不能要求它由测试模块再次声明。 +fn test_source_roots() -> BTreeMap> { + let metadata = cargo_metadata_json(); + let mut roots = BTreeMap::>::new(); + for package in metadata["packages"].as_array().expect("Cargo packages") { + let root = Path::new(package["manifest_path"].as_str().expect("package manifest")) + .parent() + .expect("package directory") + .join("tests"); + for target in package["targets"].as_array().expect("Cargo targets") { + let source = Path::new(target["src_path"].as_str().expect("target source")); + if let Ok(relative) = source.strip_prefix(&root) { + roots + .entry(root.clone()) + .or_default() + .push(relative.to_str().expect("UTF-8 source path").to_owned()); + } + } + } + roots +} + fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap { super::rust_files(root) .into_iter() diff --git a/backend/apps/gateway/tests/bootstrap.rs b/backend/apps/gateway/tests/bootstrap.rs index b1cc13a70..7627a1dfd 100644 --- a/backend/apps/gateway/tests/bootstrap.rs +++ b/backend/apps/gateway/tests/bootstrap.rs @@ -9,6 +9,54 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222"; const ADMIN_PASSWORD: &str = "test-admin-password"; const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD"; +#[test] +fn basic_cli_options_do_not_load_configuration_and_reject_unexpected_arguments() { + let directory = tempfile::tempdir().unwrap(); + fs::create_dir(directory.path().join("deploy")).unwrap(); + fs::write(directory.path().join("deploy/config.yaml"), "invalid: [").unwrap(); + for flag in ["--help", "-h", "help", "--version", "-V"] { + let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs")) + .arg(flag) + .current_dir(directory.path()) + .env_clear() + .output() + .unwrap(); + assert!(output.status.success(), "{flag}"); + assert!(!output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + } + for args in [ + vec!["unknown"], + vec!["--help", "extra"], + vec!["serve", "extra"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs")) + .args(args) + .current_dir(directory.path()) + .env_clear() + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(!String::from_utf8_lossy(&output.stderr).contains("configuration")); + } +} + +#[cfg(unix)] +#[test] +fn non_utf8_command_is_not_interpreted_as_serve() { + use std::os::unix::ffi::OsStringExt as _; + + let output = Command::new(env!("CARGO_BIN_EXE_codex-proxy-rs")) + .arg(std::ffi::OsString::from_vec(vec![0xff])) + .env_clear() + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("command must be UTF-8")); +} + #[tokio::test] async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() { use gateway_admin::ports::proxy::ProxyProbe; diff --git a/backend/apps/plugin-cli/Cargo.toml b/backend/apps/plugin-cli/Cargo.toml new file mode 100644 index 000000000..47c210973 --- /dev/null +++ b/backend/apps/plugin-cli/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "codex-proxy-plugin-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[[bin]] +name = "cpr-plugin" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.6.7", features = ["derive"] } +flate2 = "1.1.9" +gateway-plugin-sdk = { path = "../../crates/gateway-plugin/sdk" } +hex = "0.4.3" +serde_json = "1.0.150" +sha2 = "0.11.0" +tar = "0.4.44" +thiserror = "2.0.18" + +[dev-dependencies] +tempfile = "3.27.0" + +[lints] +workspace = true diff --git a/backend/apps/plugin-cli/README.md b/backend/apps/plugin-cli/README.md new file mode 100644 index 000000000..546c19a47 --- /dev/null +++ b/backend/apps/plugin-cli/README.md @@ -0,0 +1,65 @@ +# Codex Proxy Plugin CLI + +`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。 +当前输出使用 `manifestVersion: 3`、`protocolVersion: 4` 和 SDK `0.4` 合同。 + +## 安装 + +从仓库根目录安装到本地工具目录: + +```bash +cargo install --locked --path backend/apps/plugin-cli --root /path/to/local-tools +``` + +将 `/path/to/local-tools/bin` 加入 `PATH`,或使用可执行文件的完整路径。 + +## 打包 + +先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数: + +```bash +cpr-plugin package \ + --manifest /path/to/plugin/plugin.json \ + --binary /path/to/plugin/target/x86_64-unknown-linux-gnu/release/plugin \ + --target x86_64-unknown-linux-gnu \ + --resource-map web=web/dist \ + --output-dir /path/to/output +``` + +资源映射相对于作者清单所在目录解析。输出包括完整插件 ID、版本和目标 triple 命名的 `.tar.gz` 及 `.sha256`;归档根目录直接包含 `plugin.json`、可执行文件和已声明资源,不包含开发依赖。 + +作者清单可省略普通贡献项的 `id`、`version` 和固定阶段;`middleware` 的 `request`/`attempt` 仍须显式选择。 +Provider 使用一个业务组声明协议与可选功能,例如: + +```json +{ + "contributes": { + "provider": { + "capabilities": ["models", "quota", "maintenance"], + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + } +} +``` + +CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的 +`plugin.json` 会写入展开后的 `executor`、所选 Provider 子能力及完整贡献项字段,不保留另一份作者声明。 +完整规则见 [SDK 清单](../../crates/gateway-plugin/sdk/docs/manifest.md)。 + +支持 `x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu` 和 `aarch64-apple-darwin`。完整开发示例在同级 `codex-proxy-plugins/examples/workbench` 仓库目录;本工具不负责源码构建、上传或启用插件。 + +### 图标资源 + +在作者清单的 `icon` 中指定图标,并在 `resources` 中登记对应路径与 MIME 类型,配置方式见 +[SDK 插件图标](../../crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。图标与其他声明资源一样参与打包和摘要计算, +无需额外命令参数;工具保留文件原始内容,宿主安装时验证图标内容与资源限制。 + +## 开发 + +Rust 包名为 `codex-proxy-plugin-cli`,位于宿主的 `backend` workspace,共享工具链、锁文件和检查规则;内部仅依赖 `gateway-plugin-sdk`。 + +```bash +cargo run --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli -- package --help +RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -p codex-proxy-plugin-cli --locked +``` diff --git a/backend/apps/plugin-cli/src/main.rs b/backend/apps/plugin-cli/src/main.rs new file mode 100644 index 000000000..9ec4b1f53 --- /dev/null +++ b/backend/apps/plugin-cli/src/main.rs @@ -0,0 +1,562 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, File, OpenOptions}, + io::{self, Read as _, Write as _}, + path::{Component, Path, PathBuf}, +}; + +use clap::{Args, Parser, Subcommand}; +use flate2::{Compression, GzBuilder}; +use gateway_plugin_sdk::{ + Manifest, ManifestError, PROTOCOL_VERSION, Package, PackageTarget, valid_package_path, +}; +use sha2::{Digest as _, Sha256}; +use tar::{Builder, EntryType, Header, HeaderMode}; +use thiserror::Error; + +const MAXIMUM_COMPRESSED_BYTES: u64 = 32 * 1024 * 1024; +const MAXIMUM_EXPANDED_BYTES: u64 = 128 * 1024 * 1024; +const MAXIMUM_MANIFEST_BYTES: u64 = 64 * 1024; + +#[derive(Parser)] +#[command(name = "cpr-plugin", version, about = "Codex Proxy 插件开发工具")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + #[command(about = "校验清单并打包已编译的插件")] + Package(Options), +} + +#[derive(Debug, Error)] +enum PackagerError { + #[error("{0}")] + Invalid(String), + #[error("failed to {action} {path}: {source}")] + Io { + action: &'static str, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("source or generated manifest is invalid: {0}")] + Manifest(#[from] ManifestError), + #[error("failed to encode generated manifest: {0}")] + EncodeManifest(#[source] serde_json::Error), +} + +#[derive(Debug, Args)] +struct Options { + #[arg(long, value_name = "FILE", help = "作者清单 plugin.json")] + manifest: PathBuf, + #[arg(long, value_name = "FILE", help = "已编译的目标平台插件二进制")] + binary: PathBuf, + #[arg(long, value_name = "TRIPLE", help = "目标平台 triple")] + target: String, + #[arg(long, value_name = "DIRECTORY", help = "归档与 SHA-256 文件的输出目录")] + output_dir: PathBuf, + #[arg( + long = "resource-map", + value_name = "PREFIX=DIRECTORY", + help = "包内资源前缀到项目内目录的映射,可重复传入" + )] + resource_maps: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct RuntimeTarget { + os: &'static str, + architecture: &'static str, +} + +impl RuntimeTarget { + fn parse(triple: &str) -> Result { + match triple { + "x86_64-unknown-linux-gnu" => Ok(Self { + os: "linux", + architecture: "x86_64", + }), + "aarch64-unknown-linux-gnu" => Ok(Self { + os: "linux", + architecture: "aarch64", + }), + "aarch64-apple-darwin" => Ok(Self { + os: "macos", + architecture: "aarch64", + }), + _ => Err(PackagerError::Invalid(format!( + "unsupported plugin target {triple}" + ))), + } + } +} + +#[derive(Debug)] +struct PackageOutput { + archive: PathBuf, + checksum: PathBuf, +} + +fn main() -> Result<(), PackagerError> { + match Cli::parse().command { + Commands::Package(options) => { + let output = package(&options)?; + println!("{}", output.archive.display()); + println!("{}", output.checksum.display()); + Ok(()) + } + } +} + +fn parse_resource_maps(values: &[String]) -> Result, PackagerError> { + let mut resource_maps = BTreeMap::new(); + for value in values { + let (prefix, directory) = value.split_once('=').ok_or_else(|| { + PackagerError::Invalid( + "--resource-map must use package-prefix=source-directory".to_owned(), + ) + })?; + if !valid_resource_prefix(prefix) { + return Err(PackagerError::Invalid(format!( + "invalid resource-map package prefix {prefix}" + ))); + } + let directory = PathBuf::from(directory); + if !safe_relative_directory(&directory) { + return Err(PackagerError::Invalid(format!( + "resource-map source directory must be a non-empty project-relative path: {}", + directory.display() + ))); + } + if resource_maps.insert(prefix.to_owned(), directory).is_some() { + return Err(PackagerError::Invalid(format!( + "duplicate resource-map package prefix {prefix}" + ))); + } + } + Ok(resource_maps) +} + +fn valid_resource_prefix(prefix: &str) -> bool { + valid_package_path(prefix) && !prefix.contains('/') +} + +fn safe_relative_directory(path: &Path) -> bool { + !path.as_os_str().is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + +fn package(options: &Options) -> Result { + let target = RuntimeTarget::parse(&options.target)?; + let resource_maps = parse_resource_maps(&options.resource_maps)?; + let manifest_bytes = read_regular_file(&options.manifest, MAXIMUM_MANIFEST_BYTES)?; + let mut manifest = Manifest::from_author_slice(&manifest_bytes)?; + + let project_directory = options + .manifest + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let canonical_project = canonicalize(project_directory)?; + let mut sources = BTreeMap::from([(manifest.main.clone(), options.binary.clone())]); + let mut used_maps = BTreeSet::new(); + for package_path in manifest.resources.keys() { + let source = resource_source( + project_directory, + package_path, + &resource_maps, + &mut used_maps, + )?; + let canonical_source = canonicalize(&source)?; + if !canonical_source.starts_with(&canonical_project) { + return Err(PackagerError::Invalid(format!( + "resource source escapes the plugin project: {}", + source.display() + ))); + } + if sources.insert(package_path.clone(), source).is_some() { + return Err(PackagerError::Invalid(format!( + "plugin main and resource share package path {package_path}" + ))); + } + } + if used_maps.len() != resource_maps.len() { + let unused = resource_maps + .keys() + .filter(|prefix| !used_maps.contains(*prefix)) + .cloned() + .collect::>(); + return Err(PackagerError::Invalid(format!( + "unused resource-map package prefix: {}", + unused.join(", ") + ))); + } + reject_case_folded_paths(sources.keys())?; + + let mut files = BTreeMap::new(); + let mut source_bytes = 0u64; + for (package_path, source) in sources { + let remaining = MAXIMUM_EXPANDED_BYTES + .checked_sub(source_bytes) + .ok_or_else(package_limit)?; + let bytes = read_regular_file(&source, remaining)?; + source_bytes = source_bytes + .checked_add(u64::try_from(bytes.len()).map_err(|_| package_limit())?) + .ok_or_else(package_limit)?; + files.insert(package_path, bytes); + } + let digests = files + .iter() + .map(|(path, bytes)| (path.clone(), hex::encode(Sha256::digest(bytes)))) + .collect(); + manifest.package = Some(Package { + protocol_version: PROTOCOL_VERSION, + target: PackageTarget { + os: target.os.to_owned(), + architecture: target.architecture.to_owned(), + }, + files: digests, + }); + manifest.validate()?; + let mut generated_manifest = + serde_json::to_vec_pretty(&manifest).map_err(PackagerError::EncodeManifest)?; + generated_manifest.push(b'\n'); + if generated_manifest.len() as u64 > MAXIMUM_MANIFEST_BYTES { + return Err(package_limit()); + } + + create_directory(&options.output_dir)?; + let plugin_id = manifest.plugin_id()?; + let archive_name = format!("{plugin_id}-{}-{}.tar.gz", manifest.version, options.target); + let archive_path = options.output_dir.join(&archive_name); + let archive_temporary = options + .output_dir + .join(format!(".{archive_name}.{}.tmp", std::process::id())); + let mut temporary = TemporaryFile::create(archive_temporary)?; + write_archive( + temporary.file_mut(), + &generated_manifest, + &manifest.main, + &files, + )?; + temporary.sync_all()?; + let compressed_bytes = temporary.metadata_len()?; + if compressed_bytes > MAXIMUM_COMPRESSED_BYTES { + return Err(package_limit()); + } + temporary.persist(&archive_path)?; + + let archive_digest = sha256_file(&archive_path)?; + let checksum_path = options.output_dir.join(format!("{archive_name}.sha256")); + let checksum_temporary = options + .output_dir + .join(format!(".{archive_name}.sha256.{}.tmp", std::process::id())); + let mut checksum = TemporaryFile::create(checksum_temporary)?; + let checksum_temporary_path = checksum.path().to_owned(); + writeln!(checksum.file_mut(), "{archive_digest} {archive_name}").map_err(|source| { + PackagerError::Io { + action: "write", + path: checksum_temporary_path, + source, + } + })?; + checksum.sync_all()?; + checksum.persist(&checksum_path)?; + + Ok(PackageOutput { + archive: archive_path, + checksum: checksum_path, + }) +} + +fn resource_source( + project_directory: &Path, + package_path: &str, + mappings: &BTreeMap, + used_mappings: &mut BTreeSet, +) -> Result { + let (prefix, remainder) = package_path + .split_once('/') + .map_or((package_path, None), |(prefix, remainder)| { + (prefix, Some(remainder)) + }); + if let Some(directory) = mappings.get(prefix) { + let remainder = remainder.ok_or_else(|| { + PackagerError::Invalid(format!( + "mapped resource {package_path} must have a path below prefix {prefix}" + )) + })?; + used_mappings.insert(prefix.to_owned()); + Ok(project_directory.join(directory).join(remainder)) + } else { + Ok(project_directory.join(package_path)) + } +} + +fn reject_case_folded_paths<'a>( + paths: impl IntoIterator, +) -> Result<(), PackagerError> { + let mut folded = BTreeSet::new(); + for path in paths { + if !folded.insert(path.to_ascii_lowercase()) { + return Err(PackagerError::Invalid(format!( + "package path collides case-insensitively: {path}" + ))); + } + } + Ok(()) +} + +fn write_archive( + output: &mut File, + manifest: &[u8], + main_path: &str, + files: &BTreeMap>, +) -> Result<(), PackagerError> { + let encoder = GzBuilder::new().mtime(0).write(output, Compression::best()); + let limited = LimitedWriter::new(encoder, MAXIMUM_EXPANDED_BYTES); + let mut archive = Builder::new(limited); + archive.mode(HeaderMode::Deterministic); + append_file(&mut archive, "plugin.json", manifest, 0o644)?; + for (path, bytes) in files { + let mode = if path == main_path { 0o755 } else { 0o644 }; + append_file(&mut archive, path, bytes, mode)?; + } + archive.finish().map_err(|source| PackagerError::Io { + action: "finish archive", + path: PathBuf::from(""), + source, + })?; + let limited = archive.into_inner().map_err(|source| PackagerError::Io { + action: "finish archive", + path: PathBuf::from(""), + source, + })?; + limited + .into_inner() + .finish() + .map_err(|source| PackagerError::Io { + action: "finish compression", + path: PathBuf::from(""), + source, + })?; + Ok(()) +} + +fn append_file( + archive: &mut Builder, + path: &str, + bytes: &[u8], + mode: u32, +) -> Result<(), PackagerError> { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_mode(mode); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_size(bytes.len() as u64); + header.set_cksum(); + archive + .append_data(&mut header, path, bytes) + .map_err(|source| PackagerError::Io { + action: "append archive entry", + path: PathBuf::from(path), + source, + }) +} + +struct LimitedWriter { + inner: W, + written: u64, + maximum: u64, +} + +impl LimitedWriter { + const fn new(inner: W, maximum: u64) -> Self { + Self { + inner, + written: 0, + maximum, + } + } + + fn into_inner(self) -> W { + self.inner + } +} + +impl io::Write for LimitedWriter +where + W: io::Write, +{ + fn write(&mut self, bytes: &[u8]) -> io::Result { + let length = u64::try_from(bytes.len()) + .map_err(|_| io::Error::other("plugin package exceeds expanded size limit"))?; + if length > self.maximum.saturating_sub(self.written) { + return Err(io::Error::other( + "plugin package exceeds expanded size limit", + )); + } + let written = self.inner.write(bytes)?; + self.written = self + .written + .checked_add(written as u64) + .ok_or_else(|| io::Error::other("plugin package size overflow"))?; + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +struct TemporaryFile { + path: PathBuf, + file: File, +} + +impl TemporaryFile { + fn create(path: PathBuf) -> Result { + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|source| PackagerError::Io { + action: "create", + path: path.clone(), + source, + })?; + Ok(Self { path, file }) + } + + fn path(&self) -> &Path { + &self.path + } + + fn file_mut(&mut self) -> &mut File { + &mut self.file + } + + fn sync_all(&mut self) -> Result<(), PackagerError> { + let path = self.path.clone(); + self.file_mut() + .sync_all() + .map_err(|source| PackagerError::Io { + action: "sync", + path, + source, + }) + } + + fn metadata_len(&self) -> Result { + self.file + .metadata() + .map(|metadata| metadata.len()) + .map_err(|source| PackagerError::Io { + action: "inspect", + path: self.path.clone(), + source, + }) + } + + fn persist(mut self, destination: &Path) -> Result<(), PackagerError> { + fs::rename(&self.path, destination).map_err(|source| PackagerError::Io { + action: "publish", + path: destination.to_owned(), + source, + })?; + self.path.clear(); + Ok(()) + } +} + +impl Drop for TemporaryFile { + fn drop(&mut self) { + if !self.path.as_os_str().is_empty() { + let _ = fs::remove_file(&self.path); + } + } +} + +fn read_regular_file(path: &Path, maximum_bytes: u64) -> Result, PackagerError> { + let metadata = fs::symlink_metadata(path).map_err(|source| PackagerError::Io { + action: "inspect", + path: path.to_owned(), + source, + })?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(PackagerError::Invalid(format!( + "package input must be a regular non-symlink file: {}", + path.display() + ))); + } + if metadata.len() > maximum_bytes { + return Err(package_limit()); + } + let file = File::open(path).map_err(|source| PackagerError::Io { + action: "open", + path: path.to_owned(), + source, + })?; + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default()); + file.take(maximum_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|source| PackagerError::Io { + action: "read", + path: path.to_owned(), + source, + })?; + if bytes.len() as u64 > maximum_bytes { + return Err(package_limit()); + } + Ok(bytes) +} + +fn canonicalize(path: &Path) -> Result { + fs::canonicalize(path).map_err(|source| PackagerError::Io { + action: "canonicalize", + path: path.to_owned(), + source, + }) +} + +fn create_directory(path: &Path) -> Result<(), PackagerError> { + fs::create_dir_all(path).map_err(|source| PackagerError::Io { + action: "create directory", + path: path.to_owned(), + source, + }) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path).map_err(|source| PackagerError::Io { + action: "open", + path: path.to_owned(), + source, + })?; + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).map_err(|source| PackagerError::Io { + action: "hash", + path: path.to_owned(), + source, + })?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + Ok(hex::encode(digest.finalize())) +} + +fn package_limit() -> PackagerError { + PackagerError::Invalid("plugin package exceeds installation size limits".to_owned()) +} diff --git a/backend/apps/plugin-cli/tests/main.rs b/backend/apps/plugin-cli/tests/main.rs new file mode 100644 index 000000000..3f7971ec6 --- /dev/null +++ b/backend/apps/plugin-cli/tests/main.rs @@ -0,0 +1,267 @@ +use std::{ + collections::BTreeMap, + fs::{self, File}, + io::Read as _, + process::{Command, Output}, +}; + +use flate2::read::MultiGzDecoder; +use gateway_plugin_sdk::{Capability, MANIFEST_VERSION, Manifest, PROTOCOL_VERSION, Stage}; +use sha2::{Digest as _, Sha256}; + +fn cli(arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_cpr-plugin")) + .args(arguments) + .output() + .unwrap() +} + +fn assert_rejected(arguments: &[&str], message: &str) { + let output = cli(arguments); + assert!( + !output.status.success(), + "{arguments:?} unexpectedly succeeded" + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains(message), + "{arguments:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn package_arguments() -> Vec<&'static str> { + vec![ + "package", + "--manifest", + "plugin.json", + "--binary", + "plugin-bin", + "--target", + "x86_64-unknown-linux-gnu", + "--output-dir", + "dist", + ] +} + +#[test] +fn root_help_lists_package_command() { + let output = cli(&["--help"]); + assert!(output.status.success()); + let help = String::from_utf8(output.stdout).unwrap(); + assert!(help.contains("cpr-plugin "), "{help}"); + assert!(help.contains("package"), "{help}"); +} + +#[test] +fn version_uses_the_user_command_name() { + let output = cli(&["--version"]); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!("cpr-plugin {}\n", env!("CARGO_PKG_VERSION")) + ); +} + +#[test] +fn package_help_does_not_require_inputs() { + let output = cli(&["package", "--help"]); + assert!(output.status.success()); + let help = String::from_utf8(output.stdout).unwrap(); + assert!(help.contains("cpr-plugin package"), "{help}"); + assert!(help.contains("--resource-map"), "{help}"); +} + +#[test] +fn rejects_missing_subcommand() { + assert_rejected(&[], "Usage: cpr-plugin"); +} + +#[test] +fn rejects_unknown_subcommand() { + assert_rejected(&["publish"], "unrecognized subcommand"); +} + +#[test] +fn rejects_missing_package_arguments() { + assert_rejected(&["package"], "--manifest"); +} + +#[test] +fn rejects_duplicate_single_value_options() { + let mut arguments = package_arguments(); + arguments.extend(["--manifest", "other.json"]); + assert_rejected(&arguments, "cannot be used multiple times"); +} + +#[test] +fn rejects_unknown_targets() { + let mut arguments = package_arguments(); + arguments[6] = "x86_64-pc-windows-msvc"; + assert_rejected(&arguments, "unsupported plugin target"); +} + +#[test] +fn rejects_unsafe_resource_mappings_before_reading_files() { + for (mapping, message) in [ + ("web=../web/dist", "project-relative path"), + ("web=/web/dist", "project-relative path"), + ("web=", "project-relative path"), + ("web/assets=web/dist", "invalid resource-map package prefix"), + ("web", "must use package-prefix=source-directory"), + ] { + let mut arguments = package_arguments(); + arguments.extend(["--resource-map", mapping]); + assert_rejected(&arguments, message); + } +} + +#[test] +fn rejects_duplicate_resource_prefixes() { + let mut arguments = package_arguments(); + arguments.extend([ + "--resource-map", + "web=web/dist", + "--resource-map", + "web=other", + ]); + assert_rejected(&arguments, "duplicate resource-map package prefix"); +} + +#[test] +fn packages_only_generated_resources_with_v3_metadata() { + assert_packaged_resources("assets/icon.png", "image/png", b"png"); +} + +#[test] +fn packages_svg_icons_with_their_declared_mime_and_digest() { + assert_packaged_resources( + "assets/icon.svg", + "image/svg+xml", + br#""#, + ); +} + +fn assert_packaged_resources(icon_path: &str, icon_mime: &str, icon: &[u8]) { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("example"); + fs::create_dir_all(project.join("web/dist")).unwrap(); + fs::create_dir_all(project.join("assets")).unwrap(); + let mut source: serde_json::Value = serde_json::from_slice(source_manifest()).unwrap(); + source["icon"] = icon_path.into(); + let resources = source["resources"].as_object_mut().unwrap(); + resources.remove("assets/icon.png"); + resources.insert(icon_path.into(), icon_mime.into()); + fs::write( + project.join("plugin.json"), + serde_json::to_vec(&source).unwrap(), + ) + .unwrap(); + fs::write(project.join("LICENSE"), b"license").unwrap(); + fs::write(project.join(icon_path), icon).unwrap(); + fs::write(project.join("plugin-bin"), b"binary").unwrap(); + fs::write(project.join("web/index.html"), b"source html").unwrap(); + fs::write(project.join("web/dist/index.html"), b"built html").unwrap(); + fs::write(project.join("web/dist/app.js"), b"built js").unwrap(); + fs::write(project.join("web/dist/app.css"), b"built css").unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_cpr-plugin")) + .arg("package") + .arg("--manifest") + .arg(project.join("plugin.json")) + .arg("--binary") + .arg(project.join("plugin-bin")) + .args(["--target", "x86_64-unknown-linux-gnu"]) + .arg("--output-dir") + .arg(project.join("dist")) + .args(["--resource-map", "web=web/dist"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + let archive_name = "codex-proxy.request-workbench-0.1.0-x86_64-unknown-linux-gnu.tar.gz"; + let archive_path = project.join("dist").join(archive_name); + let checksum_path = project.join("dist").join(format!("{archive_name}.sha256")); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + format!("{}\n{}\n", archive_path.display(), checksum_path.display()) + ); + let archive = File::open(&archive_path).unwrap(); + let mut package = tar::Archive::new(MultiGzDecoder::new(archive)); + let mut entries = BTreeMap::new(); + for entry in package.entries().unwrap() { + let mut entry = entry.unwrap(); + let path = entry.path().unwrap().to_string_lossy().into_owned(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + entries.insert(path, bytes); + } + assert_eq!( + entries.keys().cloned().collect::>(), + vec![ + "LICENSE".to_owned(), + icon_path.to_owned(), + "bin/plugin".to_owned(), + "plugin.json".to_owned(), + "web/app.css".to_owned(), + "web/app.js".to_owned(), + "web/index.html".to_owned(), + ] + ); + assert_eq!(entries["web/index.html"], b"built html"); + let manifest: Manifest = serde_json::from_slice(&entries["plugin.json"]).unwrap(); + assert_eq!(manifest.manifest_version, MANIFEST_VERSION); + assert_eq!( + manifest.contributes[&Capability::Middleware].id, + "codex-proxy.request-workbench.middleware" + ); + assert_eq!( + manifest.contributes[&Capability::Management].stages, + [Stage::Management] + ); + assert_eq!( + manifest.contributes[&Capability::Executor].input_formats, + ["openai"] + ); + assert_eq!( + manifest.contributes[&Capability::Quota].stages, + [Stage::Management, Stage::Maintenance] + ); + assert_eq!(manifest.resources[icon_path], icon_mime); + assert_eq!(entries[icon_path], icon); + let package = manifest.package.unwrap(); + assert_eq!(package.files[icon_path], hex::encode(Sha256::digest(icon))); + assert_eq!(package.protocol_version, PROTOCOL_VERSION); + assert_eq!(package.target.os, "linux"); + assert_eq!(package.target.architecture, "x86_64"); + assert_eq!(package.files.len(), 6); + let checksum = fs::read_to_string(checksum_path).unwrap(); + let digest = hex::encode(Sha256::digest(fs::read(archive_path).unwrap())); + assert_eq!(checksum, format!("{digest} {archive_name}\n")); +} + +fn source_manifest() -> &'static [u8] { + br#"{ + "manifestVersion":3, + "name":"request-workbench", + "displayName":"Request Workbench", + "publisher":"codex-proxy", + "version":"0.1.0", + "description":"test package", + "license":"Apache-2.0", + "engines":{"codex-proxy-rs":">=3.12.0, <4.0.0"}, + "main":"bin/plugin", + "runtime":"trustedProcess", + "contributes":{ + "middleware":{"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]}, + "management":{}, + "provider":{"capabilities":["quota"],"inputFormats":["openai"],"outputFormats":["openai"]} + }, + "permissions":["requests","accounts"], + "icon":"assets/icon.png", + "resources":{"LICENSE":"text/plain","assets/icon.png":"image/png","web/index.html":"text/html","web/app.js":"text/javascript","web/app.css":"text/css"} + }"# +} diff --git a/backend/crates/gateway-admin/Cargo.toml b/backend/crates/gateway-admin/Cargo.toml index 9345fea2a..43de20175 100644 --- a/backend/crates/gateway-admin/Cargo.toml +++ b/backend/crates/gateway-admin/Cargo.toml @@ -16,7 +16,7 @@ argon2 = "0.6.0" async-trait = "0.1.89" base64 = "0.23.0" bytes = "1.11.1" -chrono = "0.4.45" +chrono = { version = "0.4.45", features = ["serde"] } futures = "0.3.32" gateway-core = { path = "../gateway-core" } heck = "0.5.0" @@ -25,6 +25,7 @@ rand_core = { version = "0.6.4", features = ["getrandom"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } secrecy = { version = "0.10.3", features = ["serde"] } +semver = "1.0.27" sha2 = "0.11" subtle = "2.6.1" thiserror = "2.0.18" diff --git a/backend/crates/gateway-admin/src/lib.rs b/backend/crates/gateway-admin/src/lib.rs index b0ac21a79..ac6ed3830 100644 --- a/backend/crates/gateway-admin/src/lib.rs +++ b/backend/crates/gateway-admin/src/lib.rs @@ -7,7 +7,6 @@ use std::{fmt, path::Path, sync::Arc, time::Duration}; use gateway_core::{ engine::execution::ClientKeyVerifier, engine::probe::AccountProbe, - routing::ProviderKind, runtime::SnapshotControl, task::{ DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest, @@ -22,34 +21,39 @@ pub mod freeze_recovery; pub mod model; pub mod ports; mod use_case; +pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService}; pub use use_case::key_usage::KeyUsageService; pub use use_case::{ - account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService, - backup::BackupService, client_distribution::ClientDistributionService, - client_keys::ClientKeyService, import_tasks::ImportTasksService, - observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService, - settings::SettingsService, system::SystemService, xai::XaiService, + account_groups::AccountGroupService, + accounts::AccountsService, + auth::AuthService, + backup::BackupService, + client_distribution::ClientDistributionService, + client_keys::ClientKeyService, + credentials::{CredentialsService, ProviderCredentials}, + import_tasks::ImportTasksService, + observability::ObservabilityService, + proxies::ProxiesService, + settings::SettingsService, + system::SystemService, }; -use model::{AdminError, AdminErrorKind}; +use model::AdminError; use ports::{ - client_distribution::ClientDistributionResolver, - provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry}, - store::AdminStorePorts, - system::SystemOperations, + client_distribution::ClientDistributionResolver, plugin_accounts::PluginAccountAccess, + plugin_client_keys::PluginClientKeyAccess, provider::ProviderAdminRegistry, + store::AdminStorePorts, system::SystemOperations, }; use use_case::{ account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService, auth::DefaultAuthService, backup::DefaultBackupService, client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService, - observability::DefaultObservabilityService, openai::DefaultOpenAiService, - settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService, + observability::DefaultObservabilityService, settings::DefaultSettingsService, + system::DefaultSystemService, }; -const OPENAI_PROVIDER_KIND: &str = "openai"; -const XAI_PROVIDER_KIND: &str = "xai"; const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12; const WEAK_ADMIN_PASSWORDS: &[&str] = &[ "", @@ -182,6 +186,8 @@ pub enum AdminConfigError { /// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。 #[derive(Clone)] pub struct AdminServices { + plugins: Arc, + plugin_management: Arc, proxies: Arc, auth: Arc, key_usage: Arc, @@ -192,13 +198,23 @@ pub struct AdminServices { observability: Arc, settings: Arc, system: Arc, - openai: Arc, - xai: Arc, + credentials: Arc, + plugin_accounts: Arc, backups: Arc, import_tasks: Arc, } impl AdminServices { + #[must_use] + pub fn plugin_management(&self) -> &PluginManagementService { + &self.plugin_management + } + + #[must_use] + pub fn plugins(&self) -> &PluginsService { + &self.plugins + } + #[must_use] pub fn import_tasks(&self) -> &dyn ImportTasksService { self.import_tasks.as_ref() @@ -261,13 +277,14 @@ impl AdminServices { } #[must_use] - pub fn openai(&self) -> &dyn OpenAiService { - self.openai.as_ref() + pub fn credentials(&self) -> &CredentialsService { + self.credentials.as_ref() } + /// Runtime 只持有该窄端口的 Weak;AdminBundle 保持实际生命周期。 #[must_use] - pub fn xai(&self) -> &dyn XaiService { - self.xai.as_ref() + pub fn plugin_accounts_handle(&self) -> Arc { + Arc::clone(&self.plugin_accounts) } #[must_use] @@ -296,8 +313,13 @@ impl AdminBundle { /// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。 pub struct AdminRuntimePorts { + pub plugin_preparation: Arc, + pub plugin_management: Arc, + pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle, + pub plugin_distribution: Arc, + pub plugin_inspector: Arc, pub pricing_source: Arc, - pub providers: Vec>, + pub providers: ProviderAdminRegistry, pub snapshot: Arc, pub account_probe: Arc, pub proxy_probe: Arc, @@ -306,18 +328,44 @@ pub struct AdminRuntimePorts { pub client_key_verifier: Arc, } -/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。 +/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。 /// /// # Errors /// -/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。 +/// 配置非法或默认管理员初始化失败时返回错误。 pub async fn initialize( + config: AdminConfig, + client_config: ClientConfig, + store: AdminStorePorts, + runtime: AdminRuntimePorts, +) -> Result { + initialize_inner(config, client_config, store, runtime, None).await +} + +/// 使用组合根已绑定给 Runtime 的同一账号窄端口,避免为完整 Admin 重建第二实例。 +pub async fn initialize_with_plugin_accounts( + config: AdminConfig, + client_config: ClientConfig, + store: AdminStorePorts, + runtime: AdminRuntimePorts, + plugin_accounts: Arc, +) -> Result { + initialize_inner(config, client_config, store, runtime, Some(plugin_accounts)).await +} + +async fn initialize_inner( mut config: AdminConfig, mut client_config: ClientConfig, store: AdminStorePorts, runtime: AdminRuntimePorts, + plugin_accounts: Option>, ) -> Result { let AdminRuntimePorts { + plugin_preparation, + plugin_management, + published_snapshot, + plugin_distribution, + plugin_inspector, pricing_source, providers, snapshot, @@ -333,13 +381,7 @@ pub async fn initialize( client_config .resolve_and_validate(Path::new(".")) .map_err(|error| AdminError::invalid(error.to_string()))?; - let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?; - let openai = registry - .require(&provider_kind(OPENAI_PROVIDER_KIND)?) - .map_err(map_provider_registry_error)?; - let xai = registry - .require(&provider_kind(XAI_PROVIDER_KIND)?) - .map_err(map_provider_registry_error)?; + let registry = providers; let auth = Arc::new(DefaultAuthService::new( config.default_username, @@ -370,7 +412,11 @@ pub async fn initialize( backup_ports.dump(), backup_ports.object_store(), ); - let system = Arc::new(DefaultSystemService::new(system)); + let system_preflight = Arc::new(use_case::plugin_update::PluginSystemUpdatePreflight::new( + store.plugins(), + plugin_inspector.clone(), + )); + let system = Arc::new(DefaultSystemService::new(system, system_preflight)); let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new( auth.clone(), client_key_verifier, @@ -378,22 +424,32 @@ pub async fn initialize( store.observability(), system.clone(), )); - let openai = Arc::new(DefaultOpenAiService::new( - openai, + let credentials = Arc::new(CredentialsService::new( + registry.clone(), store.accounts(), store.proxies(), snapshot.clone(), )); - let xai = Arc::new(DefaultXaiService::new( - xai, - store.accounts(), - store.proxies(), - snapshot.clone(), - )); - let import_tasks = - use_case::import_tasks::DefaultImportTasksService::new(openai.clone(), xai.clone()); + let plugin_accounts = plugin_accounts.unwrap_or_else(|| { + initialize_plugin_accounts(registry.clone(), store.accounts(), snapshot.clone()) + }); + let import_tasks = use_case::import_tasks::DefaultImportTasksService::new(credentials.clone()); let import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone()); let services = AdminServices { + plugin_management: Arc::new(PluginManagementService::new( + plugin_management, + store.plugins(), + published_snapshot.clone(), + )), + plugins: Arc::new(PluginsService::new( + store.plugins(), + plugin_inspector, + PluginDistributionPorts::new(plugin_distribution, store.proxies()), + snapshot.clone(), + plugin_preparation, + published_snapshot, + store.plugin_state(), + )), key_usage, proxies: Arc::new(use_case::proxies::DefaultProxiesService::new( store.proxies(), @@ -427,8 +483,8 @@ pub async fn initialize( pricing_source, )), system, - openai, - xai, + credentials, + plugin_accounts, import_tasks, backups, }; @@ -460,6 +516,29 @@ pub async fn initialize( }) } +/// CLI 只组合账号用例,不初始化管理员、管理服务或后台任务;写入仍复用同一事务与审计。 +pub fn initialize_plugin_accounts( + providers: ports::provider::ProviderAdminRegistry, + accounts: Arc, + snapshot: Arc, +) -> Arc { + Arc::new(use_case::plugin_accounts::DefaultPluginAccountAccess::new( + providers, accounts, snapshot, + )) +} + +/// 为 Runtime 创建只暴露非秘密 Client Key 目录的窄端口。 +#[must_use] +pub fn initialize_plugin_client_keys( + providers: ports::provider::ProviderAdminRegistry, + store: Arc, + snapshot: Arc, +) -> Arc { + let service: Arc = + Arc::new(DefaultClientKeyService::new(store, snapshot, providers)); + Arc::new(use_case::plugin_client_keys::DefaultPluginClientKeyAccess::new(service)) +} + /// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。 fn backup_worker_contribution( task: backup::task::BackupTask, @@ -509,24 +588,3 @@ fn freeze_recovery_worker_contribution( .map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?; Ok(vec![WorkerContribution::Registration(registration)]) } - -fn provider_kind(value: &'static str) -> Result { - ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法")) -} - -fn map_provider_registry_error(error: ProviderAdminError) -> AdminError { - let kind = match error.kind() { - ProviderAdminErrorKind::Invalid | ProviderAdminErrorKind::Unsupported => { - AdminErrorKind::Invalid - } - ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound, - ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict, - ProviderAdminErrorKind::Ambiguous => AdminErrorKind::UpstreamResultUnknown, - ProviderAdminErrorKind::Unavailable | ProviderAdminErrorKind::CredentialRefreshRequired => { - AdminErrorKind::Unavailable - } - ProviderAdminErrorKind::BadGateway => AdminErrorKind::BadGateway, - ProviderAdminErrorKind::Internal => AdminErrorKind::Internal, - }; - AdminError::new(kind, "Provider 注册表初始化失败") -} diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 29abee64d..ff78ec446 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -204,6 +204,8 @@ pub struct AccountPage { pub items: Vec, pub total: u64, pub summary: AccountSummary, + /// 全部持久化账号的平台,不受筛选、分页和插件启停影响。 + pub providers: Vec, } /// 同一状态快照下的账号事实与唯一状态投影。 diff --git a/backend/crates/gateway-admin/src/model/auth.rs b/backend/crates/gateway-admin/src/model/auth.rs index b99129144..0ef5af021 100644 --- a/backend/crates/gateway-admin/src/model/auth.rs +++ b/backend/crates/gateway-admin/src/model/auth.rs @@ -101,7 +101,8 @@ impl From for LoginError { fn from(error: ClientAuthenticationError) -> Self { match error { ClientAuthenticationError::InvalidKey => Self::InvalidCredentials, - ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable, + ClientAuthenticationError::SnapshotUnavailable + | ClientAuthenticationError::ProviderUnavailable => Self::Unavailable, } } } diff --git a/backend/crates/gateway-admin/src/model/client_keys.rs b/backend/crates/gateway-admin/src/model/client_keys.rs index 36d523388..0cc99dfce 100644 --- a/backend/crates/gateway-admin/src/model/client_keys.rs +++ b/backend/crates/gateway-admin/src/model/client_keys.rs @@ -1,6 +1,6 @@ //! Client API Key 的 Command、Result 与安全秘密类型。 -use std::{fmt, num::NonZeroU16}; +use std::{collections::BTreeMap, fmt, num::NonZeroU16}; use chrono::{DateTime, Utc}; @@ -12,6 +12,11 @@ use gateway_core::{ use super::{AdminModelError, Revision, account_groups::AccountGroupRef}; +pub type ProviderRequestProfileOverrides = + BTreeMap; +pub type ProviderRequestProfileOverrideUpdates = + BTreeMap>; + /// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ClientKeyPageSize(NonZeroU16); @@ -86,8 +91,7 @@ pub struct ClientKeyListQuery { /// 不含完整明文 Key 的管理投影。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientKeyRecord { - pub openai_client_profile_override: Option, - pub xai_client_profile_override: Option, + pub request_profile_overrides: ProviderRequestProfileOverrides, pub id: ClientApiKeyId, pub name: String, pub label: Option, @@ -146,8 +150,7 @@ impl fmt::Debug for ClientKeySecret { /// API 提交的 Client Key 创建命令。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateClientKey { - pub openai_client_profile_override: Option, - pub xai_client_profile_override: Option, + pub request_profile_overrides: ProviderRequestProfileOverrides, pub custom_key: Option, pub name: String, pub label: Option, @@ -159,8 +162,7 @@ pub struct CreateClientKey { /// 管理用例生成 ID 与明文后的持久化命令。 #[derive(Clone, PartialEq, Eq)] pub struct NewClientKey { - pub openai_client_profile_override: Option, - pub xai_client_profile_override: Option, + pub request_profile_overrides: ProviderRequestProfileOverrides, pub id: ClientApiKeyId, pub name: String, pub label: Option, @@ -185,8 +187,7 @@ impl fmt::Debug for NewClientKey { /// 修改 Client Key 的公开策略字段。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateClientKey { - pub openai_client_profile_override: Option>, - pub xai_client_profile_override: Option>, + pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates, pub id: ClientApiKeyId, pub name: String, pub label: Option, diff --git a/backend/crates/gateway-admin/src/model/mod.rs b/backend/crates/gateway-admin/src/model/mod.rs index ae3d474f4..9ad361505 100644 --- a/backend/crates/gateway-admin/src/model/mod.rs +++ b/backend/crates/gateway-admin/src/model/mod.rs @@ -11,7 +11,10 @@ pub mod client_keys; pub mod import_tasks; pub mod key_usage; pub mod observability; +pub mod plugin_client_keys; +pub mod plugins; pub mod pricing; +pub mod provider_capabilities; pub mod provider_credentials; pub mod proxies; pub mod quota_forecast; diff --git a/backend/crates/gateway-admin/src/model/plugin_client_keys.rs b/backend/crates/gateway-admin/src/model/plugin_client_keys.rs new file mode 100644 index 000000000..e43c1d2e5 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugin_client_keys.rs @@ -0,0 +1,31 @@ +//! 插件可见的 Client Key 非秘密资源投影。 + +use gateway_core::policy::ClientApiKeyId; + +use super::PageSize; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginClientKeyCursor { + pub name: String, + pub id: ClientApiKeyId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginClientKeyListQuery { + pub cursor: Option, + pub limit: PageSize, +} + +/// 插件只能发现调用所需的公开身份,不取得 Key 前缀、策略细节或明文。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginClientKey { + pub id: ClientApiKeyId, + pub name: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginClientKeyPage { + pub items: Vec, + pub next_cursor: Option, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/distribution.rs b/backend/crates/gateway-admin/src/model/plugins/distribution.rs new file mode 100644 index 000000000..39719e279 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/distribution.rs @@ -0,0 +1,231 @@ +use std::{fmt, sync::Arc}; + +use chrono::{DateTime, Utc}; +use gateway_core::account::OutboundProxy; +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +use super::{PluginArtifactMetadata, PluginSource, PluginSourceEgress}; +use crate::model::Revision; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginUpdateSource { + Builtin, + Upload, + Url { url: String }, + Github { repository: String }, +} + +/// 检查策略只决定查询目标,任何策略都不会自动安装或切换运行实例。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginUpdatePolicy { + Manual {}, + Stable {}, + Pinned { + tag: String, + #[serde(default)] + allow_prerelease: bool, + }, +} + +impl Default for PluginUpdatePolicy { + fn default() -> Self { + Self::Manual {} + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginSourceBinding { + pub plugin_id: String, + pub source: PluginUpdateSource, + #[serde(default)] + pub policy: PluginUpdatePolicy, + #[serde(default)] + pub outbound_proxy_id: Option, +} + +#[derive(Clone)] +pub struct PluginDistributionEgress { + pub source: PluginSourceEgress, + pub proxy: OutboundProxy, +} + +impl PluginDistributionEgress { + #[must_use] + pub fn new(id: String, revision: Revision, proxy: OutboundProxy) -> Self { + Self { + source: PluginSourceEgress { + id, + revision: revision.get(), + }, + proxy, + } + } + + #[must_use] + pub fn cache_key(&self) -> String { + format!("{}:{}", self.source.id, self.source.revision) + } +} + +impl fmt::Debug for PluginDistributionEgress { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PluginDistributionEgress") + .field("source", &self.source) + .field("proxy", &"") + .finish() + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginUpdateCheck { + pub binding: PluginSourceBinding, + /// Release 元数据不是已校验的插件包,不据此声明平台或 SDK 兼容。 + pub release: PluginRelease, +} + +impl From<&PluginSource> for PluginUpdateSource { + fn from(source: &PluginSource) -> Self { + match source { + PluginSource::Builtin { .. } => Self::Builtin, + PluginSource::Upload => Self::Upload, + PluginSource::Url { url, .. } => Self::Url { url: url.clone() }, + PluginSource::Github { repository, .. } => Self::Github { + repository: repository.to_ascii_lowercase(), + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DownloadPurpose { + Metadata, + Artifact, +} + +/// 授权目标以完整 origin 和路径段边界匹配;摘要、名称和列表均不包含 secret。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceCredentialInfo { + pub id: String, + pub name: String, + pub origin: String, + pub path_prefix: String, + pub purposes: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum SourceAuthentication { + Github { + token: SecretString, + }, + Bearer { + token: SecretString, + }, + Basic { + username: String, + password: SecretString, + }, + Header { + name: String, + value: SecretString, + }, +} + +#[derive(Clone)] +pub struct SourceCredential { + pub info: SourceCredentialInfo, + pub authentication: SourceAuthentication, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GithubReleaseQuery { + pub repository: String, + pub tag: Option, + #[serde(default)] + pub allow_prerelease: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseAsset { + pub name: String, + pub size: u64, + pub sha256: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRelease { + pub repository: String, + pub tag: String, + pub name: String, + pub prerelease: bool, + pub assets: Vec, + pub queried_at: DateTime, + pub expires_at: DateTime, +} + +/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。 +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RemotePluginLocation { + Url { + url: String, + sha256: Option, + }, + Github { + repository: String, + tag: String, + asset: String, + #[serde(default)] + allow_prerelease: bool, + sha256: Option, + }, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemotePluginVerify { + pub location: RemotePluginLocation, + #[serde(default)] + pub credential_ids: Vec, + #[serde(default)] + pub outbound_proxy_id: Option, + /// 更新已有插件时锁定身份;首次安装从包内清单读取。 + pub expected_plugin_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemotePluginInstall { + pub plugin_id: String, + pub version: String, + pub location: RemotePluginLocation, + #[serde(default)] + pub credential_ids: Vec, + #[serde(default)] + pub outbound_proxy_id: Option, +} + +pub struct DownloadedPlugin { + pub archive: Arc<[u8]>, + pub sha256: String, + pub source: PluginSource, +} + +/// 只读包校验结果,不代表实例配置、授权或私有状态已完成运行时准备。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedPluginArtifact { + pub metadata: PluginArtifactMetadata, + pub source: PluginSource, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/instances.rs b/backend/crates/gateway-admin/src/model/plugins/instances.rs new file mode 100644 index 000000000..3aadbee27 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/instances.rs @@ -0,0 +1,193 @@ +use std::collections::BTreeMap; + +use secrecy::SecretString; +use serde::{Deserialize, Serialize}; + +use crate::model::Revision; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginPermissionGrant { + pub permission: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginFailurePolicy { + Reject, + Delegate, + Observe, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginCapabilityBinding { + pub contribution: String, + pub stage: String, + pub order: i32, + pub failure_policy: PluginFailurePolicy, + #[serde(default)] + pub client_key_ids: Vec, + #[serde(default)] + pub account_group_ids: Vec, + #[serde(default)] + pub provider_ids: Vec, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub identity_bindings: Vec, +} + +/// 外部 principal 到既有 Client Key 的显式宿主映射。 +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginFrontendIdentityBinding { + pub principal: String, + pub client_key_id: String, +} + +impl std::fmt::Debug for PluginFrontendIdentityBinding { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PluginFrontendIdentityBinding") + .field("principal", &"[REDACTED]") + .field("client_key_id", &self.client_key_id) + .finish() + } +} + +/// 配置和 secret 分离;Debug 与 Serialize 不得无意展开实例配置。 +#[derive(Clone)] +pub struct PluginInstance { + pub id: String, + pub name: String, + pub artifact_sha256: String, + pub enabled: bool, + pub trusted_process: bool, + pub configuration: serde_json::Value, + pub secrets: BTreeMap, + pub grants: Vec, + pub bindings: Vec, + pub revision: Revision, +} + +#[derive(Clone)] +pub struct PluginInstanceSnapshot { + pub config_revision: Revision, + pub instances: Vec, +} + +pub struct PluginInstanceMutation { + pub config_revision: Revision, + pub instance: PluginInstance, +} + +/// 控制面展示的单实例运行状态;它描述当前进程观察到的事实,不替代持久启用配置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginInstanceRuntimeStatus { + Disabled, + AwaitingPublication, + Preparing, + Running, + Blocked, + PreparationFailed, + Faulted, + Draining, +} + +/// 已脱敏且可安全返回给管理员的运行失败摘要。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstanceRuntimeFailure { + pub code: String, + pub message: String, +} + +/// 从当前发布视图、弱代次索引和 continuation retention 合成的只读诊断。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInstanceRuntime { + pub status: PluginInstanceRuntimeStatus, + pub actual_revision: Option, + pub actual_artifact_sha256: Option, + pub failure: Option, + pub draining_revisions: Vec, + pub retained_continuations: u32, +} + +pub struct PluginInstanceView { + pub instance: PluginInstance, + pub configuration_required: bool, + pub running: bool, + pub published_revision: Option, + pub runtime: PluginInstanceRuntime, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConfigurePluginInstance { + /// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。 + pub creation_id: Option, + /// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。 + pub expected_revision: Option, + /// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。 + #[serde(default)] + pub replace_instances: Vec, + pub name: String, + pub artifact_sha256: String, + pub enabled: bool, + pub configuration: serde_json::Value, + /// 缺省保留旧值;显式空对象清除全部 secret。 + pub secrets: Option>, + pub bindings: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginInstanceReplacement { + pub id: String, + pub expected_revision: u64, +} + +/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RollbackPluginInstance { + pub artifact_sha256: String, + pub expected_revision: u64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRollbackTarget { + pub artifact_sha256: String, + pub version: String, + pub platforms: Vec, +} + +/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginRollbackPlan { + pub instance_revision: u64, + pub current_version: String, + pub targets: Vec, +} + +/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。 +#[derive(Clone)] +pub struct PluginVersionConfiguration { + pub configuration: serde_json::Value, + pub secrets: BTreeMap, + pub bindings: Vec, +} + +/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。 +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginVersionPlan { + pub instance_revision: u64, + pub artifact_sha256: String, + pub configuration: serde_json::Value, + pub secret_fields: Vec, + pub bindings: Vec, + pub restored: bool, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/management.rs b/backend/crates/gateway-admin/src/model/plugins/management.rs new file mode 100644 index 000000000..6bc9d155d --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/management.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +/// 页面和业务调用绑定同一实例版本;制品摘要单独作为不可变资源身份。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginManagementTarget { + pub instance_id: String, + pub artifact_sha256: String, + pub revision: u64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementView { + pub target: PluginManagementTarget, + pub name: String, + pub configuration_schema: serde_json::Value, + pub pages: Vec, + pub routes: Vec, + pub resources: Vec, + pub callbacks: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementCallback { + pub path: String, + pub response_content_types: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StartPluginManagementCallback { + pub path: String, + pub ttl_seconds: u32, +} + +/// state 是短期一次性秘密,只交给发起登录的管理员,不进入 Debug 或日志。 +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementCallbackTicket { + pub state: String, + pub expires_at_ms: i64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementPage { + pub id: String, + pub title: String, + pub description: Option, + pub entry: String, + pub icon: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementRoute { + pub method: String, + pub path: String, + pub request_content_types: Vec, + pub response_content_types: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginManagementResource { + pub path: String, + pub content_type: String, + pub public: bool, +} + +/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志。 +pub struct PluginManagementRequest { + pub method: String, + pub path: String, + pub query: String, + pub content_type: Option, + pub body: Vec, + pub request_id: String, +} + +pub struct PluginManagementResponse { + pub status: u16, + pub content_type: String, + pub body: Arc<[u8]>, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/mod.rs b/backend/crates/gateway-admin/src/model/plugins/mod.rs new file mode 100644 index 000000000..13a0401b8 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/mod.rs @@ -0,0 +1,273 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; + +use super::Revision; + +pub mod distribution; +pub mod instances; +pub mod management; +pub mod official; +pub mod state; + +/// 一个宿主发行版明确承诺支持的插件能力版本。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginHostCapability { + pub capability: String, + pub versions: Vec, +} + +/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginHostCompatibility { + pub schema_version: u32, + pub manifest_schema_versions: Vec, + pub protocol_versions: Vec, + pub capabilities: Vec, + pub permissions: Vec, +} + +impl PluginHostCompatibility { + /// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。 + #[must_use] + pub fn is_valid(&self) -> bool { + if self.schema_version != 1 + || !valid_versions(&self.manifest_schema_versions) + || !valid_versions(&self.protocol_versions) + || self.capabilities.is_empty() + || self.capabilities.len() > 64 + || self.permissions.len() > 64 + { + return false; + } + let mut capabilities = BTreeSet::new(); + if self.capabilities.iter().any(|entry| { + !valid_identifier(&entry.capability) + || !valid_versions(&entry.versions) + || !capabilities.insert(entry.capability.as_str()) + }) { + return false; + } + let mut permissions = BTreeSet::new(); + !self.permissions.iter().any(|permission| { + !valid_identifier(permission) || !permissions.insert(permission.as_str()) + }) + } + + #[must_use] + pub fn supports_capability(&self, capability: &str, version: u32) -> bool { + self.capabilities + .iter() + .any(|entry| entry.capability == capability && entry.versions.contains(&version)) + } + + #[must_use] + pub fn supports_permission(&self, permission: &str) -> bool { + self.permissions.iter().any(|entry| entry == permission) + } +} + +fn valid_versions(versions: &[u32]) -> bool { + !versions.is_empty() + && versions.len() <= 16 + && versions.iter().all(|version| *version > 0) + && versions.iter().collect::>().len() == versions.len() +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-') + }) +} + +pub(crate) fn valid_plugin_id(value: &str) -> bool { + if value.len() > 64 { + return false; + } + let Some((publisher, name)) = value.split_once('.') else { + return false; + }; + valid_plugin_segment(publisher) && valid_plugin_segment(name) +} + +fn valid_plugin_segment(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .as_bytes() + .last() + .is_some_and(u8::is_ascii_alphanumeric) + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +/// 已安装包对目标宿主的静态要求;不包含包体、配置或 secret。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginCompatibilityRequirements { + pub host_version: String, + pub manifest_schema_version: u32, + pub protocol_version: u32, + pub capabilities: Vec<(String, u32)>, + pub permissions: Vec, +} + +/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginSourceEgress { + pub id: String, + pub revision: u64, +} + +/// 安装来源只保存公开定位信息,下载凭据和出站代理通过独立记录引用。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PluginSource { + Builtin { + release: String, + }, + Upload, + Url { + url: String, + credential_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + outbound_proxy: Option, + }, + Github { + repository: String, + tag: String, + asset: String, + credential_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + outbound_proxy: Option, + }, +} + +impl PluginSource { + #[must_use] + pub fn credential_ids(&self) -> &[String] { + match self { + Self::Url { credential_ids, .. } | Self::Github { credential_ids, .. } => { + credential_ids + } + Self::Builtin { .. } | Self::Upload => &[], + } + } + + #[must_use] + pub const fn outbound_proxy(&self) -> Option<&PluginSourceEgress> { + match self { + Self::Url { outbound_proxy, .. } | Self::Github { outbound_proxy, .. } => { + outbound_proxy.as_ref() + } + Self::Builtin { .. } | Self::Upload => None, + } + } +} + +/// 管理端保留的单项贡献声明;map key 是能力标识,`id` 是实例绑定引用的扩展项身份。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginContribution { + pub id: String, + pub version: u32, + pub stages: Vec, + pub input_formats: Vec, + pub output_formats: Vec, +} + +/// 制品清单中的展示图标;路径只用于选择已校验的包资源。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginArtifactIcon { + Path(String), + Themed(PluginArtifactIconVariants), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginArtifactIconVariants { + pub light: String, + pub dark: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginIconTheme { + Light, + Dark, +} + +/// 从不可变制品中读取的已校验图标字节。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginArtifactIconResource { + pub content_type: String, + pub body: Vec, +} + +/// 管理端使用的静态描述,不承载 SDK 的运行时消息或宿主实现类型。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginPermissionDescription { + pub permission: String, + pub label: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginArtifactMetadata { + pub plugin_id: String, + pub version: String, + pub name: String, + pub display_name: String, + pub publisher: String, + pub author: Option, + pub description: String, + pub license: String, + pub sha256: String, + pub platforms: Vec, + #[serde(default)] + pub icon: Option, + pub contributes: BTreeMap, + pub requested_permissions: Vec, + pub configuration_schema: serde_json::Value, + pub secret_fields: Vec, + #[serde(default)] + pub state_namespaces: Vec, +} + +/// 由包检查端口产生的不可变制品;不派生 Debug,包内可能含敏感用户数据。 +#[derive(Clone)] +pub struct InspectedPluginArtifact { + pub metadata: PluginArtifactMetadata, + pub archive: Arc<[u8]>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct InstalledPluginArtifact { + pub metadata: PluginArtifactMetadata, + pub source: PluginSource, + pub installed_at: chrono::DateTime, + pub accepted_at: Option>, +} + +pub struct PluginArtifactMutation { + pub config_revision: Revision, + pub artifact: InstalledPluginArtifact, +} + +pub struct PluginInstallResult { + pub mutation: PluginArtifactMutation, + pub default_instance_id: Option, + pub configuration_required: bool, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/official.rs b/backend/crates/gateway-admin/src/model/plugins/official.rs new file mode 100644 index 000000000..ca07bc5b3 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/official.rs @@ -0,0 +1,15 @@ +use super::super::Revision; + +/// 编译进宿主二进制的发行身份;不能由运行时配置或插件清单覆盖。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OfficialPluginReleaseIdentity { + pub gateway_version: String, + pub gateway_git_sha: String, +} + +/// 启动时导入官方制品的结果;导入只持久化制品,不创建或启用实例。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OfficialPluginImport { + pub artifacts: usize, + pub config_revision: Option, +} diff --git a/backend/crates/gateway-admin/src/model/plugins/state.rs b/backend/crates/gateway-admin/src/model/plugins/state.rs new file mode 100644 index 000000000..08dcce3e6 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/plugins/state.rs @@ -0,0 +1,205 @@ +use std::{collections::BTreeMap, fmt}; + +use serde::{Deserialize, Serialize}; + +use crate::model::Revision; + +/// 已由 Runtime 校验的命名空间 schema 与配额事实。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginStateSchema { + pub namespace: String, + pub schema_version: u32, + pub schema_sha256: String, + pub schema: serde_json::Value, + pub maximum_records: u32, + pub maximum_bytes: u64, + pub maximum_value_bytes: u32, + #[serde(default)] + pub migrates_from: Vec, +} + +impl fmt::Debug for PluginStateSchema { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PluginStateSchema") + .field("namespace", &self.namespace) + .field("schema_version", &self.schema_version) + .field("schema_sha256", &self.schema_sha256) + .field("schema", &"[REDACTED]") + .field("maximum_records", &self.maximum_records) + .field("maximum_bytes", &self.maximum_bytes) + .field("maximum_value_bytes", &self.maximum_value_bytes) + .field("migrates_from", &self.migrates_from) + .finish() + } +} + +#[derive(Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginStateConfiguration { + #[serde(default)] + pub namespaces: Vec, +} + +/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。 +#[derive(Clone, Default)] +pub struct PluginStateCommit { + pub configuration: PluginStateConfiguration, + pub transition_id: Option, +} + +#[derive(Clone)] +pub struct PluginStateOwnerRequest { + pub instance_id: String, + pub artifact_sha256: String, + pub instance_revision: Revision, + pub configuration: PluginStateConfiguration, +} + +/// Store 签发的实例私有状态 fence。插件协议中从不传输这些字段。 +#[derive(Clone)] +pub struct PluginStateOwner { + instance_id: String, + artifact_sha256: String, + instance_revision: Revision, + namespaces: BTreeMap, +} + +#[derive(Clone)] +pub struct PluginStateNamespaceOwner { + generation_id: String, + fence: String, + schema_version: u32, +} + +impl PluginStateOwner { + #[must_use] + pub fn from_store( + instance_id: String, + artifact_sha256: String, + instance_revision: Revision, + namespaces: BTreeMap, + ) -> Self { + Self { + instance_id, + artifact_sha256, + instance_revision, + namespaces, + } + } + + #[must_use] + pub fn instance_id(&self) -> &str { + &self.instance_id + } + + #[must_use] + pub fn artifact_sha256(&self) -> &str { + &self.artifact_sha256 + } + + #[must_use] + pub const fn instance_revision(&self) -> Revision { + self.instance_revision + } + + #[must_use] + pub fn namespace(&self, namespace: &str) -> Option<&PluginStateNamespaceOwner> { + self.namespaces.get(namespace) + } +} + +impl PluginStateNamespaceOwner { + #[must_use] + pub fn from_store(generation_id: String, fence: String, schema_version: u32) -> Self { + Self { + generation_id, + fence, + schema_version, + } + } + + #[must_use] + pub fn generation_id(&self) -> &str { + &self.generation_id + } + + #[must_use] + pub fn fence(&self) -> &str { + &self.fence + } + + #[must_use] + pub const fn schema_version(&self) -> u32 { + self.schema_version + } +} + +/// 私有值可能含插件自己的敏感状态,故意不实现 `Debug`。 +#[derive(Clone)] +pub struct PluginStateRecord { + pub key: String, + pub value: serde_json::Value, + pub version: u64, + pub schema_version: u32, +} + +pub struct PutPluginState { + pub namespace: String, + pub key: String, + pub value: serde_json::Value, + /// `None` 是 create-only;已有记录必须提交精确版本。 + pub expected_version: Option, +} + +pub struct DeletePluginState { + pub namespace: String, + pub key: String, + pub expected_version: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginStateWrite { + pub version: u64, +} + +#[derive(Clone)] +pub struct PluginStateTransition { + pub id: String, + pub instance_id: String, + pub artifact_sha256: String, + pub namespaces: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginStateMigrationNamespace { + pub namespace: String, + pub from_schema_version: u32, + pub to_schema_version: u32, +} + +pub struct PluginStateMigrationBatch { + pub cursor: Option, + pub records: Vec, +} + +pub struct ApplyPluginStateMigration { + pub transition_id: String, + pub namespace: String, + pub cursor: Option, + /// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。 + pub expected_keys: Vec, + pub changes: Vec, +} + +pub struct PluginStateMigrationChange { + pub key: String, + pub action: PluginStateMigrationAction, +} + +pub enum PluginStateMigrationAction { + Keep, + Replace(serde_json::Value), + Delete, +} diff --git a/backend/crates/gateway-admin/src/model/provider_capabilities.rs b/backend/crates/gateway-admin/src/model/provider_capabilities.rs new file mode 100644 index 000000000..7614cd262 --- /dev/null +++ b/backend/crates/gateway-admin/src/model/provider_capabilities.rs @@ -0,0 +1,42 @@ +//! 账号页面消费的能力描述;支持声明不代替实际操作时的账号范围与权限校验。 + +use gateway_core::routing::ProviderKind; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderCredentialDescriptor { + pub provider: ProviderKind, + pub capabilities: ProviderCredentialCapabilities, +} + +/// 当前账号可用的管理能力,由已发布 Provider 投影;不代替执行时的权限与身份校验。 +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ProviderAccountCapabilities { + pub quota: bool, + pub quota_refresh: bool, + pub profile: bool, + pub subscription: bool, + pub avatar: bool, + pub reset_credits: bool, + pub consume_reset_credit: bool, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ProviderCredentialCapabilities { + pub import: Option, + pub login: Option, + pub refresh: bool, + pub export: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderLoginCapability { + pub input_schema: Value, + pub completion: AuthorizationCompletion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationCompletion { + Callback, + Poll, +} diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index f3cb67400..58ed381c6 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -15,7 +15,7 @@ use serde_json::{Map, Value}; use uuid::Uuid; use super::{ - AdminError, MutationActor, MutationContext, Revision, + AdminError, MutationActor, MutationContext, PageSize, Revision, accounts::{ AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState, }, @@ -384,13 +384,19 @@ enum StoredAuthorizationOwnerV1 { System, } -/// 启动 Provider OAuth Authorization Code 流程。 -#[derive(Debug, Clone, PartialEq, Eq)] +/// 启动 Provider 登录;输入仅用于本次准备,不进入宿主的持久事务目标。 +#[derive(Debug, Clone, PartialEq)] pub struct StartAuthorization { pub context: MutationContext, pub name: String, pub reauthorization: Option, pub outbound_proxy: Option, + pub input: ProviderDocument, +} + +pub struct PrepareAuthorization { + pub pending: PendingAuthorizationMutation, + pub input: ProviderDocument, } /// OAuth 流程启动结果。 @@ -425,8 +431,8 @@ pub trait AuthorizationCommitGuard: Send + 'static { /// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。 pub enum PreparedAuthorizationCredential { - Create(PreparedCredentialCreate), - Reauthorize(PreparedCredentialRotation), + Create(Vec), + Reauthorize(Box), } /// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。 @@ -449,18 +455,131 @@ impl fmt::Debug for PreparedAuthorizationCommit { /// Store 可持久化的 OAuth credential facts,不携带 Provider guard。 #[derive(Debug, Clone, PartialEq)] pub enum AuthorizationCredentialCommit { - Create(PreparedCredentialCreate), - Reauthorize(PreparedCredentialRotationFacts), + Create(Vec), + Reauthorize(Box), } /// Admin 交给 Store 的 OAuth 原子事务命令。 #[derive(Debug, Clone, PartialEq)] pub struct AuthorizationCommit { + pub key: AuthorizationReceiptKey, pub settings: Option, pub pending: PendingAuthorizationMutation, pub credential: AuthorizationCredentialCommit, } +/// 回执以 Provider、授权流和管理员身份绑定;不持久化原始 flow 或回调材料。 +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct AuthorizationReceiptKey { + provider_kind: ProviderKind, + flow_digest: String, + owner_digest: String, +} + +impl AuthorizationReceiptKey { + pub fn new( + provider_kind: ProviderKind, + flow: &str, + context: &MutationContext, + ) -> Result { + use sha2::{Digest as _, Sha256}; + if flow.trim().is_empty() || flow.len() > 1024 || flow.chars().any(char::is_control) { + return Err(AdminError::invalid("授权流程标识无效")); + } + Ok(Self { + provider_kind, + flow_digest: hex::encode(Sha256::digest(flow.as_bytes())), + owner_digest: authorization_owner_digest(context), + }) + } + + #[must_use] + pub const fn provider_kind(&self) -> &ProviderKind { + &self.provider_kind + } + + #[must_use] + pub fn flow_digest(&self) -> &str { + &self.flow_digest + } + + #[must_use] + pub fn owner_digest(&self) -> &str { + &self.owner_digest + } + + #[must_use] + pub fn matches_context(&self, context: &MutationContext) -> bool { + authorization_owner_digest(context) == self.owner_digest + } +} + +fn authorization_owner_digest(context: &MutationContext) -> String { + use sha2::{Digest as _, Sha256}; + let owner = match &context.actor { + MutationActor::AdminSession { admin_user_id } => format!("admin_session:{admin_user_id}"), + MutationActor::AdminApiKey => "admin_api_key".into(), + MutationActor::System => "system".into(), + }; + hex::encode(Sha256::digest(owner.as_bytes())) +} + +impl fmt::Debug for AuthorizationReceiptKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationReceiptKey([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizationCommitResult { + pub result: CredentialBatchMutationResult, + pub newly_committed: bool, +} + +/// 同一授权事务产生的账号集合;重新授权的集合始终只有原账号。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialBatchMutationResult { + pub config_revision: Revision, + pub accounts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthorizedAccount { + pub account_id: ProviderAccountId, + pub credential_revision: Option, +} + +impl From for CredentialBatchMutationResult { + fn from(result: CredentialMutationResult) -> Self { + Self { + config_revision: result.config_revision, + accounts: vec![AuthorizedAccount { + account_id: result.account_id, + credential_revision: result.credential_revision, + }], + } + } +} + +/// 轮询可以附带浏览器返回值;敏感材料不能进入 Debug 或通用审计。 +pub struct PollAuthorization { + pub context: MutationContext, + pub flow_id: String, + pub callback_url: Option, + pub settings: Option, +} + +pub enum PreparedAuthorizationPoll { + Pending { retry_after: std::time::Duration }, + Complete(Box), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizationPollResult { + Pending { retry_after: std::time::Duration }, + Complete(CredentialBatchMutationResult), +} + /// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。 /// /// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并 @@ -494,6 +613,7 @@ impl PreparedAuthorizationCommit { pub(crate) fn into_commit( self, settings: Option, + key: AuthorizationReceiptKey, ) -> AuthorizationCommitSettlement { let (credential, guard) = match self.credential { PreparedAuthorizationCredential::Create(credential) => { @@ -502,13 +622,14 @@ impl PreparedAuthorizationCommit { PreparedAuthorizationCredential::Reauthorize(prepared) => { let (facts, guard) = prepared.into_parts(); ( - AuthorizationCredentialCommit::Reauthorize(facts), + AuthorizationCredentialCommit::Reauthorize(Box::new(facts)), Some(guard), ) } }; AuthorizationCommitSettlement { command: AuthorizationCommit { + key, settings, pending: self.pending, credential, @@ -519,13 +640,8 @@ impl PreparedAuthorizationCommit { } pub(crate) async fn abort(self) -> Result<(), AdminError> { - let AuthorizationCommitSettlement { - credential_guard, - authorization_guard, - .. - } = self.into_commit(None); - drop(credential_guard); - if let Some(guard) = authorization_guard { + drop(self.credential); + if let Some(guard) = self.authorization_guard { guard.abort().await?; } Ok(()) @@ -1060,6 +1176,62 @@ pub struct ProviderModels { pub observed_at: Option>, } +/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginAccountListQuery { + pub provider_kind: Option, + pub cursor: Option, + pub limit: PageSize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginAccountPage { + pub accounts: Vec, + pub next_cursor: Option, +} + +/// 原始凭据与当前账号 revision 的同一读取结果。 +#[derive(Clone, PartialEq)] +pub struct PluginAccountCredential { + pub account: AccountRecord, + pub provider_material: ProviderDocument, +} + +impl fmt::Debug for PluginAccountCredential { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PluginAccountCredential") + .field("account", &self.account) + .field("provider_material", &self.provider_material) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginAccountSaveReason { + Explicit, + ModelDiscovery, + Maintenance, +} + +/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。 +#[derive(Debug, Clone, PartialEq)] +pub enum PreparedPluginAccountSave { + Create(PreparedCredentialCreate), + Replace { + facts: PreparedCredentialRotationFacts, + authentication_kind: String, + reason: PluginAccountSaveReason, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginAccountSaveResult { + pub config_revision: Revision, + pub account_id: ProviderAccountId, + pub credential_revision: Revision, +} + /// Provider 执行 refresh 时所需的当前公共账号事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct PrepareCredentialRefresh { @@ -1095,6 +1267,7 @@ impl fmt::Debug for ProviderExport { #[derive(Debug, Clone, PartialEq)] pub struct AccountDirectoryItem { pub account: AccountRecord, + pub capabilities: super::provider_capabilities::ProviderAccountCapabilities, /// Provider 提供的套餐展示名称;未识别到套餐时为空。 pub plan_type_display: Option, pub projection: gateway_core::account::AccountStatusProjection, @@ -1109,6 +1282,7 @@ pub struct AccountDirectoryPage { pub items: Vec, pub total: u64, pub summary: AccountSummary, + pub providers: Vec, } /// 凭据刷新提交后的完整账号结果。 diff --git a/backend/crates/gateway-admin/src/model/quota_forecast.rs b/backend/crates/gateway-admin/src/model/quota_forecast.rs index f4ba91522..4286fe547 100644 --- a/backend/crates/gateway-admin/src/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/src/model/quota_forecast.rs @@ -43,6 +43,19 @@ pub struct QuotaForecastSource { pub usd: Option, } +/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。 +/// +/// Admin 的历史查询与最终投影必须共用这里的顺序,避免查询不会进入响应的窗口。 +pub(crate) fn quota_forecast_source_window( + quota: &ProviderQuota, + period: AccountUsagePeriod, +) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> { + quota + .usage_windows() + .find(|(_, source_period)| *source_period == period) + .or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period)) +} + /// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。 /// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。 #[must_use] @@ -53,10 +66,7 @@ pub fn account_quota_forecasts( samples: &[QuotaForecastSample], ) -> [AccountQuotaForecast; 2] { [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly].map(|period| { - let selected = quota - .usage_windows() - .find(|(_, source_period)| *source_period == period) - .or_else(|| quota.usage_windows().min_by_key(|(_, period)| *period)); + let selected = quota_forecast_source_window(quota, period); let mut forecast = AccountQuotaForecast { period, target_seconds: match period { diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index e49bec263..9d38b9d03 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -4,21 +4,28 @@ use std::{collections::BTreeMap, fmt}; use chrono::{DateTime, Utc}; -use gateway_core::routing::{PublicModelId, UpstreamModelId}; +use gateway_core::routing::{ProviderKind, PublicModelId, UpstreamModelId}; use super::Revision; /// 客户端模型到上游模型的全局精确映射。 pub type ModelMappings = BTreeMap; +/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。 +pub type ProviderRequestProfiles = + BTreeMap; + +/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。 +pub type ProviderRequestProfileUpdates = + BTreeMap>; + /// 账号调度策略;由 Core 拥有稳定值与 wire 映射。 pub use gateway_core::account::RotationStrategy; /// 完整运行设置事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeSettings { - pub openai_client_profile: Option, - pub xai_client_profile: Option, + pub request_profiles: ProviderRequestProfiles, pub config_revision: Revision, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, @@ -50,8 +57,8 @@ pub struct RuntimeSettings { /// 原子替换运行设置的命令。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReplaceRuntimeSettings { - pub openai_client_profile: Option, - pub xai_client_profile: Option, + /// 只覆盖提交的 Provider;未提交项保留当前持久值。 + pub request_profile_updates: ProviderRequestProfileUpdates, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, pub model_mappings: ModelMappings, @@ -134,10 +141,8 @@ impl RuntimeSettings { &self, provider: &str, ) -> Option<&gateway_core::account::OpaqueProviderData> { - match provider { - "openai" => self.openai_client_profile.as_ref(), - "xai" => self.xai_client_profile.as_ref(), - _ => None, - } + ProviderKind::new(provider.to_owned()) + .ok() + .and_then(|provider| self.request_profiles.get(&provider)) } } diff --git a/backend/crates/gateway-admin/src/ports/mod.rs b/backend/crates/gateway-admin/src/ports/mod.rs index 0b1539fd3..8b8cd3989 100644 --- a/backend/crates/gateway-admin/src/ports/mod.rs +++ b/backend/crates/gateway-admin/src/ports/mod.rs @@ -2,8 +2,14 @@ pub mod backup; pub mod client_distribution; +pub mod plugin_accounts; +pub mod plugin_client_keys; +pub mod plugin_management; +pub mod plugin_release; +pub mod plugins; pub mod pricing; pub mod provider; +pub mod provider_extensions; pub mod proxy; pub mod store; pub mod system; diff --git a/backend/crates/gateway-admin/src/ports/plugin_accounts.rs b/backend/crates/gateway-admin/src/ports/plugin_accounts.rs new file mode 100644 index 000000000..1c1586f36 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/plugin_accounts.rs @@ -0,0 +1,34 @@ +//! 插件宿主访问账号权威所需的窄端口。 + +use async_trait::async_trait; +use gateway_core::account::ProviderAccountId; + +use crate::model::{ + AdminError, MutationContext, + provider_credentials::{ + PluginAccountCredential, PluginAccountListQuery, PluginAccountPage, + PluginAccountSaveResult, PreparedPluginAccountSave, + }, +}; + +/// Runtime 只取得账号读写用例,不接触 Store、SQL 或完整管理服务集合。 +#[async_trait] +pub trait PluginAccountAccess: Send + Sync { + async fn list(&self, query: PluginAccountListQuery) -> Result; + + async fn get_runtime( + &self, + account_id: &ProviderAccountId, + ) -> Result; + + async fn get_credential( + &self, + account_id: &ProviderAccountId, + ) -> Result; + + async fn save( + &self, + command: PreparedPluginAccountSave, + context: &MutationContext, + ) -> Result; +} diff --git a/backend/crates/gateway-admin/src/ports/plugin_client_keys.rs b/backend/crates/gateway-admin/src/ports/plugin_client_keys.rs new file mode 100644 index 000000000..96e6e0ea0 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/plugin_client_keys.rs @@ -0,0 +1,16 @@ +//! 插件发现 Client Key 公开身份的窄端口。 + +use async_trait::async_trait; + +use crate::model::{ + AdminError, + plugin_client_keys::{PluginClientKeyListQuery, PluginClientKeyPage}, +}; + +#[async_trait] +pub trait PluginClientKeyAccess: Send + Sync { + async fn list( + &self, + query: PluginClientKeyListQuery, + ) -> Result; +} diff --git a/backend/crates/gateway-admin/src/ports/plugin_management.rs b/backend/crates/gateway-admin/src/ports/plugin_management.rs new file mode 100644 index 000000000..7bb5d4034 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/plugin_management.rs @@ -0,0 +1,57 @@ +use async_trait::async_trait; +use gateway_core::runtime::extensions::ExtensionSetReference; + +use crate::model::{ + AdminError, + plugins::management::{ + PluginManagementRequest, PluginManagementResponse, PluginManagementTarget, + PluginManagementView, + }, +}; + +/// 仅按 Admin 固定的发布引用分派;Runtime 不自行选择当前代次,也不拥有管理员身份。 +#[async_trait] +pub trait PluginManagement: Send + Sync { + /// 复核固定发布代次中的模型访问域;调用方已校验持久实例仍为当前目标。 + async fn authorize_models( + &self, + published: &ExtensionSetReference, + target: &PluginManagementTarget, + ) -> Result<(), AdminError>; + + async fn start_callback( + &self, + published: &ExtensionSetReference, + target: &PluginManagementTarget, + command: crate::model::plugins::management::StartPluginManagementCallback, + context: &crate::model::auth::AdminRequestContext, + ) -> Result; + + async fn callback( + &self, + published: &ExtensionSetReference, + target: &PluginManagementTarget, + state: &str, + request: PluginManagementRequest, + ) -> Result; + + async fn views( + &self, + published: &ExtensionSetReference, + ) -> Result, AdminError>; + + async fn resource( + &self, + published: &ExtensionSetReference, + target: &PluginManagementTarget, + path: &str, + public: bool, + ) -> Result; + + async fn handle( + &self, + published: &ExtensionSetReference, + target: &PluginManagementTarget, + request: PluginManagementRequest, + ) -> Result; +} diff --git a/backend/crates/gateway-admin/src/ports/plugin_release.rs b/backend/crates/gateway-admin/src/ports/plugin_release.rs new file mode 100644 index 000000000..1d9b5c3c6 --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/plugin_release.rs @@ -0,0 +1,37 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfficialPluginReleaseReadErrorKind { + Invalid, + NotFound, + Unavailable, +} + +/// 文件系统错误不携带部署路径,避免把宿主布局传播到管理域和日志之外。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("official plugin release files are unavailable")] +pub struct OfficialPluginReleaseReadError { + kind: OfficialPluginReleaseReadErrorKind, +} + +impl OfficialPluginReleaseReadError { + #[must_use] + pub const fn new(kind: OfficialPluginReleaseReadErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(self) -> OfficialPluginReleaseReadErrorKind { + self.kind + } +} + +/// Host 只暴露与宿主发行物同信任边界的只读文件,不解释官方身份或插件包。 +#[async_trait] +pub trait OfficialPluginReleaseFiles: Send + Sync { + async fn manifest(&self) -> Result>, OfficialPluginReleaseReadError>; + + async fn artifact(&self, file_name: &str) -> Result, OfficialPluginReleaseReadError>; +} diff --git a/backend/crates/gateway-admin/src/ports/plugins.rs b/backend/crates/gateway-admin/src/ports/plugins.rs new file mode 100644 index 000000000..c6b4ef84b --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/plugins.rs @@ -0,0 +1,331 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; + +use super::store::AdminStoreResult; +use crate::model::plugins::distribution::{ + DownloadedPlugin, GithubReleaseQuery, PluginDistributionEgress, PluginRelease, + PluginSourceBinding, PluginUpdateSource, RemotePluginLocation, SourceCredential, + SourceCredentialInfo, +}; +use crate::model::plugins::instances::{ + PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, PluginInstanceRuntime, + PluginInstanceSnapshot, PluginVersionConfiguration, +}; +use crate::model::plugins::state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, PluginStateConfiguration, + PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, PluginStateRecord, + PluginStateTransition, PluginStateWrite, PutPluginState, +}; +use crate::model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation, + PluginCompatibilityRequirements, PluginSource, + }, +}; +use gateway_core::runtime::extensions::ExtensionSetReference; + +/// Admin 准备完整候选并保活到提交后的发布结束;进程状态不能代替持久启用状态。 +#[async_trait] +pub trait PluginPreparation: Send + Sync { + /// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。 + async fn configuration_ready( + &self, + instance: PluginInstance, + metadata: &crate::model::plugins::PluginArtifactMetadata, + ) -> Result; + async fn validate( + &self, + instance: PluginInstance, + ) -> Result; + /// 候选可使用未来 revision;其依赖的持久化事实必须按当前 source revision 读取。 + /// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。 + async fn prepare( + &self, + source_revision: Revision, + snapshot: PluginInstanceSnapshot, + ) -> Result; + /// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。 + async fn runtime_diagnostics( + &self, + _snapshot: &PluginInstanceSnapshot, + _published_revision: Option, + _published: Option<&ExtensionSetReference>, + ) -> Option> { + None + } + async fn activate_state( + &self, + _prepared: &ExtensionSetReference, + _instance: &PluginInstance, + ) -> Result<(), AdminError> { + Ok(()) + } + async fn quiesce_instance( + &self, + _instance_id: &str, + _artifact_sha256: &str, + _revision: Revision, + ) { + } + async fn migrate_state( + &self, + _prepared: &ExtensionSetReference, + _transition: PluginStateTransition, + ) -> Result<(), AdminError> { + Err(AdminError::unavailable("插件状态迁移运行时不可用")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginStateStoreErrorKind { + Invalid, + NotFound, + Conflict, + Quota, + PermissionDenied, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("plugin state operation failed")] +pub struct PluginStateStoreError { + kind: PluginStateStoreErrorKind, +} + +impl PluginStateStoreError { + #[must_use] + pub const fn new(kind: PluginStateStoreErrorKind) -> Self { + Self { kind } + } + + #[must_use] + pub const fn kind(&self) -> PluginStateStoreErrorKind { + self.kind + } +} + +pub type PluginStateStoreResult = Result; + +/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。 +#[async_trait] +pub trait PluginStateStore: Send + Sync { + async fn load_owner( + &self, + request: PluginStateOwnerRequest, + ) -> PluginStateStoreResult>; + async fn get( + &self, + owner: &PluginStateOwner, + namespace: &str, + key: &str, + ) -> PluginStateStoreResult>; + async fn put( + &self, + owner: &PluginStateOwner, + command: PutPluginState, + ) -> PluginStateStoreResult; + async fn delete( + &self, + owner: &PluginStateOwner, + command: DeletePluginState, + ) -> PluginStateStoreResult; + async fn transition_required( + &self, + instance_id: &str, + target: &PluginStateConfiguration, + ) -> PluginStateStoreResult; + async fn begin_transition( + &self, + instance_id: &str, + expected_instance_revision: Revision, + artifact_sha256: &str, + target: PluginStateConfiguration, + ) -> PluginStateStoreResult; + async fn migration_batch( + &self, + transition_id: &str, + namespace: &str, + maximum_records: u32, + ) -> PluginStateStoreResult; + async fn apply_migration_batch( + &self, + command: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()>; + async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()>; +} + +/// Host 拥有联网、重定向、凭据作用域、限流与有界下载,不解释插件包。 +#[async_trait] +pub trait PluginDistribution: Send + Sync { + fn validate_source(&self, source: &PluginUpdateSource) -> Result<(), AdminError>; + fn validate_credential(&self, credential: &SourceCredential) -> Result<(), AdminError>; + async fn query_release( + &self, + query: GithubReleaseQuery, + credentials: Vec, + egress: Option, + ) -> Result; + async fn download( + &self, + location: RemotePluginLocation, + credentials: Vec, + egress: Option, + ) -> Result; +} + +/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。 +#[async_trait] +pub trait PluginPackageInspector: Send + Sync { + /// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。 + fn permission_descriptions( + &self, + permissions: &[String], + ) -> Vec { + permissions + .iter() + .map( + |permission| crate::model::plugins::PluginPermissionDescription { + permission: permission.clone(), + label: permission.clone(), + description: String::new(), + }, + ) + .collect() + } + + async fn inspect( + &self, + archive: Arc<[u8]>, + expected_sha256: Option, + ) -> Result; + + /// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。 + async fn icon( + &self, + _archive: Arc<[u8]>, + _expected_sha256: String, + _theme: crate::model::plugins::PluginIconTheme, + ) -> Result, AdminError> { + Err(AdminError::unavailable("插件图标读取不可用")) + } + + /// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。 + async fn compatibility( + &self, + _archive: Arc<[u8]>, + _expected_sha256: String, + ) -> Result { + Err(AdminError::unavailable("插件兼容性检查不可用")) + } +} + +#[async_trait] +pub trait PluginStore: Send + Sync { + /// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。 + async fn management_target_is_current( + &self, + target: &crate::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult; + async fn load_instances(&self) -> AdminStoreResult; + /// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。 + async fn load_version_configuration( + &self, + _id: &str, + _digest: &str, + ) -> AdminStoreResult> { + Ok(None) + } + /// 只返回有可恢复配置的制品摘要,不读取敏感值。 + async fn configuration_versions(&self, _id: &str) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn save_instance( + &self, + instance: PluginInstance, + expected_revision: Revision, + context: &MutationContext, + ) -> AdminStoreResult; + async fn save_instance_with_state( + &self, + instance: PluginInstance, + expected_revision: Revision, + state: PluginStateCommit, + context: &MutationContext, + ) -> AdminStoreResult { + if !state.configuration.namespaces.is_empty() || state.transition_id.is_some() { + return Err(super::store::AdminStoreError::new( + super::store::AdminStoreErrorKind::Unavailable, + "plugin state", + "plugin state store is unavailable", + )); + } + self.save_instance(instance, expected_revision, context) + .await + } + /// 保存目标并停用明确确认的配置,必须共享事务和配置版本检查。 + async fn save_instance_replacing( + &self, + instance: PluginInstance, + expected_revision: Revision, + state: PluginStateCommit, + replacements: &[PluginInstanceReplacement], + context: &MutationContext, + ) -> AdminStoreResult { + if !replacements.is_empty() { + return Err(super::store::AdminStoreError::new( + super::store::AdminStoreErrorKind::Unavailable, + "plugin", + "atomic plugin configuration switching is unavailable", + )); + } + self.save_instance_with_state(instance, expected_revision, state, context) + .await + } + + async fn delete_instance( + &self, + id: &str, + expected_revision: Revision, + context: &MutationContext, + ) -> AdminStoreResult; + async fn list_update_sources(&self) -> AdminStoreResult>; + async fn change_update_source( + &self, + binding: PluginSourceBinding, + context: &MutationContext, + ) -> AdminStoreResult; + async fn list_source_credentials(&self) -> AdminStoreResult>; + async fn load_source_credential(&self, id: &str) -> AdminStoreResult; + async fn save_source_credential( + &self, + credential: SourceCredential, + context: &MutationContext, + ) -> AdminStoreResult; + async fn delete_source_credential( + &self, + id: &str, + context: &MutationContext, + ) -> AdminStoreResult; + async fn list_artifacts(&self) -> AdminStoreResult>; + async fn load_artifact(&self, digest: &str) -> AdminStoreResult; + async fn install_artifact( + &self, + artifact: InspectedPluginArtifact, + source: PluginSource, + context: &MutationContext, + ) -> AdminStoreResult; + /// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。 + async fn accept_artifact( + &self, + digest: &str, + context: &MutationContext, + ) -> AdminStoreResult; + /// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。 + async fn delete_artifact( + &self, + digest: &str, + context: &MutationContext, + ) -> AdminStoreResult; +} diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index a13f0de48..0bc7452aa 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -15,11 +15,11 @@ use crate::model::observability::{ }; use crate::model::provider_credentials::{ AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit, - PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh, - PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport, - PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels, - ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, - ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type, + PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation, + PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation, + ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderProfileAvatar, + ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, + ProviderResetCredits, ProviderSubscription, explicit_plan_type, }; use crate::model::{ provider_credentials::{ProviderDocument, ProviderQuotaWindow}, @@ -117,6 +117,21 @@ pub trait ProviderAdmin: Send + Sync { fn provider_kind(&self) -> &ProviderKind; + fn credential_capabilities( + &self, + ) -> crate::model::provider_capabilities::ProviderCredentialCapabilities { + Default::default() + } + + /// 只读取当前代次的声明及账号类型,不执行网络或数据库查询。 + fn account_capabilities( + &self, + _account_id: &ProviderAccountId, + _authentication_kind: &str, + ) -> crate::model::provider_capabilities::ProviderAccountCapabilities { + Default::default() + } + /// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。 fn client_profile_options( &self, @@ -124,6 +139,11 @@ pub trait ProviderAdmin: Send + Sync { Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) } + /// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。 + fn default_client_profile(&self) -> Option { + None + } + /// 校验并投影客户端身份,结果不含认证或账号材料。 fn preview_client_profile( &self, @@ -150,7 +170,7 @@ pub trait ProviderAdmin: Send + Sync { async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {} /// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。 - fn connection_test_operation( + async fn connection_test_operation( &self, upstream_model: &UpstreamModelId, input_text: &str, @@ -180,7 +200,7 @@ pub trait ProviderAdmin: Send + Sync { async fn start_authorization( &self, - pending: PendingAuthorizationMutation, + command: crate::model::provider_credentials::PrepareAuthorization, ) -> Result; async fn complete_authorization( @@ -188,6 +208,14 @@ pub trait ProviderAdmin: Send + Sync { command: CompleteAuthorization, ) -> Result; + async fn poll_authorization( + &self, + _command: crate::model::provider_credentials::PollAuthorization, + ) -> Result + { + Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) + } + async fn prepare_rotation( &self, command: PrepareCredentialRotation, @@ -275,16 +303,69 @@ pub trait ProviderAdmin: Send + Sync { /// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。 #[derive(Clone)] pub struct ProviderAdminRegistry { - providers: Arc>>, + pub(super) providers: Arc>>, + pub(super) extensions: Option<( + super::provider_extensions::ProviderAdminExtensionIndex, + gateway_core::runtime::RuntimeSnapshotHandle, + )>, + pub(super) snapshot: Option>, } impl ProviderAdminRegistry { - #[must_use] - pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides { - self.providers + /// 返回当前冻结代次中真正提供客户端画像选项的 Provider。 + pub fn client_profile_providers(&self) -> Result, ProviderAdminError> { + let frozen = self.freeze()?; + let mut supported = Vec::new(); + for (kind, provider) in frozen.providers.iter() { + match provider.client_profile_options() { + Ok(_) => supported.push(kind.clone()), + Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => {} + Err(error) => return Err(error), + } + } + Ok(supported) + } + + /// 一次读取冻结整个目录,能力与执行器始终来自同一已发布代次。 + pub fn credential_descriptors( + &self, + ) -> Result< + Vec, + ProviderAdminError, + > { + Ok(self + .freeze()? + .providers + .iter() + .map(|(kind, provider)| { + crate::model::provider_capabilities::ProviderCredentialDescriptor { + provider: kind.clone(), + capabilities: provider.credential_capabilities(), + } + }) + .collect()) + } + + pub fn pricing_catalog( + &self, + ) -> Result { + Ok(self + .freeze()? + .providers .iter() .map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog())) - .collect() + .collect()) + } + + /// 一个管理用例可显式冻结目录,批量操作不会混入下一份配置。 + pub fn freeze(&self) -> Result { + let Some((index, snapshots)) = &self.extensions else { + return Ok(self.clone()); + }; + let snapshot = snapshots + .acquire() + .map_err(|_| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?; + index.resolve(snapshot) } /// 创建无重复 ProviderKind 的注册表。 @@ -304,17 +385,25 @@ impl ProviderAdminRegistry { } Ok(Self { providers: Arc::new(registered), + extensions: None, + snapshot: None, }) } pub fn require( &self, provider_kind: &ProviderKind, - ) -> Result, ProviderAdminError> { - self.providers - .get(provider_kind) - .cloned() - .ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) + ) -> Result { + if let Some(provider) = self.providers.get(provider_kind) { + return Ok(super::provider_extensions::ProviderAdminHandle { + provider: provider.clone(), + snapshot: self.snapshot.clone(), + }); + } + if self.extensions.is_some() { + return self.freeze()?.require(provider_kind); + } + Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) } /// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。 @@ -339,7 +428,7 @@ impl ProviderAdminRegistry { let plan_type = explicit_plan_type(plan_type)?.trim(); let provider = ProviderKind::new(provider_kind.to_owned()) .ok() - .and_then(|kind| self.providers.get(&kind)); + .and_then(|kind| self.require(&kind).ok()); let display = provider.map_or_else( || plan_type.to_owned(), |provider| provider.plan_type_display(plan_type), @@ -355,14 +444,16 @@ impl ProviderAdminRegistry { ProviderKind, gateway_core::account::OpaqueProviderData, >, - ) -> Vec { - self.providers + ) -> Result, ProviderAdminError> { + Ok(self + .freeze()? + .providers .iter() .filter_map(|(kind, provider)| match configurations.get(kind) { Some(configuration) => provider.configured_wire_profile(configuration), None => provider.dashboard_wire_profile(), }) - .collect() + .collect()) } /// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。 diff --git a/backend/crates/gateway-admin/src/ports/provider_extensions.rs b/backend/crates/gateway-admin/src/ports/provider_extensions.rs new file mode 100644 index 000000000..1a569466d --- /dev/null +++ b/backend/crates/gateway-admin/src/ports/provider_extensions.rs @@ -0,0 +1,112 @@ +//! 管理操作解析 Core 已发布的扩展集合;不持有独立的当前版本指针。 + +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock, Weak}, +}; + +use gateway_core::{ + routing::{ProviderKind, RuntimeSnapshot}, + runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetId}, +}; + +use super::provider::{ + ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry, +}; + +#[derive(Clone)] +pub struct ProviderAdminExtensionIndex { + base: Arc>>, + sets: Arc>>>, +} + +impl ProviderAdminExtensionIndex { + #[must_use] + pub fn new(base: ProviderAdminRegistry) -> Self { + Self { + base: base.providers, + sets: Arc::default(), + } + } + + pub fn register( + &self, + id: ExtensionSetId, + providers: impl IntoIterator>, + ) -> Result, ProviderAdminError> { + let registry = Arc::new(ProviderAdminRegistry::new( + self.base.values().cloned().chain(providers), + )?); + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, registry| registry.strong_count() != 0); + if sets.contains_key(&id) { + return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict)); + } + sets.insert(id, Arc::downgrade(®istry)); + Ok(registry) + } + + #[must_use] + pub fn registry(&self, snapshots: RuntimeSnapshotHandle) -> ProviderAdminRegistry { + ProviderAdminRegistry { + providers: self.base.clone(), + extensions: Some((self.clone(), snapshots)), + snapshot: None, + } + } + + pub(super) fn resolve( + &self, + snapshot: Arc, + ) -> Result { + let reference = snapshot + .extensions() + .ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?; + if !reference.can_serve() { + return Err(ProviderAdminError::new(ProviderAdminErrorKind::Unavailable)); + } + let registry = self + .sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(reference.id()) + .and_then(Weak::upgrade) + .ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unavailable))?; + Ok(ProviderAdminRegistry { + providers: registry.providers.clone(), + extensions: None, + snapshot: Some(snapshot), + }) + } +} + +/// 管理操作及其后台观测保活同一集合,避免发布切换在 await 期间终止旧进程。 +#[derive(Clone)] +pub struct ProviderAdminHandle { + pub(super) provider: Arc, + pub(super) snapshot: Option>, +} + +impl ProviderAdminHandle { + /// 管理准备与 Core 探测共享完整快照,不能在两次 await 之间重新解析插件版本。 + #[must_use] + pub fn snapshot(&self) -> Option> { + self.snapshot.clone() + } +} + +impl std::ops::Deref for ProviderAdminHandle { + type Target = dyn ProviderAdmin; + fn deref(&self) -> &Self::Target { + self.provider.as_ref() + } +} + +impl AsRef for ProviderAdminHandle { + fn as_ref(&self) -> &(dyn ProviderAdmin + 'static) { + self.provider.as_ref() + } +} diff --git a/backend/crates/gateway-admin/src/ports/store.rs b/backend/crates/gateway-admin/src/ports/store.rs index 59e4b48e3..bb440881c 100644 --- a/backend/crates/gateway-admin/src/ports/store.rs +++ b/backend/crates/gateway-admin/src/ports/store.rs @@ -32,7 +32,8 @@ use crate::model::{ }, provider_credentials::{ AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult, - CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput, + CredentialMutationResult, CredentialRotationCommit, PluginAccountListQuery, + PluginAccountPage, ProviderExportCredentialInput, }, quota_forecast_sampling::QuotaForecastHistory, settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings}, @@ -88,6 +89,12 @@ pub type AdminStoreResult = Result; /// 账号目录与公共账号写操作。 #[async_trait] pub trait AccountStore: Send + Sync { + /// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。 + async fn list_plugin_accounts( + &self, + query: PluginAccountListQuery, + ) -> AdminStoreResult; + async fn list_accounts( &self, query: AccountListQuery, @@ -123,12 +130,24 @@ pub trait AccountStore: Send + Sync { account_id: &gateway_core::account::ProviderAccountId, ) -> AdminStoreResult>; + /// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。 + async fn credential_details_by_id( + &self, + account_id: &gateway_core::account::ProviderAccountId, + ) -> AdminStoreResult>; + async fn load_credentials_for_export( &self, provider_kind: &gateway_core::routing::ProviderKind, account_ids: &[gateway_core::account::ProviderAccountId], ) -> AdminStoreResult>; + /// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。 + async fn load_credential_for_plugin( + &self, + account_id: &gateway_core::account::ProviderAccountId, + ) -> AdminStoreResult>; + async fn commit_credential_import( &self, command: CredentialImportCommit, @@ -139,7 +158,13 @@ pub trait AccountStore: Send + Sync { &self, command: AuthorizationCommit, context: &MutationContext, - ) -> AdminStoreResult; + ) -> AdminStoreResult; + + /// 已提交的授权结果独立于插件进程和 Redis 临时状态;只有原管理员身份可读。 + async fn authorization_receipt( + &self, + key: &crate::model::provider_credentials::AuthorizationReceiptKey, + ) -> AdminStoreResult>; async fn commit_credential_rotation( &self, @@ -396,6 +421,9 @@ pub trait ObservabilityStore: Send + Sync { async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult; + /// 区间内请求及错误事实的平台集合,不依赖当前注册或账号是否仍存在。 + async fn usage_provider_kinds(&self, range: TimeRange) -> AdminStoreResult>; + async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult; async fn usage_summary( @@ -488,9 +516,25 @@ pub struct AdminStorePorts { observability: Arc, settings: Arc, backup: BackupStorePorts, + plugins: Arc, + plugin_state: Arc, } impl AdminStorePorts { + #[must_use] + pub fn plugins(&self) -> Arc { + Arc::clone(&self.plugins) + } + + #[must_use] + pub fn plugin_state(&self) -> Arc { + Arc::clone(&self.plugin_state) + } + + #[expect( + clippy::too_many_arguments, + reason = "组合根需显式注入各领域窄端口,不能用服务定位器隐藏依赖" + )] #[must_use] pub fn new( accounts: AdminAccountStorePorts, @@ -499,6 +543,8 @@ impl AdminStorePorts { observability: Arc, settings: Arc, backup: BackupStorePorts, + plugins: Arc, + plugin_state: Arc, ) -> Self { Self { accounts, @@ -507,6 +553,8 @@ impl AdminStorePorts { observability, settings, backup, + plugins, + plugin_state, } } diff --git a/backend/crates/gateway-admin/src/ports/system.rs b/backend/crates/gateway-admin/src/ports/system.rs index 7196a89ce..69e274fd8 100644 --- a/backend/crates/gateway-admin/src/ports/system.rs +++ b/backend/crates/gateway-admin/src/ports/system.rs @@ -1,15 +1,33 @@ //! Host 为系统管理用例提供的进程与操作系统能力。 -use std::pin::Pin; +use std::{pin::Pin, sync::Arc}; use async_trait::async_trait; use futures::Stream; +use crate::model::Revision; use crate::model::system::{ SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus, SystemVersion, }; +/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据。 +#[derive(Clone)] +pub struct SystemUpdateCandidate { + pub target_version: String, + pub release_manifest: Arc<[u8]>, +} + +impl std::fmt::Debug for SystemUpdateCandidate { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SystemUpdateCandidate") + .field("target_version", &self.target_version) + .field("release_manifest_bytes", &self.release_manifest.len()) + .finish() + } +} + /// Host 系统操作失败类型。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SystemOperationErrorKind { @@ -51,6 +69,18 @@ impl SystemOperationError { /// 每个订阅者独占的系统事件流。 pub type SystemUpdateEventStream = Pin + Send + 'static>>; +/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。 +#[async_trait] +pub trait SystemUpdatePreflight: Send + Sync { + async fn validate( + &self, + candidate: SystemUpdateCandidate, + ) -> Result; + + /// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。 + async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>; +} + /// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。 #[async_trait] pub trait SystemOperations: Send + Sync { @@ -66,11 +96,15 @@ pub trait SystemOperations: Send + Sync { async fn perform_update( &self, target_version: Option, + preflight: Arc, ) -> Result; async fn update_status(&self) -> Result; - async fn rollback(&self) -> Result; + async fn rollback( + &self, + preflight: Arc, + ) -> Result; async fn restart(&self) -> Result; } diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 5ce26298b..389a2c367 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -1,6 +1,9 @@ //! 统一账号目录与跨 Provider 动态分派。 -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use async_trait::async_trait; use chrono::{Duration, Utc}; @@ -23,12 +26,14 @@ use crate::{ observability::TimeRange, provider_credentials::{ AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo, - AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh, - ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, - ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits, - QuotaLocalUsageAttribution, + AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit, + PrepareCredentialRefresh, ProviderModels, ProviderProfileAvatar, ProviderQuota, + ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult, + ProviderResetCredits, QuotaLocalUsageAttribution, + }, + quota_forecast::{ + AccountQuotaForecastReport, account_quota_forecasts, quota_forecast_source_window, }, - quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts}, quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample}, }, ports::{ @@ -210,7 +215,7 @@ impl DefaultAccountsService { ) -> Result< ( AccountPageItem, - Arc, + crate::ports::provider_extensions::ProviderAdminHandle, ), AdminError, > { @@ -314,14 +319,25 @@ impl DefaultAccountsService { .await .map_err(|error| map_store_error(error, "rolling account usage"))?; let rolling_usage = rolling_usage.into_iter().next(); - let mut quota = provider + let mut quota = match provider .quota(ProviderQuotaRequest { account_id: account_id.clone(), refresh: refresh_quota, rolling_usage: rolling_usage.clone(), }) .await - .map_err(|error| map_provider_error(error, "provider quota"))?; + { + Ok(quota) => quota, + // 凭据更新后的账号投影不要求 Provider 提供额度;显式刷新仍须支持该操作。 + Err(error) + if !refresh_quota + && error.kind() + == crate::ports::provider::ProviderAdminErrorKind::Unsupported => + { + empty_quota() + } + Err(error) => return Err(map_provider_error(error, "provider quota")), + }; let mut stored = if refresh_quota { self.load_account(account_id).await? } else { @@ -342,6 +358,8 @@ impl DefaultAccountsService { .and_then(|(window, _)| window.local_usage.clone()) }); Ok(AccountDirectoryItem { + capabilities: provider + .account_capabilities(account_id, &stored.account.authentication_kind), plan_type_display: self.providers.resolve_account_plan( stored.account.provider_kind.as_str(), &mut stored.account.plan_type, @@ -358,6 +376,10 @@ impl DefaultAccountsService { #[async_trait] impl AccountsService for DefaultAccountsService { async fn list(&self, query: AccountListQuery) -> Result { + let providers = self + .providers + .freeze() + .map_err(|error| map_provider_error(error, "provider directory"))?; let runtime = self .account_runtime .active_rate_limits() @@ -393,7 +415,7 @@ impl AccountsService for DefaultAccountsService { .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; // 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页: // 该账号降级为空额度投影,其余账号与页面状态照常返回。 - let provider = match self.providers.require(&account.provider_kind) { + let provider = match providers.require(&account.provider_kind) { Ok(provider) => provider, Err(error) => { tracing::warn!( @@ -434,13 +456,22 @@ impl AccountsService for DefaultAccountsService { .into_iter() .zip(quotas) .map(|(mut item, quota)| { + let id = ProviderAccountId::new(item.account.id.clone()) + .map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?; + let capabilities = providers + .require(&item.account.provider_kind) + .map(|provider| { + provider.account_capabilities(&id, &item.account.authentication_kind) + }) + .unwrap_or_default(); let usage = api_key_usage.remove(&item.account.id).or_else(|| { quota .usage_window() .and_then(|(window, _)| window.local_usage.clone()) }); - AccountDirectoryItem { - plan_type_display: self.providers.resolve_account_plan( + Ok(AccountDirectoryItem { + capabilities, + plan_type_display: providers.resolve_account_plan( item.account.provider_kind.as_str(), &mut item.account.plan_type, Some("a), @@ -449,14 +480,15 @@ impl AccountsService for DefaultAccountsService { account: item.account, projection: item.projection, quota, - } + }) }) - .collect(); + .collect::, AdminError>>()?; Ok(AccountDirectoryPage { config_revision: page.config_revision, items, total: page.total, summary: page.summary, + providers: page.providers, }) } @@ -465,6 +497,10 @@ impl AccountsService for DefaultAccountsService { context: &MutationContext, account_ids: Vec, ) -> Result { + let providers = self + .providers + .freeze() + .map_err(|error| map_provider_error(error, "provider export"))?; if account_ids.is_empty() || account_ids.len() > 200 { return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间")); } @@ -483,12 +519,21 @@ impl AccountsService for DefaultAccountsService { }) { return Err(AdminError::invalid("账号导出列表包含重复 ID")); } - let mut documents = Vec::with_capacity(grouped.len()); - for (provider_kind, ids) in grouped { - let provider = self - .providers - .require(&provider_kind) - .map_err(|error| map_provider_error(error, "provider account export"))?; + // 混选时先验证同一发布代次的全部能力,拒绝路径不读取任何一组明文凭据。 + let export_groups = grouped + .into_iter() + .map(|(provider_kind, ids)| { + let provider = providers + .require(&provider_kind) + .map_err(|error| map_provider_error(error, "provider account export"))?; + if !provider.credential_capabilities().export { + return Err(AdminError::invalid("所选账号包含不支持导出的平台")); + } + Ok((provider_kind, provider, ids)) + }) + .collect::, AdminError>>()?; + let mut documents = Vec::with_capacity(export_groups.len()); + for (provider_kind, provider, ids) in export_groups { let credentials = self .accounts .load_credentials_for_export(&provider_kind, &ids) @@ -646,7 +691,7 @@ impl AccountsService for DefaultAccountsService { let mut providers = BTreeMap::< ProviderKind, ( - Arc, + crate::ports::provider_extensions::ProviderAdminHandle, Vec, ), >::new(); @@ -710,7 +755,15 @@ impl AccountsService for DefaultAccountsService { .map_err(|error| map_provider_error(error, "forecast quota snapshot"))?; let now = Utc::now(); let mut samples = Vec::new(); - for (window, _) in quota.usage_windows() { + let mut selected_keys = BTreeSet::new(); + for period in [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly] { + let Some((window, _)) = quota_forecast_source_window("a, period) else { + continue; + }; + // 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。 + if !selected_keys.insert(window.key.as_str()) { + continue; + } let (Some(mut query), Some(observed), Some(percent)) = ( quota_usage_window(account_id.as_str(), window), quota.observed_at, @@ -897,6 +950,7 @@ impl AccountsService for DefaultAccountsService { let model = upstream_model.as_str().to_owned(); let operation = provider .connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT) + .await .map_err(|error| map_provider_error(error, "provider connection test"))?; let initial = vec![ AccountConnectionTestEvent::Started { @@ -912,12 +966,15 @@ impl AccountsService for DefaultAccountsService { let probe = Arc::clone(&self.probe); let terminal = futures::stream::once(async move { let result = probe - .probe(AccountProbeRequest { - account_id, - provider_kind: account.provider_kind, - upstream_model, - operation, - }) + .probe( + AccountProbeRequest { + account_id, + provider_kind: account.provider_kind, + upstream_model, + operation, + }, + provider.snapshot(), + ) .await; match result { Ok(result) => result diff --git a/backend/crates/gateway-admin/src/use_case/client_keys.rs b/backend/crates/gateway-admin/src/use_case/client_keys.rs index a8d03e5c0..342838ffb 100644 --- a/backend/crates/gateway-admin/src/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/src/use_case/client_keys.rs @@ -113,18 +113,11 @@ impl ClientKeyService for DefaultClientKeyService { context: &MutationContext, command: CreateClientKey, ) -> Result { - for (provider, profile) in [ - ("openai", &command.openai_client_profile_override), - ("xai", &command.xai_client_profile_override), - ] { - if let Some(profile) = profile { - let kind = gateway_core::routing::ProviderKind::new(provider) - .map_err(|_| AdminError::invalid("Provider 不合法"))?; - self.providers - .require(&kind) - .and_then(|provider| provider.preview_client_profile(profile)) - .map_err(|error| super::map_provider_error(error, "client profile"))?; - } + for (provider, profile) in &command.request_profile_overrides { + self.providers + .require(provider) + .and_then(|provider| provider.preview_client_profile(profile)) + .map_err(|error| super::map_provider_error(error, "client profile"))?; } let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple())) .map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?; @@ -139,8 +132,7 @@ impl ClientKeyService for DefaultClientKeyService { .store .create_client_key( NewClientKey { - openai_client_profile_override: command.openai_client_profile_override, - xai_client_profile_override: command.xai_client_profile_override, + request_profile_overrides: command.request_profile_overrides, id, name: command.name, label: command.label, @@ -165,15 +157,10 @@ impl ClientKeyService for DefaultClientKeyService { context: &MutationContext, command: UpdateClientKey, ) -> Result { - for (provider, profile) in [ - ("openai", &command.openai_client_profile_override), - ("xai", &command.xai_client_profile_override), - ] { - if let Some(Some(profile)) = profile { - let kind = gateway_core::routing::ProviderKind::new(provider) - .map_err(|_| AdminError::invalid("Provider 不合法"))?; + for (provider, profile) in &command.request_profile_override_updates { + if let Some(profile) = profile { self.providers - .require(&kind) + .require(provider) .and_then(|provider| provider.preview_client_profile(profile)) .map_err(|error| super::map_provider_error(error, "client profile"))?; } diff --git a/backend/crates/gateway-admin/src/use_case/credentials/flow.rs b/backend/crates/gateway-admin/src/use_case/credentials/flow.rs new file mode 100644 index 000000000..ca6101660 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/credentials/flow.rs @@ -0,0 +1,331 @@ +//! 已冻结 Provider 的凭据工作流;Admin 保持事务、审计与发布的唯一所有权。 + +use std::sync::Arc; + +use gateway_core::runtime::SnapshotControl; + +use crate::{ + model::{ + AdminError, + provider_credentials::{ + AuthorizationCommitResult, AuthorizationPollResult, AuthorizationReceiptKey, + AuthorizationStarted, CompleteAuthorization, CredentialBatchMutationResult, + CredentialDeletion, CredentialDeletionResult, CredentialImportCommit, + CredentialImportResult, CredentialMutationResult, ImportCredentials, PollAuthorization, + PrepareAuthorization, PrepareCredentialImport, PrepareCredentialRotation, + PreparedAuthorizationCredential, PreparedAuthorizationPoll, RotateCredential, + StartAuthorization, + }, + }, + ports::{provider_extensions::ProviderAdminHandle, store::AccountStore}, +}; + +use super::super::{ + commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error, + map_store_error, pending_authorization, publish_committed, + publish_credentials_and_observe_quota, required_credential, validate_authorization_commit, + validate_prepared_import, validate_prepared_rotation, +}; + +pub struct ProviderCredentials { + provider: ProviderAdminHandle, + accounts: Arc, + proxies: Arc, + snapshot: Arc, +} + +impl ProviderCredentials { + #[must_use] + pub(crate) fn new( + provider: ProviderAdminHandle, + accounts: Arc, + proxies: Arc, + snapshot: Arc, + ) -> Self { + Self { + provider, + accounts, + proxies, + snapshot, + } + } + pub async fn import_document( + &self, + command: ImportCredentials, + ) -> Result { + let context = command.context; + let proxy_reservation = super::super::import_proxy_binding( + self.proxies.as_ref(), + command.outbound_proxy_id.as_deref(), + ) + .await?; + let outbound_proxy = proxy_reservation + .as_ref() + .map(|reservation| reservation.binding.clone()); + let prepared = self + .provider + .prepare_import(PrepareCredentialImport { + default_outbound_proxy: outbound_proxy + .as_ref() + .map(|binding| binding.proxy.clone()), + document: command.document, + }) + .await + .map_err(|error| map_provider_error(error, "provider credential import"))?; + validate_prepared_import( + self.provider.provider_kind(), + &prepared, + "provider credential import", + )?; + let result = self + .accounts + .commit_credential_import( + CredentialImportCommit { + outbound_proxy, + prepared, + settings: command.settings, + }, + &context, + ) + .await + .map_err(|error| map_store_error(error, "provider credential import"))?; + drop(proxy_reservation); + publish_credentials_and_observe_quota( + &self.provider, + self.snapshot.as_ref(), + result.config_revision, + &result.credential_ids, + &context.request_id, + ) + .await?; + Ok(result) + } + + pub async fn start_authorization( + &self, + command: StartAuthorization, + ) -> Result { + let pending = pending_authorization( + self.accounts.as_ref(), + self.proxies.as_ref(), + self.provider.provider_kind(), + &command, + "provider credential", + ) + .await?; + self.provider + .start_authorization(PrepareAuthorization { + pending, + input: command.input, + }) + .await + .map_err(|error| map_provider_error(error, "provider authorization")) + } + + pub async fn rotate( + &self, + command: RotateCredential, + ) -> Result { + let context = command.mutation.context; + let account_id = command.mutation.account_id; + if command + .settings + .as_ref() + .is_some_and(|settings| settings.account_id != account_id.as_str()) + { + return Err(AdminError::invalid("凭据和账号设置的目标不一致")); + } + let disable_account = command + .settings + .as_ref() + .is_some_and(|settings| !settings.enabled); + let details = required_credential( + self.accounts.as_ref(), + self.provider.provider_kind(), + &account_id, + "provider credential rotation", + ) + .await?; + let account = details.credential; + let prepared = self + .provider + .prepare_rotation(PrepareCredentialRotation { + account: account.clone(), + provider_material: command.provider_material, + }) + .await + .map_err(|error| map_provider_error(error, "provider credential rotation"))?; + validate_prepared_rotation(&account, &prepared, "provider credential rotation")?; + let result = commit_credential_rotation( + self.accounts.as_ref(), + prepared, + command.settings, + &context, + "provider credential rotation", + ) + .await?; + if disable_account { + self.provider.account_unavailable(&account_id).await; + } + self.provider + .account_facts_changed(std::slice::from_ref(&result.account_id)) + .await; + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + Ok(result) + } + + pub async fn delete( + &self, + command: CredentialDeletion, + ) -> Result { + let result = delete_credentials( + self.accounts.as_ref(), + self.provider.as_ref(), + command, + "provider credential", + ) + .await?; + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + Ok(result) + } +} + +impl super::CredentialsService { + pub async fn complete_authorization( + &self, + kind: &gateway_core::routing::ProviderKind, + mut command: CompleteAuthorization, + ) -> Result { + let context = command.context.clone(); + let key = AuthorizationReceiptKey::new(kind.clone(), &command.flow_id, &context)?; + if let Some(result) = self + .accounts + .authorization_receipt(&key) + .await + .map_err(|error| map_store_error(error, "authorization receipt"))? + { + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + return single_authorization_result(result); + } + let scope = self.for_provider(kind)?; + let provider = &scope.provider; + let settings = command.settings.take(); + let prepared = provider + .complete_authorization(command) + .await + .map_err(|error| map_provider_error(error, "provider authorization"))?; + let prepared = + validate_authorization_commit(kind, &context, prepared, "provider authorization") + .await?; + // 回调完成入口保留单账号合同;多账号授权通过显式轮询结果交付,不能提交后再截断。 + if matches!(&prepared.credential, PreparedAuthorizationCredential::Create(accounts) if accounts.len() != 1) + { + prepared.abort().await?; + return Err(AdminError::invalid("多账号授权需要使用轮询入口")); + } + let result = commit_authorization( + self.accounts.as_ref(), + prepared, + key, + settings, + &context, + "provider authorization", + ) + .await?; + let result = self + .publish_authorization(provider, result, &context.request_id) + .await?; + single_authorization_result(result) + } + + pub async fn poll_authorization( + &self, + kind: &gateway_core::routing::ProviderKind, + mut command: PollAuthorization, + ) -> Result { + let context = command.context.clone(); + let key = AuthorizationReceiptKey::new(kind.clone(), &command.flow_id, &context)?; + if let Some(result) = self + .accounts + .authorization_receipt(&key) + .await + .map_err(|error| map_store_error(error, "authorization receipt"))? + { + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + return Ok(AuthorizationPollResult::Complete(result)); + } + let scope = self.for_provider(kind)?; + let provider = &scope.provider; + let settings = command.settings.take(); + let prepared = provider + .poll_authorization(command) + .await + .map_err(|error| map_provider_error(error, "provider authorization poll"))?; + let prepared = match prepared { + PreparedAuthorizationPoll::Pending { retry_after } => { + if retry_after.is_zero() || retry_after > std::time::Duration::from_secs(300) { + return Err(AdminError::internal("Provider 授权轮询间隔无效")); + } + return Ok(AuthorizationPollResult::Pending { retry_after }); + } + PreparedAuthorizationPoll::Complete(prepared) => *prepared, + }; + let prepared = + validate_authorization_commit(kind, &context, prepared, "provider authorization poll") + .await?; + let result = commit_authorization( + self.accounts.as_ref(), + prepared, + key, + settings, + &context, + "provider authorization poll", + ) + .await?; + let result = self + .publish_authorization(provider, result, &context.request_id) + .await?; + Ok(AuthorizationPollResult::Complete(result)) + } + + async fn publish_authorization( + &self, + provider: &ProviderAdminHandle, + outcome: AuthorizationCommitResult, + request_id: &str, + ) -> Result { + let result = outcome.result; + if outcome.newly_committed { + let ids = result + .accounts + .iter() + .map(|account| account.account_id.clone()) + .collect::>(); + publish_credentials_and_observe_quota( + provider, + self.snapshot.as_ref(), + result.config_revision, + &ids, + request_id, + ) + .await?; + } else { + publish_committed(self.snapshot.as_ref(), result.config_revision).await?; + } + Ok(result) + } +} + +fn single_authorization_result( + result: CredentialBatchMutationResult, +) -> Result { + let [account] = result + .accounts + .try_into() + .map_err(|_| AdminError::invalid("多账号授权需要使用轮询入口"))?; + Ok(CredentialMutationResult { + config_revision: result.config_revision, + account_id: account.account_id, + credential_revision: account.credential_revision, + }) +} diff --git a/backend/crates/gateway-admin/src/use_case/credentials/mod.rs b/backend/crates/gateway-admin/src/use_case/credentials/mod.rs new file mode 100644 index 000000000..b972452d2 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/credentials/mod.rs @@ -0,0 +1,59 @@ +mod flow; + +use std::sync::Arc; + +use gateway_core::{routing::ProviderKind, runtime::SnapshotControl}; + +use crate::{ + model::AdminError, + ports::{provider::ProviderAdminRegistry, proxy::ProxyStore, store::AccountStore}, +}; + +pub use flow::ProviderCredentials; + +/// 所有 Provider 共用凭据用例;每次操作从已发布目录冻结自己的管理实现。 +pub struct CredentialsService { + providers: ProviderAdminRegistry, + accounts: Arc, + proxies: Arc, + snapshot: Arc, +} + +impl CredentialsService { + pub fn providers( + &self, + ) -> Result, AdminError> + { + self.providers + .credential_descriptors() + .map_err(|error| super::map_provider_error(error, "provider capabilities")) + } + + #[must_use] + pub fn new( + providers: ProviderAdminRegistry, + accounts: Arc, + proxies: Arc, + snapshot: Arc, + ) -> Self { + Self { + providers, + accounts, + proxies, + snapshot, + } + } + + pub fn for_provider(&self, kind: &ProviderKind) -> Result { + let provider = self + .providers + .require(kind) + .map_err(|error| super::map_provider_error(error, "provider credentials"))?; + Ok(ProviderCredentials::new( + provider, + self.accounts.clone(), + self.proxies.clone(), + self.snapshot.clone(), + )) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/import_tasks.rs b/backend/crates/gateway-admin/src/use_case/import_tasks.rs index 68703c593..e23ae10cb 100644 --- a/backend/crates/gateway-admin/src/use_case/import_tasks.rs +++ b/backend/crates/gateway-admin/src/use_case/import_tasks.rs @@ -16,7 +16,7 @@ use gateway_core::{ use tokio::{sync::Notify, time::Instant}; use uuid::Uuid; -use super::{openai::OpenAiService, xai::XaiService}; +use super::credentials::CredentialsService; use crate::model::{ AdminError, AdminErrorKind, MutationActor, MutationContext, import_tasks::{ @@ -129,17 +129,15 @@ struct Registry { pub(crate) struct DefaultImportTasksService { registry: Mutex, notify: Notify, - openai: Arc, - xai: Arc, + credentials: Arc, } impl DefaultImportTasksService { - pub(crate) fn new(openai: Arc, xai: Arc) -> Arc { + pub(crate) fn new(credentials: Arc) -> Arc { Arc::new(Self { registry: Mutex::default(), notify: Notify::new(), - openai, - xai, + credentials, }) } @@ -177,11 +175,10 @@ impl DefaultImportTasksService { async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) { let operation = async { - match input.provider.as_str() { - "openai" => self.openai.import_document(input.command).await, - "xai" => self.xai.import_document(input.command).await, - _ => Err(AdminError::invalid("不支持的导入平台")), - } + self.credentials + .for_provider(&input.provider)? + .import_document(input.command) + .await }; let result = AssertUnwindSafe(operation).catch_unwind().await; let mut registry = self.registry(); @@ -222,7 +219,7 @@ impl ImportTasksService for DefaultImportTasksService { if command.items.is_empty() || command.items.len() > MAX_IMPORT_TASK_ITEMS || command.items.iter().any(|item| { - !matches!(item.provider.as_str(), "openai" | "xai") + self.credentials.for_provider(&item.provider).is_err() || item.command.context.actor != command.context.actor }) { diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index 0aa53668d..c7253683b 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -37,6 +37,12 @@ pub trait KeyUsageService: Send + Sync { /// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。 async fn budget(&self, plaintext: &str) -> Result, AdminError>; + /// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。 + async fn budget_for_client( + &self, + id: &ClientApiKeyId, + ) -> Result, AdminError>; + async fn version(&self, session_id: Option<&str>) -> Result, AdminError>; async fn config(&self, session_id: Option<&str>) @@ -111,15 +117,25 @@ impl KeyUsageService for DefaultKeyUsageService { let id = match self.verifier.verify_client_key(plaintext) { Ok(id) => id, Err(ClientAuthenticationError::InvalidKey) => return Ok(None), - Err(ClientAuthenticationError::SnapshotUnavailable) => { + Err( + ClientAuthenticationError::SnapshotUnavailable + | ClientAuthenticationError::ProviderUnavailable, + ) => { return Err(AdminError::new( AdminErrorKind::Unavailable, "密钥验证暂时不可用", )); } }; + self.budget_for_client(&id).await + } + + async fn budget_for_client( + &self, + id: &ClientApiKeyId, + ) -> Result, AdminError> { self.keys - .get_client_key(&id) + .get_client_key(id) .await .map(|key| key.filter(|key| key.enabled).map(|key| key.budget)) .map_err(|error| map_store_error(error, "key usage budget")) diff --git a/backend/crates/gateway-admin/src/use_case/mod.rs b/backend/crates/gateway-admin/src/use_case/mod.rs index 3733e70ee..fee5c163f 100644 --- a/backend/crates/gateway-admin/src/use_case/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/mod.rs @@ -6,16 +6,17 @@ pub mod auth; pub mod backup; pub mod client_distribution; pub mod client_keys; +pub mod credentials; pub mod import_tasks; pub mod key_usage; pub mod observability; -pub mod openai; +pub mod plugin_accounts; +pub mod plugin_client_keys; +pub(crate) mod plugin_update; +pub mod plugins; pub mod proxies; pub mod settings; pub mod system; -pub mod xai; - -use std::sync::Arc; use crate::{ model::{ @@ -101,8 +102,18 @@ async fn publish_committed( Ok(()) } +async fn publish_committed_deferred( + snapshot: &dyn SnapshotControl, + revision: crate::model::Revision, +) -> Result<(), AdminError> { + let revision = ConfigRevision::new(revision.get()) + .map_err(|_| AdminError::internal("已提交的配置版本不合法"))?; + snapshot.publish_committed_deferred(revision).await; + Ok(()) +} + async fn publish_credentials_and_observe_quota( - provider: &Arc, + provider: &crate::ports::provider_extensions::ProviderAdminHandle, snapshot: &dyn SnapshotControl, revision: crate::model::Revision, account_ids: &[ProviderAccountId], @@ -112,7 +123,7 @@ async fn publish_credentials_and_observe_quota( publish_committed(snapshot, revision).await?; // 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。 - let provider = Arc::clone(provider); + let provider = provider.clone(); let account_ids = account_ids.to_vec(); let request_id = request_id.to_owned(); tokio::spawn(async move { @@ -151,6 +162,18 @@ async fn required_credential( .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) } +async fn required_plugin_credential( + accounts: &dyn AccountStore, + account_id: &ProviderAccountId, + resource: &'static str, +) -> Result { + accounts + .credential_details_by_id(account_id) + .await + .map_err(|error| map_store_error(error, resource))? + .ok_or_else(|| AdminError::not_found("Provider 凭据不存在")) +} + async fn import_proxy_binding( proxies: &dyn crate::ports::proxy::ProxyStore, id: Option<&str>, @@ -233,7 +256,13 @@ fn validate_prepared_rotation( prepared: &PreparedCredentialRotation, _resource: &'static str, ) -> Result<(), AdminError> { - let facts = prepared.facts(); + validate_prepared_rotation_facts(account, prepared.facts()) +} + +fn validate_prepared_rotation_facts( + account: &crate::model::accounts::AccountRecord, + facts: &crate::model::provider_credentials::PreparedCredentialRotationFacts, +) -> Result<(), AdminError> { if facts.account_id.as_str() != account.id.as_str() || facts.provider_kind != account.provider_kind { @@ -265,8 +294,16 @@ async fn validate_authorization_commit( let matches_target = match (prepared.pending.target(), &prepared.credential) { ( AuthorizationMutationTarget::Create { .. }, - PreparedAuthorizationCredential::Create(credential), - ) => credential.provider_kind == *provider_kind, + PreparedAuthorizationCredential::Create(credentials), + ) => { + let mut identities = std::collections::BTreeSet::new(); + !credentials.is_empty() + && credentials.len() <= 200 + && credentials.iter().all(|credential| { + credential.provider_kind == *provider_kind + && identities.insert(&credential.account_id) + }) + } ( AuthorizationMutationTarget::Reauthorize { account_id }, PreparedAuthorizationCredential::Reauthorize(credential), @@ -294,10 +331,11 @@ async fn validate_authorization_commit( async fn commit_authorization( accounts: &dyn AccountStore, prepared: PreparedAuthorizationCommit, + key: crate::model::provider_credentials::AuthorizationReceiptKey, settings: Option, context: &MutationContext, resource: &'static str, -) -> Result { +) -> Result { if settings.is_some() && matches!( &prepared.credential, @@ -311,14 +349,19 @@ async fn commit_authorization( command, credential_guard, authorization_guard, - } = prepared.into_commit(settings); + } = prepared.into_commit(settings, key); match accounts.commit_authorization(command, context).await { Ok(result) => { - if let Some(guard) = credential_guard { + if let Some(guard) = credential_guard + && result.newly_committed + { guard.finish(); } - if let Some(guard) = authorization_guard { - guard.commit().await?; + if let Some(guard) = authorization_guard + && let Err(error) = guard.commit().await + { + // 账号与回执已经原子提交,Redis 清理失败不能把确定的成功改写为失败。 + tracing::warn!(resource, settlement_error = %error, "authorization committed but pending cleanup failed"); } Ok(result) } diff --git a/backend/crates/gateway-admin/src/use_case/observability.rs b/backend/crates/gateway-admin/src/use_case/observability.rs index 6535331dc..4a98ea833 100644 --- a/backend/crates/gateway-admin/src/use_case/observability.rs +++ b/backend/crates/gateway-admin/src/use_case/observability.rs @@ -105,6 +105,7 @@ pub trait ObservabilityService: Send + Sync { async fn dashboard_trend(&self, range: TimeRange, kind: TrendKind) -> Result; async fn usage_records(&self, query: UsageQuery) -> Result; + async fn usage_provider_kinds(&self, range: TimeRange) -> Result, AdminError>; async fn usage_record_detail(&self, request_id: &str) -> Result; async fn usage_summary( &self, @@ -192,20 +193,10 @@ impl DefaultObservabilityService { average(first_token_latency_sum_ms, first_token_latency_count); let trend = trend(TrendKind::Usage, observation.trend.clone())?; let health_timeline = health_timeline_at(&observation.trend, Utc::now()); - let mut configurations = std::collections::BTreeMap::new(); - if let Some(configuration) = &settings.openai_client_profile { - configurations.insert( - gateway_core::routing::ProviderKind::new("openai").expect("static provider kind"), - configuration.clone(), - ); - } - if let Some(configuration) = &settings.xai_client_profile { - configurations.insert( - gateway_core::routing::ProviderKind::new("xai").expect("static provider kind"), - configuration.clone(), - ); - } - let wire_profiles = self.providers.dashboard_wire_profiles(&configurations); + let wire_profiles = self + .providers + .dashboard_wire_profiles(&settings.request_profiles) + .map_err(|error| super::map_provider_error(error, "provider profiles"))?; let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account); let (inherited_accounts, overridden_slots) = runtime_slots .as_ref() @@ -283,6 +274,13 @@ impl ObservabilityService for DefaultObservabilityService { Ok(page) } + async fn usage_provider_kinds(&self, range: TimeRange) -> Result, AdminError> { + self.store + .usage_provider_kinds(range) + .await + .map_err(|error| map_store_error(error, "usage providers")) + } + async fn usage_record_detail(&self, request_id: &str) -> Result { if request_id.trim().is_empty() { return Err(AdminError::invalid("用量记录 ID 不能为空")); diff --git a/backend/crates/gateway-admin/src/use_case/openai.rs b/backend/crates/gateway-admin/src/use_case/openai.rs deleted file mode 100644 index bf2fd36c8..000000000 --- a/backend/crates/gateway-admin/src/use_case/openai.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! OpenAI 管理资源的中立 ProviderAdmin 委托。 - -use std::sync::Arc; - -use async_trait::async_trait; -use gateway_core::runtime::SnapshotControl; - -use crate::{ - model::{ - AdminError, - provider_credentials::{ - AuthorizationStarted, CompleteAuthorization, CredentialDeletion, - CredentialDeletionResult, CredentialImportCommit, CredentialImportResult, - CredentialMutationResult, ImportCredentials, PrepareCredentialImport, - PrepareCredentialRotation, RotateCredential, StartAuthorization, - }, - }, - ports::{provider::ProviderAdmin, store::AccountStore}, -}; - -use super::{ - commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error, - map_store_error, pending_authorization, publish_committed, - publish_credentials_and_observe_quota, required_credential, validate_authorization_commit, - validate_prepared_import, validate_prepared_rotation, -}; - -/// OpenAI 固定管理路由消费的服务。 -#[async_trait] -pub trait OpenAiService: Send + Sync { - async fn import_document( - &self, - command: ImportCredentials, - ) -> Result; - async fn start_authorization( - &self, - command: StartAuthorization, - ) -> Result; - async fn complete_authorization( - &self, - command: CompleteAuthorization, - ) -> Result; - async fn rotate( - &self, - command: RotateCredential, - ) -> Result; - async fn delete( - &self, - command: CredentialDeletion, - ) -> Result; -} - -pub(crate) struct DefaultOpenAiService { - provider: Arc, - accounts: Arc, - proxies: Arc, - snapshot: Arc, -} - -impl DefaultOpenAiService { - #[must_use] - pub(crate) fn new( - provider: Arc, - accounts: Arc, - proxies: Arc, - snapshot: Arc, - ) -> Self { - Self { - provider, - accounts, - proxies, - snapshot, - } - } -} - -#[async_trait] -impl OpenAiService for DefaultOpenAiService { - async fn import_document( - &self, - command: ImportCredentials, - ) -> Result { - let context = command.context; - let proxy_reservation = super::import_proxy_binding( - self.proxies.as_ref(), - command.outbound_proxy_id.as_deref(), - ) - .await?; - let outbound_proxy = proxy_reservation - .as_ref() - .map(|reservation| reservation.binding.clone()); - let prepared = self - .provider - .prepare_import(PrepareCredentialImport { - default_outbound_proxy: outbound_proxy - .as_ref() - .map(|binding| binding.proxy.clone()), - document: command.document, - }) - .await - .map_err(|error| map_provider_error(error, "OpenAI credential import"))?; - validate_prepared_import( - self.provider.provider_kind(), - &prepared, - "OpenAI credential import", - )?; - let result = self - .accounts - .commit_credential_import( - CredentialImportCommit { - outbound_proxy, - prepared, - settings: command.settings, - }, - &context, - ) - .await - .map_err(|error| map_store_error(error, "OpenAI credential import"))?; - drop(proxy_reservation); - publish_credentials_and_observe_quota( - &self.provider, - self.snapshot.as_ref(), - result.config_revision, - &result.credential_ids, - &context.request_id, - ) - .await?; - Ok(result) - } - - async fn start_authorization( - &self, - command: StartAuthorization, - ) -> Result { - let pending = pending_authorization( - self.accounts.as_ref(), - self.proxies.as_ref(), - self.provider.provider_kind(), - &command, - "OpenAI credential", - ) - .await?; - self.provider - .start_authorization(pending) - .await - .map_err(|error| map_provider_error(error, "OpenAI authorization")) - } - - async fn complete_authorization( - &self, - mut command: CompleteAuthorization, - ) -> Result { - let context = command.context.clone(); - let settings = command.settings.take(); - let prepared = self - .provider - .complete_authorization(command) - .await - .map_err(|error| map_provider_error(error, "OpenAI authorization"))?; - let prepared = validate_authorization_commit( - self.provider.provider_kind(), - &context, - prepared, - "OpenAI authorization", - ) - .await?; - let result = commit_authorization( - self.accounts.as_ref(), - prepared, - settings, - &context, - "OpenAI authorization", - ) - .await?; - publish_credentials_and_observe_quota( - &self.provider, - self.snapshot.as_ref(), - result.config_revision, - std::slice::from_ref(&result.account_id), - &context.request_id, - ) - .await?; - Ok(result) - } - - async fn rotate( - &self, - command: RotateCredential, - ) -> Result { - let context = command.mutation.context; - let account_id = command.mutation.account_id; - if command - .settings - .as_ref() - .is_some_and(|settings| settings.account_id != account_id.as_str()) - { - return Err(AdminError::invalid("凭据和账号设置的目标不一致")); - } - let disable_account = command - .settings - .as_ref() - .is_some_and(|settings| !settings.enabled); - let details = required_credential( - self.accounts.as_ref(), - self.provider.provider_kind(), - &account_id, - "OpenAI credential rotation", - ) - .await?; - let account = details.credential; - let prepared = self - .provider - .prepare_rotation(PrepareCredentialRotation { - account: account.clone(), - provider_material: command.provider_material, - }) - .await - .map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?; - validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?; - let result = commit_credential_rotation( - self.accounts.as_ref(), - prepared, - command.settings, - &context, - "OpenAI credential rotation", - ) - .await?; - if disable_account { - self.provider.account_unavailable(&account_id).await; - } - self.provider - .account_facts_changed(std::slice::from_ref(&result.account_id)) - .await; - publish_committed(self.snapshot.as_ref(), result.config_revision).await?; - Ok(result) - } - - async fn delete( - &self, - command: CredentialDeletion, - ) -> Result { - let result = delete_credentials( - self.accounts.as_ref(), - self.provider.as_ref(), - command, - "OpenAI credential", - ) - .await?; - publish_committed(self.snapshot.as_ref(), result.config_revision).await?; - Ok(result) - } -} diff --git a/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs new file mode 100644 index 000000000..fcc88057b --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugin_accounts.rs @@ -0,0 +1,217 @@ +//! 插件宿主账号回调;复用凭据事务,不建立第二套账号权威。 + +use std::sync::Arc; + +use async_trait::async_trait; +use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl}; + +use crate::{ + model::{ + AdminError, MutationContext, + provider_credentials::{ + CredentialImportCommit, CredentialRotationCommit, PluginAccountCredential, + PluginAccountListQuery, PluginAccountPage, PluginAccountSaveReason, + PluginAccountSaveResult, PreparedCredentialImport, PreparedPluginAccountSave, + }, + }, + ports::{ + plugin_accounts::PluginAccountAccess, provider::ProviderAdminRegistry, store::AccountStore, + }, +}; + +use super::{ + map_provider_error, map_store_error, publish_committed_deferred, + publish_credentials_and_observe_quota, required_credential, required_plugin_credential, + validate_prepared_import, validate_prepared_rotation_facts, +}; + +pub(crate) struct DefaultPluginAccountAccess { + providers: ProviderAdminRegistry, + accounts: Arc, + snapshot: Arc, +} + +impl DefaultPluginAccountAccess { + #[must_use] + pub(crate) fn new( + providers: ProviderAdminRegistry, + accounts: Arc, + snapshot: Arc, + ) -> Self { + Self { + providers, + accounts, + snapshot, + } + } + + fn provider( + &self, + provider_kind: &ProviderKind, + ) -> Result { + self.providers + .require(provider_kind) + .map_err(|error| map_provider_error(error, "plugin account access")) + } +} + +#[async_trait] +impl PluginAccountAccess for DefaultPluginAccountAccess { + async fn list(&self, query: PluginAccountListQuery) -> Result { + self.accounts + .list_plugin_accounts(query) + .await + .map_err(|error| map_store_error(error, "plugin account list")) + } + + async fn get_runtime( + &self, + account_id: &ProviderAccountId, + ) -> Result { + Ok( + required_plugin_credential( + self.accounts.as_ref(), + account_id, + "plugin account runtime", + ) + .await? + .credential, + ) + } + + async fn get_credential( + &self, + account_id: &ProviderAccountId, + ) -> Result { + let credential = self + .accounts + .load_credential_for_plugin(account_id) + .await + .map_err(|error| map_store_error(error, "plugin credential"))? + .ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))?; + Ok(PluginAccountCredential { + account: credential.account, + provider_material: credential.provider_material, + }) + } + + async fn save( + &self, + command: PreparedPluginAccountSave, + context: &MutationContext, + ) -> Result { + match command { + PreparedPluginAccountSave::Create(prepared) => { + let provider_kind = prepared.provider_kind.clone(); + let account_id = prepared.account_id.clone(); + let provider = self.provider(&provider_kind)?; + let prepared = PreparedCredentialImport { + provider_kind: provider_kind.clone(), + credentials: vec![prepared], + }; + validate_prepared_import(&provider_kind, &prepared, "plugin account save")?; + let result = self + .accounts + .commit_credential_import( + CredentialImportCommit { + outbound_proxy: None, + settings: None, + prepared, + }, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin account save"))?; + if result.credential_ids.as_slice() != std::slice::from_ref(&account_id) { + return Err(AdminError::internal("账号保存结果与准备目标不一致")); + } + let credential_revision = required_credential( + self.accounts.as_ref(), + &provider_kind, + &account_id, + "plugin account save", + ) + .await? + .credential + .credential_revision; + publish_credentials_and_observe_quota( + &provider, + self.snapshot.as_ref(), + result.config_revision, + std::slice::from_ref(&account_id), + &context.request_id, + ) + .await?; + Ok(PluginAccountSaveResult { + config_revision: result.config_revision, + account_id, + credential_revision, + }) + } + PreparedPluginAccountSave::Replace { + facts, + authentication_kind, + reason, + } => { + let account = required_credential( + self.accounts.as_ref(), + &facts.provider_kind, + &facts.account_id, + "plugin account save", + ) + .await? + .credential; + validate_prepared_rotation_facts(&account, &facts)?; + if account.authentication_kind != authentication_kind { + return Err(AdminError::conflict("插件准备结果与当前认证类型不一致")); + } + let provider = if reason == PluginAccountSaveReason::Explicit { + Some(self.provider(&facts.provider_kind)?) + } else { + None + }; + let command = CredentialRotationCommit { + prepared: facts, + settings: None, + }; + let result = match reason { + PluginAccountSaveReason::Explicit => { + self.accounts + .commit_credential_rotation(command, context) + .await + } + PluginAccountSaveReason::ModelDiscovery + | PluginAccountSaveReason::Maintenance => { + self.accounts + .commit_credential_refresh(command, context) + .await + } + } + .map_err(|error| map_store_error(error, "plugin account save"))?; + let credential_revision = result + .credential_revision + .ok_or_else(|| AdminError::internal("账号保存未返回凭据版本"))?; + if let Some(provider) = provider { + publish_credentials_and_observe_quota( + &provider, + self.snapshot.as_ref(), + result.config_revision, + std::slice::from_ref(&result.account_id), + &context.request_id, + ) + .await?; + } else { + // 模型发现可能位于快照编译内,后台维护还持有刷新租约; + // 只合并发布,由 Runtime 当前调用失效目录,避免重入准备或刷新锁。 + publish_committed_deferred(self.snapshot.as_ref(), result.config_revision) + .await?; + } + Ok(PluginAccountSaveResult { + config_revision: result.config_revision, + account_id: result.account_id, + credential_revision, + }) + } + } + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugin_client_keys.rs b/backend/crates/gateway-admin/src/use_case/plugin_client_keys.rs new file mode 100644 index 000000000..df7a72545 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugin_client_keys.rs @@ -0,0 +1,87 @@ +//! 插件 Client Key 目录;复用管理服务并收窄为非秘密投影。 + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::{ + model::{ + AdminError, + client_keys::{ + ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize, + ClientKeySort, ClientKeySortField, SortDirection, + }, + plugin_client_keys::{ + PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage, + }, + }, + ports::plugin_client_keys::PluginClientKeyAccess, + use_case::client_keys::ClientKeyService, +}; + +pub(crate) struct DefaultPluginClientKeyAccess { + service: Arc, +} + +impl DefaultPluginClientKeyAccess { + pub(crate) fn new(service: Arc) -> Self { + Self { service } + } +} + +#[async_trait] +impl PluginClientKeyAccess for DefaultPluginClientKeyAccess { + async fn list( + &self, + query: PluginClientKeyListQuery, + ) -> Result { + let sort = ClientKeySort { + field: ClientKeySortField::Name, + direction: SortDirection::Asc, + }; + let page_size = ClientKeyPageSize::new(query.limit.get()) + .map_err(|_| AdminError::internal("插件 Client Key 页大小不合法"))?; + let page = self + .service + .list(ClientKeyListQuery { + cursor: query.cursor.map(|cursor| ClientKeyCursor { + sort, + value: ClientKeyCursorValue::Name(cursor.name), + id: cursor.id, + }), + page_size, + search: None, + sort, + }) + .await?; + let next_cursor = page + .next_cursor + .map(|cursor| match cursor.value { + ClientKeyCursorValue::Name(name) + if cursor.sort.field == ClientKeySortField::Name + && cursor.sort.direction == SortDirection::Asc => + { + Ok(PluginClientKeyCursor { + name, + id: cursor.id, + }) + } + _ => Err(AdminError::internal( + "Client Key 服务返回了不匹配的插件分页游标", + )), + }) + .transpose()?; + Ok(PluginClientKeyPage { + items: page + .items + .into_iter() + .map(|item| PluginClientKey { + id: item.id, + name: item.name, + enabled: item.enabled, + }) + .collect(), + next_cursor, + }) + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugin_update.rs b/backend/crates/gateway-admin/src/use_case/plugin_update.rs new file mode 100644 index 000000000..648a7305a --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugin_update.rs @@ -0,0 +1,148 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; + +use crate::{ + model::{AdminErrorKind, Revision}, + ports::{ + plugins::{PluginPackageInspector, PluginStore}, + store::AdminStoreErrorKind, + system::{ + SystemOperationError, SystemOperationErrorKind, SystemUpdateCandidate, + SystemUpdatePreflight, + }, + }, +}; + +use super::plugins::official::update_compatibility; + +/// 只读取启用实例及其固定包体,检查目标宿主合同;不准备或执行插件。 +pub(crate) struct PluginSystemUpdatePreflight { + store: Arc, + inspector: Arc, +} + +impl PluginSystemUpdatePreflight { + pub(crate) fn new( + store: Arc, + inspector: Arc, + ) -> Self { + Self { store, inspector } + } +} + +#[async_trait] +impl SystemUpdatePreflight for PluginSystemUpdatePreflight { + async fn validate( + &self, + candidate: SystemUpdateCandidate, + ) -> Result { + let target = semver::Version::parse(candidate.target_version.trim_start_matches('v')) + .map_err(|_| invalid("目标网关版本不合法"))?; + let compatibility = update_compatibility( + candidate.release_manifest.as_ref(), + candidate.target_version.trim_start_matches('v'), + ) + .map_err(|_| invalid("目标发行物缺少有效的插件兼容声明"))?; + let snapshot = self.store.load_instances().await.map_err(map_store_error)?; + let mut inspected = BTreeMap::new(); + for instance in snapshot + .instances + .iter() + .filter(|instance| instance.enabled) + { + let requirements = if let Some(requirements) = inspected.get(&instance.artifact_sha256) + { + requirements + } else { + let artifact = self + .store + .load_artifact(&instance.artifact_sha256) + .await + .map_err(map_store_error)?; + let requirements = self + .inspector + .compatibility(artifact.archive, instance.artifact_sha256.clone()) + .await + .map_err(map_inspection_error)?; + inspected.insert(instance.artifact_sha256.clone(), requirements); + inspected + .get(&instance.artifact_sha256) + .ok_or_else(|| internal("插件兼容性检查结果不可用"))? + }; + let host_versions = semver::VersionReq::parse(&requirements.host_version) + .map_err(|_| incompatible(&instance.id))?; + if !host_versions.matches(&target) + || !compatibility + .manifest_schema_versions + .contains(&requirements.manifest_schema_version) + || !compatibility + .protocol_versions + .contains(&requirements.protocol_version) + || requirements + .capabilities + .iter() + .any(|(capability, version)| { + !compatibility.supports_capability(capability, *version) + }) + || requirements + .permissions + .iter() + .any(|permission| !compatibility.supports_permission(permission)) + { + return Err(incompatible(&instance.id)); + } + } + Ok(snapshot.config_revision) + } + + async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError> { + let current = self + .store + .load_instances() + .await + .map_err(map_store_error)? + .config_revision; + if current != expected { + return Err(conflict("插件配置已变化,请重新执行系统更新")); + } + Ok(()) + } +} + +fn map_store_error(error: crate::ports::store::AdminStoreError) -> SystemOperationError { + match error.kind() { + AdminStoreErrorKind::StaleRevision + | AdminStoreErrorKind::DuplicateName + | AdminStoreErrorKind::Conflict => conflict("插件配置已变化,请重新执行系统更新"), + AdminStoreErrorKind::Invalid | AdminStoreErrorKind::NotFound => { + conflict("启用插件的制品不完整,无法执行系统更新") + } + AdminStoreErrorKind::Unavailable => internal("插件配置暂不可读取"), + } +} + +fn map_inspection_error(error: crate::model::AdminError) -> SystemOperationError { + match error.kind() { + AdminErrorKind::Invalid | AdminErrorKind::Conflict | AdminErrorKind::NotFound => { + conflict("启用插件的制品无法通过兼容性检查") + } + _ => internal("插件兼容性检查暂不可用"), + } +} + +fn incompatible(instance_id: &str) -> SystemOperationError { + conflict(format!("启用插件实例 {instance_id} 与目标网关版本不兼容")) +} + +fn invalid(message: impl Into) -> SystemOperationError { + SystemOperationError::new(SystemOperationErrorKind::Invalid, message) +} + +fn conflict(message: impl Into) -> SystemOperationError { + SystemOperationError::new(SystemOperationErrorKind::Conflict, message) +} + +fn internal(message: impl Into) -> SystemOperationError { + SystemOperationError::new(SystemOperationErrorKind::Internal, message) +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs new file mode 100644 index 000000000..c12d09baf --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/artifacts.rs @@ -0,0 +1,431 @@ +use std::{collections::BTreeSet, sync::Arc}; + +use gateway_core::runtime::SnapshotControl; +use sha2::{Digest as _, Sha256}; + +use super::super::{map_store_error, publish_committed}; +use crate::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactIconResource, + PluginArtifactMetadata, PluginArtifactMutation, PluginIconTheme, PluginInstallResult, + PluginSource, + distribution::VerifiedPluginArtifact, + instances::{ + ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy, + PluginInstance, PluginPermissionGrant, + }, + }, + }, + ports::plugins::{ + PluginDistribution, PluginPackageInspector, PluginPreparation, PluginStateStore, + PluginStore, + }, + ports::proxy::ProxyStore, +}; + +use super::state::PluginStateService; + +/// 远程插件来源访问所需的传输与受管出站代理目录。 +pub struct PluginDistributionPorts { + pub(super) transport: Arc, + pub(super) proxies: Arc, +} + +impl PluginDistributionPorts { + #[must_use] + pub fn new(transport: Arc, proxies: Arc) -> Self { + Self { transport, proxies } + } +} + +/// 所有安装入口在取得完整包后进入同一校验与事务流程。 +pub struct PluginsService { + pub(super) store: Arc, + pub(super) inspector: Arc, + pub(super) distribution: PluginDistributionPorts, + pub(super) snapshots: Arc, + pub(super) preparation: Arc, + pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle, + pub(super) state: PluginStateService, +} + +impl PluginsService { + pub fn permission_descriptions( + &self, + permissions: &[String], + ) -> Vec { + self.inspector.permission_descriptions(permissions) + } + + #[must_use] + pub fn new( + store: Arc, + inspector: Arc, + distribution: PluginDistributionPorts, + snapshots: Arc, + preparation: Arc, + published: gateway_core::runtime::RuntimeSnapshotHandle, + state: Arc, + ) -> Self { + Self { + store, + inspector, + distribution, + snapshots, + preparation, + published, + state: PluginStateService::new(state), + } + } + + pub async fn list(&self) -> Result, AdminError> { + self.store + .list_artifacts() + .await + .map_err(|error| map_store_error(error, "plugin")) + } + + pub async fn icon( + &self, + digest: &str, + theme: PluginIconTheme, + ) -> Result { + if !valid_digest(digest) { + return Err(AdminError::invalid("插件制品摘要不合法")); + } + let artifact = self + .store + .load_artifact(digest) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if artifact.metadata.icon.is_none() { + return Err(AdminError::not_found("插件制品未提供图标")); + } + self.inspector + .icon(artifact.archive, digest.to_owned(), theme) + .await? + .ok_or_else(|| AdminError::not_found("插件制品未提供图标")) + } + + pub async fn verify_upload( + &self, + archive: Arc<[u8]>, + ) -> Result { + let artifact = self.inspector.inspect(archive, None).await?; + Ok(VerifiedPluginArtifact { + metadata: artifact.metadata, + source: PluginSource::Upload, + }) + } + + /// 管理上传固定为自定义来源;调用方不能借通用参数声明官方身份。 + pub async fn install_upload( + &self, + archive: Arc<[u8]>, + expected_sha256: Option, + context: &MutationContext, + ) -> Result { + if expected_sha256.is_none() { + return Err(AdminError::invalid("安装需要已校验的插件包摘要")); + } + let artifact = self.inspector.inspect(archive, expected_sha256).await?; + let mutation = self + .persist(artifact, PluginSource::Upload, context) + .await?; + self.complete_install(mutation, context).await + } + + /// 接受已导入但尚未安装的制品;官方发行导入也必须经过管理员的这一步。 + pub async fn accept_artifact( + &self, + digest: &str, + context: &MutationContext, + ) -> Result { + if !valid_digest(digest) { + return Err(AdminError::invalid("插件制品摘要不合法")); + } + let mutation = self + .store + .accept_artifact(digest, context) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + self.finish_install(mutation, context).await + } + + pub(super) async fn complete_install( + &self, + mutation: PluginArtifactMutation, + context: &MutationContext, + ) -> Result { + let digest = mutation.artifact.metadata.sha256.clone(); + self.accept_artifact(&digest, context).await + } + + async fn finish_install( + &self, + mut mutation: PluginArtifactMutation, + context: &MutationContext, + ) -> Result { + let metadata = mutation.artifact.metadata.clone(); + let creation_id = default_instance_id(&metadata.plugin_id); + let configuration = configuration_defaults(&metadata); + let bindings = default_bindings(&metadata); + let probe = default_instance( + &metadata, + &creation_id, + configuration.clone(), + bindings.clone(), + false, + mutation.config_revision, + ); + let configuration_required = !self + .preparation + .configuration_ready(probe, &metadata) + .await?; + + // 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID + // 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本。 + for _ in 0..3 { + let snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if let Some((default_instance_id, required)) = self + .existing_plugin_instance(&snapshot, &metadata, &creation_id) + .await? + { + mutation.config_revision = snapshot.config_revision; + return Ok(PluginInstallResult { + mutation, + default_instance_id, + configuration_required: required, + }); + } + + let input = ConfigurePluginInstance { + replace_instances: Vec::new(), + creation_id: Some(creation_id.clone()), + expected_revision: None, + name: metadata.display_name.clone(), + artifact_sha256: metadata.sha256.clone(), + enabled: !configuration_required, + configuration: configuration.clone(), + secrets: None, + bindings: bindings.clone(), + }; + match self.configure_instance(None, input, context).await { + Ok(configured) => { + mutation.config_revision = configured.config_revision; + return Ok(PluginInstallResult { + mutation, + default_instance_id: Some(configured.instance.id), + configuration_required, + }); + } + Err(error) if error.kind() == crate::model::AdminErrorKind::Conflict => {} + Err(error) => return Err(error), + } + } + Err(AdminError::conflict( + "插件安装期间配置持续变化,请刷新后重试", + )) + } + + async fn existing_plugin_instance( + &self, + snapshot: &crate::model::plugins::instances::PluginInstanceSnapshot, + metadata: &PluginArtifactMetadata, + default_id: &str, + ) -> Result, bool)>, AdminError> { + let artifacts = self + .store + .list_artifacts() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let matching_digests = artifacts + .into_iter() + .filter(|artifact| artifact.metadata.plugin_id == metadata.plugin_id) + .map(|artifact| artifact.metadata.sha256) + .collect::>(); + let Some(instance) = snapshot + .instances + .iter() + .find(|instance| matching_digests.contains(&instance.artifact_sha256)) + else { + return Ok(None); + }; + if instance.id == default_id && instance.artifact_sha256 == metadata.sha256 { + let required = !self + .preparation + .configuration_ready(instance.clone(), metadata) + .await?; + Ok(Some((Some(instance.id.clone()), required))) + } else { + Ok(Some((None, false))) + } + } + + pub(super) async fn persist( + &self, + artifact: InspectedPluginArtifact, + source: PluginSource, + context: &MutationContext, + ) -> Result { + let mutation = self + .store + .install_artifact(artifact, source, context) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + Ok(mutation) + } + + pub async fn delete( + &self, + digest: &str, + context: &MutationContext, + ) -> Result { + let revision = self + .store + .delete_artifact(digest, context) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(revision) + } +} + +fn valid_digest(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn default_instance_id(plugin_id: &str) -> String { + let mut hash = Sha256::new(); + hash.update(b"codex-proxy-rs/plugin-default-instance/v1\0"); + hash.update(plugin_id.as_bytes()); + let digest = hash.finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + // RFC 9562 UUIDv8 保留给应用自定义派生;variant 固定为 RFC 4122。 + bytes[6] = (bytes[6] & 0x0f) | 0x80; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + uuid::Uuid::from_bytes(bytes).to_string() +} + +pub(super) fn configuration_defaults(metadata: &PluginArtifactMetadata) -> serde_json::Value { + let mut configuration = schema_default(&metadata.configuration_schema) + .filter(serde_json::Value::is_object) + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + merge_property_defaults(&mut configuration, &metadata.configuration_schema); + if let Some(configuration) = configuration.as_object_mut() { + for secret in &metadata.secret_fields { + configuration.remove(secret); + } + } + configuration +} + +fn schema_default(schema: &serde_json::Value) -> Option { + let mut value = if let Some(value) = schema.get("default") { + value.clone() + } else { + let properties = schema.get("properties")?.as_object()?; + let mut object = serde_json::Map::new(); + for (name, property) in properties { + if let Some(value) = schema_default(property) { + object.insert(name.clone(), value); + } + } + if object.is_empty() { + return None; + } + serde_json::Value::Object(object) + }; + merge_property_defaults(&mut value, schema); + Some(value) +} + +fn merge_property_defaults(value: &mut serde_json::Value, schema: &serde_json::Value) { + let (Some(value), Some(properties)) = ( + value.as_object_mut(), + schema + .get("properties") + .and_then(serde_json::Value::as_object), + ) else { + return; + }; + for (name, property) in properties { + if let Some(current) = value.get_mut(name) { + merge_property_defaults(current, property); + } else if let Some(default) = schema_default(property) { + value.insert(name.clone(), default); + } + } +} + +pub(super) fn default_bindings(metadata: &PluginArtifactMetadata) -> Vec { + metadata + .contributes + .iter() + .filter(|(capability, _)| { + !matches!( + capability.as_str(), + "frontend_authentication" | "management" | "command_line" + ) + }) + .flat_map(|(_, contribution)| { + contribution + .stages + .iter() + .map(|stage| PluginCapabilityBinding { + contribution: contribution.id.clone(), + stage: stage.clone(), + order: 0, + failure_policy: if stage == "observation" { + PluginFailurePolicy::Observe + } else { + PluginFailurePolicy::Reject + }, + client_key_ids: Vec::new(), + account_group_ids: Vec::new(), + provider_ids: Vec::new(), + models: Vec::new(), + identity_bindings: Vec::new(), + }) + }) + .collect() +} + +fn default_instance( + metadata: &PluginArtifactMetadata, + id: &str, + configuration: serde_json::Value, + bindings: Vec, + enabled: bool, + revision: Revision, +) -> PluginInstance { + PluginInstance { + id: id.to_owned(), + name: metadata.display_name.clone(), + artifact_sha256: metadata.sha256.clone(), + enabled, + trusted_process: true, + configuration, + secrets: Default::default(), + grants: metadata + .requested_permissions + .iter() + .cloned() + .map(|permission| PluginPermissionGrant { permission }) + .collect(), + bindings, + revision, + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs b/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs new file mode 100644 index 000000000..2ea751d91 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/distribution.rs @@ -0,0 +1,379 @@ +use std::collections::BTreeSet; + +use super::PluginsService; +use crate::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, PluginInstallResult, PluginSource, + distribution::{ + GithubReleaseQuery, PluginDistributionEgress, PluginRelease, PluginSourceBinding, + PluginUpdateCheck, PluginUpdatePolicy, PluginUpdateSource, RemotePluginInstall, + RemotePluginLocation, RemotePluginVerify, SourceAuthentication, SourceCredential, + SourceCredentialInfo, VerifiedPluginArtifact, + }, + }, + }, + ports::store::AdminStoreErrorKind, + use_case::{map_store_error, publish_committed}, +}; + +impl PluginsService { + pub async fn update_sources( + &self, + ) -> Result, AdminError> { + self.store + .list_update_sources() + .await + .map_err(|e| map_store_error(e, "plugin")) + } + + pub async fn change_update_source( + &self, + mut binding: crate::model::plugins::distribution::PluginSourceBinding, + context: &MutationContext, + ) -> Result { + if matches!(binding.source, PluginUpdateSource::Builtin) { + return Err(AdminError::invalid("官方来源只由发行清单确认")); + } + if let PluginUpdateSource::Github { repository } = &mut binding.source { + repository.make_ascii_lowercase(); + } + self.distribution + .transport + .validate_source(&binding.source)?; + validate_update_policy(&binding)?; + validate_source_egress(&binding)?; + self.resolve_egress(binding.outbound_proxy_id.as_deref()) + .await?; + let revision = self + .store + .change_update_source(binding, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(revision) + } + + pub async fn check_update( + &self, + plugin_id: &str, + credential_ids: &[String], + ) -> Result { + let binding = self + .update_sources() + .await? + .into_iter() + .find(|binding| binding.plugin_id == plugin_id) + .ok_or_else(|| AdminError::not_found("插件更新来源不存在"))?; + validate_update_policy(&binding)?; + let PluginUpdateSource::Github { repository } = &binding.source else { + return Err(AdminError::invalid( + "该来源不提供 Release 更新查询,请显式选择并安装插件包", + )); + }; + let (tag, allow_prerelease) = match &binding.policy { + PluginUpdatePolicy::Manual {} => { + return Err(AdminError::invalid("请先为该来源设置更新检查策略")); + } + PluginUpdatePolicy::Stable {} => (None, false), + PluginUpdatePolicy::Pinned { + tag, + allow_prerelease, + } => (Some(tag.clone()), *allow_prerelease), + }; + let release = self + .query_release( + GithubReleaseQuery { + repository: repository.clone(), + tag, + allow_prerelease, + }, + credential_ids, + binding.outbound_proxy_id.as_deref(), + ) + .await?; + // 查询期间来源或策略被修改时返回冲突,不把旧来源的候选交给下一步安装。 + if !self.update_sources().await?.contains(&binding) { + return Err(AdminError::conflict("插件更新来源已变更,请重新检查")); + } + Ok(PluginUpdateCheck { binding, release }) + } + pub async fn source_credentials(&self) -> Result, AdminError> { + self.store + .list_source_credentials() + .await + .map_err(|e| map_store_error(e, "plugin")) + } + + pub async fn create_source_credential( + &self, + mut info: SourceCredentialInfo, + authentication: SourceAuthentication, + context: &MutationContext, + ) -> Result { + info.id = uuid::Uuid::now_v7().to_string(); + let credential = SourceCredential { + info: info.clone(), + authentication, + }; + self.distribution + .transport + .validate_credential(&credential)?; + let revision = self + .store + .save_source_credential(credential, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(info) + } + + pub async fn delete_source_credential( + &self, + id: &str, + context: &MutationContext, + ) -> Result { + let revision = self + .store + .delete_source_credential(id, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(revision) + } + + pub async fn query_release( + &self, + query: GithubReleaseQuery, + credential_ids: &[String], + outbound_proxy_id: Option<&str>, + ) -> Result { + let egress = self.resolve_egress(outbound_proxy_id).await?; + let release = self + .distribution + .transport + .query_release( + query, + self.credentials(credential_ids).await?, + egress.clone(), + ) + .await?; + self.ensure_egress_current(egress.as_ref()).await?; + Ok(release) + } + + pub async fn install_remote( + &self, + input: RemotePluginInstall, + context: &MutationContext, + ) -> Result { + if !crate::model::plugins::valid_plugin_id(&input.plugin_id) + || input.version.is_empty() + || input.version.len() > 128 + { + return Err(AdminError::invalid("安装需要已解析的插件 ID 与版本")); + } + let digest = match &input.location { + RemotePluginLocation::Url { sha256, .. } + | RemotePluginLocation::Github { sha256, .. } => sha256, + }; + if digest.is_none() { + return Err(AdminError::invalid("安装需要已校验的插件包摘要")); + } + let (artifact, source) = self + .inspect_remote(RemotePluginVerify { + location: input.location, + credential_ids: input.credential_ids, + outbound_proxy_id: input.outbound_proxy_id, + expected_plugin_id: Some(input.plugin_id), + }) + .await?; + if artifact.metadata.version != input.version { + return Err(AdminError::invalid("插件清单版本与安装选择不符")); + } + let mutation = self.persist(artifact, source, context).await?; + self.complete_install(mutation, context).await + } + + pub async fn verify_remote( + &self, + input: RemotePluginVerify, + ) -> Result { + let (artifact, source) = self.inspect_remote(input).await?; + Ok(VerifiedPluginArtifact { + metadata: artifact.metadata, + source, + }) + } + + async fn inspect_remote( + &self, + input: RemotePluginVerify, + ) -> Result<(InspectedPluginArtifact, PluginSource), AdminError> { + if input + .expected_plugin_id + .as_deref() + .is_some_and(|id| !crate::model::plugins::valid_plugin_id(id)) + { + return Err(AdminError::invalid("预期插件 ID 不合法")); + } + let source = match &input.location { + RemotePluginLocation::Url { url, .. } => PluginUpdateSource::Url { url: url.clone() }, + RemotePluginLocation::Github { repository, .. } => PluginUpdateSource::Github { + repository: repository.to_ascii_lowercase(), + }, + }; + let outbound_proxy_id = input.outbound_proxy_id.clone(); + self.distribution.transport.validate_source(&source)?; + let bindings = self.update_sources().await?; + let expected_binding = bindings + .iter() + .find(|binding| Some(&binding.plugin_id) == input.expected_plugin_id.as_ref()); + if expected_binding.is_some_and(|binding| { + binding.source != source || binding.outbound_proxy_id != outbound_proxy_id + }) { + return Err(AdminError::conflict( + "插件来源与已保存配置不符,请先显式修改来源", + )); + } + let egress = self.resolve_egress(outbound_proxy_id.as_deref()).await?; + let download = self + .distribution + .transport + .download( + input.location, + self.credentials(&input.credential_ids).await?, + egress.clone(), + ) + .await?; + self.ensure_egress_current(egress.as_ref()).await?; + let artifact = self + .inspector + .inspect(download.archive, Some(download.sha256)) + .await?; + if input + .expected_plugin_id + .as_ref() + .is_some_and(|id| *id != artifact.metadata.plugin_id) + { + return Err(AdminError::invalid("插件清单身份与安装选择不符")); + } + // 首次解析前身份未知,按包内身份复核下载前的来源绑定。 + let binding = bindings + .iter() + .find(|binding| binding.plugin_id == artifact.metadata.plugin_id); + if binding.is_some_and(|binding| { + binding.source != source || binding.outbound_proxy_id != outbound_proxy_id + }) { + return Err(AdminError::conflict( + "插件来源与已保存配置不符,请先显式修改来源", + )); + } + if PluginUpdateSource::from(&download.source) != source { + return Err(AdminError::invalid("下载来源与校验选择不符")); + } + if download.source.outbound_proxy() != egress.as_ref().map(|egress| &egress.source) { + return Err(AdminError::invalid("下载出站身份与安装选择不符")); + } + let current_binding = self + .update_sources() + .await? + .into_iter() + .find(|binding| binding.plugin_id == artifact.metadata.plugin_id); + if current_binding.as_ref() != binding { + return Err(AdminError::conflict("插件更新来源已变更,请重新校验")); + } + Ok((artifact, download.source)) + } + + async fn resolve_egress( + &self, + outbound_proxy_id: Option<&str>, + ) -> Result, AdminError> { + let Some(id) = outbound_proxy_id else { + return Ok(None); + }; + if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) { + return Err(AdminError::invalid("插件来源出站代理不合法")); + } + let record = self + .distribution + .proxies + .get(id) + .await + .map_err(|error| map_store_error(error, "proxy"))?; + Ok(Some(PluginDistributionEgress::new( + record.id, + record.revision, + record.proxy, + ))) + } + + async fn ensure_egress_current( + &self, + egress: Option<&PluginDistributionEgress>, + ) -> Result<(), AdminError> { + let Some(egress) = egress else { + return Ok(()); + }; + let current = match self.distribution.proxies.get(&egress.source.id).await { + Ok(current) => current, + Err(error) if error.kind() == AdminStoreErrorKind::NotFound => { + return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验")); + } + Err(error) => return Err(map_store_error(error, "proxy")), + }; + if current.revision.get() != egress.source.revision || current.proxy != egress.proxy { + return Err(AdminError::conflict("插件来源出站代理已变更,请重新校验")); + } + Ok(()) + } + + async fn credentials(&self, ids: &[String]) -> Result, AdminError> { + if ids.len() > 8 || ids.iter().collect::>().len() != ids.len() { + return Err(AdminError::invalid("下载凭据不能重复,且最多选择 8 个")); + } + let mut credentials = Vec::with_capacity(ids.len()); + for id in ids { + credentials.push( + self.store + .load_source_credential(id) + .await + .map_err(|e| map_store_error(e, "plugin"))?, + ); + } + Ok(credentials) + } +} + +fn validate_update_policy(binding: &PluginSourceBinding) -> Result<(), AdminError> { + if !matches!(binding.source, PluginUpdateSource::Github { .. }) + && !matches!(binding.policy, PluginUpdatePolicy::Manual {}) + { + return Err(AdminError::invalid( + "只有 GitHub 来源支持 Release 更新检查策略", + )); + } + if let PluginUpdatePolicy::Pinned { tag, .. } = &binding.policy + && (tag.is_empty() + || tag.len() > 128 + || tag.chars().any(char::is_control) + || tag.trim() != tag) + { + return Err(AdminError::invalid("固定 Release tag 不合法")); + } + Ok(()) +} + +fn validate_source_egress(binding: &PluginSourceBinding) -> Result<(), AdminError> { + if binding.outbound_proxy_id.is_some() + && matches!( + binding.source, + PluginUpdateSource::Builtin | PluginUpdateSource::Upload + ) + { + return Err(AdminError::invalid("本地或官方来源不能配置出站代理")); + } + Ok(()) +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/instances.rs b/backend/crates/gateway-admin/src/use_case/plugins/instances.rs new file mode 100644 index 000000000..3c983c896 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/instances.rs @@ -0,0 +1,786 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId}; +use secrecy::ExposeSecret; + +use super::PluginsService; +use crate::{ + model::{ + AdminError, MutationContext, Revision, + plugins::instances::{ + ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, + PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus, + PluginInstanceSnapshot, PluginInstanceView, PluginPermissionGrant, + }, + plugins::state::{PluginStateCommit, PluginStateConfiguration}, + }, + use_case::{map_store_error, publish_committed}, +}; + +impl PluginsService { + pub async fn instances(&self) -> Result, AdminError> { + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let published = self.published.snapshot_for_diagnostics(); + let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get()); + let published_ready = published + .as_ref() + .is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve())); + let mut diagnostics = self + .preparation + .runtime_diagnostics( + &snapshot, + published_revision, + published + .as_ref() + .and_then(|snapshot| snapshot.extensions()), + ) + .await + .unwrap_or_default(); + let target_revision = snapshot.config_revision.get(); + let artifacts = self + .list() + .await? + .into_iter() + .map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata)) + .collect::>(); + let mut views = Vec::with_capacity(snapshot.instances.len()); + for instance in snapshot.instances { + let metadata = artifacts + .get(&instance.artifact_sha256) + .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; + let configuration_required = !self + .preparation + .configuration_ready(instance.clone(), metadata) + .await?; + let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| { + let running = instance.enabled + && published_ready + && published_revision == Some(target_revision); + PluginInstanceRuntime { + status: if !instance.enabled { + PluginInstanceRuntimeStatus::Disabled + } else if running { + PluginInstanceRuntimeStatus::Running + } else { + PluginInstanceRuntimeStatus::AwaitingPublication + }, + actual_revision: running.then_some(instance.revision.get()), + actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()), + failure: None, + draining_revisions: Vec::new(), + retained_continuations: 0, + } + }); + views.push(PluginInstanceView { + configuration_required, + running: runtime.status == PluginInstanceRuntimeStatus::Running, + published_revision, + runtime, + instance, + }); + } + Ok(views) + } + + pub async fn configure_instance( + &self, + id: Option<&str>, + input: ConfigurePluginInstance, + context: &MutationContext, + ) -> Result { + if id.is_some() && input.creation_id.is_some() { + return Err(AdminError::invalid("编辑配置不能使用创建标识")); + } + if id.is_none() && input.expected_revision.is_some() { + return Err(AdminError::invalid("创建配置不能指定已有配置版本")); + } + if let Some(creation_id) = &input.creation_id { + let parsed = uuid::Uuid::parse_str(creation_id) + .map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?; + if parsed.to_string() != *creation_id { + return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式")); + } + } + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let existing = match id { + Some(id) => Some( + snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .cloned() + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?, + ), + None => input.creation_id.as_ref().and_then(|creation_id| { + snapshot + .instances + .iter() + .find(|instance| instance.id == *creation_id) + .cloned() + }), + }; + if let Some(existing) = &existing { + if id.is_none() && !same_creation_request(existing, &input) { + return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑")); + } + if input + .expected_revision + .is_some_and(|revision| revision != existing.revision.get()) + { + return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试")); + } + } + self.configure_from_snapshot(snapshot, existing, input, context) + .await + } + + pub(super) async fn configure_from_snapshot( + &self, + mut snapshot: PluginInstanceSnapshot, + existing: Option, + input: ConfigurePluginInstance, + context: &MutationContext, + ) -> Result { + let expected_revision = snapshot.config_revision; + let candidate_revision = next_revision(expected_revision)?; + let artifacts = self + .store + .list_artifacts() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let artifact = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == input.artifact_sha256) + .ok_or_else(|| AdminError::not_found("插件制品不存在"))?; + if artifact.accepted_at.is_none() { + return Err(AdminError::invalid("请先安装并接受该版本声明的权限")); + } + let same_plugin = |digest: &str| { + artifacts.iter().any(|item| { + item.metadata.sha256 == digest + && item.metadata.plugin_id == artifact.metadata.plugin_id + }) + }; + if existing + .as_ref() + .is_some_and(|previous| !same_plugin(&previous.artifact_sha256)) + { + return Err(AdminError::invalid("不能将已有配置切换为另一个插件")); + } + if input.enabled + && snapshot.instances.iter().any(|other| { + other.enabled + && existing + .as_ref() + .is_none_or(|current| current.id != other.id) + && same_plugin(&other.artifact_sha256) + && !input + .replace_instances + .iter() + .any(|replacement| replacement.id == other.id) + }) + { + return Err(AdminError::conflict( + "此插件已有启用配置,请先切换或停用旧配置", + )); + } + let saved = if let Some(previous) = &existing + && previous.artifact_sha256 != input.artifact_sha256 + && input.secrets.is_none() + { + self.store + .load_version_configuration(&previous.id, &input.artifact_sha256) + .await + .map_err(|error| map_store_error(error, "plugin"))? + } else { + None + }; + let instance = PluginInstance { + id: existing.as_ref().map_or_else( + || { + input + .creation_id + .clone() + .unwrap_or_else(|| uuid::Uuid::now_v7().to_string()) + }, + |instance| instance.id.clone(), + ), + name: input.name.trim().to_owned(), + artifact_sha256: input.artifact_sha256, + enabled: input.enabled, + trusted_process: true, + configuration: input.configuration, + secrets: input + .secrets + .or_else(|| saved.map(|saved| saved.secrets)) + .unwrap_or_else(|| { + existing + .as_ref() + .map_or_else(Default::default, |instance| instance.secrets.clone()) + }), + grants: artifact + .metadata + .requested_permissions + .iter() + .cloned() + .map(|permission| PluginPermissionGrant { permission }) + .collect(), + bindings: input.bindings, + revision: candidate_revision, + }; + validate_input(&instance)?; + if existing + .as_ref() + .is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256) + && !self + .preparation + .configuration_ready(instance.clone(), &artifact.metadata) + .await? + { + return Err(AdminError::invalid( + "目标版本仍缺少必填设置,请补充后再切换", + )); + } + + let replacements = input.replace_instances; + if !replacements.is_empty() { + if !instance.enabled || replacements.len() > 256 { + return Err(AdminError::invalid( + "仅启用配置时可以切换其他配置,单次最多 256 项", + )); + } + let mut ids = BTreeSet::new(); + for replacement in &replacements { + if replacement.id == instance.id || !ids.insert(&replacement.id) { + return Err(AdminError::invalid("停用列表不能重复或包含当前配置")); + } + let previous = snapshot + .instances + .iter() + .find(|item| item.id == replacement.id) + .ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?; + if !previous.enabled || previous.revision.get() != replacement.expected_revision { + return Err(AdminError::conflict("待停用配置已变更,请重新确认")); + } + if !artifacts.iter().any(|item| { + item.metadata.sha256 == previous.artifact_sha256 + && item.metadata.plugin_id == artifact.metadata.plugin_id + }) { + return Err(AdminError::invalid("只能切换同一插件的配置")); + } + } + } + let state = self.preparation.validate(instance.clone()).await?; + if let Some(existing) = &existing + && self.state.transition_required(&existing.id, &state).await? + { + return self + .configure_instance_with_state_transition( + snapshot, + existing.clone(), + instance, + state, + &replacements, + context, + ) + .await; + } + snapshot.instances.retain(|item| item.id != instance.id); + snapshot.instances.push(instance.clone()); + snapshot.config_revision = candidate_revision; + apply_replacements(&mut snapshot, &replacements); + let prepared = self + .preparation + .prepare(expected_revision, snapshot.clone()) + .await?; + let result = self + .store + .save_instance_replacing( + instance, + expected_revision, + PluginStateCommit { + configuration: state, + transition_id: None, + }, + &replacements, + context, + ) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let prepared = if result.config_revision == candidate_revision + && result.instance.revision == candidate_revision + { + prepared + } else { + snapshot.config_revision = result.config_revision; + snapshot + .instances + .retain(|item| item.id != result.instance.id); + snapshot.instances.push(result.instance.clone()); + apply_replacements(&mut snapshot, &replacements); + self.preparation + .prepare(result.config_revision, snapshot) + .await? + }; + self.preparation + .activate_state(&prepared, &result.instance) + .await?; + // prepared 的强引用跨过提交与唯一发布入口,防止候选在被读取之前回收。 + publish_committed(self.snapshots.as_ref(), result.config_revision).await?; + drop(prepared); + Ok(result) + } + + async fn configure_instance_with_state_transition( + &self, + mut snapshot: PluginInstanceSnapshot, + previous: PluginInstance, + mut target: PluginInstance, + target_state: PluginStateConfiguration, + replacements: &[PluginInstanceReplacement], + context: &MutationContext, + ) -> Result { + let previous_state = self.preparation.validate(previous.clone()).await?; + let original_revision = snapshot.config_revision; + // 即使最终保持停用,迁移也必须由目标制品的受控候选执行。 + let mut migration_instance = target.clone(); + migration_instance.enabled = true; + snapshot.config_revision = migration_instance.revision; + snapshot + .instances + .retain(|instance| instance.id != migration_instance.id); + snapshot.instances.push(migration_instance); + apply_replacements(&mut snapshot, replacements); + let migration_prepared = self + .preparation + .prepare(original_revision, snapshot) + .await?; + + let mut disabled_revision = None; + let disabled = if previous.enabled { + let mut disabled = previous.clone(); + disabled.enabled = false; + let mutation = self + .store + .save_instance_with_state( + disabled, + original_revision, + PluginStateCommit { + configuration: previous_state.clone(), + transition_id: None, + }, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + self.preparation + .quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision) + .await; + disabled_revision = Some(mutation.config_revision); + mutation.instance + } else { + previous.clone() + }; + let expected_revision = disabled_revision.unwrap_or(original_revision); + let transition = match self + .state + .begin_transition( + &disabled.id, + disabled.revision, + &target.artifact_sha256, + target_state.clone(), + ) + .await + { + Ok(transition) => transition, + Err(error) => { + return Err(self + .recover_failed_state_transition( + None, + &previous, + &previous_state, + disabled_revision, + context, + error, + ) + .await); + } + }; + if let Err(error) = self + .preparation + .migrate_state(&migration_prepared, transition.clone()) + .await + { + return Err(self + .recover_failed_state_transition( + Some(&transition.id), + &previous, + &previous_state, + disabled_revision, + context, + error, + ) + .await); + } + + let mut current = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if current.config_revision != expected_revision { + return Err(self + .recover_failed_state_transition( + Some(&transition.id), + &previous, + &previous_state, + disabled_revision, + context, + AdminError::conflict("迁移期间插件配置已发生变化"), + ) + .await); + } + let candidate_revision = next_revision(expected_revision)?; + target.revision = candidate_revision; + current.instances.retain(|item| item.id != target.id); + current.instances.push(target.clone()); + current.config_revision = candidate_revision; + apply_replacements(&mut current, replacements); + let prepared = match self + .preparation + .prepare(expected_revision, current.clone()) + .await + { + Ok(prepared) => prepared, + Err(error) => { + return Err(self + .recover_failed_state_transition( + Some(&transition.id), + &previous, + &previous_state, + disabled_revision, + context, + error, + ) + .await); + } + }; + let result = match self + .store + .save_instance_replacing( + target, + expected_revision, + PluginStateCommit { + configuration: target_state, + transition_id: Some(transition.id.clone()), + }, + replacements, + context, + ) + .await + { + Ok(result) => result, + Err(error) => { + return Err(self + .recover_failed_state_transition( + Some(&transition.id), + &previous, + &previous_state, + disabled_revision, + context, + map_store_error(error, "plugin"), + ) + .await); + } + }; + let prepared = if result.config_revision == candidate_revision + && result.instance.revision == candidate_revision + { + prepared + } else { + current.config_revision = result.config_revision; + current + .instances + .retain(|item| item.id != result.instance.id); + current.instances.push(result.instance.clone()); + apply_replacements(&mut current, replacements); + self.preparation + .prepare(result.config_revision, current) + .await? + }; + self.preparation + .activate_state(&prepared, &result.instance) + .await?; + publish_committed(self.snapshots.as_ref(), result.config_revision).await?; + drop(migration_prepared); + drop(prepared); + Ok(result) + } + + async fn recover_failed_state_transition( + &self, + transition_id: Option<&str>, + previous: &PluginInstance, + previous_state: &PluginStateConfiguration, + disabled_revision: Option, + context: &MutationContext, + original_error: AdminError, + ) -> AdminError { + if let Some(transition_id) = transition_id { + self.state.abort_transition(transition_id).await; + } + let Some(disabled_revision) = disabled_revision else { + return original_error; + }; + let restored = async { + let mut snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + if snapshot.config_revision != disabled_revision { + return Err(AdminError::conflict( + "状态迁移失败;实例已保持停用,且配置已被其他操作修改", + )); + } + let candidate_revision = next_revision(disabled_revision)?; + let mut restore = previous.clone(); + restore.enabled = true; + restore.revision = candidate_revision; + snapshot.instances.retain(|item| item.id != restore.id); + snapshot.instances.push(restore.clone()); + snapshot.config_revision = candidate_revision; + let prepared = self + .preparation + .prepare(disabled_revision, snapshot.clone()) + .await?; + let mutation = self + .store + .save_instance_with_state( + restore, + disabled_revision, + PluginStateCommit { + configuration: previous_state.clone(), + transition_id: None, + }, + context, + ) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let prepared = if mutation.config_revision == candidate_revision + && mutation.instance.revision == candidate_revision + { + prepared + } else { + snapshot.config_revision = mutation.config_revision; + snapshot + .instances + .retain(|item| item.id != mutation.instance.id); + snapshot.instances.push(mutation.instance.clone()); + self.preparation + .prepare(mutation.config_revision, snapshot) + .await? + }; + self.preparation + .activate_state(&prepared, &mutation.instance) + .await?; + publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?; + Ok::<(), AdminError>(()) + } + .await; + match restored { + Ok(()) => original_error, + Err(error) => { + tracing::warn!( + error_kind = ?error.kind(), + "plugin state migration failed and the previous instance could not be restored" + ); + AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用") + } + } + } + + pub async fn delete_instance( + &self, + id: &str, + context: &MutationContext, + ) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let instance = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + if instance.enabled { + return Err(AdminError::conflict("请先停用插件实例")); + } + let revision = self + .store + .delete_instance(id, snapshot.config_revision, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), revision).await?; + Ok(revision) + } + + /// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同。 + pub async fn disable_instance( + &self, + id: &str, + context: &MutationContext, + ) -> Result { + let PluginInstanceSnapshot { + config_revision, + instances, + } = self + .store + .load_instances() + .await + .map_err(|e| map_store_error(e, "plugin"))?; + let mut instance = instances + .into_iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + instance.enabled = false; + let result = self + .store + .save_instance(instance, config_revision, context) + .await + .map_err(|e| map_store_error(e, "plugin"))?; + publish_committed(self.snapshots.as_ref(), result.config_revision).await?; + Ok(result) + } +} + +fn apply_replacements( + snapshot: &mut PluginInstanceSnapshot, + replacements: &[PluginInstanceReplacement], +) { + for instance in &mut snapshot.instances { + if replacements.iter().any(|item| item.id == instance.id) { + instance.enabled = false; + instance.revision = snapshot.config_revision; + } + } +} + +fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool { + let same_secrets = input + .secrets + .as_ref() + .map_or(existing.secrets.is_empty(), |secrets| { + secrets.len() == existing.secrets.len() + && secrets.iter().all(|(key, value)| { + existing + .secrets + .get(key) + .is_some_and(|current| current.expose_secret() == value.expose_secret()) + }) + }); + existing.name == input.name.trim() + && existing.artifact_sha256 == input.artifact_sha256 + && existing.enabled == input.enabled + && existing.configuration == input.configuration + && existing.bindings == input.bindings + && same_secrets +} + +fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> { + if instance.name.is_empty() + || instance.name.len() > 128 + || instance.name.chars().any(char::is_control) + || instance.artifact_sha256.len() != 64 + || !instance + .artifact_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !instance.configuration.is_object() + || serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024) + || instance.secrets.len() > 64 + || instance.grants.len() > 32 + || instance.bindings.len() > 64 + { + return Err(AdminError::invalid("插件实例配置不合法或超过大小限制")); + } + use secrecy::ExposeSecret as _; + if instance + .secrets + .iter() + .any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192) + || instance + .secrets + .values() + .map(|value| value.expose_secret().len()) + .sum::() + > 48 * 1024 + { + return Err(AdminError::invalid("插件敏感配置超过大小限制")); + } + if instance.bindings.iter().any(|binding| { + binding.contribution.is_empty() + || binding.contribution.len() > 128 + || !binding + .contribution + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + || binding.client_key_ids.len() > 256 + || binding.account_group_ids.len() > 256 + || binding.client_key_ids.iter().collect::>().len() + != binding.client_key_ids.len() + || binding + .account_group_ids + .iter() + .collect::>() + .len() + != binding.account_group_ids.len() + || binding + .client_key_ids + .iter() + .any(|id| ClientApiKeyId::new(id.clone()).is_err()) + || binding + .account_group_ids + .iter() + .any(|id| AccountGroupId::new(id.clone()).is_err()) + || binding.identity_bindings.len() > 256 + || binding + .identity_bindings + .iter() + .map(|identity| &identity.principal) + .collect::>() + .len() + != binding.identity_bindings.len() + || binding.identity_bindings.iter().any(|identity| { + identity.principal.trim() != identity.principal + || identity.principal.is_empty() + || identity.principal.len() > 256 + || identity.principal.chars().any(char::is_control) + || ClientApiKeyId::new(identity.client_key_id.clone()).is_err() + }) + }) { + return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法")); + } + Ok(()) +} + +fn next_revision(revision: Revision) -> Result { + Revision::new( + revision + .get() + .checked_add(1) + .ok_or_else(|| AdminError::internal("配置版本已耗尽"))?, + ) + .map_err(|_| AdminError::internal("配置版本不合法")) +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/management.rs b/backend/crates/gateway-admin/src/use_case/plugins/management.rs new file mode 100644 index 000000000..d49efeded --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/management.rs @@ -0,0 +1,145 @@ +use std::sync::Arc; + +use gateway_core::runtime::{RuntimeSnapshotHandle, extensions::ExtensionSetReference}; + +use crate::{ + model::{ + AdminError, + plugins::{ + instances::PluginInstance, + management::{ + PluginManagementRequest, PluginManagementResponse, PluginManagementTarget, + PluginManagementView, + }, + }, + }, + ports::{plugin_management::PluginManagement, plugins::PluginStore}, +}; + +/// 菜单是发布视图的投影;每次业务动作仍复核持久实例,旧页面不能保留已撤销权限。 +pub struct PluginManagementService { + runtime: Arc, + store: Arc, + published: RuntimeSnapshotHandle, +} + +impl PluginManagementService { + /// 页面发起模型请求前复核持久目标与当前发布代次;长响应可重复调用以收敛撤销。 + pub async fn authorize_models( + &self, + target: &PluginManagementTarget, + ) -> Result<(), AdminError> { + let reference = self.authorize(target).await?; + self.runtime.authorize_models(&reference, target).await + } + + pub async fn start_callback( + &self, + target: &PluginManagementTarget, + command: crate::model::plugins::management::StartPluginManagementCallback, + context: &crate::model::auth::AdminRequestContext, + ) -> Result { + let reference = self.authorize(target).await?; + self.runtime + .start_callback(&reference, target, command, context) + .await + } + + pub async fn callback( + &self, + target: &PluginManagementTarget, + state: &str, + request: PluginManagementRequest, + ) -> Result { + let reference = self.authorize(target).await?; + self.runtime + .callback(&reference, target, state, request) + .await + } + + #[must_use] + pub fn new( + runtime: Arc, + store: Arc, + published: RuntimeSnapshotHandle, + ) -> Self { + Self { + runtime, + store, + published, + } + } + + pub async fn views(&self) -> Result, AdminError> { + let Some(reference) = self.reference()? else { + return Ok(Vec::new()); + }; + let mut views = self.runtime.views(&reference).await?; + let snapshot = self + .store + .load_instances() + .await + .map_err(|_| AdminError::unavailable("无法复核插件页面授权"))?; + views.retain(|view| { + snapshot + .instances + .iter() + .any(|instance| matches(instance, &view.target)) + }); + Ok(views) + } + + pub async fn resource( + &self, + target: &PluginManagementTarget, + path: &str, + public: bool, + ) -> Result { + let reference = self.authorize(target).await?; + self.runtime + .resource(&reference, target, path, public) + .await + } + + pub async fn handle( + &self, + target: &PluginManagementTarget, + request: PluginManagementRequest, + ) -> Result { + let reference = self.authorize(target).await?; + self.runtime.handle(&reference, target, request).await + } + + fn reference(&self) -> Result, AdminError> { + self.published + .acquire() + .map(|snapshot| snapshot.extensions().cloned()) + .map_err(|_| AdminError::unavailable("插件发布视图暂不可用")) + } + + async fn authorize( + &self, + target: &PluginManagementTarget, + ) -> Result { + let reference = self + .reference()? + .ok_or_else(|| AdminError::not_found("插件管理扩展不存在"))?; + let current = self + .store + .management_target_is_current(target) + .await + .map_err(|_| AdminError::unavailable("无法复核插件管理授权"))?; + if !current { + return Err(AdminError::conflict("插件版本或授权已变化,请刷新页面")); + } + Ok(reference) + } +} + +fn matches(instance: &PluginInstance, target: &PluginManagementTarget) -> bool { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/mod.rs b/backend/crates/gateway-admin/src/use_case/plugins/mod.rs new file mode 100644 index 000000000..06ef142d1 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/mod.rs @@ -0,0 +1,10 @@ +mod artifacts; +mod distribution; +mod instances; +mod management; +pub(crate) mod official; +mod state; +mod versions; + +pub use artifacts::{PluginDistributionPorts, PluginsService}; +pub use management::PluginManagementService; diff --git a/backend/crates/gateway-admin/src/use_case/plugins/official.rs b/backend/crates/gateway-admin/src/use_case/plugins/official.rs new file mode 100644 index 000000000..1af450186 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/official.rs @@ -0,0 +1,362 @@ +use std::{collections::BTreeSet, path::Path}; + +use serde::Deserialize; + +use crate::{ + model::{ + AdminError, AdminErrorKind, MutationActor, MutationContext, + plugins::{ + InspectedPluginArtifact, PluginHostCompatibility, PluginSource, + official::{OfficialPluginImport, OfficialPluginReleaseIdentity}, + }, + }, + ports::plugin_release::{ + OfficialPluginReleaseFiles, OfficialPluginReleaseReadError, + OfficialPluginReleaseReadErrorKind, + }, +}; + +use super::PluginsService; + +const MANIFEST_SCHEMA_VERSION: u32 = 1; +const MAXIMUM_MANIFEST_BYTES: usize = 256 * 1024; +const MAXIMUM_PLUGINS: usize = 64; +const MAXIMUM_ARTIFACTS_PER_PLUGIN: usize = 8; +const MAXIMUM_VERSION_BYTES: usize = 128; +const MAXIMUM_PUBLISHER_BYTES: usize = 256; +const MAXIMUM_STABILITY_BYTES: usize = 32; +const MAXIMUM_ARCHIVE_NAME_BYTES: usize = 255; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SealedReleaseManifest { + schema_version: u32, + sealed: bool, + gateway_version: String, + gateway_git_sha: String, + plugin_host: PluginHostCompatibility, + plugins: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OfficialPlugin { + id: String, + version: String, + publisher: String, + stability: String, + artifacts: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct OfficialArtifact { + release_os: String, + release_architecture: String, + target: String, + os: String, + architecture: String, + archive: String, + sha256: String, +} + +struct ValidatedOfficialArtifact { + inspected: InspectedPluginArtifact, +} + +#[derive(Clone, Copy)] +struct Platform { + release_os: &'static str, + release_architecture: &'static str, + target: &'static str, + runtime_os: &'static str, + runtime_architecture: &'static str, +} + +impl PluginsService { + /// 从与宿主发行物同信任边界的封口清单导入官方包。 + /// + /// 所有清单项和包体会先完成静态校验,再进入与上传/远程安装相同的持久化用例。 + pub async fn import_official_release( + &self, + files: &dyn OfficialPluginReleaseFiles, + identity: &OfficialPluginReleaseIdentity, + context: &MutationContext, + ) -> Result { + if !matches!(context.actor, MutationActor::System) { + return Err(AdminError::new( + AdminErrorKind::Forbidden, + "官方插件只能由宿主发行导入", + )); + } + let Some(bytes) = files.manifest().await.map_err(map_read_error)? else { + return Ok(OfficialPluginImport { + artifacts: 0, + config_revision: None, + }); + }; + let ValidatedManifest { + gateway_version, + plugins, + plugins_len, + } = validate_manifest(&bytes, identity)?; + let platform = current_platform() + .ok_or_else(|| AdminError::invalid("当前平台不支持官方插件发行物"))?; + let mut validated = Vec::with_capacity(plugins.len()); + + // 先完成整份清单的静态检查;持久化故障后的已提交前缀由下次启动幂等补齐。 + for plugin in plugins { + let artifact = select_artifact(&plugin, platform)?; + let archive = files + .artifact(&artifact.archive) + .await + .map_err(map_read_error)?; + let inspected = self + .inspector + .inspect(archive, Some(artifact.sha256.clone())) + .await?; + validate_inspected(&plugin, &artifact, &inspected)?; + validated.push(ValidatedOfficialArtifact { inspected }); + } + + let mut revision = None; + for artifact in validated { + let mutation = self + .persist( + artifact.inspected, + PluginSource::Builtin { + release: gateway_version.clone(), + }, + context, + ) + .await?; + revision = Some(mutation.config_revision); + } + Ok(OfficialPluginImport { + artifacts: plugins_len, + config_revision: revision, + }) + } +} + +struct ValidatedManifest { + gateway_version: String, + plugins: Vec, + plugins_len: usize, +} + +fn validate_manifest( + bytes: &[u8], + identity: &OfficialPluginReleaseIdentity, +) -> Result { + let manifest = parse_manifest(bytes)?; + if manifest.gateway_version != identity.gateway_version + || manifest.gateway_git_sha != identity.gateway_git_sha + { + return Err(invalid_manifest()); + } + let plugins_len = manifest.plugins.len(); + Ok(ValidatedManifest { + gateway_version: manifest.gateway_version, + plugins: manifest.plugins, + plugins_len, + }) +} + +pub(crate) fn update_compatibility( + bytes: &[u8], + target_version: &str, +) -> Result { + let manifest = parse_manifest(bytes)?; + (manifest.gateway_version == target_version) + .then_some(manifest.plugin_host) + .ok_or_else(invalid_manifest) +} + +fn parse_manifest(bytes: &[u8]) -> Result { + if bytes.len() > MAXIMUM_MANIFEST_BYTES { + return Err(invalid_manifest()); + } + let manifest: SealedReleaseManifest = + serde_json::from_slice(bytes).map_err(|_| invalid_manifest())?; + if manifest.schema_version != MANIFEST_SCHEMA_VERSION + || !manifest.sealed + || !valid_text(&manifest.gateway_version, MAXIMUM_VERSION_BYTES) + || !valid_git_sha(&manifest.gateway_git_sha) + || !manifest.plugin_host.is_valid() + || manifest.plugins.len() > MAXIMUM_PLUGINS + { + return Err(invalid_manifest()); + } + let mut plugin_ids = BTreeSet::new(); + let mut archives = BTreeSet::new(); + for plugin in &manifest.plugins { + if !crate::model::plugins::valid_plugin_id(&plugin.id) + || !valid_text(&plugin.version, MAXIMUM_VERSION_BYTES) + || !valid_text(&plugin.publisher, MAXIMUM_PUBLISHER_BYTES) + || plugin.id.split_once('.').map(|(publisher, _)| publisher) + != Some(plugin.publisher.as_str()) + || !valid_text(&plugin.stability, MAXIMUM_STABILITY_BYTES) + || !plugin_ids.insert(plugin.id.as_str()) + || plugin.artifacts.is_empty() + || plugin.artifacts.len() > MAXIMUM_ARTIFACTS_PER_PLUGIN + { + return Err(invalid_manifest()); + } + let mut platforms = BTreeSet::new(); + for artifact in &plugin.artifacts { + if !valid_artifact(plugin, artifact) + || !platforms.insert(( + artifact.release_os.as_str(), + artifact.release_architecture.as_str(), + )) + || !archives.insert(artifact.archive.as_str()) + { + return Err(invalid_manifest()); + } + } + } + Ok(manifest) +} + +fn valid_artifact(plugin: &OfficialPlugin, artifact: &OfficialArtifact) -> bool { + supported_platform(artifact).is_some() + && valid_archive_name(&artifact.archive) + && valid_sha256(&artifact.sha256) + && artifact.archive + == format!( + "{}-{}-{}-{}.tar.gz", + plugin.id, plugin.version, artifact.os, artifact.architecture + ) +} + +fn supported_platform(artifact: &OfficialArtifact) -> Option { + SUPPORTED_PLATFORMS.iter().copied().find(|platform| { + artifact.release_os == platform.release_os + && artifact.release_architecture == platform.release_architecture + && artifact.target == platform.target + && artifact.os == platform.runtime_os + && artifact.architecture == platform.runtime_architecture + }) +} + +fn select_artifact( + plugin: &OfficialPlugin, + current: Platform, +) -> Result { + let mut matching = plugin.artifacts.iter().filter(|artifact| { + artifact.release_os == current.release_os + && artifact.release_architecture == current.release_architecture + && artifact.target == current.target + && artifact.os == current.runtime_os + && artifact.architecture == current.runtime_architecture + }); + let selected = matching.next().cloned().ok_or_else(invalid_manifest)?; + if matching.next().is_some() { + return Err(invalid_manifest()); + } + Ok(selected) +} + +fn validate_inspected( + plugin: &OfficialPlugin, + artifact: &OfficialArtifact, + inspected: &InspectedPluginArtifact, +) -> Result<(), AdminError> { + if inspected.metadata.plugin_id != plugin.id + || inspected.metadata.publisher != plugin.publisher + || inspected.metadata.version != plugin.version + || inspected.metadata.sha256 != artifact.sha256 + || !inspected + .metadata + .platforms + .iter() + .any(|value| value == &format!("{}-{}", artifact.os, artifact.architecture)) + { + return Err(AdminError::invalid("官方插件包身份与发行清单不一致")); + } + Ok(()) +} + +fn valid_text(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn valid_git_sha(value: &str) -> bool { + value.len() == 40 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn valid_archive_name(value: &str) -> bool { + value.len() <= MAXIMUM_ARCHIVE_NAME_BYTES + && value.ends_with(".tar.gz") + && Path::new(value).file_name().and_then(|name| name.to_str()) == Some(value) + && Path::new(value).components().count() == 1 + && !value.chars().any(char::is_control) +} + +fn map_read_error(error: OfficialPluginReleaseReadError) -> AdminError { + match error.kind() { + OfficialPluginReleaseReadErrorKind::Invalid + | OfficialPluginReleaseReadErrorKind::NotFound => invalid_manifest(), + OfficialPluginReleaseReadErrorKind::Unavailable => { + AdminError::unavailable("官方插件发行物暂不可读取") + } + } +} + +fn invalid_manifest() -> AdminError { + AdminError::invalid("官方插件发行清单不合法或不属于当前宿主发行物") +} + +fn current_platform() -> Option { + SUPPORTED_PLATFORMS.iter().copied().find(|platform| { + platform.runtime_os == std::env::consts::OS + && platform.runtime_architecture == std::env::consts::ARCH + && target_environment_matches(platform.target) + }) +} + +fn target_environment_matches(target: &str) -> bool { + match target { + "x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"), + "aarch64-apple-darwin" => cfg!(target_os = "macos"), + _ => false, + } +} + +const SUPPORTED_PLATFORMS: &[Platform] = &[ + Platform { + release_os: "linux", + release_architecture: "amd64", + target: "x86_64-unknown-linux-gnu", + runtime_os: "linux", + runtime_architecture: "x86_64", + }, + Platform { + release_os: "linux", + release_architecture: "arm64", + target: "aarch64-unknown-linux-gnu", + runtime_os: "linux", + runtime_architecture: "aarch64", + }, + Platform { + release_os: "darwin", + release_architecture: "arm64", + target: "aarch64-apple-darwin", + runtime_os: "macos", + runtime_architecture: "aarch64", + }, +]; diff --git a/backend/crates/gateway-admin/src/use_case/plugins/state.rs b/backend/crates/gateway-admin/src/use_case/plugins/state.rs new file mode 100644 index 000000000..37672f332 --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/state.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use crate::{ + model::{ + AdminError, Revision, + plugins::state::{PluginStateConfiguration, PluginStateTransition}, + }, + ports::plugins::{PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind}, +}; + +pub(super) struct PluginStateService { + store: Arc, +} + +impl PluginStateService { + #[must_use] + pub(super) fn new(store: Arc) -> Self { + Self { store } + } + + pub(super) async fn transition_required( + &self, + instance_id: &str, + target: &PluginStateConfiguration, + ) -> Result { + self.store + .transition_required(instance_id, target) + .await + .map_err(map_state_error) + } + + pub(super) async fn begin_transition( + &self, + instance_id: &str, + expected_instance_revision: Revision, + artifact_sha256: &str, + target: PluginStateConfiguration, + ) -> Result { + self.store + .begin_transition( + instance_id, + expected_instance_revision, + artifact_sha256, + target, + ) + .await + .map_err(map_state_error) + } + + pub(super) async fn abort_transition(&self, transition_id: &str) { + if let Err(error) = self.store.abort_transition(transition_id).await { + tracing::warn!( + error_kind = ?error.kind(), + "failed to discard plugin state staging generation" + ); + } + } +} + +fn map_state_error(error: PluginStateStoreError) -> AdminError { + match error.kind() { + PluginStateStoreErrorKind::Invalid => AdminError::invalid("插件状态声明不合法"), + PluginStateStoreErrorKind::NotFound => AdminError::not_found("插件状态实例不存在"), + PluginStateStoreErrorKind::Conflict => { + AdminError::conflict("插件状态版本不兼容或已发生并发变更") + } + PluginStateStoreErrorKind::Quota => AdminError::conflict("插件状态迁移超过声明配额"), + PluginStateStoreErrorKind::PermissionDenied => { + AdminError::conflict("插件状态 fence 已失效") + } + PluginStateStoreErrorKind::Unavailable => AdminError::unavailable("插件状态存储暂不可用"), + } +} diff --git a/backend/crates/gateway-admin/src/use_case/plugins/versions.rs b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs new file mode 100644 index 000000000..28be61efa --- /dev/null +++ b/backend/crates/gateway-admin/src/use_case/plugins/versions.rs @@ -0,0 +1,318 @@ +use super::{ + PluginsService, + artifacts::{configuration_defaults, default_bindings}, +}; +use crate::{ + model::{ + AdminError, MutationContext, + plugins::{ + PluginArtifactMetadata, + instances::{ + ConfigurePluginInstance, PluginInstance, PluginInstanceMutation, + PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration, + PluginVersionPlan, RollbackPluginInstance, + }, + }, + }, + use_case::map_store_error, +}; + +impl PluginsService { + pub async fn rollback_instance( + &self, + id: &str, + input: RollbackPluginInstance, + context: &MutationContext, + ) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let existing = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .cloned() + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + if existing.revision.get() != input.expected_revision { + return Err(AdminError::conflict("插件实例已变更,请重新确认回滚目标")); + } + let artifacts = self.list().await?; + let current = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == existing.artifact_sha256) + .ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?; + let target = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == input.artifact_sha256) + .ok_or_else(|| AdminError::not_found("回滚目标制品尚未安装"))?; + if rollback_version(¤t.metadata, &target.metadata)?.is_none() { + return Err(AdminError::invalid("回滚目标必须是同一插件的较早版本")); + } + let saved = self + .store + .load_version_configuration(id, &input.artifact_sha256) + .await + .map_err(|error| map_store_error(error, "plugin"))? + .ok_or_else(|| { + AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置") + })?; + let configuration = ConfigurePluginInstance { + creation_id: None, + expected_revision: None, + replace_instances: Vec::new(), + name: existing.name.clone(), + artifact_sha256: input.artifact_sha256, + enabled: existing.enabled, + configuration: saved.configuration, + secrets: Some(saved.secrets), + bindings: saved.bindings, + }; + // 沿用同一份快照的 CAS 和状态迁移流程,不能在检查目标后重新读取并覆盖并发配置。 + self.configure_from_snapshot(snapshot, Some(existing), configuration, context) + .await + } + + pub async fn rollback_plan(&self, id: &str) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let instance = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + let artifacts = self.list().await?; + let current = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256) + .ok_or_else(|| AdminError::not_found("当前插件制品不存在"))?; + let saved_versions = self + .store + .configuration_versions(id) + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let mut targets = Vec::new(); + for artifact in artifacts.iter().filter(|artifact| { + artifact.accepted_at.is_some() && saved_versions.contains(&artifact.metadata.sha256) + }) { + if let Some(version) = rollback_version(¤t.metadata, &artifact.metadata)? { + targets.push(( + version, + PluginRollbackTarget { + artifact_sha256: artifact.metadata.sha256.clone(), + version: artifact.metadata.version.clone(), + platforms: artifact.metadata.platforms.clone(), + }, + )); + } + } + targets.sort_by(|(left, left_target), (right, right_target)| { + right.cmp_precedence(left).then_with(|| { + left_target + .artifact_sha256 + .cmp(&right_target.artifact_sha256) + }) + }); + Ok(PluginRollbackPlan { + instance_revision: instance.revision.get(), + current_version: current.metadata.version.clone(), + targets: targets.into_iter().map(|(_, target)| target).collect(), + }) + } + + pub async fn version_plan( + &self, + id: &str, + digest: &str, + ) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let instance = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + let (configuration, restored) = self.version_configuration(instance, digest).await?; + Ok(PluginVersionPlan { + instance_revision: instance.revision.get(), + artifact_sha256: digest.to_owned(), + configuration: configuration.configuration, + secret_fields: configuration.secrets.into_keys().collect(), + bindings: configuration.bindings, + restored, + }) + } + + pub async fn switch_instance_version( + &self, + id: &str, + input: RollbackPluginInstance, + context: &MutationContext, + ) -> Result { + let snapshot = self + .store + .load_instances() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let existing = snapshot + .instances + .iter() + .find(|instance| instance.id == id) + .cloned() + .ok_or_else(|| AdminError::not_found("插件实例不存在"))?; + if existing.revision.get() != input.expected_revision { + return Err(AdminError::conflict("插件设置已变更,请刷新后重试")); + } + if existing.artifact_sha256 == input.artifact_sha256 { + return Err(AdminError::invalid("已在使用此版本")); + } + let (configuration, _) = self + .version_configuration(&existing, &input.artifact_sha256) + .await?; + let request = ConfigurePluginInstance { + creation_id: None, + expected_revision: Some(existing.revision.get()), + replace_instances: Vec::new(), + name: existing.name.clone(), + artifact_sha256: input.artifact_sha256, + enabled: existing.enabled, + configuration: configuration.configuration, + secrets: Some(configuration.secrets), + bindings: configuration.bindings, + }; + // 同一次读取的 CAS 覆盖草稿计算、准备与提交,不能覆盖并发设置。 + self.configure_from_snapshot(snapshot, Some(existing), request, context) + .await + } + + async fn version_configuration( + &self, + instance: &PluginInstance, + digest: &str, + ) -> Result<(PluginVersionConfiguration, bool), AdminError> { + let artifacts = self + .store + .list_artifacts() + .await + .map_err(|error| map_store_error(error, "plugin"))?; + let current = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == instance.artifact_sha256) + .ok_or_else(|| AdminError::not_found("当前插件版本不存在"))?; + let target = artifacts + .iter() + .find(|artifact| artifact.metadata.sha256 == digest && artifact.accepted_at.is_some()) + .ok_or_else(|| AdminError::not_found("目标插件版本尚未安装"))?; + if current.metadata.plugin_id != target.metadata.plugin_id { + return Err(AdminError::invalid("只能切换同一插件的版本")); + } + if digest != instance.artifact_sha256 + && let Some(saved) = self + .store + .load_version_configuration(&instance.id, digest) + .await + .map_err(|error| map_store_error(error, "plugin"))? + { + return Ok((saved, true)); + } + let mut configuration = instance.configuration.clone(); + // 只补充缺失的默认值,保留显式值及未知字段,让不兼容项由校验反馈给用户。 + merge_missing_defaults(&mut configuration, configuration_defaults(&target.metadata)); + Ok(( + PluginVersionConfiguration { + configuration, + secrets: instance.secrets.clone(), + bindings: version_bindings(instance, ¤t.metadata, &target.metadata), + }, + false, + )) + } +} + +fn version_bindings( + instance: &PluginInstance, + current: &PluginArtifactMetadata, + target: &PluginArtifactMetadata, +) -> Vec { + let mut bindings = default_bindings(target); + // 已关闭的既有能力继续关闭,新声明能力才采用包默认值。 + bindings.retain(|binding| { + let capability = target + .contributes + .iter() + .find(|(_, contribution)| contribution.id == binding.contribution) + .map(|(capability, _)| capability); + let previous = capability.and_then(|capability| current.contributes.get(capability)); + previous.is_none_or(|contribution| { + !contribution.stages.contains(&binding.stage) + || instance + .bindings + .iter() + .any(|old| old.contribution == contribution.id && old.stage == binding.stage) + }) + }); + for previous in &instance.bindings { + let Some((capability, _)) = current + .contributes + .iter() + .find(|(_, contribution)| contribution.id == previous.contribution) + else { + continue; + }; + let Some(contribution) = target + .contributes + .get(capability) + .filter(|contribution| contribution.stages.contains(&previous.stage)) + else { + continue; + }; + let mut binding = previous.clone(); + binding.contribution.clone_from(&contribution.id); + if let Some(default) = bindings.iter_mut().find(|default| { + default.contribution == binding.contribution && default.stage == binding.stage + }) { + *default = binding; + } else { + bindings.push(binding); + } + } + bindings +} + +fn rollback_version( + current: &PluginArtifactMetadata, + target: &PluginArtifactMetadata, +) -> Result, AdminError> { + if current.plugin_id != target.plugin_id { + return Ok(None); + } + let current_version = semver::Version::parse(¤t.version) + .map_err(|_| AdminError::invalid("当前插件版本无效"))?; + let target_version = semver::Version::parse(&target.version) + .map_err(|_| AdminError::invalid("回滚目标版本无效"))?; + Ok(target_version + .cmp_precedence(¤t_version) + .is_lt() + .then_some(target_version)) +} + +fn merge_missing_defaults(current: &mut serde_json::Value, defaults: serde_json::Value) { + let (Some(current), serde_json::Value::Object(defaults)) = (current.as_object_mut(), defaults) + else { + return; + }; + for (key, default) in defaults { + if let Some(value) = current.get_mut(&key) { + merge_missing_defaults(value, default); + } else { + current.insert(key, default); + } + } +} diff --git a/backend/crates/gateway-admin/src/use_case/settings.rs b/backend/crates/gateway-admin/src/use_case/settings.rs index c0cf52588..a670d1671 100644 --- a/backend/crates/gateway-admin/src/use_case/settings.rs +++ b/backend/crates/gateway-admin/src/use_case/settings.rs @@ -37,6 +37,11 @@ pub trait SettingsService: Send + Sync { context: &MutationContext, command: crate::model::pricing::UpdatePricing, ) -> Result<(), AdminError>; + async fn client_profile_providers( + &self, + ) -> Result, AdminError> { + Ok(Vec::new()) + } async fn client_profile_options( &self, _provider: &str, @@ -78,7 +83,7 @@ impl DefaultSettingsService { fn profile_provider( &self, provider: &str, - ) -> Result, AdminError> { + ) -> Result { let kind = gateway_core::routing::ProviderKind::new(provider) .map_err(|_| AdminError::invalid("Provider 不合法"))?; self.providers @@ -129,6 +134,17 @@ impl SettingsService for DefaultSettingsService { { return Err(AdminError::invalid("请选择 1 至 10000 个模型")); } + let providers = self + .providers + .pricing_catalog() + .map_err(|error| super::map_provider_error(error, "provider pricing"))?; + if command + .models + .keys() + .any(|provider| !providers.contains_key(provider)) + { + return Err(AdminError::invalid("Provider 不支持价目管理")); + } let mut current = self.pricing_source.fetch().await?; if current != command.preview { return Err(AdminError::invalid( @@ -174,7 +190,10 @@ impl SettingsService for DefaultSettingsService { .await .map_err(|error| map_store_error(error, "model pricing"))?; Ok(crate::model::pricing::PricingCatalog { - defaults: self.providers.pricing_catalog(), + defaults: self + .providers + .pricing_catalog() + .map_err(|error| super::map_provider_error(error, "provider pricing"))?, overrides: stored.overrides, synced: stored.synced, synced_at: stored.synced_at, @@ -258,12 +277,20 @@ impl SettingsService for DefaultSettingsService { publish_committed(self.snapshot.as_ref(), revision).await } + async fn client_profile_providers( + &self, + ) -> Result, AdminError> { + self.providers + .client_profile_providers() + .map_err(|error| super::map_provider_error(error, "client profile")) + } + async fn client_profile_options( &self, provider: &str, ) -> Result { - let mut options = self - .profile_provider(provider)? + let profile_provider = self.profile_provider(provider)?; + let mut options = profile_provider .client_profile_options() .map_err(|error| super::map_provider_error(error, "client profile"))? .into_inner(); @@ -272,6 +299,7 @@ impl SettingsService for DefaultSettingsService { .await? .client_profile(provider) .cloned() + .or_else(|| profile_provider.default_client_profile()) .ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?; options.insert( "globalConfiguration".to_owned(), @@ -295,6 +323,7 @@ impl SettingsService for DefaultSettingsService { .await? .client_profile(provider) .cloned() + .or_else(|| profile_provider.default_client_profile()) .ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?; (&global, "global") }; @@ -316,16 +345,41 @@ impl SettingsService for DefaultSettingsService { async fn replace( &self, context: &MutationContext, - command: ReplaceRuntimeSettings, + mut command: ReplaceRuntimeSettings, ) -> Result { validate_settings(&command)?; - for (provider, profile) in [ - ("openai", &command.openai_client_profile), - ("xai", &command.xai_client_profile), - ] { - if let Some(profile) = profile { - self.profile_provider(provider)? - .preview_client_profile(profile) + if command + .request_profile_updates + .values() + .any(Option::is_some) + { + let current = self + .store + .load_runtime_settings() + .await + .map_err(|error| map_store_error(error, "runtime settings"))?; + command.request_profile_updates.retain(|provider, update| { + !update + .as_ref() + .is_some_and(|profile| current.request_profiles.get(provider) == Some(profile)) + }); + } + if command + .request_profile_updates + .values() + .any(Option::is_some) + { + let providers = self + .providers + .freeze() + .map_err(|error| super::map_provider_error(error, "client profile"))?; + for (provider, profile) in &command.request_profile_updates { + let Some(profile) = profile else { + continue; + }; + providers + .require(provider) + .and_then(|provider| provider.preview_client_profile(profile)) .map_err(|error| super::map_provider_error(error, "client profile"))?; } } diff --git a/backend/crates/gateway-admin/src/use_case/system.rs b/backend/crates/gateway-admin/src/use_case/system.rs index 0fcc4c69d..27dffce97 100644 --- a/backend/crates/gateway-admin/src/use_case/system.rs +++ b/backend/crates/gateway-admin/src/use_case/system.rs @@ -11,6 +11,7 @@ use crate::{ }, ports::system::{ SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream, + SystemUpdatePreflight, }, }; @@ -32,12 +33,19 @@ pub trait SystemService: Send + Sync { /// 保持 Host 能力窄边界的默认系统用例。 pub(crate) struct DefaultSystemService { operations: Arc, + preflight: Arc, } impl DefaultSystemService { #[must_use] - pub(crate) const fn new(operations: Arc) -> Self { - Self { operations } + pub(crate) fn new( + operations: Arc, + preflight: Arc, + ) -> Self { + Self { + operations, + preflight, + } } } @@ -66,7 +74,7 @@ impl SystemService for DefaultSystemService { .map(|version| version.trim().to_owned()) .filter(|version| !version.is_empty()); self.operations - .perform_update(target_version) + .perform_update(target_version, Arc::clone(&self.preflight)) .await .map_err(map_system_error) } @@ -79,7 +87,10 @@ impl SystemService for DefaultSystemService { } async fn rollback(&self) -> Result { - self.operations.rollback().await.map_err(map_system_error) + self.operations + .rollback(Arc::clone(&self.preflight)) + .await + .map_err(map_system_error) } async fn restart(&self) -> Result { diff --git a/backend/crates/gateway-admin/src/use_case/xai.rs b/backend/crates/gateway-admin/src/use_case/xai.rs deleted file mode 100644 index babfae0cf..000000000 --- a/backend/crates/gateway-admin/src/use_case/xai.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! xAI 管理资源的中立 ProviderAdmin 委托。 - -use std::sync::Arc; - -use async_trait::async_trait; -use gateway_core::runtime::SnapshotControl; - -use crate::{ - model::{ - AdminError, - provider_credentials::{ - AuthorizationStarted, CompleteAuthorization, CredentialDeletion, - CredentialDeletionResult, CredentialImportCommit, CredentialImportResult, - CredentialMutationResult, ImportCredentials, PrepareCredentialImport, - StartAuthorization, - }, - }, - ports::{provider::ProviderAdmin, store::AccountStore}, -}; - -use super::{ - commit_authorization, delete_credentials, map_provider_error, map_store_error, - pending_authorization, publish_committed, publish_credentials_and_observe_quota, - validate_authorization_commit, validate_prepared_import, -}; - -/// xAI 固定管理路由消费的服务。 -#[async_trait] -pub trait XaiService: Send + Sync { - async fn import_document( - &self, - command: ImportCredentials, - ) -> Result; - async fn start_authorization( - &self, - command: StartAuthorization, - ) -> Result; - async fn complete_authorization( - &self, - command: CompleteAuthorization, - ) -> Result; - async fn delete( - &self, - command: CredentialDeletion, - ) -> Result; -} - -pub(crate) struct DefaultXaiService { - provider: Arc, - accounts: Arc, - proxies: Arc, - snapshot: Arc, -} - -impl DefaultXaiService { - #[must_use] - pub(crate) fn new( - provider: Arc, - accounts: Arc, - proxies: Arc, - snapshot: Arc, - ) -> Self { - Self { - provider, - accounts, - proxies, - snapshot, - } - } -} - -#[async_trait] -impl XaiService for DefaultXaiService { - async fn import_document( - &self, - command: ImportCredentials, - ) -> Result { - let context = command.context; - let proxy_reservation = super::import_proxy_binding( - self.proxies.as_ref(), - command.outbound_proxy_id.as_deref(), - ) - .await?; - let outbound_proxy = proxy_reservation - .as_ref() - .map(|reservation| reservation.binding.clone()); - let prepared = self - .provider - .prepare_import(PrepareCredentialImport { - default_outbound_proxy: outbound_proxy - .as_ref() - .map(|binding| binding.proxy.clone()), - document: command.document, - }) - .await - .map_err(|error| map_provider_error(error, "xAI credential import"))?; - validate_prepared_import( - self.provider.provider_kind(), - &prepared, - "xAI credential import", - )?; - let result = self - .accounts - .commit_credential_import( - CredentialImportCommit { - outbound_proxy, - prepared, - settings: command.settings, - }, - &context, - ) - .await - .map_err(|error| map_store_error(error, "xAI credential import"))?; - drop(proxy_reservation); - publish_credentials_and_observe_quota( - &self.provider, - self.snapshot.as_ref(), - result.config_revision, - &result.credential_ids, - &context.request_id, - ) - .await?; - Ok(result) - } - - async fn start_authorization( - &self, - command: StartAuthorization, - ) -> Result { - let pending = pending_authorization( - self.accounts.as_ref(), - self.proxies.as_ref(), - self.provider.provider_kind(), - &command, - "xAI credential", - ) - .await?; - self.provider - .start_authorization(pending) - .await - .map_err(|error| map_provider_error(error, "xAI authorization")) - } - - async fn complete_authorization( - &self, - mut command: CompleteAuthorization, - ) -> Result { - let context = command.context.clone(); - let settings = command.settings.take(); - let prepared = self - .provider - .complete_authorization(command) - .await - .map_err(|error| map_provider_error(error, "xAI authorization"))?; - let prepared = validate_authorization_commit( - self.provider.provider_kind(), - &context, - prepared, - "xAI authorization", - ) - .await?; - let result = commit_authorization( - self.accounts.as_ref(), - prepared, - settings, - &context, - "xAI authorization", - ) - .await?; - publish_credentials_and_observe_quota( - &self.provider, - self.snapshot.as_ref(), - result.config_revision, - std::slice::from_ref(&result.account_id), - &context.request_id, - ) - .await?; - Ok(result) - } - - async fn delete( - &self, - command: CredentialDeletion, - ) -> Result { - let result = delete_credentials( - self.accounts.as_ref(), - self.provider.as_ref(), - command, - "xAI credential", - ) - .await?; - publish_committed(self.snapshot.as_ref(), result.config_revision).await?; - Ok(result) - } -} diff --git a/backend/crates/gateway-admin/tests/model/quota_forecast.rs b/backend/crates/gateway-admin/tests/model/quota_forecast.rs index 3740fcb87..32207ee98 100644 --- a/backend/crates/gateway-admin/tests/model/quota_forecast.rs +++ b/backend/crates/gateway-admin/tests/model/quota_forecast.rs @@ -141,6 +141,22 @@ fn corresponding_actual_window_takes_priority_and_retains_its_duration() { assert_eq!(week.estimated_tokens, Some(1_129)); } +#[test] +fn source_selection_keeps_provider_order_with_the_maximum_window_count() { + let windows = (0..64) + .map(|index| { + if index % 2 == 0 { + window(&format!("week-{index}"), 7) + } else { + window(&format!("month-{index}"), 30) + } + }) + .collect(); + let [week, month] = forecast("a(windows)); + assert_eq!(week.source.unwrap().label, "week-0"); + assert_eq!(month.source.unwrap().label, "month-1"); +} + #[test] fn short_or_model_specific_windows_are_not_account_capacity() { let mut model = window("model", 7); diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 55cde2459..5207e33f4 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -29,20 +29,21 @@ use gateway_admin::{ BatchUpdateAccounts, DeleteAccounts, UpdateAccount, }, observability::TimeRange, + provider_capabilities::ProviderCredentialCapabilities, provider_credentials::{ AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit, AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialMutationResult, - CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport, - PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit, - PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport, - PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, - ProviderExport, ProviderExportCredentialInput, ProviderModels, - ProviderProfileActivityInsights, ProviderProfileStatistics, - ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, - ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription, - QuotaLocalUsageAttribution, + CredentialRotationCommit, PendingAuthorizationMutation, PluginAccountSaveReason, + PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation, + PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedCredentialCreate, + PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, + PreparedPluginAccountSave, ProviderDocument, ProviderExport, + ProviderExportCredentialInput, ProviderModels, ProviderProfileActivityInsights, + ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota, + ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult, + ProviderSubscription, QuotaLocalUsageAttribution, }, quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage}, settings::{ @@ -65,12 +66,20 @@ pub(super) type EventLog = Arc>>; pub(super) struct FakeProviderAdmin { kind: ProviderKind, + credential_capabilities: Mutex, + capabilities: Mutex, events: EventLog, failure: Mutex>, import_gate: Mutex>>, quota_failure: Mutex>, pending: Arc>>, retry_authorization_after_abort: Mutex, + authorization_accounts: Mutex>>, + authorization_polls: Mutex< + std::collections::VecDeque< + gateway_admin::model::provider_credentials::PreparedAuthorizationPoll, + >, + >, export_inputs: Mutex>, import_account_ids: Mutex>, quota_requests: Mutex>, @@ -86,15 +95,33 @@ pub(super) struct FakeProviderAdmin { } impl FakeProviderAdmin { + pub(super) fn set_authorization_accounts(&self, accounts: Vec) { + *self.authorization_accounts.lock().unwrap() = Some(accounts); + } + + pub(super) fn queue_authorization_poll( + &self, + result: gateway_admin::model::provider_credentials::PreparedAuthorizationPoll, + ) { + self.authorization_polls.lock().unwrap().push_back(result); + } + pub(super) fn new(kind: &str, events: EventLog) -> Arc { Arc::new(Self { kind: ProviderKind::new(kind).expect("provider kind"), + credential_capabilities: Mutex::new(ProviderCredentialCapabilities { + export: true, + ..Default::default() + }), + capabilities: Mutex::default(), events, failure: Mutex::new(None), import_gate: Mutex::new(None), quota_failure: Mutex::new(None), pending: Arc::new(Mutex::new(None)), retry_authorization_after_abort: Mutex::new(false), + authorization_accounts: Mutex::new(None), + authorization_polls: Mutex::default(), export_inputs: Mutex::new(Vec::new()), import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]), quota_requests: Mutex::new(Vec::new()), @@ -258,6 +285,17 @@ impl FakeProviderAdmin { #[async_trait] impl ProviderAdmin for FakeProviderAdmin { + fn credential_capabilities(&self) -> ProviderCredentialCapabilities { + self.credential_capabilities.lock().unwrap().clone() + } + + fn account_capabilities( + &self, + _account_id: &ProviderAccountId, + _authentication_kind: &str, + ) -> gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + *self.capabilities.lock().unwrap() + } fn provider_kind(&self) -> &ProviderKind { &self.kind } @@ -300,7 +338,7 @@ impl ProviderAdmin for FakeProviderAdmin { self.record("provider.account_facts_changed"); } - fn connection_test_operation( + async fn connection_test_operation( &self, model: &gateway_core::routing::UpstreamModelId, input: &str, @@ -364,8 +402,9 @@ impl ProviderAdmin for FakeProviderAdmin { async fn start_authorization( &self, - pending: PendingAuthorizationMutation, + command: gateway_admin::model::provider_credentials::PrepareAuthorization, ) -> Result { + let pending = command.pending; self.record("provider.start_authorization"); self.require_available()?; *self.pending.lock().expect("pending authorization") = Some(pending); @@ -398,12 +437,20 @@ impl ProviderAdmin for FakeProviderAdmin { .then(|| pending.clone()); let credential = match pending.target() { AuthorizationMutationTarget::Create { name } => { - PreparedAuthorizationCredential::Create(prepared_create(self.kind.clone(), name)) + PreparedAuthorizationCredential::Create( + self.authorization_accounts + .lock() + .unwrap() + .take() + .unwrap_or_else(|| vec![prepared_create(self.kind.clone(), name)]), + ) } AuthorizationMutationTarget::Reauthorize { account_id } => { let mut account = account_record(self.kind.as_str()); account.id = account_id.as_str().to_owned(); - PreparedAuthorizationCredential::Reauthorize(self.prepared_rotation(&account)) + PreparedAuthorizationCredential::Reauthorize(Box::new( + self.prepared_rotation(&account), + )) } }; let prepared = PreparedAuthorizationCommit::new(pending, credential); @@ -419,6 +466,22 @@ impl ProviderAdmin for FakeProviderAdmin { }) } + async fn poll_authorization( + &self, + _command: gateway_admin::model::provider_credentials::PollAuthorization, + ) -> Result< + gateway_admin::model::provider_credentials::PreparedAuthorizationPoll, + ProviderAdminError, + > { + self.record("provider.poll_authorization"); + self.require_available()?; + self.authorization_polls + .lock() + .unwrap() + .pop_front() + .ok_or_else(unsupported) + } + async fn prepare_rotation( &self, command: PrepareCredentialRotation, @@ -539,6 +602,12 @@ pub(super) struct FakeAccountStore { accounts: Mutex>, account_after_probe: Mutex>, fail_commit: Mutex, + receipts: Mutex< + std::collections::BTreeMap< + gateway_admin::model::provider_credentials::AuthorizationReceiptKey, + gateway_admin::model::provider_credentials::CredentialBatchMutationResult, + >, + >, audit_requests: Mutex>, import_settings: Mutex>>, quota_window_usage: Mutex>, @@ -559,6 +628,7 @@ impl FakeAccountStore { accounts: Mutex::new(vec![account]), account_after_probe: Mutex::new(None), fail_commit: Mutex::new(false), + receipts: Mutex::new(Default::default()), audit_requests: Mutex::new(Vec::new()), import_settings: Mutex::new(Vec::new()), quota_window_usage: Mutex::new(Vec::new()), @@ -654,6 +724,17 @@ impl FakeAccountStore { #[async_trait] impl AccountStore for FakeAccountStore { + async fn list_plugin_accounts( + &self, + _: gateway_admin::model::provider_credentials::PluginAccountListQuery, + ) -> AdminStoreResult { + Err(AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin accounts", + "unavailable", + )) + } + async fn list_accounts( &self, _: AccountListQuery, @@ -664,6 +745,12 @@ impl AccountStore for FakeAccountStore { let total = accounts.len() as u64; Ok(AccountPage { config_revision: revision(1), + providers: accounts + .iter() + .map(|account| account.provider_kind.to_string()) + .collect::>() + .into_iter() + .collect(), items: accounts.into_iter().map(Self::page_item).collect(), total, summary: AccountSummary { @@ -756,6 +843,24 @@ impl AccountStore for FakeAccountStore { })) } + async fn credential_details_by_id( + &self, + account_id: &ProviderAccountId, + ) -> AdminStoreResult> { + self.record("store.credential_details_by_id"); + let account = self + .accounts + .lock() + .expect("accounts") + .iter() + .find(|account| account.id == account_id.as_str()) + .cloned(); + Ok(account.map(|credential| CredentialDetails { + config_revision: revision(1), + credential, + })) + } + async fn load_credentials_for_export( &self, provider_kind: &ProviderKind, @@ -784,6 +889,24 @@ impl AccountStore for FakeAccountStore { .collect()) } + async fn load_credential_for_plugin( + &self, + account_id: &ProviderAccountId, + ) -> AdminStoreResult> { + self.record("store.load_credential_for_plugin"); + Ok(self + .accounts + .lock() + .expect("accounts") + .iter() + .find(|account| account.id == account_id.as_str()) + .cloned() + .map(|account| ProviderExportCredentialInput { + account, + provider_material: document(), + })) + } + async fn commit_credential_import( &self, command: CredentialImportCommit, @@ -811,26 +934,65 @@ impl AccountStore for FakeAccountStore { &self, command: AuthorizationCommit, context: &MutationContext, - ) -> AdminStoreResult { + ) -> AdminStoreResult + { self.import_settings .lock() .expect("import settings") .push(command.settings); + if let Some(result) = self.receipts.lock().unwrap().get(&command.key).cloned() { + return Ok( + gateway_admin::model::provider_credentials::AuthorizationCommitResult { + result, + newly_committed: false, + }, + ); + } self.record("store.commit_authorization"); self.record_context(context); self.require_commit()?; - let (account_id, credential_revision) = match command.credential { - AuthorizationCredentialCommit::Create(credential) => (credential.account_id, None), - AuthorizationCredentialCommit::Reauthorize(credential) => ( - credential.account_id, - Some(revision(credential.expected_credential_revision.get() + 1)), - ), + let accounts = match command.credential { + AuthorizationCredentialCommit::Create(credentials) => credentials + .into_iter() + .map( + |credential| gateway_admin::model::provider_credentials::AuthorizedAccount { + account_id: credential.account_id, + credential_revision: None, + }, + ) + .collect(), + AuthorizationCredentialCommit::Reauthorize(credential) => vec![ + gateway_admin::model::provider_credentials::AuthorizedAccount { + account_id: credential.account_id, + credential_revision: Some(revision( + credential.expected_credential_revision.get() + 1, + )), + }, + ], }; - Ok(CredentialMutationResult { + let result = gateway_admin::model::provider_credentials::CredentialBatchMutationResult { config_revision: revision(2), - account_id, - credential_revision, - }) + accounts, + }; + self.receipts + .lock() + .unwrap() + .insert(command.key, result.clone()); + Ok( + gateway_admin::model::provider_credentials::AuthorizationCommitResult { + result, + newly_committed: true, + }, + ) + } + + async fn authorization_receipt( + &self, + key: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey, + ) -> AdminStoreResult< + Option, + > { + Ok(self.receipts.lock().unwrap().get(key).cloned()) } async fn commit_credential_rotation( @@ -997,8 +1159,7 @@ impl SettingsStore for StaticSettingsStore { } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), @@ -1258,6 +1419,66 @@ async fn connection_test_should_preserve_disabled_account_status() { )); } +#[tokio::test] +async fn accounts_export_should_reject_unsupported_before_loading_secrets() { + let events = events(); + let provider = FakeProviderAdmin::new("local-echo", events.clone()); + provider.credential_capabilities.lock().unwrap().export = false; + let store = FakeAccountStore::new("local-echo", events.clone()); + let error = accounts_service(provider, store) + .await + .accounts() + .export( + &context("unsupported-export"), + vec![ProviderAccountId::new("acct_test").unwrap()], + ) + .await + .unwrap_err(); + + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); + assert_eq!(recorded(&events), ["store.load_account"]); +} + +#[tokio::test] +async fn accounts_export_should_validate_all_providers_before_loading_any_secret() { + for registered in [true, false] { + let events = events(); + let native = FakeProviderAdmin::new("openai", events.clone()); + let plugin = FakeProviderAdmin::new("z-plugin", events.clone()); + plugin.credential_capabilities.lock().unwrap().export = false; + let store = FakeAccountStore::new("openai", events.clone()); + let mut other = account_record("z-plugin"); + other.id = "acct_plugin".to_owned(); + store.accounts.lock().unwrap().push(other); + let mut harness = super::AdminHarness::new() + .accounts(store) + .settings(Arc::new(StaticSettingsStore)) + .provider(native); + if registered { + harness = harness.provider(plugin); + } + let error = harness + .build() + .await + .accounts() + .export( + &context("mixed-provider-export"), + vec![ + ProviderAccountId::new("acct_test").unwrap(), + ProviderAccountId::new("acct_plugin").unwrap(), + ], + ) + .await + .unwrap_err(); + + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); + assert_eq!( + recorded(&events), + ["store.load_account", "store.load_account"] + ); + } +} + #[tokio::test] async fn accounts_export_should_pass_store_loaded_timestamps_and_material_to_provider() { let provider = FakeProviderAdmin::new("openai", events()); @@ -1321,6 +1542,162 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() { assert_eq!(store.audit_requests(), ["refresh-request"]); } +#[tokio::test] +async fn plugin_account_saves_reuse_commits_and_defer_publication_inside_runtime_calls() { + for (reason, commit_event, request_id) in [ + ( + PluginAccountSaveReason::ModelDiscovery, + "store.commit_refresh", + "plugin-model-discovery", + ), + ( + PluginAccountSaveReason::Maintenance, + "store.commit_refresh", + "plugin-maintenance", + ), + ( + PluginAccountSaveReason::Explicit, + "store.commit_rotation", + "plugin-explicit-save", + ), + ] { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let store = FakeAccountStore::new("openai", events.clone()); + let snapshot = Arc::new(RecordingPluginAccountPublication(events.clone())); + let access = gateway_admin::initialize_plugin_accounts( + ProviderAdminRegistry::new([provider.clone() as Arc]).unwrap(), + store.clone(), + snapshot, + ); + + let result = access + .save( + PreparedPluginAccountSave::Replace { + facts: plugin_rotation_facts(&account_record("openai")), + authentication_kind: "oauth".to_owned(), + reason, + }, + &context(request_id), + ) + .await + .expect("save prepared plugin account facts"); + if reason == PluginAccountSaveReason::Explicit { + tokio::time::timeout( + std::time::Duration::from_secs(1), + provider.quota_started.notified(), + ) + .await + .expect("start post-commit quota observation"); + } else { + // Runtime 内保存时仍持有编译或刷新资源,不能重入 Provider/额度查询。 + tokio::task::yield_now().await; + } + + assert_eq!(result.account_id.as_str(), "acct_test"); + assert_eq!(result.credential_revision, revision(2)); + assert_eq!(store.audit_requests(), [request_id]); + let mut expected = vec!["store.credential_details", commit_event]; + if reason == PluginAccountSaveReason::Explicit { + expected.extend([ + "provider.account_facts_changed", + "snapshot.publish_committed", + "provider.quota", + ]); + } else { + expected.push("snapshot.publish_committed_deferred"); + } + assert_eq!(recorded(&events), expected); + } +} + +#[tokio::test] +async fn plugin_account_reads_resolve_the_authoritative_provider_from_the_account_id() { + let events = events(); + let store = FakeAccountStore::new("xai", events.clone()); + // 读取端口不能让调用方提供或伪造 Provider;注册表中即使只有另一 Provider, + // 也必须按 Store 中该 account ID 的权威事实返回。 + let registered_provider = FakeProviderAdmin::new("openai", events.clone()); + let access = gateway_admin::initialize_plugin_accounts( + ProviderAdminRegistry::new([registered_provider as Arc]).unwrap(), + store, + Arc::new(RecordingPluginAccountPublication(events.clone())), + ); + let account_id = ProviderAccountId::new("acct_test").expect("account ID"); + + let runtime = access + .get_runtime(&account_id) + .await + .expect("load plugin account runtime"); + let credential = access + .get_credential(&account_id) + .await + .expect("load plugin account credential"); + + assert_eq!(runtime.provider_kind.as_str(), "xai"); + assert_eq!(credential.account.provider_kind.as_str(), "xai"); + assert_eq!(credential.account.id, account_id.as_str()); + assert_eq!( + recorded(&events), + [ + "store.credential_details_by_id", + "store.load_credential_for_plugin", + ] + ); +} + +struct RecordingPluginAccountPublication(EventLog); + +impl gateway_core::runtime::SnapshotControl for RecordingPluginAccountPublication { + fn publish_committed( + &self, + revision: gateway_core::routing::ConfigRevision, + ) -> BoxFuture<'_, ()> { + Box::pin(async move { + assert_eq!(revision.get(), 2); + self.0.lock().unwrap().push("snapshot.publish_committed"); + }) + } + + fn publish_committed_deferred( + &self, + revision: gateway_core::routing::ConfigRevision, + ) -> BoxFuture<'_, ()> { + Box::pin(async move { + assert_eq!(revision.get(), 2); + self.0 + .lock() + .unwrap() + .push("snapshot.publish_committed_deferred"); + }) + } +} + +#[tokio::test] +async fn plugin_account_save_rejects_stale_authentication_kind_before_commit() { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let store = FakeAccountStore::new("openai", events.clone()); + let services = accounts_service(provider, store.clone()).await; + + let error = services + .plugin_accounts_handle() + .save( + PreparedPluginAccountSave::Replace { + facts: plugin_rotation_facts(&account_record("openai")), + authentication_kind: "api_key".to_owned(), + reason: PluginAccountSaveReason::Explicit, + }, + &context("plugin-stale-auth-kind"), + ) + .await + .expect_err("authentication kind change must be rejected"); + + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict); + assert_eq!(recorded(&events), ["store.credential_details"]); + assert!(store.audit_requests().is_empty()); +} + #[tokio::test] async fn accounts_quota_refresh_should_return_the_updated_account_status() { let events = events(); @@ -1644,6 +2021,12 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() { #[tokio::test] async fn accounts_list_should_return_complete_directory_semantics() { let provider = FakeProviderAdmin::new("openai", events()); + let capabilities = gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + quota: true, + profile: true, + ..Default::default() + }; + *provider.capabilities.lock().unwrap() = capabilities; let mut stored = account_record("openai"); stored.plan_type = Some(" self_serve_business_prolite ".to_owned()); let store = FakeAccountStore::with_account(stored, events()); @@ -1665,6 +2048,7 @@ async fn accounts_list_should_return_complete_directory_semantics() { assert_eq!(page.summary.total, 1); assert_eq!(page.summary.normal, 1); let account = page.items.first().expect("account item"); + assert_eq!(account.capabilities, capabilities); assert_eq!(account.account.provider_kind.as_str(), "openai"); assert_eq!( account.projection.status, @@ -1690,6 +2074,7 @@ async fn accounts_list_should_return_complete_directory_semantics() { .await .expect("account detail"); assert_eq!(detail.plan_type_display, account.plan_type_display); + assert_eq!(detail.capabilities, account.capabilities); assert_eq!(detail.account.plan_type, account.account.plan_type); } @@ -2039,6 +2424,11 @@ async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time( report.forecasts[0].source.as_ref().unwrap().observed_at, Some(observed) ); + assert_eq!( + report.forecasts[1].source.as_ref().unwrap().label, + report.forecasts[0].source.as_ref().unwrap().label, + "缺少月窗口时两个展示周期复用同一周窗口" + ); let queries = store.quota_window_queries(); assert_eq!(queries.len(), 1); assert_eq!(queries[0].range.start, reset - TimeDelta::days(7)); @@ -2069,6 +2459,70 @@ async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() { assert!(!provider.quota_requests.lock().unwrap()[0].refresh); } +#[tokio::test] +async fn quota_forecast_only_queries_the_two_selected_source_windows() { + let provider = FakeProviderAdmin::new("openai", events()); + let now = Utc::now(); + let observed = now - TimeDelta::minutes(1); + let reset = now + TimeDelta::days(1); + let windows = (0..64) + .map(|index| { + let monthly = index % 2 == 1; + ProviderQuotaWindow { + key: if monthly { + format!("month-{index}") + } else { + format!("week-{index}") + }, + group: if monthly { "monthly" } else { "shortTerm" }.to_owned(), + label: format!("window-{index}"), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(if monthly { 30 } else { 7 } * 86_400), + used_percent: Some(20.0), + reset_at: Some(reset), + limit_reached: false, + local_usage: None, + provider_data: None, + } + }) + .collect(); + provider.set_quota(ProviderQuota { + plan_type: Some("pro".to_owned()), + observed_at: Some(observed), + windows, + ..empty_quota() + }); + let mut account = account_record("openai"); + account.created_at = now - TimeDelta::days(60); + let store = FakeAccountStore::with_account(account, events()); + let report = accounts_service(provider, store.clone()) + .await + .accounts() + .quota_forecast(&ProviderAccountId::new("acct_test").unwrap()) + .await + .expect("quota forecast"); + + let queries = store.quota_window_queries(); + assert_eq!( + queries + .iter() + .map(|query| query.key.as_str()) + .collect::>(), + ["week-0", "month-1"] + ); + assert_eq!( + report.forecasts[0].source.as_ref().unwrap().label, + "window-0" + ); + assert_eq!( + report.forecasts[1].source.as_ref().unwrap().label, + "window-1" + ); +} + #[tokio::test] async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() { use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint; @@ -2530,6 +2984,52 @@ async fn reset_credit_oauth_refresh_should_reuse_the_exact_consume_command() { assert!(recorded(&events).contains(&"store.commit_refresh")); } +#[tokio::test] +async fn reset_credit_refresh_without_quota_should_complete_with_the_same_command() { + let events = events(); + let provider = FakeProviderAdmin::new("example", events.clone()); + provider.fail_next(ProviderAdminErrorKind::CredentialRefreshRequired); + *provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported); + let store = FakeAccountStore::new("example", events.clone()); + let services = accounts_service(provider.clone(), store).await; + let command = ConsumeProviderResetCredit { + account_id: ProviderAccountId::new("acct_test").unwrap(), + credit_id: Some("credit_1".into()), + redeem_request_id: uuid::Uuid::parse_str("dbabeb20-040e-4e9f-8615-0b7c81e79987").unwrap(), + }; + + let result = services + .accounts() + .consume_reset_credit(&context("reset-without-quota"), command.clone()) + .await + .expect("缺少可选额度能力不能中断凭据刷新后的消费重试"); + + assert_eq!(result.code, "reset"); + assert_eq!(provider.reset_credit_commands(), [command.clone(), command]); + assert_eq!( + recorded(&events) + .iter() + .filter(|event| **event == "store.commit_refresh") + .count(), + 1 + ); +} + +#[tokio::test] +async fn accounts_quota_refresh_should_still_reject_an_unsupported_operation() { + let provider = FakeProviderAdmin::new("example", events()); + *provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unsupported); + let services = accounts_service(provider, FakeAccountStore::new("example", events())).await; + + let error = services + .accounts() + .quota(&ProviderAccountId::new("acct_test").unwrap(), true) + .await + .expect_err("显式刷新不能被解释为空额度投影"); + + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); +} + #[tokio::test] async fn reset_credit_unknown_result_should_keep_a_stable_admin_kind() { let events = events(); @@ -2710,7 +3210,7 @@ fn prepared_create(provider_kind: ProviderKind, name: &str) -> PreparedCredentia prepared_create_with_id(provider_kind, "acct_prepared", name) } -fn prepared_create_with_id( +pub(super) fn prepared_create_with_id( provider_kind: ProviderKind, account_id: &str, name: &str, @@ -2747,6 +3247,23 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul } } +fn plugin_rotation_facts(account: &AccountRecord) -> PreparedCredentialRotationFacts { + PreparedCredentialRotationFacts { + account_id: ProviderAccountId::new(account.id.clone()).expect("account ID"), + provider_kind: account.provider_kind.clone(), + expected_credential_revision: account.credential_revision, + replacement_identity: None, + name: "plugin refreshed account".to_owned(), + email: account.email.clone(), + plan_type: account.plan_type.clone(), + preserve_profile: true, + provider_material: document(), + has_refresh_token: account.has_refresh_token, + access_token_expires_at: account.access_token_expires_at, + next_refresh_at: account.next_refresh_at, + } +} + fn empty_profile_statistics() -> ProviderProfileStatistics { ProviderProfileStatistics { display_name: Some("Preview user".to_owned()), @@ -2935,6 +3452,7 @@ impl AccountProbe for SuccessfulAccountProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> BoxFuture<'_, Result> { Box::pin(async { Ok(AccountProbeResult { @@ -2950,6 +3468,7 @@ impl AccountProbe for FailingAccountProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> BoxFuture<'_, Result> { Box::pin(async { let detail = ClientVisibleUpstreamError::new( @@ -2979,6 +3498,7 @@ impl AccountProbe for FreeModelQuotaProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> BoxFuture<'_, Result> { Box::pin(async move { self.store.set_account_after_probe(account_record("xai")); @@ -3022,3 +3542,29 @@ pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImport ], } } + +#[tokio::test] +async fn unregistered_provider_accounts_have_no_live_management_capabilities() { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::with_account(account_record("retired-provider"), events()); + let services = accounts_service(provider, store).await; + let page = services + .accounts() + .list(AccountListQuery { + page: 1, + page_size: gateway_admin::model::PageSize::new(20).unwrap(), + provider_kind: None, + group_filter: None, + search: None, + status: None, + sort: None, + }) + .await + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!( + page.items[0].account.provider_kind.as_str(), + "retired-provider" + ); + assert_eq!(page.items[0].capabilities, Default::default()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/client_keys.rs b/backend/crates/gateway-admin/tests/use_case/client_keys.rs index 166c6f7c4..f26fad5dd 100644 --- a/backend/crates/gateway-admin/tests/use_case/client_keys.rs +++ b/backend/crates/gateway-admin/tests/use_case/client_keys.rs @@ -6,20 +6,26 @@ use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}; use gateway_admin::{ model::{ - AdminErrorKind, MutationActor, MutationContext, Revision, + AdminErrorKind, MutationActor, MutationContext, PageSize, Revision, client_keys::{ ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField, CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey, }, + plugin_client_keys::{ + PluginClientKey, PluginClientKeyCursor, PluginClientKeyListQuery, PluginClientKeyPage, + }, }, + ports::provider::{ProviderAdmin, ProviderAdminRegistry}, ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore}, }; #[derive(Default)] struct TestClientKeyStore { plaintexts: Mutex>, + list_queries: Mutex>, + list_response: Mutex>, create_error: Option, update_error: Option, resets: Mutex>, @@ -44,6 +50,10 @@ impl ClientKeyStore for TestClientKeyStore { } async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult { + self.list_queries.lock().unwrap().push(query.clone()); + if let Some(page) = self.list_response.lock().unwrap().clone() { + return Ok(page); + } assert_eq!(query.page_size.get(), u16::MAX); Ok(ClientKeyPage { config_revision: Revision::new(1).expect("revision"), @@ -74,8 +84,7 @@ impl ClientKeyStore for TestClientKeyStore { } self.plaintexts.lock().unwrap().push(key.plaintext); let record = ClientKeyRecord { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), id: key.id, name: key.name, label: key.label, @@ -196,6 +205,91 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() { assert_eq!(page.total, 0); } +#[tokio::test] +async fn plugin_client_key_list_projects_only_public_identity_and_uses_stable_name_cursor() { + let now = Utc::now(); + let key_id = ClientApiKeyId::new("key_public").expect("key ID"); + let next_id = ClientApiKeyId::new("key_next").expect("next key ID"); + let sort = ClientKeySort { + field: ClientKeySortField::Name, + direction: SortDirection::Asc, + }; + let store = Arc::new(TestClientKeyStore { + list_response: Mutex::new(Some(ClientKeyPage { + config_revision: Revision::new(7).expect("revision"), + items: vec![ClientKeyRecord { + request_profile_overrides: Default::default(), + id: key_id.clone(), + name: "Public identity".to_owned(), + label: Some("private management label".to_owned()), + groups: Vec::new(), + provider_kinds: Vec::new(), + prefix: "sk_must_not_escape".to_owned(), + enabled: true, + limits: RateLimits::unlimited(), + budget: Default::default(), + last_used_at: Some(now), + created_at: now, + updated_at: now, + }], + total: 1, + next_cursor: Some(ClientKeyCursor { + sort, + value: ClientKeyCursorValue::Name("Public identity".to_owned()), + id: next_id.clone(), + }), + })), + ..Default::default() + }); + let providers = ProviderAdminRegistry::new(Vec::>::new()) + .expect("empty provider registry"); + let access = gateway_admin::initialize_plugin_client_keys( + providers, + store.clone(), + Arc::new(super::NoopSnapshot), + ); + let input_cursor = PluginClientKeyCursor { + name: "Before".to_owned(), + id: ClientApiKeyId::new("key_before").expect("cursor key ID"), + }; + + let page = access + .list(PluginClientKeyListQuery { + cursor: Some(input_cursor.clone()), + limit: PageSize::new(25).expect("page size"), + }) + .await + .expect("list plugin client keys"); + + assert_eq!( + page, + PluginClientKeyPage { + items: vec![PluginClientKey { + id: key_id, + name: "Public identity".to_owned(), + enabled: true, + }], + next_cursor: Some(PluginClientKeyCursor { + name: "Public identity".to_owned(), + id: next_id, + }), + } + ); + assert_eq!( + *store.list_queries.lock().unwrap(), + vec![ClientKeyListQuery { + cursor: Some(ClientKeyCursor { + sort, + value: ClientKeyCursorValue::Name(input_cursor.name), + id: input_cursor.id, + }), + page_size: ClientKeyPageSize::new(25).expect("page size"), + search: None, + sort, + }] + ); +} + #[tokio::test] async fn create_preserves_migrated_keys_and_keeps_default_generation() { let store = Arc::new(TestClientKeyStore::default()); @@ -260,8 +354,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat .update( &mutation_context(), UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), id: ClientApiKeyId::new("key_existing").unwrap(), name: "Migration".to_owned(), label: None, @@ -281,8 +374,7 @@ async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_updat fn create_command(key: Option<&str>) -> CreateClientKey { CreateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()), name: "Migration".to_owned(), label: None, diff --git a/backend/crates/gateway-admin/tests/use_case/credentials/flow.rs b/backend/crates/gateway-admin/tests/use_case/credentials/flow.rs new file mode 100644 index 000000000..62bd050ea --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/credentials/flow.rs @@ -0,0 +1,388 @@ +use std::time::Duration; + +use async_trait::async_trait; +use gateway_admin::model::{ + AdminError, AdminErrorKind, + provider_credentials::{ + AuthorizationCommitGuard, AuthorizationMutationTarget, AuthorizationOwnerBinding, + AuthorizationPollResult, PendingAuthorizationMutation, PollAuthorization, + PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedAuthorizationPoll, + }, +}; +use gateway_core::routing::ProviderKind; + +use super::super::{ + AdminHarness, + accounts::{ + EventLog, FakeAccountStore, FakeProviderAdmin, context, events, prepared_create_with_id, + recorded, + }, +}; + +struct Claim(EventLog); + +struct FailedConsumptionClaim(EventLog); + +#[tokio::test] +async fn unavailable_receipts_prevent_replaying_upstream_authorization() { + let log = events(); + let kind = ProviderKind::new("example").unwrap(); + let services = AdminHarness::new() + .provider(FakeProviderAdmin::new("example", log.clone())) + .build() + .await; + let error = services + .credentials() + .poll_authorization(&kind, poll()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Unavailable); + assert!(recorded(&log).is_empty()); +} + +#[async_trait] +impl AuthorizationCommitGuard for FailedConsumptionClaim { + async fn commit(self: Box) -> Result<(), AdminError> { + self.0.lock().unwrap().push("claim.consume_failed"); + Err(AdminError::unavailable("injected pending store failure")) + } + + async fn abort(self: Box) -> Result<(), AdminError> { + panic!("a committed authorization must not be aborted") + } +} + +#[tokio::test] +async fn committed_authorization_survives_cleanup_failure_and_does_not_require_a_provider_on_retry() +{ + let log = events(); + let kind = ProviderKind::new("example").unwrap(); + let provider = FakeProviderAdmin::new("example", log.clone()); + let PreparedAuthorizationPoll::Complete(prepared) = completed(&log, &["acct_receipt"]) else { + unreachable!() + }; + provider.queue_authorization_poll(PreparedAuthorizationPoll::Complete(Box::new( + prepared.with_authorization_guard(Box::new(FailedConsumptionClaim(log.clone()))), + ))); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store.clone()) + .build() + .await; + let original = services + .credentials() + .poll_authorization(&kind, poll()) + .await + .unwrap(); + // 只挂载同一个持久化端口,不再注册 Provider,证明恢复不重放任何插件操作。 + let restarted = AdminHarness::new().accounts(store.clone()).build().await; + assert_eq!( + restarted + .credentials() + .poll_authorization(&kind, poll()) + .await + .unwrap(), + original + ); + assert_eq!(store.audit_requests().len(), 1); + assert_eq!( + recorded(&log) + .iter() + .filter(|event| **event == "provider.poll_authorization") + .count(), + 1 + ); + assert!(recorded(&log).contains(&"claim.consume_failed")); +} + +#[async_trait] +impl AuthorizationCommitGuard for Claim { + async fn commit(self: Box) -> Result<(), AdminError> { + self.0.lock().unwrap().push("claim.commit"); + Ok(()) + } + + async fn abort(self: Box) -> Result<(), AdminError> { + self.0.lock().unwrap().push("claim.abort"); + Ok(()) + } +} + +fn poll() -> PollAuthorization { + PollAuthorization { + context: context("poll"), + flow_id: "flow-test".into(), + callback_url: None, + settings: None, + } +} + +fn completed(log: &EventLog, ids: &[&str]) -> PreparedAuthorizationPoll { + let kind = ProviderKind::new("example").unwrap(); + PreparedAuthorizationPoll::Complete(Box::new( + PreparedAuthorizationCommit::new( + PendingAuthorizationMutation::new( + kind.clone(), + AuthorizationMutationTarget::Create { + name: "login".into(), + }, + AuthorizationOwnerBinding::from_context(&context("start")), + ), + PreparedAuthorizationCredential::Create( + ids.iter() + .map(|id| prepared_create_with_id(kind.clone(), id, "account")) + .collect(), + ), + ) + .with_authorization_guard(Box::new(Claim(log.clone()))), + )) +} + +#[tokio::test] +async fn pending_poll_never_commits_or_observes_accounts() { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.queue_authorization_poll(PreparedAuthorizationPoll::Pending { + retry_after: Duration::from_secs(5), + }); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store.clone()) + .build() + .await; + let result = services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .unwrap(); + assert_eq!( + result, + AuthorizationPollResult::Pending { + retry_after: Duration::from_secs(5) + } + ); + assert_eq!(recorded(&log), ["provider.poll_authorization"]); + assert!(store.audit_requests().is_empty()); +} + +#[tokio::test] +async fn multi_account_poll_commits_once_then_consumes_claim_and_publishes_all_accounts() { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.queue_authorization_poll(completed(&log, &["acct_one", "acct_two"])); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider.clone()) + .accounts(store.clone()) + .build() + .await; + let result = services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .unwrap(); + let AuthorizationPollResult::Complete(result) = result else { + panic!("expected completed login") + }; + assert_eq!( + result + .accounts + .iter() + .map(|account| account.account_id.as_str()) + .collect::>(), + ["acct_one", "acct_two"] + ); + provider.wait_for_quota_requests(2).await; + assert_eq!( + &recorded(&log)[..4], + [ + "provider.poll_authorization", + "store.commit_authorization", + "claim.commit", + "provider.account_facts_changed" + ] + ); + assert_eq!(store.audit_requests(), ["poll"]); + assert_eq!(provider.quota_requests().len(), 2); +} + +#[tokio::test] +async fn failed_batch_commit_releases_claim_without_publishing_partial_success() { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.queue_authorization_poll(completed(&log, &["acct_one", "acct_two"])); + let store = FakeAccountStore::new("example", log.clone()); + store.fail_next_commit(); + let services = AdminHarness::new() + .provider(provider) + .accounts(store) + .build() + .await; + assert!( + services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .is_err() + ); + assert_eq!( + recorded(&log), + [ + "provider.poll_authorization", + "store.commit_authorization", + "claim.abort" + ] + ); +} + +#[tokio::test] +async fn invalid_account_collections_release_claim_before_store() { + for ids in [vec![], vec!["acct_one", "acct_one"], vec!["acct_one"; 201]] { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.queue_authorization_poll(completed(&log, &ids)); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store) + .build() + .await; + let error = services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!( + recorded(&log), + ["provider.poll_authorization", "claim.abort"] + ); + } +} + +#[tokio::test] +async fn forged_poll_owner_or_provider_cannot_commit_accounts() { + for wrong_owner in [true, false] { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + let PreparedAuthorizationPoll::Complete(mut prepared) = completed(&log, &["acct_one"]) + else { + unreachable!() + }; + if wrong_owner { + let mut foreign = context("foreign-start"); + foreign.actor = gateway_admin::model::MutationActor::AdminSession { + admin_user_id: "different-admin".into(), + }; + prepared.pending = PendingAuthorizationMutation::new( + ProviderKind::new("example").unwrap(), + AuthorizationMutationTarget::Create { + name: "login".into(), + }, + AuthorizationOwnerBinding::from_context(&foreign), + ); + } else { + let PreparedAuthorizationCredential::Create(accounts) = &mut prepared.credential else { + unreachable!() + }; + accounts[0].provider_kind = ProviderKind::new("another").unwrap(); + } + provider.queue_authorization_poll(PreparedAuthorizationPoll::Complete(prepared)); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store) + .build() + .await; + let error = services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!( + recorded(&log), + ["provider.poll_authorization", "claim.abort"] + ); + } +} + +#[tokio::test] +async fn pending_poll_requires_a_bounded_positive_interval() { + for retry_after in [Duration::ZERO, Duration::from_secs(301)] { + let log = events(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.queue_authorization_poll(PreparedAuthorizationPoll::Pending { retry_after }); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store) + .build() + .await; + let error = services + .credentials() + .poll_authorization(&ProviderKind::new("example").unwrap(), poll()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Internal); + assert_eq!(recorded(&log), ["provider.poll_authorization"]); + } +} + +#[tokio::test] +async fn single_account_completion_rejects_multiple_accounts_before_commit() { + let log = events(); + let kind = ProviderKind::new("example").unwrap(); + let provider = FakeProviderAdmin::new("example", log.clone()); + provider.retry_authorization_after_abort(); + provider.set_authorization_accounts(vec![ + prepared_create_with_id(kind.clone(), "acct_one", "one"), + prepared_create_with_id(kind.clone(), "acct_two", "two"), + ]); + let store = FakeAccountStore::new("example", log.clone()); + let services = AdminHarness::new() + .provider(provider) + .accounts(store.clone()) + .build() + .await; + let credentials = services.credentials().for_provider(&kind).unwrap(); + credentials + .start_authorization( + gateway_admin::model::provider_credentials::StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), + context: context("start"), + name: "login".into(), + reauthorization: None, + outbound_proxy: None, + }, + ) + .await + .unwrap(); + let error = services + .credentials() + .complete_authorization( + &kind, + gateway_admin::model::provider_credentials::CompleteAuthorization { + context: context("complete"), + flow_id: "flow-test".into(), + callback_url: "https://example.invalid/callback".into(), + settings: None, + }, + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert_eq!( + recorded(&log), + [ + "provider.start_authorization", + "provider.complete_authorization", + "authorization_guard.abort" + ] + ); + assert!(store.audit_requests().is_empty()); +} diff --git a/backend/crates/gateway-admin/tests/use_case/credentials/mod.rs b/backend/crates/gateway-admin/tests/use_case/credentials/mod.rs new file mode 100644 index 000000000..20e1f0eca --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/credentials/mod.rs @@ -0,0 +1,3 @@ +mod flow; +mod openai; +mod xai; diff --git a/backend/crates/gateway-admin/tests/use_case/openai.rs b/backend/crates/gateway-admin/tests/use_case/credentials/openai.rs similarity index 75% rename from backend/crates/gateway-admin/tests/use_case/openai.rs rename to backend/crates/gateway-admin/tests/use_case/credentials/openai.rs index 45b19d51a..5cf3d9a61 100644 --- a/backend/crates/gateway-admin/tests/use_case/openai.rs +++ b/backend/crates/gateway-admin/tests/use_case/credentials/openai.rs @@ -11,7 +11,7 @@ use gateway_admin::{ ports::provider::ProviderAdminErrorKind, }; -use super::accounts::{ +use super::super::accounts::{ FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision, }; @@ -23,7 +23,9 @@ async fn openai_delete_should_commit_then_release_provider_resources() { let services = service(provider, store.clone()).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .delete(deletion("acct_test")) .await .expect("delete credential"); @@ -48,10 +50,12 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { let services = service(provider.clone(), store.clone()).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, - settings: Some(super::accounts::import_settings()), + settings: Some(super::super::accounts::import_settings()), context: context("import-openai"), document: document(), }) @@ -79,7 +83,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() { ); assert_eq!( store.import_settings(), - [Some(super::accounts::import_settings())] + [Some(super::super::accounts::import_settings())] ); assert_eq!(store.audit_requests(), ["import-openai"]); } @@ -106,7 +110,9 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ let store = FakeAccountStore::new("openai", events.clone()); let services = service(provider, store.clone()).await; let error = services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -132,7 +138,9 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() { let services = service(provider.clone(), store).await; let result = services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -177,7 +185,9 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() { let services = service(provider.clone(), store).await; let result = services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -204,8 +214,13 @@ async fn openai_authorization_create_should_observe_initial_quota() { let services = service(provider.clone(), store.clone()).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("oauth-start-openai-create"), name: "new OpenAI credential".to_owned(), @@ -214,13 +229,16 @@ async fn openai_authorization_create_should_observe_initial_quota() { .await .expect("start authorization"); services - .openai() - .complete_authorization(CompleteAuthorization { - settings: Some(super::accounts::import_settings()), - context: context("oauth-start-openai-create"), - flow_id: "flow-test".to_owned(), - callback_url: "http://localhost/callback?code=test&state=test".to_owned(), - }) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + CompleteAuthorization { + settings: Some(super::super::accounts::import_settings()), + context: context("oauth-start-openai-create"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }, + ) .await .expect("complete authorization"); @@ -246,7 +264,7 @@ async fn openai_authorization_create_should_observe_initial_quota() { ); assert_eq!( store.import_settings(), - [Some(super::accounts::import_settings())] + [Some(super::super::accounts::import_settings())] ); assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]); } @@ -260,8 +278,13 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota let services = service(provider.clone(), store).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("oauth-start-openai-quota-failure"), name: "new OpenAI credential".to_owned(), @@ -270,13 +293,16 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota .await .expect("start authorization"); let result = services - .openai() - .complete_authorization(CompleteAuthorization { - settings: None, - context: context("oauth-start-openai-quota-failure"), - flow_id: "flow-test".to_owned(), - callback_url: "http://localhost/callback?code=test&state=test".to_owned(), - }) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + CompleteAuthorization { + settings: None, + context: context("oauth-start-openai-quota-failure"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }, + ) .await .expect("quota observation must not fail authorization"); @@ -302,8 +328,13 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() { }; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("oauth-store-retry"), name: "new OpenAI credential".to_owned(), @@ -312,13 +343,19 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() { .await .expect("start authorization"); services - .openai() - .complete_authorization(command()) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + command(), + ) .await .expect_err("Store commit must fail"); services - .openai() - .complete_authorization(command()) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + command(), + ) .await .expect("released claim must permit retry"); @@ -350,7 +387,9 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() { let services = service(provider, store).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -370,8 +409,13 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances let store = FakeAccountStore::new("openai", events.clone()); let services = service(provider.clone(), store.clone()).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("oauth-start-openai"), name: "reauthorize".to_owned(), @@ -396,13 +440,16 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances provider.set_current_credential_revision(revision(2)); let result = services - .openai() - .complete_authorization(CompleteAuthorization { - settings: None, - context: context("oauth-complete-openai"), - flow_id: "flow-test".to_owned(), - callback_url: "http://localhost/callback?code=test&state=test".to_owned(), - }) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + CompleteAuthorization { + settings: None, + context: context("oauth-complete-openai"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }, + ) .await .expect("complete reauthorization"); @@ -427,7 +474,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances } async fn service(provider: Arc, store: Arc) -> AdminServices { - super::AdminHarness::new() + super::super::AdminHarness::new() .provider(provider) .accounts(store) .build() @@ -448,8 +495,13 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing( let store = FakeAccountStore::new("openai", events.clone()); let services = service(provider, store.clone()).await; services - .openai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("reauthorization-settings"), name: "existing account".to_owned(), @@ -458,13 +510,16 @@ async fn reauthorization_with_import_settings_releases_claim_without_committing( .await .expect("start reauthorization"); let error = services - .openai() - .complete_authorization(CompleteAuthorization { - settings: Some(super::accounts::import_settings()), - context: context("reauthorization-settings"), - flow_id: "flow-test".to_owned(), - callback_url: "http://localhost/callback?code=test&state=test".to_owned(), - }) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("openai").unwrap(), + CompleteAuthorization { + settings: Some(super::super::accounts::import_settings()), + context: context("reauthorization-settings"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }, + ) .await .expect_err("reauthorization must preserve settings"); assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid); diff --git a/backend/crates/gateway-admin/tests/use_case/xai.rs b/backend/crates/gateway-admin/tests/use_case/credentials/xai.rs similarity index 76% rename from backend/crates/gateway-admin/tests/use_case/xai.rs rename to backend/crates/gateway-admin/tests/use_case/credentials/xai.rs index 12f5bc01f..24b764710 100644 --- a/backend/crates/gateway-admin/tests/use_case/xai.rs +++ b/backend/crates/gateway-admin/tests/use_case/credentials/xai.rs @@ -10,7 +10,9 @@ use gateway_admin::{ ports::provider::ProviderAdminErrorKind, }; -use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded}; +use super::super::accounts::{ + FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, +}; #[tokio::test] async fn xai_import_should_prepare_before_atomic_store_commit() { @@ -20,10 +22,12 @@ async fn xai_import_should_prepare_before_atomic_store_commit() { let services = service(provider.clone(), store.clone()).await; services - .xai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, - settings: Some(super::accounts::import_settings()), + settings: Some(super::super::accounts::import_settings()), context: context("import-xai"), document: document(), }) @@ -50,7 +54,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() { ); assert_eq!( store.import_settings(), - [Some(super::accounts::import_settings())] + [Some(super::super::accounts::import_settings())] ); assert_eq!(store.audit_requests(), ["import-xai"]); } @@ -64,7 +68,9 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() { let services = service(provider.clone(), store).await; services - .xai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -101,7 +107,9 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() { let services = service(provider.clone(), store).await; let result = services - .xai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -127,8 +135,13 @@ async fn xai_authorization_should_observe_quota_without_affecting_commit() { let store = FakeAccountStore::new("xai", events.clone()); let services = service(provider.clone(), store.clone()).await; services - .xai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() .start_authorization(StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: None, context: context("oauth-xai-create"), name: "new xAI credential".to_owned(), @@ -137,13 +150,16 @@ async fn xai_authorization_should_observe_quota_without_affecting_commit() { .await .expect("start authorization"); let result = services - .xai() - .complete_authorization(CompleteAuthorization { - settings: None, - context: context("oauth-xai-create"), - flow_id: "flow-test".to_owned(), - callback_url: "http://localhost/callback?code=test&state=test".to_owned(), - }) + .credentials() + .complete_authorization( + &gateway_core::routing::ProviderKind::new("xai").unwrap(), + CompleteAuthorization { + settings: None, + context: context("oauth-xai-create"), + flow_id: "flow-test".to_owned(), + callback_url: "http://localhost/callback?code=test&state=test".to_owned(), + }, + ) .await .expect("quota observation must not fail authorization"); @@ -186,8 +202,22 @@ async fn credential_import_should_return_while_initial_quota_is_pending() { }; let result = tokio::time::timeout(std::time::Duration::from_secs(5), async { match kind { - "openai" => services.openai().import_document(command).await, - _ => services.xai().import_document(command).await, + "openai" => { + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() + .import_document(command) + .await + } + _ => { + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() + .import_document(command) + .await + } } }) .await @@ -208,7 +238,9 @@ async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provide let services = service(provider.clone(), store).await; services - .xai() + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() .import_document(ImportCredentials { outbound_proxy_id: None, settings: None, @@ -226,7 +258,7 @@ async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provide } async fn service(provider: Arc, store: Arc) -> AdminServices { - super::AdminHarness::new() + super::super::AdminHarness::new() .provider(provider) .accounts(store) .build() diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 7cafdc72a..46b75b3ad 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -25,8 +25,7 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings { RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: revision(1), @@ -201,6 +200,7 @@ impl AccountProbe for SuccessfulProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> futures::future::BoxFuture<'_, Result> { Box::pin(async { Ok(AccountProbeResult { @@ -216,6 +216,7 @@ impl AccountProbe for FailingProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> futures::future::BoxFuture<'_, Result> { Box::pin(async { Err(AccountProbeError::new( diff --git a/backend/crates/gateway-admin/tests/use_case/mod.rs b/backend/crates/gateway-admin/tests/use_case/mod.rs index b9dea9e50..d01958718 100644 --- a/backend/crates/gateway-admin/tests/use_case/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/mod.rs @@ -4,14 +4,15 @@ mod auth; mod auth_key; mod backup; mod client_keys; +mod credentials; mod freeze_recovery; mod import_tasks; mod observability; -mod openai; +mod plugin_update; +mod plugins; mod proxies; mod settings; mod system; -mod xai; use std::{ str::FromStr, @@ -49,10 +50,10 @@ use gateway_admin::{ provider_credentials::{ AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialMutationResult, - CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport, - PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit, - PreparedCredentialImport, PreparedCredentialRotation, ProviderExport, - ProviderExportCredentialInput, ProviderModels, ProviderQuota, + CredentialRotationCommit, PrepareCredentialImport, PrepareCredentialRefresh, + PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport, + PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, + ProviderModels, ProviderQuota, }, settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings}, system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion}, @@ -101,6 +102,8 @@ pub(super) struct AdminHarness { providers: Vec>, probe: Arc, system: Arc, + plugin_store: Arc, + plugin_inspector: Arc, client_key_verifier: Arc, } @@ -126,6 +129,8 @@ impl AdminHarness { ], probe: Arc::new(UnavailableProbe), system: Arc::new(UnavailableSystem), + plugin_store: Arc::new(plugins::TestPluginPorts), + plugin_inspector: Arc::new(plugins::TestPluginPorts), client_key_verifier: Arc::new(UnavailableClientKeyVerifier), } } @@ -215,6 +220,16 @@ impl AdminHarness { self } + pub(super) fn plugins( + mut self, + store: Arc, + inspector: Arc, + ) -> Self { + self.plugin_store = store; + self.plugin_inspector = inspector; + self + } + pub(super) async fn build(self) -> AdminServices { self.build_bundle().await.services() } @@ -241,10 +256,20 @@ impl AdminHarness { self.observability, self.settings, self.backup, + self.plugin_store, + Arc::new(plugins::TestPluginPorts), ), gateway_admin::AdminRuntimePorts { + plugin_preparation: Arc::new(plugins::TestPluginPorts), + plugin_management: Arc::new(plugins::TestPluginPorts), + published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle::default(), + plugin_inspector: self.plugin_inspector, + plugin_distribution: Arc::new(plugins::TestPluginPorts), pricing_source: Arc::new(UnavailablePricingSource), - providers: self.providers, + providers: gateway_admin::ports::provider::ProviderAdminRegistry::new( + self.providers, + ) + .unwrap(), snapshot: Arc::new(NoopSnapshot), account_probe: self.probe, proxy_probe: Arc::new(proxies::TestProxies::default()), @@ -416,6 +441,13 @@ impl AccountGroupStore for UnavailableAccountGroupStore { #[async_trait] impl AccountStore for UnavailableStore { + async fn list_plugin_accounts( + &self, + _: gateway_admin::model::provider_credentials::PluginAccountListQuery, + ) -> AdminStoreResult { + Err(unavailable("plugin accounts")) + } + async fn list_accounts( &self, _: AccountListQuery, @@ -462,6 +494,13 @@ impl AccountStore for UnavailableStore { Err(unavailable("credential")) } + async fn credential_details_by_id( + &self, + _: &ProviderAccountId, + ) -> AdminStoreResult> { + Err(unavailable("credential")) + } + async fn load_credentials_for_export( &self, _: &ProviderKind, @@ -470,6 +509,13 @@ impl AccountStore for UnavailableStore { Err(unavailable("credential export")) } + async fn load_credential_for_plugin( + &self, + _: &ProviderAccountId, + ) -> AdminStoreResult> { + Err(unavailable("credential export")) + } + async fn commit_credential_import( &self, _: CredentialImportCommit, @@ -478,11 +524,21 @@ impl AccountStore for UnavailableStore { Err(unavailable("credential import")) } + async fn authorization_receipt( + &self, + _: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey, + ) -> AdminStoreResult< + Option, + > { + Err(unavailable("authorization receipt")) + } + async fn commit_authorization( &self, _: AuthorizationCommit, _: &MutationContext, - ) -> AdminStoreResult { + ) -> AdminStoreResult + { Err(unavailable("authorization")) } @@ -682,6 +738,10 @@ impl ObservabilityStore for UnavailableStore { Err(unavailable("usage records")) } + async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult> { + Err(unavailable("usage providers")) + } + async fn usage_record_detail(&self, _: &str) -> AdminStoreResult { Err(unavailable("usage detail")) } @@ -784,6 +844,10 @@ impl UnavailableProvider { } } +pub(super) fn test_provider(kind: &str) -> Arc { + Arc::new(UnavailableProvider::new(kind)) +} + #[async_trait] impl ProviderAdmin for UnavailableProvider { fn provider_kind(&self) -> &ProviderKind { @@ -792,7 +856,7 @@ impl ProviderAdmin for UnavailableProvider { async fn account_unavailable(&self, _: &ProviderAccountId) {} - fn connection_test_operation( + async fn connection_test_operation( &self, _: &gateway_core::routing::UpstreamModelId, _: &str, @@ -823,7 +887,7 @@ impl ProviderAdmin for UnavailableProvider { async fn start_authorization( &self, - _: PendingAuthorizationMutation, + _: gateway_admin::model::provider_credentials::PrepareAuthorization, ) -> Result { Err(unsupported_provider()) } @@ -955,6 +1019,7 @@ impl AccountProbe for UnavailableProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> BoxFuture<'_, Result> { Box::pin(async { Err(GatewayError::new( @@ -985,6 +1050,7 @@ impl SystemOperations for UnavailableSystem { async fn perform_update( &self, _: Option, + _: Arc, ) -> Result { Err(unavailable_system()) } @@ -993,7 +1059,10 @@ impl SystemOperations for UnavailableSystem { Err(unavailable_system()) } - async fn rollback(&self) -> Result { + async fn rollback( + &self, + _: Arc, + ) -> Result { Err(unavailable_system()) } diff --git a/backend/crates/gateway-admin/tests/use_case/observability.rs b/backend/crates/gateway-admin/tests/use_case/observability.rs index 37a3fe4d7..1dba81a03 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability.rs @@ -750,6 +750,10 @@ impl ObservabilityStore for FixtureObservabilityStore { )) } + async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult> { + Ok(vec!["retired-provider".into()]) + } + async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult { let items = self.usage_records.lock().expect("usage records").clone(); let total = u64::try_from(items.len()).unwrap_or(u64::MAX); @@ -808,8 +812,7 @@ impl SettingsStore for FixtureSettingsStore { } async fn load_runtime_settings(&self) -> AdminStoreResult { Ok(RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(1).expect("revision"), diff --git a/backend/crates/gateway-admin/tests/use_case/plugin_update.rs b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs new file mode 100644 index 000000000..4824e769a --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugin_update.rs @@ -0,0 +1,368 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginCompatibilityRequirements, PluginSource, + distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo}, + instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot}, + }, + system::{ + SystemOperationAccepted, SystemOperationState, SystemOperationStatus, + SystemUpdateDetail, SystemUpdateStatus, SystemVersion, + }, + }, + ports::{ + plugins::{PluginPackageInspector, PluginStore}, + store::AdminStoreResult, + system::{ + SystemOperationError, SystemOperations, SystemUpdateCandidate, SystemUpdateEventStream, + SystemUpdatePreflight, + }, + }, +}; + +struct Fixture { + revisions: std::sync::Mutex>, + requirements: PluginCompatibilityRequirements, +} + +impl Fixture { + fn new(requirements: PluginCompatibilityRequirements, revisions: &[u64]) -> Arc { + Arc::new(Self { + revisions: std::sync::Mutex::new( + revisions + .iter() + .map(|revision| Revision::new(*revision).expect("revision")) + .collect(), + ), + requirements, + }) + } + + fn next_revision(&self) -> Revision { + let mut revisions = self.revisions.lock().expect("revisions"); + if revisions.len() > 1 { + revisions.remove(0) + } else { + *revisions.first().expect("fixture revision") + } + } +} + +#[async_trait] +impl PluginPackageInspector for Fixture { + async fn inspect( + &self, + _: Arc<[u8]>, + _: Option, + ) -> Result { + unreachable!() + } + + async fn compatibility( + &self, + _: Arc<[u8]>, + expected_sha256: String, + ) -> Result { + assert_eq!(expected_sha256, "a".repeat(64)); + Ok(self.requirements.clone()) + } +} + +#[async_trait] +impl PluginStore for Fixture { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances(&self) -> AdminStoreResult { + Ok(PluginInstanceSnapshot { + config_revision: self.next_revision(), + instances: vec![PluginInstance { + id: "enabled-fixture".into(), + name: "Enabled fixture".into(), + artifact_sha256: "a".repeat(64), + enabled: true, + trusted_process: true, + configuration: serde_json::json!({}), + secrets: BTreeMap::new(), + grants: Vec::new(), + bindings: Vec::new(), + revision: Revision::new(1).expect("revision"), + }], + }) + } + + async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + assert_eq!(digest, "a".repeat(64)); + Ok(InspectedPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: "test.fixture".into(), + version: "1.0.0".into(), + name: "fixture".into(), + display_name: "Fixture".into(), + publisher: "test".into(), + author: Some("project".into()), + description: "fixture".into(), + license: "MIT".into(), + sha256: digest.into(), + platforms: Vec::new(), + icon: None, + contributes: BTreeMap::new(), + requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), + secret_fields: Vec::new(), + state_namespaces: Vec::new(), + }, + archive: Arc::from([1_u8]), + }) + } + + async fn save_instance( + &self, + _: PluginInstance, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn list_update_sources(&self) -> AdminStoreResult> { + unreachable!() + } + + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn list_source_credentials(&self) -> AdminStoreResult> { + unreachable!() + } + + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + unreachable!() + } + + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn list_artifacts(&self) -> AdminStoreResult> { + unreachable!() + } + + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + unreachable!() + } +} + +struct PreflightingSystem { + candidate: SystemUpdateCandidate, +} + +#[async_trait] +impl SystemOperations for PreflightingSystem { + async fn version(&self) -> Result { + unreachable!() + } + + async fn update_detail(&self, _: bool) -> Result { + unreachable!() + } + + fn update_events(&self) -> SystemUpdateEventStream { + Box::pin(futures::stream::empty()) + } + + async fn perform_update( + &self, + _: Option, + preflight: Arc, + ) -> Result { + let revision = preflight.validate(self.candidate.clone()).await?; + preflight.confirm_revision(revision).await?; + Ok(SystemOperationAccepted::Update { + operation_id: "fixture".into(), + deployment_mode: "fixture".into(), + message: "accepted".into(), + target_version: self.candidate.target_version.clone(), + }) + } + + async fn update_status(&self) -> Result { + Ok(SystemUpdateStatus { + previous_version: None, + current_version: None, + need_restart: false, + operation: SystemOperationState { + operation_id: None, + kind: None, + status: SystemOperationStatus::Idle, + target_version: None, + message: None, + error: None, + started_at: None, + finished_at: None, + }, + }) + } + + async fn rollback( + &self, + _: Arc, + ) -> Result { + unreachable!() + } + + async fn restart(&self) -> Result { + unreachable!() + } +} + +#[tokio::test] +async fn system_update_accepts_compatible_enabled_plugins_at_the_same_revision() { + let fixture = Fixture::new(requirements("^1.0", "executor", "log"), &[7]); + let services = super::AdminHarness::new() + .plugins(fixture.clone(), fixture) + .system(Arc::new(PreflightingSystem { + candidate: candidate("executor", "log"), + })) + .build() + .await; + + services + .system() + .perform_update(Some("1.2.0".into())) + .await + .expect("compatible update"); +} + +#[tokio::test] +async fn system_update_rejects_missing_target_capability_and_revision_changes() { + let incompatible = Fixture::new(requirements("^1.0", "executor", "log"), &[7]); + let services = super::AdminHarness::new() + .plugins(incompatible.clone(), incompatible) + .system(Arc::new(PreflightingSystem { + candidate: candidate("models", "log"), + })) + .build() + .await; + assert!( + services + .system() + .perform_update(Some("1.2.0".into())) + .await + .is_err() + ); + + let stale = Fixture::new(requirements("^1.0", "executor", "log"), &[7, 8]); + let services = super::AdminHarness::new() + .plugins(stale.clone(), stale) + .system(Arc::new(PreflightingSystem { + candidate: candidate("executor", "log"), + })) + .build() + .await; + assert!( + services + .system() + .perform_update(Some("1.2.0".into())) + .await + .is_err() + ); +} + +fn requirements( + host_version: &str, + capability: &str, + permission: &str, +) -> PluginCompatibilityRequirements { + PluginCompatibilityRequirements { + host_version: host_version.into(), + manifest_schema_version: 2, + protocol_version: 3, + capabilities: vec![(capability.into(), 1)], + permissions: vec![permission.into()], + } +} + +fn candidate(capability: &str, permission: &str) -> SystemUpdateCandidate { + let manifest = serde_json::to_vec(&serde_json::json!({ + "schema_version": 1, + "sealed": true, + "gateway_version": "1.2.0", + "gateway_git_sha": "a".repeat(40), + "plugin_host": { + "schema_version": 1, + "manifest_schema_versions": [2], + "protocol_versions": [3], + "capabilities": [{ "capability": capability, "versions": [1] }], + "permissions": [permission], + }, + "plugins": [], + })) + .expect("manifest"); + SystemUpdateCandidate { + target_version: "1.2.0".into(), + release_manifest: manifest.into(), + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs new file mode 100644 index 000000000..462a1928f --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/artifacts.rs @@ -0,0 +1,439 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use async_trait::async_trait; +use futures::future::BoxFuture; +use gateway_admin::{ + PluginDistributionPorts, PluginsService, + model::{ + AdminError, MutationActor, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginContribution, PluginSource, + distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo}, + instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot}, + state::PluginStateConfiguration, + }, + }, + ports::{ + plugins::{PluginPreparation, PluginStore}, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use gateway_core::{ + routing::ConfigRevision, + runtime::{ + RuntimeSnapshotHandle, SnapshotControl, + extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, + }, +}; +use serde_json::json; + +use super::TestPluginPorts; + +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +struct FixtureData { + artifact: InstalledPluginArtifact, + snapshot: PluginInstanceSnapshot, + saves: usize, + publications: Vec, +} + +struct Fixture { + data: Mutex, +} + +impl Fixture { + fn new(metadata: PluginArtifactMetadata, existing: Option) -> Arc { + Arc::new(Self { + data: Mutex::new(FixtureData { + artifact: InstalledPluginArtifact { + metadata, + source: PluginSource::Upload, + installed_at: chrono::Utc::now(), + accepted_at: None, + }, + snapshot: PluginInstanceSnapshot { + config_revision: revision(1), + instances: existing.into_iter().collect(), + }, + saves: 0, + publications: Vec::new(), + }), + }) + } + + fn service(self: &Arc) -> PluginsService { + let unused = Arc::new(TestPluginPorts); + PluginsService::new( + self.clone(), + unused.clone(), + PluginDistributionPorts::new(unused.clone(), unused.clone()), + self.clone(), + self.clone(), + RuntimeSnapshotHandle::default(), + unused, + ) + } +} + +impl SnapshotControl for Fixture { + fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> { + self.data.lock().unwrap().publications.push(revision.get()); + Box::pin(async {}) + } +} + +struct Lease; + +impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } +} + +#[async_trait] +impl PluginPreparation for Fixture { + async fn configuration_ready( + &self, + instance: PluginInstance, + _: &gateway_admin::model::plugins::PluginArtifactMetadata, + ) -> Result { + Ok(instance + .configuration + .get("requiredName") + .is_some_and(serde_json::Value::is_string)) + } + + async fn validate(&self, _: PluginInstance) -> Result { + Ok(PluginStateConfiguration { + namespaces: Vec::new(), + }) + } + + async fn prepare( + &self, + source_revision: Revision, + _: PluginInstanceSnapshot, + ) -> Result { + if source_revision != self.data.lock().unwrap().snapshot.config_revision { + return Err(AdminError::conflict("fixture configuration changed")); + } + Ok(ExtensionSetReference::new( + ExtensionSetId::new("install-fixture".into()).unwrap(), + Arc::new(Lease), + )) + } +} + +#[async_trait] +impl PluginStore for Fixture { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances(&self) -> AdminStoreResult { + Ok(self.data.lock().unwrap().snapshot.clone()) + } + + async fn save_instance( + &self, + mut instance: PluginInstance, + expected_revision: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + let mut data = self.data.lock().unwrap(); + if data.snapshot.config_revision != expected_revision { + return Err(store_error(AdminStoreErrorKind::Conflict)); + } + let next = revision(expected_revision.get() + 1); + instance.revision = next; + data.snapshot.config_revision = next; + data.snapshot + .instances + .retain(|current| current.id != instance.id); + data.snapshot.instances.push(instance.clone()); + data.saves += 1; + Ok(PluginInstanceMutation { + config_revision: next, + instance, + }) + } + + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_update_sources(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_source_credentials(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_artifacts(&self) -> AdminStoreResult> { + Ok(vec![self.data.lock().unwrap().artifact.clone()]) + } + + async fn load_artifact(&self, _: &str) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } + + async fn accept_artifact( + &self, + digest: &str, + _: &MutationContext, + ) -> AdminStoreResult { + let mut data = self.data.lock().unwrap(); + if data.artifact.metadata.sha256 != digest { + return Err(store_error(AdminStoreErrorKind::NotFound)); + } + if data.artifact.accepted_at.is_none() { + data.artifact.accepted_at = Some(chrono::Utc::now()); + data.snapshot.config_revision = revision(data.snapshot.config_revision.get() + 1); + } + Ok(PluginArtifactMutation { + config_revision: data.snapshot.config_revision, + artifact: data.artifact.clone(), + }) + } + + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + Err(store_error(AdminStoreErrorKind::Unavailable)) + } +} + +#[tokio::test] +async fn acceptance_creates_one_stable_default_instance_with_defaults_and_bindings() { + let fixture = Fixture::new(metadata(true), None); + let service = fixture.service(); + + let first = service.accept_artifact(DIGEST, &context()).await.unwrap(); + let repeated = service.accept_artifact(DIGEST, &context()).await.unwrap(); + assert_eq!(first.default_instance_id, repeated.default_instance_id); + assert!(!first.configuration_required); + assert!(!repeated.configuration_required); + + let data = fixture.data.lock().unwrap(); + assert_eq!(data.saves, 1, "同一摘要重试不能复制默认实例"); + assert_eq!(data.snapshot.instances.len(), 1); + let instance = &data.snapshot.instances[0]; + assert_eq!( + Some(instance.id.as_str()), + first.default_instance_id.as_deref() + ); + assert!(instance.enabled); + assert_eq!(instance.configuration["mode"], "safe"); + assert_eq!(instance.configuration["nested"]["limit"], 3); + assert_eq!(instance.configuration["requiredName"], "ready"); + assert!(instance.configuration.get("token").is_none()); + assert_eq!( + instance + .bindings + .iter() + .map(|binding| ( + binding.contribution.as_str(), + binding.stage.as_str(), + &binding.failure_policy, + )) + .collect::>(), + [ + ( + "test.example.middleware", + "observation", + &gateway_admin::model::plugins::instances::PluginFailurePolicy::Observe, + ), + ( + "test.example.middleware", + "request", + &gateway_admin::model::plugins::instances::PluginFailurePolicy::Reject, + ), + ] + ); + assert!(instance.bindings.iter().all(|binding| !matches!( + binding.contribution.as_str(), + "test.example.auth" | "test.example.cli" | "test.example.management" + ))); +} + +#[tokio::test] +async fn missing_required_configuration_creates_a_disabled_pending_instance() { + let fixture = Fixture::new(metadata(false), None); + let result = fixture + .service() + .accept_artifact(DIGEST, &context()) + .await + .unwrap(); + assert!(result.configuration_required); + assert!(result.default_instance_id.is_some()); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.instances.len(), 1); + assert!(!data.snapshot.instances[0].enabled); +} + +#[tokio::test] +async fn existing_plugin_instance_prevents_an_extra_default_instance() { + let metadata = metadata(true); + let existing = PluginInstance { + id: uuid::Uuid::now_v7().to_string(), + name: "Existing".into(), + artifact_sha256: metadata.sha256.clone(), + enabled: false, + trusted_process: false, + configuration: json!({}), + secrets: BTreeMap::new(), + grants: Vec::new(), + bindings: Vec::new(), + revision: revision(1), + }; + let fixture = Fixture::new(metadata, Some(existing)); + let result = fixture + .service() + .accept_artifact(DIGEST, &context()) + .await + .unwrap(); + assert_eq!(result.default_instance_id, None); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.saves, 0); + assert_eq!(data.snapshot.instances.len(), 1); +} + +fn metadata(with_required_default: bool) -> PluginArtifactMetadata { + let required_name = if with_required_default { + json!({"type":"string","default":"ready"}) + } else { + json!({"type":"string"}) + }; + PluginArtifactMetadata { + plugin_id: "test.example".into(), + version: "1.0.0".into(), + name: "example".into(), + display_name: "Example".into(), + publisher: "test".into(), + author: None, + description: "fixture".into(), + license: "MIT".into(), + sha256: DIGEST.into(), + platforms: vec!["linux-x86_64".into()], + icon: None, + contributes: BTreeMap::from([ + ( + "command_line".into(), + contribution("test.example.cli", &["command"]), + ), + ( + "frontend_authentication".into(), + contribution("test.example.auth", &["authentication"]), + ), + ( + "management".into(), + contribution("test.example.management", &["management"]), + ), + ( + "middleware".into(), + contribution("test.example.middleware", &["observation", "request"]), + ), + ]), + requested_permissions: vec!["network".into()], + configuration_schema: json!({ + "type":"object", + "properties": { + "mode": {"type":"string","default":"safe"}, + "nested": { + "type":"object", + "default": {}, + "properties": {"limit":{"type":"integer","default":3}} + }, + "requiredName": required_name, + "token": {"type":"string","default":"never-copy-secret"} + }, + "required":["requiredName"] + }), + secret_fields: vec!["token".into()], + state_namespaces: Vec::new(), + } +} + +fn contribution(id: &str, stages: &[&str]) -> PluginContribution { + PluginContribution { + id: id.into(), + version: 1, + stages: stages.iter().map(|stage| (*stage).into()).collect(), + input_formats: Vec::new(), + output_formats: Vec::new(), + } +} + +fn revision(value: u64) -> Revision { + Revision::new(value).unwrap() +} + +fn context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "install-fixture".into(), + } +} + +fn store_error(kind: AdminStoreErrorKind) -> AdminStoreError { + AdminStoreError::new(kind, "plugin", "install fixture") +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs b/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs new file mode 100644 index 000000000..4aca3a2eb --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/distribution.rs @@ -0,0 +1,766 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use futures::future::BoxFuture; +use gateway_admin::{ + PluginDistributionPorts, PluginsService, + model::{ + AdminError, AdminErrorKind, MutationActor, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginSource, PluginSourceEgress, + distribution::{ + DownloadedPlugin, GithubReleaseQuery, PluginDistributionEgress, PluginRelease, + PluginSourceBinding, PluginUpdatePolicy, PluginUpdateSource, RemotePluginInstall, + RemotePluginLocation, RemotePluginVerify, SourceCredential, SourceCredentialInfo, + }, + instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot}, + }, + proxies::{ + NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery, ProxyMutation, + ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy, + }, + }, + ports::{ + plugins::{PluginDistribution, PluginPackageInspector, PluginStore}, + proxy::{ProxyImportReservation, ProxyStore}, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use gateway_core::{ + account::{OutboundProxy, ProviderAccountId}, + routing::ConfigRevision, + runtime::{RuntimeSnapshotHandle, SnapshotControl}, +}; + +use super::TestPluginPorts; + +struct Fixture { + binding: Mutex, + queries: Mutex>, + publications: Mutex>, + changes: Mutex, + query_fails: bool, + change_during_query: bool, + package: Option, + downloads: Mutex, + change_during_download: bool, + proxy: Mutex>, + egresses: Mutex>>, + change_proxy_during_download: bool, +} + +impl Fixture { + fn new(policy: PluginUpdatePolicy) -> Self { + Self { + binding: Mutex::new(PluginSourceBinding { + plugin_id: "test.example".into(), + source: PluginUpdateSource::Github { + repository: "owner/plugin".into(), + }, + policy, + outbound_proxy_id: None, + }), + queries: Mutex::new(vec![]), + publications: Mutex::new(vec![]), + changes: Mutex::new(0), + query_fails: false, + change_during_query: false, + package: None, + downloads: Mutex::new(0), + change_during_download: false, + proxy: Mutex::new(None), + egresses: Mutex::new(vec![]), + change_proxy_during_download: false, + } + } + + fn service(self: &Arc) -> PluginsService { + let unused = Arc::new(TestPluginPorts); + PluginsService::new( + self.clone(), + self.clone(), + PluginDistributionPorts::new(self.clone(), self.clone()), + self.clone(), + unused.clone(), + RuntimeSnapshotHandle::default(), + unused, + ) + } +} + +impl SnapshotControl for Fixture { + fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> { + self.publications.lock().unwrap().push(revision.get()); + Box::pin(async {}) + } +} + +#[async_trait] +impl ProxyStore for Fixture { + async fn reserve_import(&self, _: &str) -> AdminStoreResult { + unreachable!() + } + + async fn list(&self, _: ProxyListQuery) -> AdminStoreResult { + unreachable!() + } + + async fn list_accounts(&self, _: ProxyAccountListQuery) -> AdminStoreResult { + unreachable!() + } + + async fn get(&self, id: &str) -> AdminStoreResult { + self.proxy + .lock() + .unwrap() + .as_ref() + .filter(|record| record.id == id) + .cloned() + .ok_or_else(|| { + AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "fixture not found") + }) + } + + async fn remove_account( + &self, + _: &str, + _: &ProviderAccountId, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult { + unreachable!() + } + + async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult { + unreachable!() + } + + async fn delete( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + + async fn record_test( + &self, + _: &str, + _: Revision, + _: ProxyTestResult, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } +} + +#[async_trait] +impl PluginDistribution for Fixture { + fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> { + Ok(()) + } + fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> { + unreachable!() + } + async fn query_release( + &self, + query: GithubReleaseQuery, + credentials: Vec, + egress: Option, + ) -> Result { + assert!(credentials.is_empty()); + self.egresses + .lock() + .unwrap() + .push(egress.map(|egress| egress.source)); + self.queries.lock().unwrap().push(query.clone()); + if self.query_fails { + return Err(AdminError::unavailable("fixture rate limited")); + } + if self.change_during_query { + self.binding.lock().unwrap().policy = PluginUpdatePolicy::Manual {}; + } + Ok(PluginRelease { + repository: query.repository, + tag: query.tag.unwrap_or_else(|| "v2.0.0".into()), + name: "fixture".into(), + prerelease: query.allow_prerelease, + assets: vec![], + queried_at: chrono::Utc::now(), + expires_at: chrono::Utc::now(), + }) + } + async fn download( + &self, + location: RemotePluginLocation, + _: Vec, + egress: Option, + ) -> Result { + let outbound_proxy = egress.as_ref().map(|egress| egress.source.clone()); + self.egresses.lock().unwrap().push(outbound_proxy.clone()); + let package = self.package.as_ref().expect("检查更新不能下载产物"); + *self.downloads.lock().unwrap() += 1; + if self.change_during_download { + self.binding.lock().unwrap().policy = PluginUpdatePolicy::Manual {}; + } + if self.change_proxy_during_download { + self.proxy.lock().unwrap().as_mut().unwrap().revision = Revision::new(2).unwrap(); + } + let RemotePluginLocation::Github { + repository, + tag, + asset, + .. + } = location + else { + unreachable!() + }; + Ok(DownloadedPlugin { + archive: package.archive.clone(), + sha256: package.metadata.sha256.clone(), + source: PluginSource::Github { + repository: repository.to_ascii_lowercase(), + tag, + asset, + credential_ids: vec![], + outbound_proxy, + }, + }) + } +} + +#[async_trait] +impl PluginPackageInspector for Fixture { + async fn inspect( + &self, + archive: Arc<[u8]>, + expected_sha256: Option, + ) -> Result { + let package = self.package.as_ref().expect("检查 Release 不能校验插件包"); + assert_eq!(archive, package.archive); + if let Some(digest) = expected_sha256 { + assert_eq!(digest, package.metadata.sha256); + } + Ok(package.clone()) + } +} + +#[async_trait] +impl PluginStore for Fixture { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn list_update_sources(&self) -> AdminStoreResult> { + Ok(vec![self.binding.lock().unwrap().clone()]) + } + async fn change_update_source( + &self, + binding: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + *self.binding.lock().unwrap() = binding; + *self.changes.lock().unwrap() += 1; + Ok(Revision::new(2).unwrap()) + } + async fn load_instances(&self) -> AdminStoreResult { + unreachable!() + } + async fn save_instance( + &self, + _: PluginInstance, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn list_source_credentials(&self) -> AdminStoreResult> { + unreachable!() + } + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(super::unavailable()) + } + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn list_artifacts(&self) -> AdminStoreResult> { + unreachable!() + } + async fn load_artifact(&self, _: &str) -> AdminStoreResult { + unreachable!() + } + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("检查更新不能安装产物") + } + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + unreachable!() + } +} + +fn context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "plugin-update-test".into(), + } +} + +#[tokio::test] +async fn update_check_uses_persisted_policy_without_downloading_writing_or_publishing() { + for (policy, tag, prerelease) in [ + (PluginUpdatePolicy::Stable {}, None, false), + ( + PluginUpdatePolicy::Pinned { + tag: "v2.0.0-rc.1".into(), + allow_prerelease: true, + }, + Some("v2.0.0-rc.1"), + true, + ), + ] { + let fixture = Arc::new(Fixture::new(policy.clone())); + let check = fixture + .service() + .check_update("test.example", &[]) + .await + .unwrap(); + assert_eq!(check.binding.policy, policy); + assert_eq!( + fixture.queries.lock().unwrap().as_slice(), + &[GithubReleaseQuery { + repository: "owner/plugin".into(), + tag: tag.map(str::to_owned), + allow_prerelease: prerelease, + }] + ); + assert_eq!(*fixture.changes.lock().unwrap(), 0); + assert!(fixture.publications.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn update_check_never_turns_query_failure_or_changed_source_into_no_updates() { + for changed in [false, true] { + let mut fixture = Fixture::new(PluginUpdatePolicy::Stable {}); + fixture.query_fails = !changed; + fixture.change_during_query = changed; + let fixture = Arc::new(fixture); + let error = fixture + .service() + .check_update("test.example", &[]) + .await + .unwrap_err(); + assert_eq!( + error.kind(), + if changed { + AdminErrorKind::Conflict + } else { + AdminErrorKind::Unavailable + } + ); + assert!(fixture.publications.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn update_check_rejects_manual_missing_and_duplicate_credential_selections_before_query() { + let fixture = Arc::new(Fixture::new(PluginUpdatePolicy::Manual {})); + assert_eq!( + fixture + .service() + .check_update("test.example", &[]) + .await + .unwrap_err() + .kind(), + AdminErrorKind::Invalid + ); + assert_eq!( + fixture + .service() + .check_update("missing", &[]) + .await + .unwrap_err() + .kind(), + AdminErrorKind::NotFound + ); + fixture.binding.lock().unwrap().policy = PluginUpdatePolicy::Stable {}; + assert_eq!( + fixture + .service() + .check_update("test.example", &["same".into(), "same".into()]) + .await + .unwrap_err() + .kind(), + AdminErrorKind::Invalid + ); + assert!(fixture.queries.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn changing_source_validates_policy_and_publishes_only_explicit_valid_changes() { + let fixture = Arc::new(Fixture::new(PluginUpdatePolicy::Manual {})); + let original = fixture.binding.lock().unwrap().clone(); + for (source, policy) in [ + (PluginUpdateSource::Upload, PluginUpdatePolicy::Stable {}), + (PluginUpdateSource::Builtin, PluginUpdatePolicy::Manual {}), + ( + original.source.clone(), + PluginUpdatePolicy::Pinned { + tag: " ".into(), + allow_prerelease: false, + }, + ), + ] { + let error = fixture + .service() + .change_update_source( + PluginSourceBinding { + plugin_id: "test.example".into(), + source, + policy, + outbound_proxy_id: None, + }, + &context(), + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + } + assert_eq!(*fixture.changes.lock().unwrap(), 0); + let binding = PluginSourceBinding { + plugin_id: "test.example".into(), + source: PluginUpdateSource::Github { + repository: "Owner/Plugin".into(), + }, + policy: PluginUpdatePolicy::Stable {}, + outbound_proxy_id: None, + }; + fixture + .service() + .change_update_source(binding, &context()) + .await + .unwrap(); + assert_eq!(fixture.binding.lock().unwrap().source, original.source); + assert_eq!(*fixture.changes.lock().unwrap(), 1); + assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]); +} + +#[test] +fn existing_source_payload_defaults_to_manual_and_rejects_unknown_policy_fields() { + let binding: PluginSourceBinding = serde_json::from_value( + serde_json::json!({"pluginId":"test.example","source":{"kind":"upload"}}), + ) + .unwrap(); + assert_eq!(binding.policy, PluginUpdatePolicy::Manual {}); + assert!(serde_json::from_value::(serde_json::json!({"pluginId":"test.example","source":{"kind":"github","repository":"owner/plugin"},"policy":{"kind":"stable","autoInstall":true}})).is_err()); +} + +fn preview_fixture() -> Fixture { + let mut fixture = Fixture::new(PluginUpdatePolicy::Stable {}); + fixture.package = Some(InspectedPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: "test.example".into(), + version: "2.0.0".into(), + name: "example".into(), + display_name: "Example".into(), + publisher: "test".into(), + author: Some("Tests".into()), + description: String::new(), + license: "MIT".into(), + sha256: "a".repeat(64), + platforms: vec!["linux-x86_64".into()], + icon: None, + contributes: Default::default(), + requested_permissions: vec!["log".into()], + configuration_schema: serde_json::json!({"type":"object"}), + secret_fields: vec![], + state_namespaces: vec![], + }, + archive: b"bounded archive fixture".as_slice().into(), + }); + fixture +} + +fn source_proxy() -> ProxyRecord { + let now = chrono::Utc::now(); + ProxyRecord { + location: None, + id: "source-proxy".into(), + name: "Source proxy".into(), + proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:18080").unwrap(), + revision: Revision::new(1).unwrap(), + account_count: 0, + last_test_at: None, + last_test: None, + created_at: now, + updated_at: now, + } +} + +fn preview_request() -> RemotePluginVerify { + RemotePluginVerify { + expected_plugin_id: Some("test.example".into()), + credential_ids: vec![], + outbound_proxy_id: None, + location: RemotePluginLocation::Github { + repository: "Owner/Plugin".into(), + tag: "v2.0.0".into(), + asset: "plugin.tar.gz".into(), + allow_prerelease: false, + sha256: Some("a".repeat(64)), + }, + } +} + +fn install_request() -> RemotePluginInstall { + let preview = preview_request(); + RemotePluginInstall { + plugin_id: "test.example".into(), + version: "2.0.0".into(), + credential_ids: preview.credential_ids, + outbound_proxy_id: preview.outbound_proxy_id, + location: preview.location, + } +} + +#[tokio::test] +async fn saved_source_proxy_is_resolved_for_queries_and_frozen_into_verified_source() { + let mut fixture = preview_fixture(); + fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into()); + *fixture.proxy.get_mut().unwrap() = Some(source_proxy()); + let fixture = Arc::new(fixture); + fixture + .service() + .check_update("test.example", &[]) + .await + .unwrap(); + let mut request = preview_request(); + request.outbound_proxy_id = Some("source-proxy".into()); + let verified = fixture.service().verify_remote(request).await.unwrap(); + assert!(matches!( + verified.source.outbound_proxy(), + Some(PluginSourceEgress { id, revision: 1 }) if id == "source-proxy" + )); + assert_eq!( + fixture.egresses.lock().unwrap().as_slice(), + &[ + Some(PluginSourceEgress { + id: "source-proxy".into(), + revision: 1, + }), + Some(PluginSourceEgress { + id: "source-proxy".into(), + revision: 1, + }), + ] + ); +} + +#[tokio::test] +async fn proxy_revision_change_during_download_rejects_the_verified_candidate() { + let mut fixture = preview_fixture(); + fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into()); + *fixture.proxy.get_mut().unwrap() = Some(source_proxy()); + fixture.change_proxy_during_download = true; + let fixture = Arc::new(fixture); + let mut request = preview_request(); + request.outbound_proxy_id = Some("source-proxy".into()); + assert_eq!( + fixture + .service() + .verify_remote(request) + .await + .unwrap_err() + .kind(), + AdminErrorKind::Conflict + ); +} + +#[tokio::test] +async fn verifying_remote_package_uses_inspector_and_returns_fixed_facts_without_installing() { + let fixture = Arc::new(preview_fixture()); + let verified = fixture + .service() + .verify_remote(preview_request()) + .await + .unwrap(); + assert_eq!(verified.metadata.sha256, "a".repeat(64)); + assert_eq!(verified.metadata.requested_permissions, vec!["log"]); + assert!( + matches!(verified.source, PluginSource::Github { ref repository, ref tag, ref asset, .. } + if repository == "owner/plugin" && tag == "v2.0.0" && asset == "plugin.tar.gz") + ); + assert_eq!(*fixture.downloads.lock().unwrap(), 1); + assert_eq!(*fixture.changes.lock().unwrap(), 0); + assert!(fixture.publications.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn remote_install_rejects_non_namespaced_plugin_ids_before_download() { + for plugin_id in [ + "example", + "Test.example", + "test.-example", + "test.example_legacy", + "test.example.extra", + ] { + let fixture = Arc::new(preview_fixture()); + let mut request = preview_request(); + request.expected_plugin_id = Some(plugin_id.into()); + let error = fixture.service().verify_remote(request).await.unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid, "{plugin_id}"); + assert_eq!(*fixture.downloads.lock().unwrap(), 0, "{plugin_id}"); + } +} + +#[tokio::test] +async fn installation_rejects_identity_version_and_source_changes_without_persisting() { + for change in 0..4 { + let mut fixture = preview_fixture(); + match change { + 0 => fixture.package.as_mut().unwrap().metadata.plugin_id = "foreign".into(), + 1 => fixture.package.as_mut().unwrap().metadata.version = "3.0.0".into(), + 2 => fixture.binding.lock().unwrap().source = PluginUpdateSource::Upload, + _ => fixture.change_during_download = true, + } + let fixture = Arc::new(fixture); + let error = fixture + .service() + .install_remote(install_request(), &context()) + .await + .err() + .unwrap(); + assert_eq!( + error.kind(), + if change < 2 { + AdminErrorKind::Invalid + } else { + AdminErrorKind::Conflict + } + ); + assert_eq!(*fixture.downloads.lock().unwrap(), usize::from(change != 2)); + assert!(fixture.publications.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn preview_discovers_identity_and_version_from_manifest_without_installing() { + let fixture = Arc::new(preview_fixture()); + let mut request = preview_request(); + request.expected_plugin_id = None; + let RemotePluginLocation::Github { sha256, .. } = &mut request.location else { + unreachable!() + }; + *sha256 = None; + let verified = fixture.service().verify_remote(request).await.unwrap(); + assert_eq!(verified.metadata.plugin_id, "test.example"); + assert_eq!(verified.metadata.version, "2.0.0"); + assert!(fixture.publications.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn discovered_identity_cannot_bypass_saved_source_or_concurrent_changes() { + for changed_during_download in [false, true] { + let mut fixture = preview_fixture(); + if changed_during_download { + fixture.change_during_download = true; + } else { + fixture.binding.get_mut().unwrap().source = PluginUpdateSource::Upload; + } + let fixture = Arc::new(fixture); + let mut request = preview_request(); + request.expected_plugin_id = None; + let error = fixture.service().verify_remote(request).await.unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert!(fixture.publications.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn installation_requires_pinned_digest_before_downloading() { + let fixture = Arc::new(preview_fixture()); + let mut request = install_request(); + let RemotePluginLocation::Github { sha256, .. } = &mut request.location else { + unreachable!() + }; + *sha256 = None; + let error = fixture + .service() + .install_remote(request, &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert_eq!(*fixture.downloads.lock().unwrap(), 0); +} + +#[tokio::test] +async fn upload_preview_inspects_without_persisting_or_publishing() { + let fixture = Arc::new(preview_fixture()); + let archive = fixture.package.as_ref().unwrap().archive.clone(); + let verified = fixture.service().verify_upload(archive).await.unwrap(); + assert_eq!(verified.metadata.plugin_id, "test.example"); + assert_eq!(verified.source, PluginSource::Upload); + assert!(fixture.publications.lock().unwrap().is_empty()); +} + +#[test] +fn url_preview_accepts_only_a_location_without_identity_or_digest() { + let input: RemotePluginVerify = serde_json::from_value(serde_json::json!({ + "location": {"kind": "url", "url": "https://example.org/plugin.tar.gz"} + })) + .unwrap(); + assert!(input.expected_plugin_id.is_none()); + assert!(matches!( + input.location, + RemotePluginLocation::Url { sha256: None, .. } + )); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs new file mode 100644 index 000000000..6ec60da7f --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs @@ -0,0 +1,1408 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; + +use async_trait::async_trait; +use futures::future::BoxFuture; +use gateway_admin::{ + PluginDistributionPorts, PluginsService, + model::{ + AdminError, AdminErrorKind, MutationActor, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginContribution, PluginSource, + distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo}, + instances::{ + ConfigurePluginInstance, PluginCapabilityBinding, PluginFailurePolicy, + PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, + PluginInstanceRuntime, PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, + PluginInstanceSnapshot, PluginPermissionGrant, PluginVersionConfiguration, + RollbackPluginInstance, + }, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, + PluginStateConfiguration, PluginStateMigrationBatch, PluginStateMigrationNamespace, + PluginStateOwner, PluginStateOwnerRequest, PluginStateRecord, PluginStateSchema, + PluginStateTransition, PluginStateWrite, PutPluginState, + }, + }, + }, + ports::{ + plugins::{ + PluginPreparation, PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind, + PluginStateStoreResult, PluginStore, + }, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use gateway_core::{ + routing::ConfigRevision, + runtime::{ + RuntimeSnapshotHandle, SnapshotControl, + extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, + }, +}; +use serde_json::json; + +use super::TestPluginPorts; + +const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const NEW_ARTIFACT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +#[derive(Clone, Copy)] +enum MigrationBehavior { + Succeed, + Fail, + ConcurrentChangeThenFail, +} + +struct SavedInstance { + artifact_sha256: String, + enabled: bool, + transition_id: Option, +} + +struct FixtureData { + snapshot: PluginInstanceSnapshot, + artifacts: Vec, + artifact_reads: usize, + change_during_artifact_read: bool, + fail_preparation: bool, + configuration_ready: bool, + prepared_snapshots: Vec, + saves: Vec, + history: BTreeMap, + aborted: usize, + activated: usize, + quiesced: usize, + diagnostics: Option>, +} + +struct LifecycleFixture { + behavior: MigrationBehavior, + data: Mutex, + next_prepared: AtomicU64, +} + +impl LifecycleFixture { + fn new(behavior: MigrationBehavior) -> Arc { + Arc::new(Self { + behavior, + data: Mutex::new(FixtureData { + snapshot: PluginInstanceSnapshot { + config_revision: revision(10), + instances: vec![PluginInstance { + id: "00000000-0000-7000-8000-000000000001".into(), + name: "previous".into(), + artifact_sha256: OLD_ARTIFACT.into(), + enabled: true, + trusted_process: true, + configuration: json!({}), + secrets: Default::default(), + grants: vec![], + bindings: vec![], + revision: revision(10), + }], + }, + artifacts: vec![ + artifact(OLD_ARTIFACT, "test.lifecycle", "2.0.0"), + artifact(NEW_ARTIFACT, "test.lifecycle", "1.0.0"), + ], + artifact_reads: 0, + change_during_artifact_read: false, + fail_preparation: false, + configuration_ready: true, + prepared_snapshots: vec![], + saves: vec![], + history: BTreeMap::from([( + NEW_ARTIFACT.into(), + PluginVersionConfiguration { + configuration: json!({"oldVersion": true}), + secrets: BTreeMap::from([("token".into(), "old-version-secret".into())]), + bindings: vec![], + }, + )]), + aborted: 0, + activated: 0, + quiesced: 0, + diagnostics: None, + }), + next_prepared: AtomicU64::new(1), + }) + } + + fn snapshot(&self) -> PluginInstanceSnapshot { + self.data.lock().unwrap().snapshot.clone() + } + + fn save( + &self, + mut instance: PluginInstance, + expected: Revision, + transition_id: Option, + replacements: &[PluginInstanceReplacement], + ) -> AdminStoreResult { + let mut data = self.data.lock().unwrap(); + if data.snapshot.config_revision != expected { + return Err(admin_error(AdminStoreErrorKind::Conflict)); + } + let next = revision(expected.get() + 1); + for replacement in replacements { + let current = data + .snapshot + .instances + .iter_mut() + .find(|item| item.id == replacement.id) + .unwrap(); + assert!(current.enabled); + assert_eq!(current.revision.get(), replacement.expected_revision); + current.enabled = false; + current.revision = next; + } + if instance.enabled { + data.history.insert( + instance.artifact_sha256.clone(), + PluginVersionConfiguration { + configuration: instance.configuration.clone(), + secrets: instance.secrets.clone(), + bindings: instance.bindings.clone(), + }, + ); + } + instance.revision = next; + data.snapshot.config_revision = next; + data.snapshot + .instances + .retain(|current| current.id != instance.id); + data.snapshot.instances.push(instance.clone()); + data.saves.push(SavedInstance { + artifact_sha256: instance.artifact_sha256.clone(), + enabled: instance.enabled, + transition_id, + }); + Ok(PluginInstanceMutation { + config_revision: next, + instance, + }) + } +} + +struct Lease; + +impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } +} + +#[async_trait] +impl PluginPreparation for LifecycleFixture { + async fn configuration_ready( + &self, + instance: PluginInstance, + metadata: &gateway_admin::model::plugins::PluginArtifactMetadata, + ) -> Result { + assert_eq!(instance.artifact_sha256, metadata.sha256); + Ok(self.data.lock().unwrap().configuration_ready) + } + + async fn validate( + &self, + instance: PluginInstance, + ) -> Result { + Ok(state(if instance.artifact_sha256 == OLD_ARTIFACT { + 1 + } else { + 2 + })) + } + + async fn prepare( + &self, + source_revision: Revision, + snapshot: PluginInstanceSnapshot, + ) -> Result { + let mut data = self.data.lock().unwrap(); + if source_revision != data.snapshot.config_revision { + return Err(AdminError::conflict( + "configuration changed during preparation", + )); + } + if data.fail_preparation { + return Err(AdminError::invalid("候选配置准备失败")); + } + assert!( + snapshot + .instances + .iter() + .all(|instance| instance.revision <= snapshot.config_revision), + "候选集合必须包含本次实例变更的 revision,才能严格拒绝目标插件启动失败" + ); + data.prepared_snapshots.push(snapshot); + let id = self.next_prepared.fetch_add(1, Ordering::Relaxed); + Ok(ExtensionSetReference::new( + ExtensionSetId::new(format!("state-candidate-{id}")).unwrap(), + Arc::new(Lease), + )) + } + + async fn runtime_diagnostics( + &self, + _: &PluginInstanceSnapshot, + _: Option, + _: Option<&ExtensionSetReference>, + ) -> Option> { + self.data.lock().unwrap().diagnostics.clone() + } + + async fn activate_state( + &self, + _: &ExtensionSetReference, + _: &PluginInstance, + ) -> Result<(), AdminError> { + self.data.lock().unwrap().activated += 1; + Ok(()) + } + + async fn quiesce_instance(&self, _: &str, _: &str, _: Revision) { + self.data.lock().unwrap().quiesced += 1; + } + + async fn migrate_state( + &self, + _: &ExtensionSetReference, + _: PluginStateTransition, + ) -> Result<(), AdminError> { + match self.behavior { + MigrationBehavior::Succeed => Ok(()), + MigrationBehavior::Fail => Err(AdminError::invalid("migration fixture failed")), + MigrationBehavior::ConcurrentChangeThenFail => { + let mut data = self.data.lock().unwrap(); + let next = revision(data.snapshot.config_revision.get() + 1); + data.snapshot.config_revision = next; + let instance = &mut data.snapshot.instances[0]; + instance.name = "concurrent edit".into(); + instance.revision = next; + Err(AdminError::invalid("migration fixture failed")) + } + } + } +} + +#[async_trait] +impl PluginStateStore for LifecycleFixture { + async fn load_owner( + &self, + _: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + state_unavailable() + } + + async fn get( + &self, + _: &PluginStateOwner, + _: &str, + _: &str, + ) -> PluginStateStoreResult> { + state_unavailable() + } + + async fn put( + &self, + _: &PluginStateOwner, + _: PutPluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + + async fn delete( + &self, + _: &PluginStateOwner, + _: DeletePluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + + async fn transition_required( + &self, + _: &str, + target: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + Ok(target.namespaces[0].schema_version == 2) + } + + async fn begin_transition( + &self, + instance_id: &str, + expected_instance_revision: Revision, + artifact_sha256: &str, + _: PluginStateConfiguration, + ) -> PluginStateStoreResult { + let data = self.data.lock().unwrap(); + let current = &data.snapshot.instances[0]; + if current.enabled + || current.id != instance_id + || current.revision != expected_instance_revision + || artifact_sha256 != NEW_ARTIFACT + { + return Err(PluginStateStoreError::new( + PluginStateStoreErrorKind::Conflict, + )); + } + Ok(PluginStateTransition { + id: "00000000-0000-7000-8000-000000000002".into(), + instance_id: instance_id.into(), + artifact_sha256: artifact_sha256.into(), + namespaces: vec![PluginStateMigrationNamespace { + namespace: "cache".into(), + from_schema_version: 1, + to_schema_version: 2, + }], + }) + } + + async fn migration_batch( + &self, + _: &str, + _: &str, + _: u32, + ) -> PluginStateStoreResult { + state_unavailable() + } + + async fn apply_migration_batch( + &self, + _: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + state_unavailable() + } + + async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> { + self.data.lock().unwrap().aborted += 1; + Ok(()) + } +} + +#[async_trait] +impl PluginStore for LifecycleFixture { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances(&self) -> AdminStoreResult { + Ok(self.snapshot()) + } + + async fn load_version_configuration( + &self, + _: &str, + digest: &str, + ) -> AdminStoreResult> { + Ok(self.data.lock().unwrap().history.get(digest).cloned()) + } + async fn configuration_versions(&self, _: &str) -> AdminStoreResult> { + Ok(self.data.lock().unwrap().history.keys().cloned().collect()) + } + + async fn save_instance( + &self, + instance: PluginInstance, + expected_revision: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + self.save(instance, expected_revision, None, &[]) + } + + async fn save_instance_with_state( + &self, + instance: PluginInstance, + expected_revision: Revision, + state: PluginStateCommit, + _: &MutationContext, + ) -> AdminStoreResult { + self.save(instance, expected_revision, state.transition_id, &[]) + } + + async fn save_instance_replacing( + &self, + instance: PluginInstance, + expected_revision: Revision, + state: PluginStateCommit, + replacements: &[PluginInstanceReplacement], + _: &MutationContext, + ) -> AdminStoreResult { + self.save( + instance, + expected_revision, + state.transition_id, + replacements, + ) + } + + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_update_sources(&self) -> AdminStoreResult> { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_source_credentials(&self) -> AdminStoreResult> { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn list_artifacts(&self) -> AdminStoreResult> { + let mut data = self.data.lock().unwrap(); + data.artifact_reads += 1; + if data.change_during_artifact_read { + let next = revision(data.snapshot.config_revision.get() + 1); + data.snapshot.config_revision = next; + data.snapshot.instances[0].revision = next; + data.snapshot.instances[0].name = "concurrent edit".into(); + } + Ok(data.artifacts.clone()) + } + + async fn load_artifact(&self, _: &str) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } + + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + Err(admin_error(AdminStoreErrorKind::Unavailable)) + } +} + +#[derive(Default)] +struct Published { + revisions: Mutex>, +} + +impl SnapshotControl for Published { + fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> { + self.revisions.lock().unwrap().push(revision.get()); + Box::pin(async {}) + } +} + +fn service(fixture: Arc, published: Arc) -> PluginsService { + PluginsService::new( + fixture.clone(), + Arc::new(TestPluginPorts), + PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)), + published, + fixture.clone(), + RuntimeSnapshotHandle::default(), + fixture, + ) +} + +fn state(version: u32) -> PluginStateConfiguration { + PluginStateConfiguration { + namespaces: vec![PluginStateSchema { + namespace: "cache".into(), + schema_version: version, + schema_sha256: char::from_digit(version, 16) + .unwrap() + .to_string() + .repeat(64), + schema: json!({"type":"object"}), + maximum_records: 16, + maximum_bytes: 1024, + maximum_value_bytes: 128, + migrates_from: (version == 2).then_some(1).into_iter().collect(), + }], + } +} + +fn artifact(digest: &str, plugin_id: &str, version: &str) -> InstalledPluginArtifact { + InstalledPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: plugin_id.into(), + version: version.into(), + name: plugin_id.strip_prefix("test.").unwrap_or(plugin_id).into(), + display_name: "Lifecycle fixture".into(), + publisher: "test".into(), + author: None, + description: String::new(), + license: "MIT".into(), + sha256: digest.into(), + platforms: vec!["linux/x86_64".into()], + icon: None, + contributes: BTreeMap::from([( + "middleware".into(), + PluginContribution { + id: format!("{plugin_id}.middleware"), + version: 1, + stages: vec!["request".into()], + input_formats: Vec::new(), + output_formats: Vec::new(), + }, + )]), + requested_permissions: vec![], + configuration_schema: json!({"type":"object"}), + secret_fields: vec![], + state_namespaces: vec![], + }, + source: PluginSource::Upload, + installed_at: chrono::Utc::now(), + accepted_at: Some(chrono::Utc::now()), + } +} + +fn rollback_input() -> RollbackPluginInstance { + RollbackPluginInstance { + artifact_sha256: NEW_ARTIFACT.into(), + expected_revision: 10, + } +} + +fn input() -> ConfigurePluginInstance { + ConfigurePluginInstance { + replace_instances: Vec::new(), + creation_id: None, + expected_revision: None, + name: "upgraded".into(), + artifact_sha256: NEW_ARTIFACT.into(), + enabled: true, + configuration: json!({}), + secrets: None, + bindings: vec![], + } +} + +#[tokio::test] +async fn instance_list_loads_metadata_once_for_shared_artifacts_without_loading_archives() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let mut second = fixture.snapshot().instances[0].clone(); + second.id = "00000000-0000-7000-8000-000000000002".into(); + second.enabled = false; + fixture.data.lock().unwrap().snapshot.instances.push(second); + let views = service(fixture.clone(), Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!(views.len(), 2); + assert!(views.iter().all(|view| !view.configuration_required)); + assert_eq!(fixture.data.lock().unwrap().artifact_reads, 1); +} + +#[tokio::test] +async fn enable_replacement_prepares_and_publishes_one_atomic_switch() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let published = Arc::new(Published::default()); + let service = service(fixture.clone(), published.clone()); + let previous = fixture.snapshot().instances[0].clone(); + let mut request = input(); + request.replace_instances = vec![PluginInstanceReplacement { + id: previous.id.clone(), + expected_revision: 10, + }]; + let result = service + .configure_instance(None, request, &context()) + .await + .unwrap(); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.saves.len(), 1); + assert_eq!(*published.revisions.lock().unwrap(), vec![11]); + for snapshot in data.prepared_snapshots.iter().chain([&data.snapshot]) { + let old = snapshot + .instances + .iter() + .find(|item| item.id == previous.id) + .unwrap(); + let current = snapshot + .instances + .iter() + .find(|item| item.id == result.instance.id) + .unwrap(); + assert!(!old.enabled); + assert!(current.enabled); + assert_eq!(old.revision, current.revision); + assert_eq!(old.configuration, previous.configuration); + assert_eq!(old.artifact_sha256, previous.artifact_sha256); + } +} + +#[tokio::test] +async fn enable_replacement_rejects_stale_or_invalid_confirmation_without_writing() { + for scenario in [ + "stale", + "missing", + "disabled", + "foreign", + "duplicate", + "self", + "save_disabled", + ] { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let published = Arc::new(Published::default()); + let service = service(fixture.clone(), published.clone()); + let previous = fixture.snapshot().instances[0].clone(); + let mut request = input(); + request.replace_instances = vec![PluginInstanceReplacement { + id: previous.id.clone(), + expected_revision: 10, + }]; + let mut target_id = None; + match scenario { + "stale" => request.replace_instances[0].expected_revision = 9, + "missing" => request.replace_instances[0].id = uuid::Uuid::now_v7().to_string(), + "disabled" => fixture.data.lock().unwrap().snapshot.instances[0].enabled = false, + "foreign" => { + fixture.data.lock().unwrap().artifacts[0].metadata.plugin_id = + "another.plugin".into() + } + "duplicate" => request + .replace_instances + .push(request.replace_instances[0].clone()), + "self" => target_id = Some(previous.id.as_str()), + "save_disabled" => request.enabled = false, + _ => unreachable!(), + } + assert!( + service + .configure_instance(target_id, request, &context()) + .await + .is_err(), + "{scenario}" + ); + assert!(fixture.data.lock().unwrap().saves.is_empty(), "{scenario}"); + assert!(published.revisions.lock().unwrap().is_empty(), "{scenario}"); + } +} + +#[tokio::test] +async fn enable_replacement_preparation_failure_preserves_the_enabled_configuration() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().fail_preparation = true; + let service = service(fixture.clone(), Arc::new(Published::default())); + let previous = fixture.snapshot().instances[0].clone(); + let mut request = input(); + request.replace_instances = vec![PluginInstanceReplacement { + id: previous.id.clone(), + expected_revision: 10, + }]; + assert!( + service + .configure_instance(None, request, &context()) + .await + .is_err() + ); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert!(fixture.snapshot().instances[0].enabled); +} + +#[tokio::test] +async fn enable_replacement_state_migration_only_switches_old_configuration_after_success() { + for behavior in [MigrationBehavior::Succeed, MigrationBehavior::Fail] { + let fixture = LifecycleFixture::new(behavior); + let target_id = fixture.snapshot().instances[0].id.clone(); + let mut old = fixture.snapshot().instances[0].clone(); + old.id = uuid::Uuid::now_v7().to_string(); + { + let mut data = fixture.data.lock().unwrap(); + data.snapshot.instances[0].enabled = false; + data.snapshot.instances.push(old.clone()); + } + let service = service(fixture.clone(), Arc::new(Published::default())); + let mut request = input(); + request.replace_instances = vec![PluginInstanceReplacement { + id: old.id.clone(), + expected_revision: 10, + }]; + let result = service + .configure_instance(Some(&target_id), request, &context()) + .await; + let succeeded = matches!(behavior, MigrationBehavior::Succeed); + assert_eq!(result.is_ok(), succeeded); + let snapshot = fixture.snapshot(); + assert_eq!( + snapshot + .instances + .iter() + .find(|item| item.id == old.id) + .unwrap() + .enabled, + !succeeded + ); + assert_eq!( + snapshot + .instances + .iter() + .find(|item| item.id == target_id) + .unwrap() + .enabled, + succeeded + ); + } +} + +#[tokio::test] +async fn configuration_retry_reuses_creation_id_and_rejects_changed_draft() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let service = service(fixture.clone(), Arc::new(Published::default())); + let creation_id = "00000000-0000-7000-8000-000000000002"; + let request = || { + let mut value = input(); + value.creation_id = Some(creation_id.into()); + // 此用例验证创建重试,使用不需要私有状态迁移的固定版本。 + value.artifact_sha256 = OLD_ARTIFACT.into(); + value.enabled = false; + value + }; + let created = service + .configure_instance(None, request(), &context()) + .await + .unwrap(); + let retried = service + .configure_instance(None, request(), &context()) + .await + .unwrap(); + assert_eq!(created.instance.id, creation_id); + assert_eq!(retried.instance.id, creation_id); + assert_eq!(fixture.snapshot().instances.len(), 2); + let before = fixture.snapshot().config_revision; + let mut changed = request(); + changed.name = "changed draft".into(); + let error = service + .configure_instance(None, changed, &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!(fixture.snapshot().config_revision, before); +} + +#[tokio::test] +async fn configuration_update_rejects_stale_revision_without_writing() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let mut request = input(); + request.expected_revision = Some(9); + let error = service(fixture.clone(), Arc::new(Published::default())) + .configure_instance( + Some("00000000-0000-7000-8000-000000000001"), + request, + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert!(fixture.data.lock().unwrap().saves.is_empty()); +} + +fn context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "state-lifecycle-test".into(), + } +} + +fn revision(value: u64) -> Revision { + Revision::new(value).unwrap() +} + +fn admin_error(kind: AdminStoreErrorKind) -> AdminStoreError { + AdminStoreError::new(kind, "plugin", "state lifecycle fixture") +} + +fn state_unavailable() -> PluginStateStoreResult { + Err(PluginStateStoreError::new( + PluginStateStoreErrorKind::Unavailable, + )) +} + +#[tokio::test] +async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let expected = PluginInstanceRuntime { + status: PluginInstanceRuntimeStatus::PreparationFailed, + actual_revision: Some(9), + actual_artifact_sha256: Some(OLD_ARTIFACT.into()), + failure: Some(PluginInstanceRuntimeFailure { + code: "unavailable".into(), + message: "插件候选准备失败".into(), + }), + draining_revisions: vec![8], + retained_continuations: 1, + }; + fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([( + "00000000-0000-7000-8000-000000000001".into(), + expected.clone(), + )])); + + let views = service(fixture, Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!(views.len(), 1); + assert!(!views[0].running); + assert_eq!(views[0].runtime, expected); +} + +#[tokio::test] +async fn instance_list_falls_back_without_inventing_runtime_failures() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let views = service(fixture, Arc::new(Published::default())) + .instances() + .await + .unwrap(); + assert_eq!(views.len(), 1); + assert_eq!( + views[0].runtime.status, + PluginInstanceRuntimeStatus::AwaitingPublication + ); + assert!(!views[0].running); + assert!(views[0].runtime.failure.is_none()); +} + +#[tokio::test] +async fn incompatible_state_upgrade_quiesces_migrates_and_promotes_the_target() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let published = Arc::new(Published::default()); + let result = service(fixture.clone(), published.clone()) + .configure_instance( + Some("00000000-0000-7000-8000-000000000001"), + input(), + &context(), + ) + .await + .unwrap(); + assert_eq!(result.config_revision, revision(12)); + assert_eq!(result.instance.artifact_sha256, NEW_ARTIFACT); + assert!(result.instance.enabled); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.quiesced, 1); + assert_eq!(data.activated, 1); + assert_eq!(data.aborted, 0); + assert_eq!(data.saves.len(), 2); + assert_eq!(data.saves[0].artifact_sha256, OLD_ARTIFACT); + assert!(!data.saves[0].enabled); + assert!(data.saves[0].transition_id.is_none()); + assert_eq!(data.saves[1].artifact_sha256, NEW_ARTIFACT); + assert!(data.saves[1].enabled); + assert!(data.saves[1].transition_id.is_some()); + assert_eq!(published.revisions.lock().unwrap().as_slice(), [11, 12]); +} + +#[tokio::test] +async fn failed_state_migration_restores_the_previous_enabled_version_with_cas() { + let fixture = LifecycleFixture::new(MigrationBehavior::Fail); + let published = Arc::new(Published::default()); + let error = service(fixture.clone(), published.clone()) + .configure_instance( + Some("00000000-0000-7000-8000-000000000001"), + input(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.config_revision, revision(12)); + assert_eq!(data.snapshot.instances[0].artifact_sha256, OLD_ARTIFACT); + assert!(data.snapshot.instances[0].enabled); + assert_eq!(data.quiesced, 1); + assert_eq!(data.activated, 1); + assert_eq!(data.aborted, 1); + assert_eq!(data.saves.len(), 2); + assert!(!data.saves[0].enabled); + assert!(data.saves[1].enabled); + assert_eq!(published.revisions.lock().unwrap().as_slice(), [11, 12]); +} + +#[tokio::test] +async fn failed_state_migration_never_overwrites_a_concurrent_configuration_change() { + let fixture = LifecycleFixture::new(MigrationBehavior::ConcurrentChangeThenFail); + let published = Arc::new(Published::default()); + let error = service(fixture.clone(), published.clone()) + .configure_instance( + Some("00000000-0000-7000-8000-000000000001"), + input(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.config_revision, revision(12)); + assert_eq!(data.snapshot.instances[0].name, "concurrent edit"); + assert_eq!(data.snapshot.instances[0].artifact_sha256, OLD_ARTIFACT); + assert!(!data.snapshot.instances[0].enabled); + assert_eq!(data.aborted, 1); + assert_eq!(data.activated, 0); + assert_eq!(data.saves.len(), 1, "恢复不能覆盖迁移期间的新配置"); + assert_eq!(published.revisions.lock().unwrap().as_slice(), [11]); +} + +#[tokio::test] +async fn rollback_restores_target_configuration_secrets_and_bindings_and_derives_permissions() { + use secrecy::ExposeSecret as _; + + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.artifacts[1].metadata.requested_permissions = vec!["network".into()]; + let instance = &mut data.snapshot.instances[0]; + instance.configuration = json!({"label":"retained"}); + instance + .secrets + .insert("token".into(), "fixture-secret".into()); + instance.bindings.push(PluginCapabilityBinding { + contribution: "test.lifecycle.middleware".into(), + stage: "request".into(), + order: 7, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: Vec::new(), + account_group_ids: Vec::new(), + provider_ids: Vec::new(), + models: Vec::new(), + identity_bindings: Vec::new(), + }); + } + let before = fixture.snapshot().instances.remove(0); + let result = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance(&before.id, rollback_input(), &context()) + .await + .unwrap(); + assert_eq!(result.instance.artifact_sha256, NEW_ARTIFACT); + assert_eq!(result.instance.name, before.name); + assert_eq!(result.instance.enabled, before.enabled); + assert_eq!(result.instance.configuration, json!({"oldVersion": true})); + assert_eq!( + result.instance.grants, + [PluginPermissionGrant { + permission: "network".into() + }] + ); + assert!(result.instance.bindings.is_empty()); + assert_eq!( + result.instance.secrets["token"].expose_secret(), + "old-version-secret" + ); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.quiesced, 1); + assert_eq!(data.activated, 1); + assert!(data.saves.last().unwrap().transition_id.is_some()); +} + +#[tokio::test] +async fn rollback_rejects_stale_confirmation_without_preparing_or_writing() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let mut input = rollback_input(); + input.expected_revision = 9; + let error = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance(&fixture.snapshot().instances[0].id, input, &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert_eq!(fixture.next_prepared.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn rollback_rejects_foreign_newer_equal_or_uninstalled_artifacts() { + for (plugin_id, version, installed, expected) in [ + ("test.other-plugin", "1.0.0", true, AdminErrorKind::Invalid), + ("test.lifecycle", "2.0.1", true, AdminErrorKind::Invalid), + ("test.lifecycle", "2.0.0", true, AdminErrorKind::Invalid), + ( + "test.lifecycle", + "2.0.0+older-build", + true, + AdminErrorKind::Invalid, + ), + ("test.lifecycle", "1.0.0", false, AdminErrorKind::NotFound), + ] { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.artifacts.truncate(1); + if installed { + data.artifacts + .push(artifact(NEW_ARTIFACT, plugin_id, version)); + } + } + let error = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance( + &fixture.snapshot().instances[0].id, + rollback_input(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), expected, "{plugin_id}/{version}/{installed}"); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert_eq!(fixture.next_prepared.load(Ordering::Relaxed), 1); + } +} + +#[tokio::test] +async fn rollback_keeps_the_original_snapshot_fence_during_artifact_lookup() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().change_during_artifact_read = true; + let error = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance( + &fixture.snapshot().instances[0].id, + rollback_input(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.instances[0].name, "concurrent edit"); + assert_eq!(data.snapshot.instances[0].artifact_sha256, OLD_ARTIFACT); + assert!(data.saves.is_empty()); +} + +#[tokio::test] +async fn rollback_migration_failure_restores_the_previous_version_without_replaying() { + let fixture = LifecycleFixture::new(MigrationBehavior::Fail); + let error = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance( + &fixture.snapshot().instances[0].id, + rollback_input(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + let data = fixture.data.lock().unwrap(); + assert_eq!(data.snapshot.instances[0].artifact_sha256, OLD_ARTIFACT); + assert!(data.snapshot.instances[0].enabled); + assert_eq!(data.aborted, 1); + assert!( + data.saves + .iter() + .all(|saved| saved.artifact_sha256 == OLD_ARTIFACT) + ); +} + +#[tokio::test] +async fn rollback_of_a_disabled_instance_never_enables_it() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().snapshot.instances[0].enabled = false; + let result = service(fixture.clone(), Arc::new(Published::default())) + .rollback_instance( + &fixture.snapshot().instances[0].id, + rollback_input(), + &context(), + ) + .await + .unwrap(); + assert_eq!(result.instance.artifact_sha256, NEW_ARTIFACT); + assert!(!result.instance.enabled); + assert!( + fixture + .data + .lock() + .unwrap() + .saves + .iter() + .all(|saved| !saved.enabled) + ); +} + +#[tokio::test] +async fn rollback_plan_is_read_only_and_orders_only_earlier_versions_of_the_same_plugin() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.artifacts.extend([ + artifact(&"c".repeat(64), "test.lifecycle", "2.0.0-rc.2"), + artifact(&"d".repeat(64), "test.lifecycle", "2.0.0-rc.10"), + artifact(&"e".repeat(64), "test.lifecycle", "2.0.0+another-build"), + artifact(&"f".repeat(64), "test.other-plugin", "0.1.0"), + ]); + let saved = data.history[NEW_ARTIFACT].clone(); + data.history.insert("c".repeat(64), saved.clone()); + data.history.insert("d".repeat(64), saved); + data.snapshot.instances[0] + .secrets + .insert("token".into(), "fixture-secret".into()); + } + let plan = service(fixture.clone(), Arc::new(Published::default())) + .rollback_plan(&fixture.snapshot().instances[0].id) + .await + .unwrap(); + assert_eq!(plan.instance_revision, 10); + assert_eq!(plan.current_version, "2.0.0"); + assert_eq!( + plan.targets + .iter() + .map(|target| target.version.as_str()) + .collect::>(), + ["2.0.0-rc.10", "2.0.0-rc.2", "1.0.0"], + ); + let public = serde_json::to_string(&plan).unwrap(); + assert!(!public.contains("fixture-secret")); + assert!(!public.contains("token")); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert_eq!(fixture.next_prepared.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn rollback_plan_excludes_unaccepted_older_artifacts() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let digest = "c".repeat(64); + let mut pending = artifact(&digest, "test.lifecycle", "1.5.0"); + pending.accepted_at = None; + fixture.data.lock().unwrap().artifacts.push(pending); + + let plan = service(fixture.clone(), Arc::new(Published::default())) + .rollback_plan(&fixture.snapshot().instances[0].id) + .await + .unwrap(); + + assert!( + plan.targets + .iter() + .all(|target| target.artifact_sha256 != digest) + ); +} + +#[tokio::test] +async fn enabling_another_instance_requires_an_explicit_replacement() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let error = service(fixture.clone(), Arc::new(Published::default())) + .configure_instance(None, input(), &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert!(fixture.data.lock().unwrap().saves.is_empty()); + assert!(fixture.snapshot().instances[0].enabled); +} + +#[tokio::test] +async fn version_plan_keeps_explicit_values_adds_defaults_and_remaps_bindings_without_writing() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.history.clear(); + data.artifacts[1].metadata.configuration_schema = json!({ + "type":"object", "default":{"fromRoot":42}, "properties": { + "label":{"type":"string","default":"default"}, + "nested":{"type":"object","properties":{"added":{"default":true},"kept":{"default":100}}}, + "token":{"type":"string","default":"never-copy-secret"} + } + }); + data.artifacts[1].metadata.secret_fields = vec!["token".into()]; + data.artifacts[1] + .metadata + .contributes + .get_mut("middleware") + .unwrap() + .id = "renamed.middleware".into(); + data.snapshot.instances[0].configuration = + json!({"label":"mine","nested":{"kept":0},"obsolete":"keep-for-validation"}); + data.snapshot.instances[0] + .secrets + .insert("token".into(), "private-value".into()); + data.snapshot.instances[0].bindings = vec![PluginCapabilityBinding { + contribution: "test.lifecycle.middleware".into(), + stage: "request".into(), + order: 3, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec!["test-model".into()], + identity_bindings: vec![], + }]; + } + let before = fixture.snapshot(); + let plan = service(fixture.clone(), Arc::new(Published::default())) + .version_plan(&before.instances[0].id, NEW_ARTIFACT) + .await + .unwrap(); + assert!(!plan.restored); + assert_eq!( + plan.configuration, + json!({"label":"mine","nested":{"kept":0,"added":true},"obsolete":"keep-for-validation","fromRoot":42}) + ); + assert_eq!(plan.bindings[0].contribution, "renamed.middleware"); + assert_eq!(plan.bindings[0].models, ["test-model"]); + assert_eq!(plan.secret_fields, ["token"]); + let public = serde_json::to_string(&plan).unwrap(); + assert!(!public.contains("private-value")); + assert!(!public.contains("never-copy-secret")); + assert_eq!(fixture.snapshot().config_revision, before.config_revision); + assert!(fixture.data.lock().unwrap().saves.is_empty()); +} + +#[tokio::test] +async fn version_switch_preparation_failure_preserves_current_and_saved_settings() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().fail_preparation = true; + let before = fixture.snapshot(); + let result = service(fixture.clone(), Arc::new(Published::default())) + .switch_instance_version(&before.instances[0].id, rollback_input(), &context()) + .await; + assert!(result.is_err()); + assert_eq!(fixture.snapshot().config_revision, before.config_revision); + assert_eq!( + fixture.snapshot().instances[0].artifact_sha256, + OLD_ARTIFACT + ); + let data = fixture.data.lock().unwrap(); + assert!(data.saves.is_empty()); + assert_eq!( + data.history[NEW_ARTIFACT].configuration, + json!({"oldVersion":true}) + ); +} + +#[tokio::test] +async fn version_edit_preserves_the_target_versions_secrets_when_omitted() { + use secrecy::ExposeSecret as _; + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let before = fixture.snapshot(); + let mut request = input(); + request.secrets = None; + let result = service(fixture, Arc::new(Published::default())) + .configure_instance(Some(&before.instances[0].id), request, &context()) + .await + .unwrap(); + assert_eq!( + result.instance.secrets["token"].expose_secret(), + "old-version-secret" + ); +} + +#[tokio::test] +async fn rollback_requires_a_saved_configuration_instead_of_reusing_incompatible_current_settings() +{ + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + fixture.data.lock().unwrap().history.clear(); + let service = service(fixture.clone(), Arc::new(Published::default())); + let id = fixture.snapshot().instances[0].id.clone(); + assert!(service.rollback_plan(&id).await.unwrap().targets.is_empty()); + let error = service + .rollback_instance(&id, rollback_input(), &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert!(fixture.data.lock().unwrap().saves.is_empty()); +} + +#[tokio::test] +async fn switching_a_disabled_plugin_also_requires_complete_target_settings() { + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + { + let mut data = fixture.data.lock().unwrap(); + data.configuration_ready = false; + data.snapshot.instances[0].enabled = false; + } + let before = fixture.snapshot(); + let error = service(fixture.clone(), Arc::new(Published::default())) + .switch_instance_version(&before.instances[0].id, rollback_input(), &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert_eq!(fixture.snapshot().config_revision, before.config_revision); + assert!(fixture.data.lock().unwrap().saves.is_empty()); +} + +#[tokio::test] +async fn switching_versions_uses_saved_settings_and_rejects_stale_retry() { + use secrecy::ExposeSecret as _; + let fixture = LifecycleFixture::new(MigrationBehavior::Succeed); + let service = service(fixture.clone(), Arc::new(Published::default())); + let id = fixture.snapshot().instances[0].id.clone(); + let result = service + .switch_instance_version(&id, rollback_input(), &context()) + .await + .unwrap(); + assert_eq!(result.instance.artifact_sha256, NEW_ARTIFACT); + assert_eq!(result.instance.configuration, json!({"oldVersion":true})); + assert_eq!( + result.instance.secrets["token"].expose_secret(), + "old-version-secret" + ); + assert!(result.instance.enabled); + let revision = fixture.snapshot().config_revision; + let error = service + .switch_instance_version(&id, rollback_input(), &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!(fixture.snapshot().config_revision, revision); +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs new file mode 100644 index 000000000..924af2951 --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/mod.rs @@ -0,0 +1,404 @@ +use async_trait::async_trait; +use gateway_admin::model::plugins::distribution::{ + DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential, + SourceCredentialInfo, +}; +use gateway_admin::model::plugins::distribution::{PluginSourceBinding, PluginUpdateSource}; +use gateway_admin::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation, PluginSource, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration, + PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, + PluginStateRecord, PluginStateTransition, PluginStateWrite, PutPluginState, + }, + }, + }, + ports::{ + plugins::{ + PluginPackageInspector, PluginStateStore, PluginStateStoreError, + PluginStateStoreErrorKind, PluginStateStoreResult, PluginStore, + }, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use std::sync::Arc; + +mod artifacts; +mod distribution; +mod instances; +mod official; + +pub(super) struct TestPluginPorts; + +#[async_trait] +impl gateway_admin::ports::proxy::ProxyStore for TestPluginPorts { + async fn reserve_import( + &self, + _: &str, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list( + &self, + _: gateway_admin::model::proxies::ProxyListQuery, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list_accounts( + &self, + _: gateway_admin::model::proxies::ProxyAccountListQuery, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn get(&self, _: &str) -> AdminStoreResult { + Err(unavailable()) + } + + async fn remove_account( + &self, + _: &str, + _: &gateway_core::account::ProviderAccountId, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn create( + &self, + _: gateway_admin::model::proxies::NewProxy, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn update( + &self, + _: gateway_admin::model::proxies::UpdateProxy, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn delete( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn record_test( + &self, + _: &str, + _: Revision, + _: gateway_admin::model::proxies::ProxyTestResult, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } +} + +#[async_trait] +impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts { + async fn authorize_models( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> Result<(), AdminError> { + Err(AdminError::not_found("unused plugin fixture")) + } + + async fn start_callback( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: gateway_admin::model::plugins::management::StartPluginManagementCallback, + _: &gateway_admin::model::auth::AdminRequestContext, + ) -> Result + { + Err(AdminError::not_found("unused plugin fixture")) + } + async fn callback( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: &str, + _: gateway_admin::model::plugins::management::PluginManagementRequest, + ) -> Result + { + Err(AdminError::not_found("unused plugin fixture")) + } + async fn views( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + ) -> Result, AdminError> + { + Ok(Vec::new()) + } + async fn resource( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: &str, + _: bool, + ) -> Result + { + Err(AdminError::not_found("unused plugin fixture")) + } + async fn handle( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: gateway_admin::model::plugins::management::PluginManagementRequest, + ) -> Result + { + Err(AdminError::not_found("unused plugin fixture")) + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts { + async fn configuration_ready( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + _: &gateway_admin::model::plugins::PluginArtifactMetadata, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + + async fn validate( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + async fn prepare( + &self, + _: Revision, + _: gateway_admin::model::plugins::instances::PluginInstanceSnapshot, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } +} + +fn unavailable() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin", + "unused plugin fixture", + ) +} + +fn state_unavailable() -> PluginStateStoreResult { + Err(PluginStateStoreError::new( + PluginStateStoreErrorKind::Unavailable, + )) +} + +#[async_trait] +impl PluginStateStore for TestPluginPorts { + async fn load_owner( + &self, + _: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + state_unavailable() + } + async fn get( + &self, + _: &PluginStateOwner, + _: &str, + _: &str, + ) -> PluginStateStoreResult> { + state_unavailable() + } + async fn put( + &self, + _: &PluginStateOwner, + _: PutPluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn delete( + &self, + _: &PluginStateOwner, + _: DeletePluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn transition_required( + &self, + _: &str, + _: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn begin_transition( + &self, + _: &str, + _: Revision, + _: &str, + _: PluginStateConfiguration, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn migration_batch( + &self, + _: &str, + _: &str, + _: u32, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn apply_migration_batch( + &self, + _: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + state_unavailable() + } + async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> { + state_unavailable() + } +} + +#[async_trait] +impl PluginStore for TestPluginPorts { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances( + &self, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn save_instance( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn list_update_sources(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list_source_credentials(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(unavailable()) + } + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list_artifacts(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn load_artifact(&self, _: &str) -> AdminStoreResult { + Err(unavailable()) + } + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + Err(unavailable()) + } +} + +#[async_trait] +impl PluginPackageInspector for TestPluginPorts { + async fn inspect( + &self, + _: Arc<[u8]>, + _: Option, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts { + fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> { + Err(AdminError::invalid("unused plugin fixture")) + } + + fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> { + Err(AdminError::invalid("unused plugin fixture")) + } + async fn query_release( + &self, + _: GithubReleaseQuery, + _: Vec, + _: Option, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + async fn download( + &self, + _: RemotePluginLocation, + _: Vec, + _: Option, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/official.rs b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs new file mode 100644 index 000000000..bb37f0d6d --- /dev/null +++ b/backend/crates/gateway-admin/tests/use_case/plugins/official.rs @@ -0,0 +1,416 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use futures::future::BoxFuture; +use gateway_admin::{ + PluginDistributionPorts, PluginsService, + model::{ + AdminError, AdminErrorKind, MutationActor, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginSource, + distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo}, + instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot}, + official::OfficialPluginReleaseIdentity, + }, + }, + ports::{ + plugin_release::{ + OfficialPluginReleaseFiles, OfficialPluginReleaseReadError, + OfficialPluginReleaseReadErrorKind, + }, + plugins::{PluginPackageInspector, PluginStore}, + store::AdminStoreResult, + }, +}; +use gateway_core::{ + routing::ConfigRevision, + runtime::{RuntimeSnapshotHandle, SnapshotControl}, +}; + +use super::TestPluginPorts; + +const VERSION: &str = "3.13.0"; +const GIT_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +struct Fixture { + manifest: Option>, + archives: BTreeMap>, + inspected_publisher: String, + installs: std::sync::Mutex>, + publications: std::sync::Mutex>, + fail_archive: Option>, +} + +impl Fixture { + fn new(plugins: &[(&str, u8)]) -> Arc { + let artifacts = plugins + .iter() + .map(|(id, marker)| plugin_entry(id, *marker)) + .collect::>(); + let manifest = serde_json::to_vec(&serde_json::json!({ + "schema_version": 1, + "sealed": true, + "gateway_version": VERSION, + "gateway_git_sha": GIT_SHA, + "plugin_host": plugin_host(), + "plugins": artifacts, + })) + .expect("manifest"); + let archives = plugins + .iter() + .map(|(id, marker)| { + ( + format!( + "{id}-1.0.0-{}-{}.tar.gz", + std::env::consts::OS, + std::env::consts::ARCH + ), + Arc::<[u8]>::from(vec![*marker]), + ) + }) + .collect(); + Arc::new(Self { + manifest: Some(manifest.into()), + archives, + inspected_publisher: "test".into(), + installs: std::sync::Mutex::new(Vec::new()), + publications: std::sync::Mutex::new(Vec::new()), + fail_archive: None, + }) + } + + fn service(self: &Arc) -> PluginsService { + let unused = Arc::new(TestPluginPorts); + PluginsService::new( + self.clone(), + self.clone(), + PluginDistributionPorts::new(unused.clone(), unused.clone()), + self.clone(), + unused.clone(), + RuntimeSnapshotHandle::default(), + unused, + ) + } +} + +#[async_trait] +impl OfficialPluginReleaseFiles for Fixture { + async fn manifest(&self) -> Result>, OfficialPluginReleaseReadError> { + Ok(self.manifest.clone()) + } + + async fn artifact(&self, file_name: &str) -> Result, OfficialPluginReleaseReadError> { + self.archives.get(file_name).cloned().ok_or_else(|| { + OfficialPluginReleaseReadError::new(OfficialPluginReleaseReadErrorKind::NotFound) + }) + } +} + +#[async_trait] +impl PluginPackageInspector for Fixture { + async fn inspect( + &self, + archive: Arc<[u8]>, + expected_sha256: Option, + ) -> Result { + if self + .fail_archive + .as_ref() + .is_some_and(|expected| archive.as_ref() == expected) + { + return Err(AdminError::invalid("fixture rejected package")); + } + let marker = archive.first().copied().expect("marker"); + let name = format!("official-{marker}"); + let id = format!("test.{name}"); + Ok(InspectedPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: id, + version: "1.0.0".into(), + name, + display_name: "Official fixture".into(), + publisher: self.inspected_publisher.clone(), + author: Some("project".into()), + description: String::new(), + license: "MIT".into(), + sha256: expected_sha256.expect("expected digest"), + platforms: vec![format!( + "{}-{}", + std::env::consts::OS, + std::env::consts::ARCH + )], + icon: None, + contributes: Default::default(), + requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), + secret_fields: Vec::new(), + state_namespaces: Vec::new(), + }, + archive, + }) + } +} + +impl SnapshotControl for Fixture { + fn publish_committed(&self, revision: ConfigRevision) -> BoxFuture<'_, ()> { + self.publications.lock().unwrap().push(revision.get()); + Box::pin(async {}) + } +} + +#[async_trait] +impl PluginStore for Fixture { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances(&self) -> AdminStoreResult { + unreachable!() + } + async fn save_instance( + &self, + _: PluginInstance, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn list_update_sources(&self) -> AdminStoreResult> { + unreachable!() + } + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn list_source_credentials(&self) -> AdminStoreResult> { + unreachable!() + } + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + unreachable!() + } + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn list_artifacts(&self) -> AdminStoreResult> { + unreachable!() + } + async fn load_artifact(&self, _: &str) -> AdminStoreResult { + unreachable!() + } + async fn install_artifact( + &self, + artifact: InspectedPluginArtifact, + source: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + self.installs + .lock() + .unwrap() + .push((artifact.metadata.plugin_id.clone(), source.clone())); + Ok(PluginArtifactMutation { + config_revision: Revision::new(2).unwrap(), + artifact: InstalledPluginArtifact { + metadata: artifact.metadata, + source, + installed_at: chrono::Utc::now(), + accepted_at: None, + }, + }) + } + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + unreachable!() + } + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + unreachable!() + } +} + +#[tokio::test] +async fn official_release_validates_all_packages_then_uses_builtin_install_path() { + let fixture = Fixture::new(&[("test.official-1", 1)]); + let result = fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .expect("official import"); + + assert_eq!(result.artifacts, 1); + assert_eq!(result.config_revision.map(Revision::get), Some(2)); + assert_eq!( + fixture.installs.lock().unwrap().as_slice(), + &[( + "test.official-1".into(), + PluginSource::Builtin { + release: VERSION.into() + } + )] + ); + assert_eq!(fixture.publications.lock().unwrap().as_slice(), &[2]); +} + +#[tokio::test] +async fn official_release_never_persists_a_valid_prefix_when_later_validation_fails() { + let mut fixture = Fixture::new(&[("test.official-1", 1), ("test.official-2", 2)]); + Arc::get_mut(&mut fixture).unwrap().fail_archive = Some(vec![2]); + let error = fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .expect_err("second package must fail"); + + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert!(fixture.installs.lock().unwrap().is_empty()); + assert!(fixture.publications.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn official_release_rejects_publisher_identity_mismatches() { + let fixture = Fixture::new(&[("other.official-1", 1)]); + assert_eq!( + fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .expect_err("publisher must match the plugin ID namespace") + .kind(), + AdminErrorKind::Invalid + ); + assert!(fixture.installs.lock().unwrap().is_empty()); + + let mut fixture = Fixture::new(&[("test.official-1", 1)]); + Arc::get_mut(&mut fixture).unwrap().inspected_publisher = "other".into(); + assert_eq!( + fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &system_context()) + .await + .expect_err("inspected publisher must match the sealed release") + .kind(), + AdminErrorKind::Invalid + ); + assert!(fixture.installs.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn official_release_rejects_non_system_callers_and_mismatched_build_identity() { + let fixture = Fixture::new(&[("test.official-1", 1)]); + let admin = MutationContext { + actor: MutationActor::AdminApiKey, + request_id: "fixture".into(), + }; + assert_eq!( + fixture + .service() + .import_official_release(fixture.as_ref(), &identity(), &admin) + .await + .expect_err("admin caller") + .kind(), + AdminErrorKind::Forbidden + ); + let wrong = OfficialPluginReleaseIdentity { + gateway_version: "9.9.9".into(), + gateway_git_sha: GIT_SHA.into(), + }; + assert_eq!( + fixture + .service() + .import_official_release(fixture.as_ref(), &wrong, &system_context()) + .await + .expect_err("mismatched identity") + .kind(), + AdminErrorKind::Invalid + ); + assert!(fixture.installs.lock().unwrap().is_empty()); +} + +fn identity() -> OfficialPluginReleaseIdentity { + OfficialPluginReleaseIdentity { + gateway_version: VERSION.into(), + gateway_git_sha: GIT_SHA.into(), + } +} + +fn plugin_host() -> serde_json::Value { + serde_json::json!({ + "schema_version": 1, + "manifest_schema_versions": [2], + "protocol_versions": [3], + "capabilities": [{ "capability": "executor", "versions": [1] }], + "permissions": ["log"], + }) +} + +fn system_context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "official-import-fixture".into(), + } +} + +fn plugin_entry(id: &str, marker: u8) -> serde_json::Value { + let (release_os, release_architecture, target) = release_platform(); + serde_json::json!({ + "id": id, + "version": "1.0.0", + "publisher": "test", + "stability": "experimental", + "artifacts": [{ + "release_os": release_os, + "release_architecture": release_architecture, + "target": target, + "os": std::env::consts::OS, + "architecture": std::env::consts::ARCH, + "archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH), + "sha256": format!("{marker:01x}").repeat(64), + }] + }) +} + +fn release_platform() -> (&'static str, &'static str, &'static str) { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"), + ("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"), + ("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"), + platform => panic!("unsupported test platform: {platform:?}"), + } +} diff --git a/backend/crates/gateway-admin/tests/use_case/proxies.rs b/backend/crates/gateway-admin/tests/use_case/proxies.rs index e170a364f..e329ceacc 100644 --- a/backend/crates/gateway-admin/tests/use_case/proxies.rs +++ b/backend/crates/gateway-admin/tests/use_case/proxies.rs @@ -147,15 +147,28 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() { .build() .await; let command = StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), outbound_proxy: Some(AccountProxySelection::Saved("proxy_oauth".to_owned())), context: context("oauth-proxy-status"), name: "授权账号".to_owned(), reauthorization: None, }; let result = if kind == "openai" { - services.openai().start_authorization(command).await + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() + .start_authorization(command) + .await } else { - services.xai().start_authorization(command).await + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() + .start_authorization(command) + .await }; assert!(result.is_ok(), "{kind}, {probe_success:?}: {result:?}"); assert_eq!(recorded(&events), ["provider.start_authorization"]); @@ -263,9 +276,19 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err document: document(), }; let result = if kind == "openai" { - services.openai().import_document(command).await + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("openai").unwrap()) + .unwrap() + .import_document(command) + .await } else { - services.xai().import_document(command).await + services + .credentials() + .for_provider(&gateway_core::routing::ProviderKind::new("xai").unwrap()) + .unwrap() + .import_document(command) + .await }; assert_eq!(result.is_err(), failure.is_some()); let events = recorded(&events); diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index 3a0b17581..e74e5ac19 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -1,8 +1,14 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, +}; + use async_trait::async_trait; use gateway_admin::{ model::{ - AdminErrorKind, MutationContext, + AdminErrorKind, MutationContext, Revision, + pricing::{PricingChange, PricingSyncPreview, StoredPricing, SyncPricing, UpdatePricing}, settings::{ AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings, @@ -13,10 +19,15 @@ use gateway_admin::{ struct UnusedSettingsStore; +#[derive(Default)] +struct PricingSettingsStore { + updated: Mutex>, +} + #[tokio::test] async fn client_profile_preview_rejects_unknown_provider_before_loading_settings() { let services = super::AdminHarness::new() - .settings(std::sync::Arc::new(UnusedSettingsStore)) + .settings(Arc::new(UnusedSettingsStore)) .build() .await; let error = services @@ -27,6 +38,61 @@ async fn client_profile_preview_rejects_unknown_provider_before_loading_settings assert_eq!(error.kind(), AdminErrorKind::Invalid); } +#[tokio::test] +async fn pricing_accepts_registered_plugin_provider_and_rejects_unknown_sync_provider() { + let store = Arc::new(PricingSettingsStore::default()); + let services = super::AdminHarness::new() + .settings(store.clone()) + .provider(super::test_provider("example-plugin")) + .build() + .await; + let context = MutationContext { + actor: gateway_admin::model::MutationActor::System, + request_id: "request-plugin-pricing".to_owned(), + }; + let pricing = serde_json::from_value(serde_json::json!({ + "multiplierBps": 10000, + "bands": {"standard": { + "input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "0" + }} + })) + .expect("plugin pricing"); + let update = UpdatePricing { + provider: "example-plugin".to_owned(), + models: vec!["example-model".to_owned()], + change: PricingChange::Replace(pricing), + }; + + services + .settings() + .update_pricing(&context, update.clone()) + .await + .expect("registered plugin provider pricing"); + assert_eq!( + store.updated.lock().expect("updated pricing").as_ref(), + Some(&update) + ); + + let error = services + .settings() + .sync_pricing( + &context, + SyncPricing { + preview: PricingSyncPreview { + prices: BTreeMap::new(), + skipped: Vec::new(), + }, + models: BTreeMap::from([( + "unregistered-provider".to_owned(), + BTreeSet::from(["example-model".to_owned()]), + )]), + }, + ) + .await + .expect_err("unknown provider cannot enter pricing sync"); + assert_eq!(error.kind(), AdminErrorKind::Invalid); +} + #[async_trait] impl SettingsStore for UnusedSettingsStore { async fn load_pricing(&self) -> AdminStoreResult { @@ -78,10 +144,65 @@ impl SettingsStore for UnusedSettingsStore { } } +#[async_trait] +impl SettingsStore for PricingSettingsStore { + async fn load_pricing(&self) -> AdminStoreResult { + Ok(StoredPricing::default()) + } + + async fn sync_pricing( + &self, + _: gateway_admin::model::pricing::PricingSyncChanges, + _: &MutationContext, + ) -> AdminStoreResult { + panic!("unexpected pricing sync") + } + + async fn update_pricing( + &self, + command: UpdatePricing, + _: &MutationContext, + ) -> AdminStoreResult { + *self.updated.lock().expect("updated pricing") = Some(command); + Ok(Revision::new(2).expect("revision")) + } + + async fn load_runtime_settings(&self) -> AdminStoreResult { + Err(unused()) + } + + async fn admin_api_key_exists(&self) -> AdminStoreResult { + Err(unused()) + } + + async fn replace_runtime_settings( + &self, + _: ReplaceRuntimeSettings, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + + async fn replace_admin_api_key( + &self, + _: AdminApiKey, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + + async fn delete_admin_api_key( + &self, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } +} + #[tokio::test] async fn settings_should_reject_zero_refresh_margin_before_store_call() { let services = super::AdminHarness::new() - .settings(std::sync::Arc::new(UnusedSettingsStore)) + .settings(Arc::new(UnusedSettingsStore)) .build() .await; let error = services @@ -92,8 +213,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { request_id: "request-settings".to_owned(), }, ReplaceRuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profile_updates: Default::default(), request_location_enabled: false, request_location: Default::default(), model_mappings: Default::default(), diff --git a/backend/crates/gateway-admin/tests/use_case/system.rs b/backend/crates/gateway-admin/tests/use_case/system.rs index 8cf1f412b..633d2a843 100644 --- a/backend/crates/gateway-admin/tests/use_case/system.rs +++ b/backend/crates/gateway-admin/tests/use_case/system.rs @@ -54,6 +54,7 @@ impl SystemOperations for RecordingSystemOperations { async fn perform_update( &self, target_version: Option, + _: std::sync::Arc, ) -> Result { *self.target.lock().expect("target") = Some(target_version.clone()); Ok(SystemOperationAccepted::Update { @@ -82,7 +83,10 @@ impl SystemOperations for RecordingSystemOperations { }) } - async fn rollback(&self) -> Result { + async fn rollback( + &self, + _: std::sync::Arc, + ) -> Result { Ok(SystemOperationAccepted::Rollback { operation_id: "operation-rollback".to_owned(), message: "accepted".to_owned(), diff --git a/backend/crates/gateway-api/Cargo.toml b/backend/crates/gateway-api/Cargo.toml index ac13c5b55..e76e0c8fa 100644 --- a/backend/crates/gateway-api/Cargo.toml +++ b/backend/crates/gateway-api/Cargo.toml @@ -21,6 +21,7 @@ futures = "0.3.32" gateway-admin = { path = "../gateway-admin" } gateway-core = { path = "../gateway-core" } gateway-protocol = { path = "../gateway-protocol" } +headers = "0.4.1" secrecy = "0.10" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } diff --git a/backend/crates/gateway-api/src/admin/accounts/capabilities.rs b/backend/crates/gateway-api/src/admin/accounts/capabilities.rs new file mode 100644 index 000000000..29fd97864 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/accounts/capabilities.rs @@ -0,0 +1,79 @@ +//! 已发布 Provider 的账号能力;响应只包含静态 schema,不包含配置或凭据。 + +use gateway_admin::model::provider_capabilities::{ + AuthorizationCompletion, ProviderCredentialDescriptor, +}; + +use super::*; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountProviderData { + pub provider: String, + pub credentials: AccountCredentialCapabilitiesData, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountCredentialCapabilitiesData { + pub import: Option, + pub login: Option, + pub refresh: bool, + pub export: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountLoginCapabilityData { + pub input_schema: Value, + pub completion: AccountAuthorizationCompletion, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountAuthorizationCompletion { + Callback, + Poll, +} + +impl From for AccountProviderData { + fn from(value: ProviderCredentialDescriptor) -> Self { + let capabilities = value.capabilities; + Self { + provider: value.provider.as_str().to_owned(), + credentials: AccountCredentialCapabilitiesData { + import: capabilities.import, + login: capabilities.login.map(|login| AccountLoginCapabilityData { + input_schema: login.input_schema, + completion: match login.completion { + AuthorizationCompletion::Callback => { + AccountAuthorizationCompletion::Callback + } + AuthorizationCompletion::Poll => AccountAuthorizationCompletion::Poll, + }, + }), + refresh: capabilities.refresh, + export: capabilities.export, + }, + } + } +} + +pub(super) async fn providers( + _auth: AdminAuth, + State(state): State, +) -> Result +where + S: crate::auth::SessionState + Send + Sync, +{ + let descriptors = state + .admin_services() + .credentials() + .providers() + .map_err(map_service_error)?; + let data: Vec<_> = descriptors + .into_iter() + .map(AccountProviderData::from) + .collect(); + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) +} diff --git a/backend/crates/gateway-api/src/admin/accounts/credentials.rs b/backend/crates/gateway-api/src/admin/accounts/credentials.rs index f65a1a526..cd700163f 100644 --- a/backend/crates/gateway-api/src/admin/accounts/credentials.rs +++ b/backend/crates/gateway-api/src/admin/accounts/credentials.rs @@ -14,20 +14,8 @@ fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AccountProvider { - OpenAi, - Xai, -} - -impl AccountProvider { - fn parse(value: &str) -> Result { - match value.trim() { - "openai" => Ok(Self::OpenAi), - "xai" => Ok(Self::Xai), - _ => Err(WireValidationError::new("provider")), - } - } +fn parse_provider(value: &str) -> Result { + ProviderKind::new(value.trim().to_owned()).map_err(|_| WireValidationError::new("provider")) } /// 导入统一设置,复用编辑账号的备注、调度和分组约束。 @@ -84,7 +72,7 @@ impl AccountImportRequest { if let Some(settings) = &self.settings { settings.validate()?; } - AccountProvider::parse(&self.provider)?; + parse_provider(&self.provider)?; if !self.data.is_object() || serde_json::to_vec(&self.data) .map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES) @@ -97,9 +85,9 @@ impl AccountImportRequest { pub(super) fn into_command( self, context: gateway_admin::model::MutationContext, - ) -> Result<(AccountProvider, ImportCredentials), WireValidationError> { + ) -> Result<(ProviderKind, ImportCredentials), WireValidationError> { self.validate()?; - let provider = AccountProvider::parse(&self.provider)?; + let provider = parse_provider(&self.provider)?; Ok(( provider, ImportCredentials { @@ -115,7 +103,7 @@ impl AccountImportRequest { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct StartAccountAuthorizationRequest { pub outbound_proxy_id: Option, @@ -123,12 +111,17 @@ pub struct StartAccountAuthorizationRequest { pub name: String, pub account_id: Option, pub outbound_proxy_url: Option, + #[serde(default)] + pub input: Map, } impl StartAccountAuthorizationRequest { pub fn validate(&self) -> Result<(), WireValidationError> { - AccountProvider::parse(&self.provider)?; + parse_provider(&self.provider)?; require_text(&self.name, MAX_NAME_BYTES, "name")?; + if serde_json::to_vec(&self.input).map_or(true, |data| data.len() > 64 * 1024) { + return Err(WireValidationError::new("input")); + } if let Some(account_id) = self.account_id.as_deref() { require_account_id(account_id, "accountId")?; } @@ -138,9 +131,9 @@ impl StartAccountAuthorizationRequest { pub(super) fn into_command( self, context: gateway_admin::model::MutationContext, - ) -> Result<(AccountProvider, StartAuthorization), WireValidationError> { + ) -> Result<(ProviderKind, StartAuthorization), WireValidationError> { self.validate()?; - let provider = AccountProvider::parse(&self.provider)?; + let provider = parse_provider(&self.provider)?; let reauthorization = self .account_id .map(ProviderAccountId::new) @@ -150,6 +143,7 @@ impl StartAccountAuthorizationRequest { provider, StartAuthorization { context, + input: ProviderDocument::new(OpaqueProviderData::new(self.input)), name: self.name, reauthorization, outbound_proxy: super::wire::proxy_selection( @@ -175,27 +169,17 @@ impl CompleteAccountAuthorizationRequest { if let Some(settings) = &self.settings { settings.validate()?; } - let provider = AccountProvider::parse(&self.provider)?; - match provider { - AccountProvider::OpenAi => { - if !URL_SAFE_NO_PAD - .decode(&self.flow_id) - .is_ok_and(|decoded| decoded.len() == 32) - { - return Err(WireValidationError::new("flowId")); - } - } - AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?, - } + let provider = parse_provider(&self.provider)?; + validate_authorization_flow(&provider, &self.flow_id)?; require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl") } pub(super) fn into_command( self, context: gateway_admin::model::MutationContext, - ) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> { + ) -> Result<(ProviderKind, CompleteAuthorization), WireValidationError> { self.validate()?; - let provider = AccountProvider::parse(&self.provider)?; + let provider = parse_provider(&self.provider)?; Ok(( provider, CompleteAuthorization { @@ -211,9 +195,97 @@ impl CompleteAccountAuthorizationRequest { } } -#[derive(Debug, Clone, Deserialize)] +fn validate_authorization_flow( + provider: &ProviderKind, + flow_id: &str, +) -> Result<(), WireValidationError> { + if provider.as_str() == "openai" { + if !URL_SAFE_NO_PAD + .decode(flow_id) + .is_ok_and(|decoded| decoded.len() == 32) + { + return Err(WireValidationError::new("flowId")); + } + Ok(()) + } else { + require_wire_id(flow_id, "flowId") + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PollAccountAuthorizationRequest { + pub provider: String, + pub flow_id: String, + pub callback_url: Option, + pub settings: Option, +} + +impl PollAccountAuthorizationRequest { + pub fn validate(&self) -> Result<(), WireValidationError> { + validate_authorization_flow(&parse_provider(&self.provider)?, &self.flow_id)?; + if let Some(callback) = &self.callback_url { + require_text(callback, MAX_CALLBACK_URL_BYTES, "callbackUrl")?; + } + if let Some(settings) = &self.settings { + settings.validate()?; + } + Ok(()) + } + + pub(super) fn into_command( + self, + context: gateway_admin::model::MutationContext, + ) -> Result<(ProviderKind, PollAuthorization), WireValidationError> { + self.validate()?; + Ok(( + parse_provider(&self.provider)?, + PollAuthorization { + context, + flow_id: self.flow_id, + callback_url: self.callback_url, + settings: self + .settings + .map(AccountImportSettingsRequest::into_settings) + .transpose()?, + }, + )) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde( + tag = "status", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum AccountAuthorizationPollData { + Pending { retry_after_ms: u64 }, + Complete { account_ids: Vec }, +} + +impl From for AccountAuthorizationPollData { + fn from(result: AuthorizationPollResult) -> Self { + match result { + AuthorizationPollResult::Pending { retry_after } => Self::Pending { + retry_after_ms: retry_after.as_secs() * 1000 + + u64::from(retry_after.subsec_millis()), + }, + AuthorizationPollResult::Complete(result) => Self::Complete { + account_ids: result + .accounts + .into_iter() + .map(|account| account.account_id.to_string()) + .collect(), + }, + } + } +} + +#[derive(Clone, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateAccountRequest { + pub connection: Option, pub outbound_proxy_id: Option, pub outbound_proxy_url: Option, pub account_id: String, @@ -229,6 +301,9 @@ pub struct UpdateAccountRequest { impl UpdateAccountRequest { pub fn validate(&self) -> Result<(), WireValidationError> { require_account_id(&self.account_id, "accountId")?; + if let Some(connection) = &self.connection { + connection.validate()?; + } validate_account_notes(self.notes.as_deref())?; parse_concurrency_limit(self.concurrency_limit)?; parse_account_weight(self.weight)?; @@ -236,9 +311,14 @@ impl UpdateAccountRequest { Ok(()) } - pub(super) fn into_command(self) -> Result { + pub(super) fn into_command( + self, + ) -> Result<(UpdateAccount, Option), WireValidationError> { self.validate()?; - Ok(UpdateAccount { + let connection = self + .connection + .map(AccountConnectionUpdateRequest::into_document); + let settings = UpdateAccount { outbound_proxy: super::wire::proxy_selection( self.outbound_proxy_id, self.outbound_proxy_url, @@ -250,7 +330,45 @@ impl UpdateAccountRequest { weight: parse_account_weight(self.weight)?, model_access: self.model_access, group_ids: validate_wire_group_ids(&self.group_ids)?, - }) + }; + Ok((settings, connection)) + } +} + +/// 编辑 OpenAI API Key 账号的连接设置;省略密钥时保留已保存的值。 +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccountConnectionUpdateRequest { + pub base_url: String, + pub transport: String, + pub api_key: Option, +} + +impl AccountConnectionUpdateRequest { + fn validate(&self) -> Result<(), WireValidationError> { + require_text(&self.base_url, 2048, "connection.baseUrl")?; + if !matches!(self.transport.as_str(), "http" | "prefer_websocket") { + return Err(WireValidationError::new("connection.transport")); + } + if self.api_key.as_ref().is_some_and(|key| { + key.is_empty() + || key.len() > 16 * 1024 + || !key.bytes().all(|byte| byte.is_ascii_graphic()) + }) { + return Err(WireValidationError::new("connection.apiKey")); + } + Ok(()) + } + + fn into_document(self) -> ProviderDocument { + let mut material = Map::from_iter([ + ("base_url".to_owned(), Value::String(self.base_url)), + ("transport".to_owned(), Value::String(self.transport)), + ]); + if let Some(key) = self.api_key { + material.insert("api_key".to_owned(), Value::String(key)); + } + ProviderDocument::new(OpaqueProviderData::new(material)) } } @@ -279,7 +397,7 @@ pub struct AccountDeletionRequest { impl AccountDeletionRequest { pub fn validate(&self) -> Result<(), WireValidationError> { - AccountProvider::parse(&self.provider)?; + parse_provider(&self.provider)?; if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH { return Err(WireValidationError::new("accountIds")); } @@ -296,9 +414,9 @@ impl AccountDeletionRequest { pub(super) fn into_command( self, context: gateway_admin::model::MutationContext, - ) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> { + ) -> Result<(ProviderKind, CredentialDeletion), WireValidationError> { self.validate()?; - let provider = AccountProvider::parse(&self.provider)?; + let provider = parse_provider(&self.provider)?; Ok(( provider, CredentialDeletion { @@ -314,106 +432,6 @@ impl AccountDeletionRequest { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RotateAccountRequest { - pub provider: String, - pub account_id: String, - pub access_token: Option, - pub refresh_token: Option, - pub id_token: Option, - pub base_url: Option, - pub api_key: Option, - pub transport: Option, - pub settings: Option, -} - -impl RotateAccountRequest { - pub fn validate(&self) -> Result<(), WireValidationError> { - if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi { - return Err(WireValidationError::new("provider")); - } - require_account_id(&self.account_id, "accountId")?; - if let Some(settings) = &self.settings { - settings.validate()?; - if settings.account_id != self.account_id { - return Err(WireValidationError::new("settings.accountId")); - } - } - if let Some(base_url) = &self.base_url { - if self.access_token.is_some() - || self.refresh_token.is_some() - || self.id_token.is_some() - || base_url.is_empty() - || base_url.len() > 2048 - || !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket")) - || self.api_key.as_ref().is_some_and(|key| { - key.is_empty() - || key.len() > 16 * 1024 - || !key.bytes().all(|byte| byte.is_ascii_graphic()) - }) - { - return Err(WireValidationError::new("credential")); - } - Ok(()) - } else { - if self.api_key.is_some() || self.transport.is_some() { - return Err(WireValidationError::new("credential")); - } - validate_oauth_material( - self.access_token - .as_deref() - .ok_or_else(|| WireValidationError::new("accessToken"))?, - self.refresh_token.as_deref(), - self.id_token.as_deref(), - ) - } - } - - pub(super) fn into_command( - self, - context: gateway_admin::model::MutationContext, - ) -> Result { - self.validate()?; - let mut material = Map::new(); - if let Some(base_url) = self.base_url { - material.insert("base_url".to_owned(), Value::String(base_url)); - material.insert( - "transport".to_owned(), - self.transport.map_or(Value::Null, Value::String), - ); - if let Some(key) = self.api_key { - material.insert("api_key".to_owned(), Value::String(key)); - } - } else { - material.insert( - "access_token".to_owned(), - self.access_token.map_or(Value::Null, Value::String), - ); - material.insert( - "refresh_token".to_owned(), - self.refresh_token.map_or(Value::Null, Value::String), - ); - material.insert( - "id_token".to_owned(), - self.id_token.map_or(Value::Null, Value::String), - ); - } - Ok(RotateCredential { - settings: self - .settings - .map(UpdateAccountRequest::into_command) - .transpose()?, - mutation: CredentialMutation { - context, - account_id: ProviderAccountId::new(self.account_id) - .map_err(|_| WireValidationError::new("accountId"))?, - }, - provider_material: ProviderDocument::new(OpaqueProviderData::new(material)), - }) - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountImportData { @@ -579,47 +597,6 @@ fn require_text( Ok(()) } -fn validate_oauth_material( - access_token: &str, - refresh_token: Option<&str>, - id_token: Option<&str>, -) -> Result<(), WireValidationError> { - if access_token.len() > MAX_ACCESS_TOKEN_BYTES - || !valid_visible_ascii(access_token) - || !valid_compact_jwt_shape(access_token) - { - return Err(WireValidationError::new("accessToken")); - } - if refresh_token.is_some_and(|token| { - token.len() > MAX_REFRESH_TOKEN_BYTES - || !valid_visible_ascii(token) - || token == access_token - }) { - return Err(WireValidationError::new("refreshToken")); - } - if id_token.is_some_and(|token| { - token.len() > MAX_ID_TOKEN_BYTES - || !valid_visible_ascii(token) - || !valid_compact_jwt_shape(token) - }) { - return Err(WireValidationError::new("idToken")); - } - Ok(()) -} - -fn valid_compact_jwt_shape(value: &str) -> bool { - let mut segments = value.split('.'); - matches!( - (segments.next(), segments.next(), segments.next(), segments.next()), - (Some(header), Some(payload), Some(signature), None) - if !header.is_empty() && !payload.is_empty() && !signature.is_empty() - ) -} - -fn valid_visible_ascii(value: &str) -> bool { - !value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) -} - pub(super) fn provider_document_value(document: ProviderDocument) -> Value { Value::Object(document.into_provider_data().into_inner()) } diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 9440d2318..0e952e3f2 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -12,12 +12,15 @@ where Router::new() .merge(super::import_tasks::router::()) .route("/api/admin/accounts", get(list_accounts::)) + .route( + "/api/admin/accounts/providers", + get(super::capabilities::providers::), + ) .route("/api/admin/accounts/detail", get(account_detail::)) .route("/api/admin/accounts/export", get(export_accounts::)) .route("/api/admin/accounts/import", post(import_accounts::)) .route("/api/admin/accounts/refresh", post(refresh_account::)) .route("/api/admin/accounts/recover", post(recover_account::)) - .route("/api/admin/accounts/rotate", post(rotate_account::)) .route("/api/admin/accounts/update", post(update_account::)) .route("/api/admin/accounts/delete", post(delete_accounts::)) .route( @@ -62,6 +65,10 @@ where "/api/admin/accounts/oauth/complete", post(complete_account_authorization::), ) + .route( + "/api/admin/accounts/oauth/poll", + post(poll_account_authorization::), + ) } async fn batch_update_accounts( @@ -168,17 +175,14 @@ where let (provider, command) = request .into_command(auth.context().mutation_context()) .map_err(map_wire_error)?; - let result = match provider { - AccountProvider::OpenAi => { - state - .admin_services() - .openai() - .import_document(command) - .await - } - AccountProvider::Xai => state.admin_services().xai().import_document(command).await, - } - .map_err(map_service_error)?; + let result = state + .admin_services() + .credentials() + .for_provider(&provider) + .map_err(map_service_error)? + .import_document(command) + .await + .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, AdminEnvelope::ok(AccountImportData::from_result(result)), @@ -196,23 +200,14 @@ where let (provider, command) = request .into_command(auth.context().mutation_context()) .map_err(map_wire_error)?; - let result = match provider { - AccountProvider::OpenAi => { - state - .admin_services() - .openai() - .start_authorization(command) - .await - } - AccountProvider::Xai => { - state - .admin_services() - .xai() - .start_authorization(command) - .await - } - } - .map_err(map_service_error)?; + let result = state + .admin_services() + .credentials() + .for_provider(&provider) + .map_err(map_service_error)? + .start_authorization(command) + .await + .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, AdminEnvelope::ok(AccountAuthorizationData::from(result)), @@ -230,49 +225,38 @@ where let (provider, command) = request .into_command(auth.context().mutation_context()) .map_err(map_wire_error)?; - let result = match provider { - AccountProvider::OpenAi => { - state - .admin_services() - .openai() - .complete_authorization(command) - .await - } - AccountProvider::Xai => { - state - .admin_services() - .xai() - .complete_authorization(command) - .await - } - } - .map_err(map_service_error)?; + let result = state + .admin_services() + .credentials() + .complete_authorization(&provider, command) + .await + .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::CREATED, AdminEnvelope::ok(AccountMutationData::from(result)), )) } -async fn rotate_account( +async fn poll_account_authorization( auth: AdminAuth, State(state): State, - AdminJson(request): AdminJson, + AdminJson(request): AdminJson, ) -> Result where S: SessionState + Send + Sync, { - let command = request + let (provider, command) = request .into_command(auth.context().mutation_context()) .map_err(map_wire_error)?; let result = state .admin_services() - .openai() - .rotate(command) + .credentials() + .poll_authorization(&provider, command) .await .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountMutationData::from(result)), + AdminEnvelope::ok(AccountAuthorizationPollData::from(result)), )) } @@ -284,13 +268,39 @@ async fn update_account( where S: SessionState + Send + Sync, { - let command = request.into_command().map_err(map_wire_error)?; - let result = state - .admin_services() - .accounts() - .update(&auth.context().mutation_context(), command) - .await - .map_err(map_service_error)?; + let (command, connection) = request.into_command().map_err(map_wire_error)?; + let context = auth.context().mutation_context(); + let result = if let Some(provider_material) = connection { + let provider = ProviderKind::new("openai").map_err(|_| AdminError::internal())?; + let account_id = ProviderAccountId::new(command.account_id.clone()) + .map_err(|_| AdminError::internal())?; + let result = state + .admin_services() + .credentials() + .for_provider(&provider) + .map_err(map_service_error)? + .rotate(RotateCredential { + mutation: CredentialMutation { + context, + account_id, + }, + settings: Some(command), + provider_material, + }) + .await + .map_err(map_service_error)?; + AccountUpdateResult { + account_id: result.account_id, + config_revision: result.config_revision, + } + } else { + state + .admin_services() + .accounts() + .update(&context, command) + .await + .map_err(map_service_error)? + }; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(UpdatedAccountData::from(result)), @@ -308,11 +318,14 @@ where let (provider, command) = request .into_command(auth.context().mutation_context()) .map_err(map_wire_error)?; - let result = match provider { - AccountProvider::OpenAi => state.admin_services().openai().delete(command).await, - AccountProvider::Xai => state.admin_services().xai().delete(command).await, - } - .map_err(map_service_error)?; + let result = state + .admin_services() + .credentials() + .for_provider(&provider) + .map_err(map_service_error)? + .delete(command) + .await + .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(AccountDeletionData::from(result)), diff --git a/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs b/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs index c81894960..8a8257b76 100644 --- a/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs +++ b/backend/crates/gateway-api/src/admin/accounts/import_tasks.rs @@ -152,11 +152,6 @@ where .into_iter() .map(|item| { let (provider, command) = item.into_command(context.clone())?; - let provider = ProviderKind::new(match provider { - AccountProvider::OpenAi => "openai", - AccountProvider::Xai => "xai", - }) - .map_err(|_| WireValidationError::new("provider"))?; Ok(ImportTaskInput { provider, command }) }) .collect::, WireValidationError>>() diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index 5681138f0..ade2207b5 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -26,10 +26,11 @@ use gateway_admin::model::{ }, provider_credentials::{ AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo, - AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization, - ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult, - CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials, - ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar, + AccountRefreshResult, AccountUsagePeriod, AuthorizationPollResult, AuthorizationStarted, + CompleteAuthorization, ConsumeProviderResetCredit, CredentialDeletion, + CredentialDeletionResult, CredentialImportResult, CredentialMutation, + CredentialMutationResult, ImportCredentials, PollAuthorization, ProviderDocument, + ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential, @@ -56,28 +57,27 @@ const MAX_SEARCH_BYTES: usize = 256; const MAX_ID_BYTES: usize = 256; const MAX_NAME_BYTES: usize = 512; const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024; -const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024; -const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024; -const MAX_ID_TOKEN_BYTES: usize = 16 * 1024; const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024; const MAX_ACCOUNT_DELETE_BATCH: usize = 200; const MAX_ACCOUNT_GROUP_BATCH: usize = 1000; const MAX_AVATAR_VERSION_BYTES: usize = 32; +mod capabilities; mod credentials; mod handlers; mod import_tasks; mod presenter; mod wire; +pub use capabilities::*; pub use credentials::*; pub use handlers::{profile_avatar_response, router}; pub(super) use presenter::quota_window_view; pub use wire::*; use credentials::{ - AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value, - require_account_id, validate_wire_group_ids, + parse_account_weight, parse_concurrency_limit, provider_document_value, require_account_id, + validate_wire_group_ids, }; use presenter::*; use wire::BatchUpdatedAccountsData; diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index 2f2980f1b..1c1e7f167 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -15,6 +15,7 @@ pub(super) fn account_page_data( u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX) }; AccountPageData { + providers: result.providers, items: result .items .into_iter() @@ -60,6 +61,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData { pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> AccountView { let AccountDirectoryItem { account, + capabilities, plan_type_display, projection, usage, @@ -77,6 +79,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime) -> Ac } let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now); AccountView { + capabilities: capabilities.into(), id: account.id.clone(), name: account.name, notes: account.notes, diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index cee3b5eab..cdcfcdd86 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -162,7 +162,7 @@ impl ListQuery { if page_size == 0 || page_size > MAX_PAGE_SIZE { return Err(WireValidationError::new("pageSize")); } - let provider_kind = parse_provider(self.provider.as_deref().unwrap_or("all"))?; + let provider_kind = parse_provider(self.provider.as_deref().unwrap_or_default())?; let group_filter = match self.group_id.as_deref().map(str::trim) { None | Some("") => None, Some("ungrouped") => Some(AccountGroupFilter::Ungrouped), @@ -213,7 +213,7 @@ impl ListQuery { fn parse_provider(value: &str) -> Result, WireValidationError> { let value = value.trim(); - if value.is_empty() || value.eq_ignore_ascii_case("all") { + if value.is_empty() { return Ok(None); } ProviderKind::new(value.to_owned()) @@ -248,6 +248,7 @@ pub struct AccountPageData { pub items: Vec, pub page: PageMeta, pub summary: AccountSummaryView, + pub providers: Vec, } /// 账号概览计数。 @@ -266,6 +267,7 @@ pub struct AccountSummaryView { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountView { + pub capabilities: AccountCapabilitiesView, pub outbound_proxy_endpoint: Option, pub id: String, pub name: String, @@ -303,6 +305,36 @@ pub struct AccountView { pub usage: AccountUsageView, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountCapabilitiesView { + pub quota: bool, + pub quota_refresh: bool, + pub profile: bool, + pub subscription: bool, + pub avatar: bool, + pub reset_credits: bool, + pub consume_reset_credit: bool, +} + +impl From + for AccountCapabilitiesView +{ + fn from( + value: gateway_admin::model::provider_capabilities::ProviderAccountCapabilities, + ) -> Self { + Self { + quota: value.quota, + quota_refresh: value.quota_refresh, + profile: value.profile, + subscription: value.subscription, + avatar: value.avatar, + reset_credits: value.reset_credits, + consume_reset_credit: value.consume_reset_credit, + } + } +} + /// 容量估算仅供管理端展示;金额不是订阅账单或可消费余额。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/backend/crates/gateway-api/src/admin/client_keys.rs b/backend/crates/gateway-api/src/admin/client_keys.rs index 941a131ef..2b7e610ee 100644 --- a/backend/crates/gateway-api/src/admin/client_keys.rs +++ b/backend/crates/gateway-api/src/admin/client_keys.rs @@ -2,7 +2,7 @@ use crate::auth::SessionState; -use std::fmt; +use std::{collections::BTreeMap, fmt}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Utc}; @@ -17,7 +17,7 @@ use gateway_core::{ engine::budget::ClientBudgetLimits, metering::Decimal, policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits}, - routing::AccountGroupId, + routing::{AccountGroupId, ProviderKind}, }; use serde::{Deserialize, Serialize}; @@ -38,6 +38,10 @@ const MAX_CURSOR_BYTES: usize = 512; const MAX_SEARCH_BYTES: usize = 256; const DEFAULT_PAGE_SIZE: u16 = 50; +type ProviderRequestProfileOverrides = BTreeMap>; +type ProviderRequestProfileOverrideUpdates = + BTreeMap>>; + fn parse_budget( value: Option, field: &'static str, @@ -147,6 +151,8 @@ impl ClientKeySort { #[derive(Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CreateClientKeyRequest { + #[serde(default)] + provider_request_profile_overrides: ProviderRequestProfileOverrides, openai_client_profile_override: Option>, xai_client_profile_override: Option>, custom_key: Option, @@ -177,13 +183,13 @@ impl CreateClientKeyRequest { let group_ids = validate_group_ids(self.group_ids)?; validate_limit(self.max_concurrency, "maxConcurrency")?; validate_limit(self.requests_per_minute, "requestsPerMinute")?; + let request_profile_overrides = normalize_request_profile_overrides( + self.provider_request_profile_overrides, + self.openai_client_profile_override, + self.xai_client_profile_override, + )?; Ok(CreateClientKey { - openai_client_profile_override: self - .openai_client_profile_override - .map(gateway_core::account::OpaqueProviderData::new), - xai_client_profile_override: self - .xai_client_profile_override - .map(gateway_core::account::OpaqueProviderData::new), + request_profile_overrides, custom_key: self .custom_key .filter(|key| !key.is_empty()) @@ -210,6 +216,8 @@ impl CreateClientKeyRequest { #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateClientKeyRequest { + #[serde(default)] + provider_request_profile_overrides: ProviderRequestProfileOverrideUpdates, #[serde(default, deserialize_with = "deserialize_profile_override")] openai_client_profile_override: Option>>, #[serde(default, deserialize_with = "deserialize_profile_override")] @@ -233,13 +241,13 @@ impl UpdateClientKeyRequest { let group_ids = validate_group_ids(self.group_ids)?; validate_limit(self.max_concurrency, "maxConcurrency")?; validate_limit(self.requests_per_minute, "requestsPerMinute")?; + let request_profile_override_updates = normalize_request_profile_override_updates( + self.provider_request_profile_overrides, + self.openai_client_profile_override, + self.xai_client_profile_override, + )?; Ok(UpdateClientKey { - openai_client_profile_override: self - .openai_client_profile_override - .map(|value| value.map(gateway_core::account::OpaqueProviderData::new)), - xai_client_profile_override: self - .xai_client_profile_override - .map(|value| value.map(gateway_core::account::OpaqueProviderData::new)), + request_profile_override_updates, id: client_key_id(self.id, "clientKeyMutationNotFound")?, name: self.name, label: self.label, @@ -325,7 +333,10 @@ impl ClientKeyMutationRequest { #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct ClientKeyView { + provider_request_profile_overrides: ProviderRequestProfileOverrides, + /// 固定兼容字段;值始终从 provider_request_profile_overrides 派生。 openai_client_profile_override: Option>, + /// 固定兼容字段;值始终从 provider_request_profile_overrides 派生。 xai_client_profile_override: Option>, id: String, name: String, @@ -364,13 +375,17 @@ impl From for ClientKeyView { } else { "groups" }; + let provider_request_profile_overrides = record + .request_profile_overrides + .into_iter() + .map(|(provider, profile)| (provider.as_str().to_owned(), profile.into_inner())) + .collect::(); Self { - openai_client_profile_override: record - .openai_client_profile_override - .map(gateway_core::account::OpaqueProviderData::into_inner), - xai_client_profile_override: record - .xai_client_profile_override - .map(gateway_core::account::OpaqueProviderData::into_inner), + openai_client_profile_override: provider_request_profile_overrides + .get("openai") + .cloned(), + xai_client_profile_override: provider_request_profile_overrides.get("xai").cloned(), + provider_request_profile_overrides, id: record.id.to_string(), name: record.name, label: record.label, @@ -702,6 +717,102 @@ fn validate_limit(value: u64, field: &'static str) -> Result<(), WireValidationE Ok(()) } +fn normalize_request_profile_overrides( + profiles: ProviderRequestProfileOverrides, + openai: Option>, + xai: Option>, +) -> Result +{ + if profiles.len() > 256 { + return Err(WireValidationError::new("providerRequestProfileOverrides")); + } + let mut normalized = profiles + .into_iter() + .map(|(provider, profile)| { + validate_request_profile_size(&profile)?; + Ok(( + ProviderKind::new(provider) + .map_err(|_| WireValidationError::new("providerRequestProfileOverrides"))?, + gateway_core::account::OpaqueProviderData::new(profile), + )) + }) + .collect::>( + )?; + for (provider, profile) in [("openai", openai), ("xai", xai)] { + let Some(profile) = profile else { + continue; + }; + validate_request_profile_size(&profile)?; + let provider = ProviderKind::new(provider).expect("static Provider kind is valid"); + let profile = gateway_core::account::OpaqueProviderData::new(profile); + if normalized + .get(&provider) + .is_some_and(|current| current != &profile) + { + return Err(WireValidationError::new("providerRequestProfileOverrides")); + } + normalized.insert(provider, profile); + } + Ok(normalized) +} + +fn normalize_request_profile_override_updates( + profiles: ProviderRequestProfileOverrideUpdates, + openai: Option>>, + xai: Option>>, +) -> Result< + gateway_admin::model::client_keys::ProviderRequestProfileOverrideUpdates, + WireValidationError, +> { + if profiles.len() > 256 { + return Err(WireValidationError::new("providerRequestProfileOverrides")); + } + let mut normalized = profiles + .into_iter() + .map(|(provider, profile)| { + if let Some(profile) = profile.as_ref() { + validate_request_profile_size(profile)?; + } + Ok(( + ProviderKind::new(provider).map_err(|_| { + WireValidationError::new("providerRequestProfileOverrides") + })?, + profile.map(gateway_core::account::OpaqueProviderData::new), + )) + }) + .collect::>()?; + for (provider, profile) in [("openai", openai), ("xai", xai)] { + let Some(profile) = profile else { + continue; + }; + if let Some(profile) = profile.as_ref() { + validate_request_profile_size(profile)?; + } + let provider = ProviderKind::new(provider).expect("static Provider kind is valid"); + let profile = profile.map(gateway_core::account::OpaqueProviderData::new); + if normalized + .get(&provider) + .is_some_and(|current| current != &profile) + { + return Err(WireValidationError::new("providerRequestProfileOverrides")); + } + normalized.insert(provider, profile); + } + Ok(normalized) +} + +fn validate_request_profile_size( + profile: &serde_json::Map, +) -> Result<(), WireValidationError> { + if serde_json::to_vec(profile).map_or(true, |encoded| encoded.len() > 64 * 1024) { + return Err(WireValidationError::new("providerRequestProfileOverrides")); + } + Ok(()) +} + fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireValidationError> { if value.trim().is_empty() { return Err(WireValidationError::new(field)); diff --git a/backend/crates/gateway-api/src/admin/mod.rs b/backend/crates/gateway-api/src/admin/mod.rs index e5061e5ed..230f2f7b6 100644 --- a/backend/crates/gateway-api/src/admin/mod.rs +++ b/backend/crates/gateway-api/src/admin/mod.rs @@ -17,6 +17,7 @@ pub mod backups; pub mod client_keys; mod extract; pub mod observability; +mod plugins; pub mod presenter; pub mod proxies; pub mod settings; @@ -30,7 +31,11 @@ pub use wire::{ AdminPageData, AdminResponse, PageMeta, WireValidationError, }; -/// 构造完整且固定的 `/api/admin` 路由。 +pub(crate) fn model_router() -> Router { + plugins::model_router().layer(middleware::map_response(no_store)) +} + +/// 构造管理用例路由;模型执行桥由完整 API 组合单独装配。 pub fn router() -> Router where S: SessionState + Clone + Send + Sync + 'static, @@ -38,6 +43,7 @@ where Router::new() .merge(account_groups::router::()) .merge(proxies::router::()) + .merge(plugins::router::()) .merge(accounts::router::()) .merge(backups::router::()) .merge(client_keys::router::()) diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs index 3b0acc118..6171e81ec 100644 --- a/backend/crates/gateway-api/src/admin/observability/query.rs +++ b/backend/crates/gateway-api/src/admin/observability/query.rs @@ -23,6 +23,14 @@ impl DashboardQuery { } } +/// 用量页面的平台筛选范围,与分页及当前平台选择无关。 +#[derive(Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UsageProvidersQuery { + pub start_time: Option, + pub end_time: Option, +} + /// 逻辑请求列表、汇总和洞察查询参数。 #[derive(Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] diff --git a/backend/crates/gateway-api/src/admin/observability/routes.rs b/backend/crates/gateway-api/src/admin/observability/routes.rs index e0f147eb0..182841c0d 100644 --- a/backend/crates/gateway-api/src/admin/observability/routes.rs +++ b/backend/crates/gateway-api/src/admin/observability/routes.rs @@ -12,6 +12,7 @@ where .route("/api/admin/dashboard/summary", get(dashboard_summary::)) .route("/api/admin/dashboard/trend", get(dashboard_trend::)) .route("/api/admin/usage/records", get(usage_records::)) + .route("/api/admin/usage/providers", get(usage_providers::)) .route( "/api/admin/usage/records/detail", get(usage_record_detail::), @@ -118,6 +119,28 @@ where )) } +pub(crate) async fn usage_providers( + _auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +where + S: SessionState + Send + Sync, +{ + let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref()) + .map_err(map_wire_error)?; + let result = state + .admin_services() + .observability() + .usage_provider_kinds(range) + .await + .map_err(map_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(result), + )) +} + pub(crate) async fn usage_records_summary( _auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/src/admin/plugins/artifacts.rs b/backend/crates/gateway-api/src/admin/plugins/artifacts.rs new file mode 100644 index 000000000..41a3afbdb --- /dev/null +++ b/backend/crates/gateway-api/src/admin/plugins/artifacts.rs @@ -0,0 +1,282 @@ +use axum::{ + Router, + body::{Body, Bytes}, + extract::{DefaultBodyLimit, Path, State, rejection::BytesRejection}, + http::{HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use gateway_admin::model::plugins::{ + InstalledPluginArtifact, PluginArtifactMetadata, PluginIconTheme, PluginInstallResult, + PluginPermissionDescription, PluginSource, distribution::VerifiedPluginArtifact, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + admin::{ + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, + wire::map_admin_service_error, + }, + auth::SessionState, +}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ArtifactView { + metadata: MetadataView, + source: PluginSource, + installed_at: String, + accepted_at: Option, +} + +impl ArtifactView { + fn new(value: InstalledPluginArtifact, service: &gateway_admin::PluginsService) -> Self { + Self { + metadata: MetadataView::new(value.metadata, service), + source: value.source, + installed_at: value.installed_at.to_rfc3339(), + accepted_at: value.accepted_at.map(|value| value.to_rfc3339()), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct MetadataView { + #[serde(flatten)] + metadata: PluginArtifactMetadata, + permission_descriptions: Vec, +} + +impl MetadataView { + fn new(metadata: PluginArtifactMetadata, service: &gateway_admin::PluginsService) -> Self { + Self { + permission_descriptions: service + .permission_descriptions(&metadata.requested_permissions), + metadata, + } + } +} + +#[derive(Serialize)] +pub(super) struct VerifiedArtifactView { + metadata: MetadataView, + source: PluginSource, +} + +impl VerifiedArtifactView { + pub(super) fn new( + value: VerifiedPluginArtifact, + service: &gateway_admin::PluginsService, + ) -> Self { + Self { + metadata: MetadataView::new(value.metadata, service), + source: value.source, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct InstallResultView { + artifact: ArtifactView, + config_revision: u64, + default_instance_id: Option, + configuration_required: bool, +} + +impl InstallResultView { + pub(super) fn new(value: PluginInstallResult, service: &gateway_admin::PluginsService) -> Self { + Self { + artifact: ArtifactView::new(value.mutation.artifact, service), + config_revision: value.mutation.config_revision.get(), + default_instance_id: value.default_instance_id, + configuration_required: value.configuration_required, + } + } +} + +pub(in crate::admin) fn router() -> Router { + Router::new() + .route("/api/admin/plugins/artifacts", get(list::)) + .route("/api/admin/plugins/artifacts/{sha256}/icon", get(icon::)) + .route( + "/api/admin/plugins/artifacts/upload", + post(upload::).layer(DefaultBodyLimit::max(32 * 1024 * 1024)), + ) + .route( + "/api/admin/plugins/artifacts/upload/verify", + post(verify_upload::).layer(DefaultBodyLimit::max(32 * 1024 * 1024)), + ) + .route("/api/admin/plugins/artifacts/accept", post(accept::)) + .route("/api/admin/plugins/artifacts/delete", post(remove::)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase", deny_unknown_fields)] +enum IconTheme { + Light, + Dark, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct IconQuery { + theme: IconTheme, +} + +async fn icon( + _: AdminAuth, + State(state): State, + Path(sha256): Path, + AdminQuery(query): AdminQuery, +) -> Result { + let theme = match query.theme { + IconTheme::Light => PluginIconTheme::Light, + IconTheme::Dark => PluginIconTheme::Dark, + }; + let icon = state + .admin_services() + .plugins() + .icon(&sha256, theme) + .await + .map_err(map_admin_service_error)?; + let content_type = + HeaderValue::from_str(&icon.content_type).map_err(|_| AdminError::internal())?; + let mut response = Response::new(Body::from(icon.body)); + let headers = response.headers_mut(); + headers.insert(header::CONTENT_TYPE, content_type); + headers.insert( + header::CONTENT_SECURITY_POLICY, + // 图标只作为图片加载;SVG 可用内联样式与内嵌图片,但不能执行脚本或请求外部资源。 + HeaderValue::from_static("sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=31536000, immutable"), + ); + Ok(response) +} + +async fn list( + _: AdminAuth, + State(state): State, +) -> Result { + let items: Vec = state + .admin_services() + .plugins() + .list() + .await + .map_err(map_admin_service_error)? + .into_iter() + .map(|artifact| ArtifactView::new(artifact, state.admin_services().plugins())) + .collect(); + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(items))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UploadQuery { + sha256: Option, +} + +async fn verify_upload( + _: AdminAuth, + State(state): State, + bytes: Result, +) -> Result { + let bytes = bytes.map_err(|error| { + AdminError::invalid_request(error.status(), "插件包读取失败或超过大小限制") + })?; + let result = state + .admin_services() + .plugins() + .verify_upload(bytes.to_vec().into()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(VerifiedArtifactView::new( + result, + state.admin_services().plugins(), + )), + )) +} + +async fn upload( + auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, + bytes: Result, +) -> Result { + let bytes = bytes.map_err(|error| { + AdminError::invalid_request(error.status(), "插件包读取失败或超过大小限制") + })?; + let result = state + .admin_services() + .plugins() + .install_upload( + bytes.to_vec().into(), + query.sha256, + &auth.context().mutation_context(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok(InstallResultView::new( + result, + state.admin_services().plugins(), + )), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AcceptArtifact { + sha256: String, +} + +async fn accept( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .accept_artifact(&request.sha256, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok(InstallResultView::new( + result, + state.admin_services().plugins(), + )), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DeleteArtifact { + sha256: String, +} + +async fn remove( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + state + .admin_services() + .plugins() + .delete(&request.sha256, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(()))) +} diff --git a/backend/crates/gateway-api/src/admin/plugins/distribution.rs b/backend/crates/gateway-api/src/admin/plugins/distribution.rs new file mode 100644 index 000000000..2473318ba --- /dev/null +++ b/backend/crates/gateway-api/src/admin/plugins/distribution.rs @@ -0,0 +1,238 @@ +use axum::{ + Router, + extract::State, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use gateway_admin::model::plugins::distribution::{ + DownloadPurpose, GithubReleaseQuery, RemotePluginInstall, RemotePluginVerify, + SourceAuthentication, SourceCredentialInfo, +}; +use serde::Deserialize; + +use super::artifacts::{InstallResultView, VerifiedArtifactView}; +use crate::{ + admin::{ + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, + wire::map_admin_service_error, + }, + auth::SessionState, +}; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/api/admin/plugins/update-sources", + get(update_sources::).post(change_update_source::), + ) + .route("/api/admin/plugins/releases/query", post(query::)) + .route("/api/admin/plugins/updates/check", post(check_update::)) + .route("/api/admin/plugins/artifacts/install", post(install::)) + .route("/api/admin/plugins/artifacts/verify", post(verify::)) + .route( + "/api/admin/plugins/source-credentials", + get(credentials::).post(create_credential::), + ) + .route( + "/api/admin/plugins/source-credentials/delete", + post(remove_credential::), + ) +} + +async fn update_sources( + _: AdminAuth, + State(state): State, +) -> Result { + let sources = state + .admin_services() + .plugins() + .update_sources() + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(sources), + )) +} + +async fn change_update_source( + auth: AdminAuth, + State(state): State, + AdminJson(binding): AdminJson, +) -> Result { + let revision = state + .admin_services() + .plugins() + .change_update_source(binding, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision":revision.get()})), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReleaseQueryRequest { + query: GithubReleaseQuery, + #[serde(default)] + credential_ids: Vec, + #[serde(default)] + outbound_proxy_id: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct UpdateCheckRequest { + plugin_id: String, + #[serde(default)] + credential_ids: Vec, +} + +async fn check_update( + _: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let check = state + .admin_services() + .plugins() + .check_update(&request.plugin_id, &request.credential_ids) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(check))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CredentialRequest { + name: String, + origin: String, + path_prefix: String, + purposes: Vec, + authentication: SourceAuthentication, +} + +async fn query( + _: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let release = state + .admin_services() + .plugins() + .query_release( + request.query, + &request.credential_ids, + request.outbound_proxy_id.as_deref(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(release), + )) +} + +async fn verify( + _: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .verify_remote(request) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(VerifiedArtifactView::new( + result, + state.admin_services().plugins(), + )), + )) +} + +async fn install( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .install_remote(request, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok(InstallResultView::new( + result, + state.admin_services().plugins(), + )), + )) +} + +async fn credentials( + _: AdminAuth, + State(state): State, +) -> Result { + let credentials = state + .admin_services() + .plugins() + .source_credentials() + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(credentials), + )) +} + +async fn create_credential( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let info = SourceCredentialInfo { + id: String::new(), + name: request.name, + origin: request.origin, + path_prefix: request.path_prefix, + purposes: request.purposes, + }; + let info = state + .admin_services() + .plugins() + .create_source_credential( + info, + request.authentication, + &auth.context().mutation_context(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok(info), + )) +} + +async fn remove_credential( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let revision = state + .admin_services() + .plugins() + .delete_source_credential(&request.id, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision":revision.get()})), + )) +} diff --git a/backend/crates/gateway-api/src/admin/plugins/instances.rs b/backend/crates/gateway-api/src/admin/plugins/instances.rs new file mode 100644 index 000000000..abece7523 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/plugins/instances.rs @@ -0,0 +1,351 @@ +use axum::{ + Router, + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, +}; +use gateway_admin::model::plugins::instances::{ + ConfigurePluginInstance, PluginCapabilityBinding, PluginInstance, PluginInstanceRuntime, + PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, PluginInstanceView, + RollbackPluginInstance, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + admin::{ + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, + wire::map_admin_service_error, + }, + auth::SessionState, +}; + +pub(super) fn router() -> Router { + Router::new() + .route( + "/api/admin/plugins/instances", + get(list::).post(create::), + ) + .route("/api/admin/plugins/instances/update", post(configure::)) + .route("/api/admin/plugins/instances/rollback", post(rollback::)) + .route( + "/api/admin/plugins/instances/switch-version", + post(switch_version::), + ) + .route( + "/api/admin/plugins/instances/version-plan", + get(version_plan::), + ) + .route( + "/api/admin/plugins/instances/rollback-plan", + get(rollback_plan::), + ) + .route("/api/admin/plugins/instances/disable", post(disable::)) + .route("/api/admin/plugins/instances/delete", post(remove::)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstanceView { + id: String, + name: String, + artifact_sha256: String, + enabled: bool, + configuration_required: bool, + configuration: serde_json::Value, + secret_fields: Vec, + bindings: Vec, + revision: u64, + running: bool, + published_revision: Option, + runtime: InstanceRuntimeView, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +enum InstanceRuntimeStatus { + Disabled, + AwaitingPublication, + Preparing, + Running, + Blocked, + PreparationFailed, + Faulted, + Draining, +} + +impl From for InstanceRuntimeStatus { + fn from(value: PluginInstanceRuntimeStatus) -> Self { + match value { + PluginInstanceRuntimeStatus::Disabled => Self::Disabled, + PluginInstanceRuntimeStatus::AwaitingPublication => Self::AwaitingPublication, + PluginInstanceRuntimeStatus::Preparing => Self::Preparing, + PluginInstanceRuntimeStatus::Running => Self::Running, + PluginInstanceRuntimeStatus::Blocked => Self::Blocked, + PluginInstanceRuntimeStatus::PreparationFailed => Self::PreparationFailed, + PluginInstanceRuntimeStatus::Faulted => Self::Faulted, + PluginInstanceRuntimeStatus::Draining => Self::Draining, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstanceRuntimeFailure { + code: String, + message: String, +} + +impl From for InstanceRuntimeFailure { + fn from(value: PluginInstanceRuntimeFailure) -> Self { + Self { + code: value.code, + message: value.message, + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InstanceRuntimeView { + status: InstanceRuntimeStatus, + actual_revision: Option, + actual_artifact_sha256: Option, + failure: Option, + draining_revisions: Vec, + retained_continuations: u32, +} + +impl From for InstanceRuntimeView { + fn from(value: PluginInstanceRuntime) -> Self { + Self { + status: value.status.into(), + actual_revision: value.actual_revision, + actual_artifact_sha256: value.actual_artifact_sha256, + failure: value.failure.map(Into::into), + draining_revisions: value.draining_revisions, + retained_continuations: value.retained_continuations, + } + } +} + +impl From for InstanceView { + fn from(value: PluginInstanceView) -> Self { + let PluginInstance { + id, + name, + artifact_sha256, + enabled, + trusted_process: _, + configuration, + secrets, + grants: _, + bindings, + revision, + } = value.instance; + Self { + id, + name, + artifact_sha256, + enabled, + configuration_required: value.configuration_required, + configuration, + secret_fields: secrets.into_keys().collect(), + bindings, + revision: revision.get(), + running: value.running, + published_revision: value.published_revision, + runtime: value.runtime.into(), + } + } +} + +async fn list( + _: AdminAuth, + State(state): State, +) -> Result { + let instances: Vec = state + .admin_services() + .plugins() + .instances() + .await + .map_err(map_admin_service_error)? + .into_iter() + .map(Into::into) + .collect(); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(instances), + )) +} + +async fn create( + auth: AdminAuth, + State(state): State, + AdminJson(input): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .configure_instance(None, input, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::CREATED, + AdminEnvelope::ok( + serde_json::json!({"id":result.instance.id,"configRevision":result.config_revision.get()}), + ), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigureRequest { + id: String, + instance: ConfigurePluginInstance, +} + +async fn configure( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .configure_instance( + Some(&request.id), + request.instance, + &auth.context().mutation_context(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + serde_json::json!({"id":result.instance.id,"configRevision":result.config_revision.get()}), + ), + )) +} + +async fn disable( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .disable_instance(&request.id, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision":result.config_revision.get()})), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RollbackRequest { + id: String, + target: RollbackPluginInstance, +} + +async fn rollback_plan( + _: AdminAuth, + State(state): State, + Query(request): Query, +) -> Result { + let plan = state + .admin_services() + .plugins() + .rollback_plan(&request.id) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(plan))) +} + +async fn rollback( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .rollback_instance( + &request.id, + request.target, + &auth.context().mutation_context(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + serde_json::json!({"id":result.instance.id,"configRevision":result.config_revision.get()}), + ), + )) +} + +async fn remove( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let revision = state + .admin_services() + .plugins() + .delete_instance(&request.id, &auth.context().mutation_context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(serde_json::json!({"configRevision":revision.get()})), + )) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VersionPlanQuery { + id: String, + artifact_sha256: String, +} + +async fn version_plan( + _: AdminAuth, + State(state): State, + Query(request): Query, +) -> Result { + let plan = state + .admin_services() + .plugins() + .version_plan(&request.id, &request.artifact_sha256) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(plan))) +} + +async fn switch_version( + auth: AdminAuth, + State(state): State, + AdminJson(request): AdminJson, +) -> Result { + let result = state + .admin_services() + .plugins() + .switch_instance_version( + &request.id, + request.target, + &auth.context().mutation_context(), + ) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok( + serde_json::json!({"id":result.instance.id,"configRevision":result.config_revision.get()}), + ), + )) +} diff --git a/backend/crates/gateway-api/src/admin/plugins/management.rs b/backend/crates/gateway-api/src/admin/plugins/management.rs new file mode 100644 index 000000000..e165e28a2 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/plugins/management.rs @@ -0,0 +1,456 @@ +use std::{net::SocketAddr, sync::Arc}; + +use axum::{ + Router, + body::{Body, Bytes, to_bytes}, + extract::{ + ConnectInfo, DefaultBodyLimit, Extension, Path, Request, State, rejection::BytesRejection, + }, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{any, get, post}, +}; +use base64::Engine as _; +use futures::future::BoxFuture; +use gateway_admin::model::plugins::management::{ + PluginManagementRequest, PluginManagementResponse, PluginManagementTarget, + StartPluginManagementCallback, +}; +use gateway_core::{ + error::{GatewayError, GatewayErrorKind}, + policy::ClientApiKeyId, +}; +use headers::{ETag, HeaderMapExt as _, IfNoneMatch}; +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; + +use crate::{ + admin::{ + AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, + wire::map_admin_service_error, + }, + auth::SessionState, + openai::{ + error::{gateway_error_response, protocol_error_response}, + responses::{ + ResponseAuthorization, ResponsesHttpRequest, decode_request_with_body, + execute_prepared_responses, + }, + }, +}; + +const MAXIMUM_BODY_BYTES: usize = 1024 * 1024; +const MAXIMUM_MODEL_BODY_BYTES: usize = 8 * 1024 * 1024; +// HTML/SVG 即使被直接导航打开也不能获得管理端 origin;执行入口由隔离 iframe 的消息桥承载。 +const RESOURCE_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"; + +pub(super) fn router() -> Router { + Router::new() + .route("/api/admin/plugins/extensions", get(views::)) + .route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/callback-tickets", post(start_callback::)) + .route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/api/{*path}", any(handle::)) + .route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/resources/{*path}", get(resource::)) + .route("/plugins/resources/{instance_id}/{artifact_sha256}/{revision}/{*path}", get(public_resource::)) + .route("/plugins/callbacks/{instance_id}/{artifact_sha256}/{revision}/{*path}", get(callback::)) +} + +pub(in crate::admin) fn model_router() -> Router { + Router::new().route( + "/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/models/responses", + post(model_responses).layer(DefaultBodyLimit::max(MAXIMUM_MODEL_BODY_BYTES)), + ) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ModelResponsesQuery { + client_key_id: String, +} + +#[derive(Deserialize)] +struct TargetPath { + instance_id: String, + artifact_sha256: String, + revision: u64, +} + +impl TargetPath { + fn target(&self) -> PluginManagementTarget { + PluginManagementTarget { + instance_id: self.instance_id.clone(), + artifact_sha256: self.artifact_sha256.clone(), + revision: self.revision, + } + } +} + +async fn model_responses( + _: AdminAuth, + State(state): State, + Path(path): Path, + AdminQuery(query): AdminQuery, + parts: axum::http::request::Parts, + body: Result, +) -> Result { + let body = body.map_err(|error| { + AdminError::invalid_request(error.status(), "插件模型请求正文过大或无法读取") + })?; + let target = path.target(); + state + .admin_services() + .plugin_management() + .authorize_models(&target) + .await + .map_err(map_admin_service_error)?; + let client_key_id = ClientApiKeyId::new(query.client_key_id) + .map_err(|_| AdminError::bad_request("Client Key 标识不合法"))?; + let headers = model_request_headers(&parts.headers); + let (decoded, middleware_body) = + match decode_request_with_body(&body, &headers, MAXIMUM_MODEL_BODY_BYTES) { + Ok(decoded) => decoded, + Err(error) => { + return Ok(harden_model_response(protocol_error_response( + StatusCode::BAD_REQUEST, + error.protocol_body(), + ))); + } + }; + let service = state.openai().clone(); + let execution = service.execution(); + let prepared = match execution.prepare_plugin_execution(&client_key_id).await { + Ok(prepared) => prepared, + Err(error) => return Ok(harden_model_response(gateway_error_response(&error))), + }; + let authorization: Arc = + Arc::new(PluginModelsAuthorization { state, target }); + let response = execute_prepared_responses( + service, + prepared, + ResponsesHttpRequest { + peer_address: parts + .extensions + .get::>() + .map(|info| info.0), + ingress_id: parts + .extensions + .get::() + .cloned() + .map(Extension), + headers, + decoded, + middleware_body, + maximum_body_bytes: MAXIMUM_MODEL_BODY_BYTES, + }, + Some(authorization), + ) + .await; + Ok(harden_model_response(response)) +} + +struct PluginModelsAuthorization { + state: crate::ApiState, + target: PluginManagementTarget, +} + +impl ResponseAuthorization for PluginModelsAuthorization { + fn authorize(&self) -> BoxFuture<'_, Result<(), GatewayError>> { + Box::pin(async move { + self.state + .admin_services() + .plugin_management() + .authorize_models(&self.target) + .await + .map_err(plugin_model_authorization_error) + }) + } +} + +fn plugin_model_authorization_error(error: gateway_admin::model::AdminError) -> GatewayError { + let (kind, message) = match error.kind() { + gateway_admin::model::AdminErrorKind::Unavailable + | gateway_admin::model::AdminErrorKind::Internal => ( + GatewayErrorKind::ProviderInfrastructureUnavailable, + "plugin model authorization is unavailable", + ), + _ => ( + GatewayErrorKind::PolicyDenied, + "plugin model authorization was revoked", + ), + }; + GatewayError::new(kind, message) +} + +fn model_request_headers(headers: &HeaderMap) -> HeaderMap { + let mut sanitized = HeaderMap::new(); + for name in [ + header::ACCEPT, + header::CONTENT_ENCODING, + header::CONTENT_TYPE, + header::USER_AGENT, + HeaderName::from_static("cf-connecting-ip"), + HeaderName::from_static("x-forwarded-for"), + HeaderName::from_static("x-real-ip"), + ] { + for value in headers.get_all(&name) { + sanitized.append(name.clone(), value.clone()); + } + } + sanitized +} + +fn harden_model_response(mut response: Response) -> Response { + for name in [ + header::SET_COOKIE, + HeaderName::from_static("set-cookie2"), + header::WWW_AUTHENTICATE, + header::PROXY_AUTHENTICATE, + header::AUTHORIZATION, + header::PROXY_AUTHORIZATION, + ] { + response.headers_mut().remove(name); + } + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'; sandbox"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} + +async fn start_callback( + auth: AdminAuth, + State(state): State, + Path((instance_id, artifact_sha256, revision)): Path<(String, String, u64)>, + AdminJson(command): AdminJson, +) -> Result { + let target = PluginManagementTarget { + instance_id, + artifact_sha256, + revision, + }; + let ticket = state + .admin_services() + .plugin_management() + .start_callback(&target, command, auth.context()) + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ticket), + )) +} + +async fn callback( + State(state): State, + Path(path): Path, + request: Request, +) -> Result { + // Axum 为 GET 路由自动提供 HEAD;预取或探测不能消耗一次性登录票据。 + if request.method() != axum::http::Method::GET { + return Err(AdminError::invalid_request( + StatusCode::METHOD_NOT_ALLOWED, + "插件登录回调仅允许 GET", + )); + } + let query = request.uri().query().unwrap_or_default().to_owned(); + if query.len() > 8192 { + return Err(AdminError::bad_request("插件登录回调查询过大")); + } + let mut values = + url::form_urlencoded::parse(query.as_bytes()).filter(|(name, _)| name == "state"); + let nonce = values + .next() + .map(|(_, value)| value.into_owned()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| AdminError::bad_request("插件登录回调缺少 state"))?; + if values.next().is_some() || to_bytes(request.into_body(), 0).await.is_err() { + return Err(AdminError::bad_request("插件登录回调 state 重复或包含正文")); + } + let result = state + .admin_services() + .plugin_management() + .callback( + &path.target(), + &nonce, + PluginManagementRequest { + method: "GET".into(), + path: path.path, + query, + content_type: None, + body: Vec::new(), + request_id: uuid::Uuid::now_v7().to_string(), + }, + ) + .await + .map_err(map_admin_service_error)?; + raw_response(result) +} + +#[derive(Deserialize)] +struct ResourcePath { + instance_id: String, + artifact_sha256: String, + revision: u64, + path: String, +} + +impl ResourcePath { + fn target(&self) -> PluginManagementTarget { + PluginManagementTarget { + instance_id: self.instance_id.clone(), + artifact_sha256: self.artifact_sha256.clone(), + revision: self.revision, + } + } +} + +async fn views( + _: AdminAuth, + State(state): State, +) -> Result { + let views = state + .admin_services() + .plugin_management() + .views() + .await + .map_err(map_admin_service_error)?; + Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(views))) +} + +async fn handle( + auth: AdminAuth, + State(state): State, + Path(path): Path, + request: Request, +) -> Result { + let content_type = request + .headers() + .get(header::CONTENT_TYPE) + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| AdminError::bad_request("Content-Type 无效"))?; + let target = path.target(); + let method = request.method().as_str().to_owned(); + let query = request.uri().query().unwrap_or_default().to_owned(); + let body = to_bytes(request.into_body(), MAXIMUM_BODY_BYTES) + .await + .map_err(|_| { + AdminError::invalid_request(StatusCode::PAYLOAD_TOO_LARGE, "插件管理正文过大或无法读取") + })?; + let result = state + .admin_services() + .plugin_management() + .handle( + &target, + PluginManagementRequest { + method, + path: path.path, + query, + content_type, + body: body.to_vec(), + request_id: auth.context().request_id.clone(), + }, + ) + .await + .map_err(map_admin_service_error)?; + raw_response(result) +} + +async fn resource( + _: AdminAuth, + State(state): State, + Path(path): Path, + headers: HeaderMap, +) -> Result { + load_resource(&state, path, false, &headers).await +} + +async fn public_resource( + State(state): State, + Path(path): Path, + headers: HeaderMap, +) -> Result { + // 只分派已授予 public_resource 的不可变静态字节,不启动 handler 或继承 Cookie 权限。 + load_resource(&state, path, true, &headers).await +} + +async fn load_resource( + state: &S, + path: ResourcePath, + public: bool, + headers: &HeaderMap, +) -> Result { + let result = state + .admin_services() + .plugin_management() + .resource(&path.target(), &path.path, public) + .await + .map_err(map_admin_service_error)?; + // 先复核会话、实例版本和资源授权;条件请求不能绕过撤销检查。 + if result.status != 200 || result.body.len() > MAXIMUM_BODY_BYTES { + return raw_response(result); + } + let mut digest = Sha256::new(); + digest.update(result.content_type.as_bytes()); + digest.update([0]); + digest.update(&result.body); + let etag: ETag = format!( + "\"{}\"", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest.finalize()) + ) + .parse() + .map_err(|_| AdminError::bad_gateway())?; + let not_modified = headers + .typed_get::() + .is_some_and(|condition| !condition.precondition_passes(&etag)); + let mut response = raw_response(result)?; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-cache"), + ); + response.headers_mut().typed_insert(etag); + if not_modified { + *response.status_mut() = StatusCode::NOT_MODIFIED; + *response.body_mut() = Body::empty(); + } + Ok(response) +} + +fn raw_response(result: PluginManagementResponse) -> Result { + if result.body.len() > MAXIMUM_BODY_BYTES || !(200..=599).contains(&result.status) { + return Err(AdminError::bad_gateway()); + } + let content_type = + HeaderValue::from_str(&result.content_type).map_err(|_| AdminError::bad_gateway())?; + let mut response = Response::new(Body::from(Bytes::from_owner(result.body))); + *response.status_mut() = + StatusCode::from_u16(result.status).map_err(|_| AdminError::bad_gateway())?; + let headers = response.headers_mut(); + headers.insert(header::CONTENT_TYPE, content_type); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(RESOURCE_CSP), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); + Ok(response) +} diff --git a/backend/crates/gateway-api/src/admin/plugins/mod.rs b/backend/crates/gateway-api/src/admin/plugins/mod.rs new file mode 100644 index 000000000..9abf98de1 --- /dev/null +++ b/backend/crates/gateway-api/src/admin/plugins/mod.rs @@ -0,0 +1,20 @@ +mod artifacts; +mod distribution; +mod instances; +mod management; + +pub(super) use management::model_router; + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct IdRequest { + id: String, +} + +pub(super) fn router() +-> axum::Router { + artifacts::router() + .merge(distribution::router()) + .merge(instances::router()) + .merge(management::router()) +} diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index abd8c734d..ebd32e487 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -29,12 +29,18 @@ use super::{ /// 客户端模型到上游模型的全局精确映射。 pub type ModelMappings = BTreeMap; +pub type ProviderRequestProfiles = BTreeMap>; +pub type ProviderRequestProfileUpdates = + BTreeMap>>; /// 运行配置投影与设置页字段的聚合响应。 #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct RuntimeSettingsView { + pub provider_request_profiles: ProviderRequestProfiles, + /// 固定兼容字段;值始终从 provider_request_profiles 派生。 pub openai_client_profile: Option>, + /// 固定兼容字段;值始终从 provider_request_profiles 派生。 pub xai_client_profile: Option>, pub request_location_enabled: bool, pub request_location: gateway_core::account::RequestLocation, @@ -67,8 +73,12 @@ pub struct RuntimeSettingsView { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UpdateRuntimeSettingsRequest { + #[serde(default)] + pub provider_request_profiles: ProviderRequestProfileUpdates, + /// 兼容既有 wire;与泛化字段冲突时拒绝整个请求。 #[serde(default, deserialize_with = "deserialize_profile_update")] pub openai_client_profile: Option>, + /// 兼容既有 wire;与泛化字段冲突时拒绝整个请求。 #[serde(default, deserialize_with = "deserialize_profile_update")] pub xai_client_profile: Option>, pub request_location_enabled: bool, @@ -179,13 +189,13 @@ impl UpdateRuntimeSettingsRequest { fn into_command(self) -> Result { self.validate()?; + let request_profile_updates = normalize_request_profile_updates( + self.provider_request_profiles, + self.openai_client_profile, + self.xai_client_profile, + )?; Ok(ReplaceRuntimeSettings { - openai_client_profile: self - .openai_client_profile - .map(gateway_core::account::OpaqueProviderData::new), - xai_client_profile: self - .xai_client_profile - .map(gateway_core::account::OpaqueProviderData::new), + request_profile_updates, request_location_enabled: self.request_location_enabled, request_location: self .request_location @@ -226,13 +236,15 @@ impl UpdateRuntimeSettingsRequest { impl From for RuntimeSettingsView { fn from(settings: RuntimeSettings) -> Self { + let provider_request_profiles = settings + .request_profiles + .into_iter() + .map(|(provider, profile)| (provider.as_str().to_owned(), profile.into_inner())) + .collect::(); Self { - openai_client_profile: settings - .openai_client_profile - .map(gateway_core::account::OpaqueProviderData::into_inner), - xai_client_profile: settings - .xai_client_profile - .map(gateway_core::account::OpaqueProviderData::into_inner), + openai_client_profile: provider_request_profiles.get("openai").cloned(), + xai_client_profile: provider_request_profiles.get("xai").cloned(), + provider_request_profiles, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, model_mappings: wire_model_mappings(settings.model_mappings), @@ -291,6 +303,12 @@ pub struct DeletedAdminApiKey { pub message: &'static str, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClientProfileProvidersView { + providers: Vec, +} + #[derive(Debug, Clone, Copy, Default, Deserialize)] #[serde(default, deny_unknown_fields)] struct ClientDownloadsQuery { @@ -368,6 +386,10 @@ where ) .route("/api/admin/settings/pricing/sync", post(sync_pricing::)) .route("/api/admin/settings", get(settings::)) + .route( + "/api/admin/settings/client-profiles", + get(client_profile_providers::), + ) .route( "/api/admin/settings/client-profiles/{provider}", get(client_profile_options::), @@ -719,6 +741,7 @@ fn map_wire_error(error: WireValidationError) -> AdminError { "accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(), "accountAutoFreezeDurationSeconds" => "账号自动冻结时长应为 300~604800 秒".to_owned(), "accountAutoFreezeProbeModel" => "探测模型格式不合法".to_owned(), + "providerRequestProfiles" => "Provider 请求画像格式不合法或字段冲突".to_owned(), "minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(), "minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(), field => format!("{field} 字段不合法"), @@ -730,6 +753,43 @@ fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError { map_admin_service_error(error) } +fn normalize_request_profile_updates( + profiles: ProviderRequestProfileUpdates, + openai: Option>, + xai: Option>, +) -> Result { + if profiles.len() > 256 { + return Err(WireValidationError::new("providerRequestProfiles")); + } + let mut normalized = profiles + .into_iter() + .map(|(provider, profile)| { + let provider = gateway_core::routing::ProviderKind::new(provider) + .map_err(|_| WireValidationError::new("providerRequestProfiles"))?; + Ok(( + provider, + profile.map(gateway_core::account::OpaqueProviderData::new), + )) + }) + .collect::>()?; + for (provider, profile) in [("openai", openai), ("xai", xai)] { + let Some(profile) = profile else { + continue; + }; + let provider = gateway_core::routing::ProviderKind::new(provider) + .expect("static Provider kind is valid"); + let profile = gateway_core::account::OpaqueProviderData::new(profile); + if normalized + .get(&provider) + .is_some_and(|current| current.as_ref() != Some(&profile)) + { + return Err(WireValidationError::new("providerRequestProfiles")); + } + normalized.insert(provider, Some(profile)); + } + Ok(normalized) +} + // 字段省略时保留已有配置;显式 null 不能清空唯一的通用默认。 fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>( deserializer: D, @@ -737,6 +797,28 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>( serde_json::Map::::deserialize(deserializer).map(Some) } +async fn client_profile_providers( + _auth: AdminAuth, + State(state): State, +) -> Result +where + S: SessionState + Send + Sync, +{ + let providers = state + .admin_services() + .settings() + .client_profile_providers() + .await + .map_err(map_service_error)? + .into_iter() + .map(|provider| provider.as_str().to_owned()) + .collect(); + Ok(AdminResponse::new( + StatusCode::OK, + AdminEnvelope::ok(ClientProfileProvidersView { providers }), + )) +} + async fn client_profile_options( _auth: AdminAuth, Path(provider): Path, diff --git a/backend/crates/gateway-api/src/admin/wire.rs b/backend/crates/gateway-api/src/admin/wire.rs index 25d7ac6ba..b72e2f27d 100644 --- a/backend/crates/gateway-api/src/admin/wire.rs +++ b/backend/crates/gateway-api/src/admin/wire.rs @@ -110,6 +110,8 @@ impl AdminErrorCode { pub const CONFLICT: Self = Self(40901); /// 登录尝试过多。 pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901); + /// 管理操作请求过于频繁。 + pub const RATE_LIMITED: Self = Self(42902); /// 设置持久化失败。 pub const SETTINGS_PERSIST: Self = Self(50000); /// 未分类内部错误。 @@ -232,6 +234,11 @@ const TOO_MANY_LOGIN_ATTEMPTS: AdminErrorSpec = AdminErrorSpec::new( AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS, "登录尝试过多,请稍后重试", ); +const RATE_LIMITED: AdminErrorSpec = AdminErrorSpec::new( + StatusCode::TOO_MANY_REQUESTS, + AdminErrorCode::RATE_LIMITED, + "请求过于频繁,请稍后重试", +); const INTERNAL: AdminErrorSpec = AdminErrorSpec::new( StatusCode::INTERNAL_SERVER_ERROR, AdminErrorCode::INTERNAL, @@ -306,6 +313,10 @@ impl AdminError { Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS) } + pub fn rate_limited() -> Self { + Self::from_spec(RATE_LIMITED) + } + pub fn forbidden() -> Self { Self::new(StatusCode::FORBIDDEN, AdminErrorCode::FORBIDDEN, "无权访问") } @@ -351,7 +362,7 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) - AdminErrorKind::Unauthorized => AdminError::session_required(), AdminErrorKind::NotFound => AdminError::not_found(error.message()), AdminErrorKind::Conflict => AdminError::conflict(error.message()), - AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(), + AdminErrorKind::RateLimited => AdminError::rate_limited(), AdminErrorKind::BadGateway => AdminError::bad_gateway(), AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(), AdminErrorKind::Unavailable => AdminError::service_unavailable(), @@ -361,7 +372,8 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) - // 认证与未知内部异常仍使用固定提示,不放行底层诊断或任意 500 消息。 if matches!( error.kind(), - AdminErrorKind::BadGateway + AdminErrorKind::RateLimited + | AdminErrorKind::BadGateway | AdminErrorKind::UpstreamResultUnknown | AdminErrorKind::Unavailable ) && !error.message().trim().is_empty() diff --git a/backend/crates/gateway-api/src/lib.rs b/backend/crates/gateway-api/src/lib.rs index dc9d9c66d..587ea52dd 100644 --- a/backend/crates/gateway-api/src/lib.rs +++ b/backend/crates/gateway-api/src/lib.rs @@ -33,6 +33,7 @@ pub mod auth; mod health; mod key_usage; pub mod openai; +mod provider; mod session_cookie; /// API-owned HTTP 与静态资源配置。 @@ -141,10 +142,18 @@ pub fn initialize( let mut router = Router::new() .route("/healthz", get(health::healthz)) .merge(openai::router::router()) + .merge(provider::router()) .merge(admin::router::()) + .merge(admin::model_router()) .merge(auth::router::()) .merge(key_usage::router::()) - .fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index))); + .fallback_service( + Router::new() + .fallback_service( + ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)), + ) + .layer(axum::middleware::map_response(static_cache_control)), + ); if !config.cors_allowed_origins.is_empty() { let origins = config .cors_allowed_origins @@ -207,6 +216,14 @@ pub fn initialize( Ok(ApiBundle { router }) } +async fn static_cache_control(mut response: axum::response::Response) -> axum::response::Response { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-cache"), + ); + response +} + /// API 初始化失败的脱敏分类。 #[derive(Debug, thiserror::Error)] pub enum ApiError { diff --git a/backend/crates/gateway-api/src/openai/auth.rs b/backend/crates/gateway-api/src/openai/auth.rs index 7bbe2bc6e..b3d465c15 100644 --- a/backend/crates/gateway-api/src/openai/auth.rs +++ b/backend/crates/gateway-api/src/openai/auth.rs @@ -4,7 +4,9 @@ use axum::{ http::{HeaderMap, header::AUTHORIZATION}, response::{IntoResponse, Response}, }; -use gateway_core::engine::execution::AuthenticatedClient; +use gateway_core::engine::{ + authentication::ClientAuthenticationRequest, execution::AuthenticatedClient, +}; use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion}; use super::{ @@ -106,12 +108,12 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu Ok(token) } -pub(crate) fn authenticate_client( +pub(crate) async fn authenticate_client( service: &OpenAiService, headers: &HeaderMap, ) -> Result { - let key = bearer_client_api_key(headers)?; - let client = service.authenticate(key)?; + let request = client_authentication_request(headers)?; + let client = service.authenticate(request).await?; if let Some(identified) = identify_codex_client(headers) { client .snapshot() @@ -121,6 +123,27 @@ pub(crate) fn authenticate_client( Ok(client) } +/// 复用数据面入口认证计划,但不记录 Key 使用事实或执行推理准入。 +pub(crate) async fn verify_client( + service: &OpenAiService, + headers: &HeaderMap, +) -> Result { + let request = client_authentication_request(headers)?; + service.verify(request).await +} + +fn client_authentication_request( + headers: &HeaderMap, +) -> Result { + let raw = headers + .get(AUTHORIZATION) + .ok_or(ClientApiKeyAuthError::MissingAuthorization)? + .to_str() + .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?; + ClientAuthenticationRequest::new(raw.to_owned()) + .map_err(|_| ClientApiKeyAuthError::MalformedAuthorization) +} + pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response { match error { ClientAccessError::Authentication(error) => { diff --git a/backend/crates/gateway-api/src/openai/endpoint.rs b/backend/crates/gateway-api/src/openai/endpoint.rs index 81390c5fb..4bf232c73 100644 --- a/backend/crates/gateway-api/src/openai/endpoint.rs +++ b/backend/crates/gateway-api/src/openai/endpoint.rs @@ -1,66 +1,134 @@ //! OpenAI Provider 原生非流式 JSON 端点的公共交付边界。 +use std::net::IpAddr; + use axum::{ body::{Body, Bytes}, - http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; -use gateway_core::engine::execution::{ExecutionSession, StartedExecution}; +use gateway_core::{ + engine::{ + execution::{AuthenticatedClient, StartedExecution}, + middleware::{MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareResponse}, + }, + error::GatewayError, + operation::Operation, +}; + +use crate::openai::middleware::{ + ExpectedBody, HttpMiddlewareInput, PendingExecution, buffered_response, error_response, + into_http_response, invoke_http_middleware, pending_execution_response, request_headers, + request_parts, +}; use gateway_core::event::{ProviderEvent, ProviderResponseHeader}; use super::{ - error::{engine_error_response, protocol_error_response}, - responses::{PendingExecution, ProtocolError, ProtocolErrorBody}, + error::{engine_error_response, gateway_error_response, protocol_error_response}, + responses::{ProtocolError, ProtocolErrorBody}, + service::OpenAiService, }; -/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。 -pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response { - let response = collect_raw_json_session(started.session).await; - super::with_model_request_id(response, &started.request_id) +/// Provider 自有端点也先冻结身份,再通过统一请求链进入原有执行与结算路径。 +pub(super) async fn provider_endpoint_response( + service: OpenAiService, + client: AuthenticatedClient, + input: HttpMiddlewareInput, + client_ip: Option, + user_agent: Option, + decode: F, +) -> Response +where + F: FnOnce(Bytes, &HeaderMap) -> Result + Send + 'static, +{ + let execution = service.execution(); + let prepared = match execution.prepare_execution(client).await { + Ok(prepared) => prepared, + Err(error) => return gateway_error_response(&error), + }; + let request_id = prepared.request_id().clone(); + let endpoint = input.endpoint.clone(); + let result = invoke_http_middleware( + execution, + prepared, + input, + Box::new(move |prepared, request| { + Box::pin(async move { + let (protocol, headers, body) = request_parts(request)?; + if protocol != "openai" { + return Err(MiddlewareError::Rejected); + } + let operation = decode(body, &headers)?; + let started = match service + .start_prepared_provider_endpoint( + prepared, operation, client_ip, user_agent, endpoint, + ) + .await + { + Ok(started) => started, + Err(error) => { + return buffered_response("openai", gateway_error_response(&error)).await; + } + }; + collect_raw_json_response(started).await + }) + }), + ) + .await; + let response = match result { + Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await, + Err(error) => error_response(error), + }; + if response.headers().contains_key("x-gateway-request-id") { + response + } else { + super::with_model_request_id(response, &request_id) + } } -async fn collect_raw_json_session(session: Box) -> Response { - let mut execution = PendingExecution::new(session); - let Some(session) = execution.session_mut() else { - return invalid_upstream_response(); +/// 只收集正文,不提前 commit;外层完成响应变换与校验后才提交 Core。 +async fn collect_raw_json_response( + started: StartedExecution, +) -> Result { + let mut execution = PendingExecution::new(started.session); + let session = execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)?; + let events = session.collect_uncommitted().await; + let transformed = events + .as_ref() + .is_ok_and(|events| events.iter().any(ProviderEvent::middleware_transformed)); + let response = match events { + Ok(events) => match raw_json_body(events) { + Some(body) => { + let status = session + .response_status_code() + .and_then(|status| StatusCode::from_u16(status).ok()) + .filter(StatusCode::is_success) + .unwrap_or(StatusCode::OK); + json_body_response(body, status, session.response_headers()) + } + None => invalid_upstream_response(), + }, + Err(error) => engine_error_response(&error), }; - let events = match session.collect_uncommitted().await { - Ok(events) => events, - Err(error) => { - let response = engine_error_response(&error); - return execution.record_response_status(response).await; - } + let response = super::with_model_request_id(response, &started.request_id); + let (parts, body) = response.into_parts(); + let framing = if parts.status.is_success() { + MiddlewareFraming::JsonDocument + } else { + MiddlewareFraming::RawBytes }; - let body = match raw_json_body(events) { - Some(body) => body, - None => { - let response = invalid_upstream_response(); - let response = execution.record_response_status(response).await; - execution.cancel_and_finalize().await; - return response; - } - }; - let status = session - .response_status_code() - .and_then(|status| StatusCode::from_u16(status).ok()) - .filter(StatusCode::is_success) - .unwrap_or(StatusCode::OK); - let response_headers = session.response_headers().to_vec(); - let response = json_body_response(body, status, &response_headers); - let Some(session) = execution.session_mut() else { - return invalid_upstream_response(); - }; - if let Err(error) = session.commit_downstream(Some(status.as_u16())).await { - execution.cancel_and_finalize().await; - let response = engine_error_response(&error); - return execution.record_response_status(response).await; - } - if !session.is_finalized() { - execution.cancel_and_finalize().await; - return invalid_upstream_response(); - } - execution.disarm(); - response + let bytes = axum::body::to_bytes(body, usize::MAX) + .await + .map_err(|_| MiddlewareError::Fault)?; + Ok(pending_execution_response( + "openai".to_owned(), + parts.status.as_u16(), + request_headers(&parts.headers), + MiddlewareFrame::new(bytes, framing, true).with_transformed(transformed), + execution, + )) } fn raw_json_body(events: Vec) -> Option { diff --git a/backend/crates/gateway-api/src/openai/images/http.rs b/backend/crates/gateway-api/src/openai/images/http.rs index 1e93ca349..919257034 100644 --- a/backend/crates/gateway-api/src/openai/images/http.rs +++ b/backend/crates/gateway-api/src/openai/images/http.rs @@ -8,16 +8,20 @@ use axum::{ http::HeaderMap, response::Response, }; +use gateway_core::engine::execution::ClientTransport; use gateway_core::error::{GatewayError, GatewayErrorKind}; -use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload}; +use gateway_core::operation::{ + ImageRequest, ImageRequestKind, Operation, OperationKind, RawJsonPayload, +}; use serde_json::Value; use crate::ApiState; +use crate::openai::middleware::{HttpMiddlewareInput, request_headers}; use crate::openai::{ auth::{authenticate_client, client_access_error_response}, - endpoint::collect_raw_json_response, - error::gateway_error_response, + endpoint::provider_endpoint_response, responses::{OpenAiRequestHeaders, request_client_context}, + router::{IMAGE_EDITS_PATH, IMAGE_GENERATIONS_PATH}, }; const OPENAI_PROTOCOL: &str = "openai"; @@ -36,7 +40,7 @@ pub(crate) async fn image_generations( headers, body, ImageRequestKind::Generation, - "/v1/images/generations", + IMAGE_GENERATIONS_PATH, ) .await } @@ -54,7 +58,7 @@ pub(crate) async fn image_edits( headers, body, ImageRequestKind::Edit, - "/v1/images/edits", + IMAGE_EDITS_PATH, ) .await } @@ -68,7 +72,7 @@ async fn handle_image_request( endpoint: &'static str, ) -> Response { let service = state.openai(); - let client = match authenticate_client(service, &headers) { + let client = match authenticate_client(service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; @@ -76,18 +80,23 @@ async fn handle_image_request( &headers, connect_info.map(|Extension(ConnectInfo(address))| address), ); - let operation = match image_operation(body, &headers, kind) { - Ok(operation) => operation, - Err(error) => return gateway_error_response(&error), - }; - let started = match service - .start_provider_endpoint(client, operation, client_ip, user_agent, endpoint) - .await - { - Ok(started) => started, - Err(error) => return gateway_error_response(&error), - }; - collect_raw_json_response(started).await + provider_endpoint_response( + service.clone(), + client, + HttpMiddlewareInput { + endpoint: endpoint.to_owned(), + protocol: OPENAI_PROTOCOL.to_owned(), + operation: Some(OperationKind::GenerateImage), + transport: ClientTransport::HttpJson, + model_hint: None, + headers: request_headers(&headers), + body, + }, + client_ip, + user_agent, + move |body, headers| image_operation(body, headers, kind), + ) + .await } fn image_operation( diff --git a/backend/crates/gateway-api/src/openai/middleware/http.rs b/backend/crates/gateway-api/src/openai/middleware/http.rs new file mode 100644 index 000000000..01d6ed057 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/middleware/http.rs @@ -0,0 +1,280 @@ +//! 未提交响应的 HTTP 交付;只验证协议边界,不解释或重算 Core 的业务事实。 + +use super::request::{decode_headers, request_headers}; +use crate::openai::error::{ + gateway_error_from_engine, gateway_error_response, openai_error_response, +}; +use axum::{ + body::Body, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse as _, Response}, +}; +use bytes::Bytes; +use futures::{StreamExt as _, future::BoxFuture}; +use gateway_core::{ + engine::middleware::{ + MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader, + MiddlewareResponse, + }, + error::GatewayError, +}; +use gateway_protocol::openai::response_header_is_forwardable; + +/// 只读目录/用量与协议错误共用的单帧响应;没有 Provider attempt 或计量副作用。 +pub(crate) async fn buffered_response( + protocol: &str, + response: Response, +) -> Result { + let (parts, body) = response.into_parts(); + let bytes = axum::body::to_bytes(body, usize::MAX) + .await + .map_err(|_| MiddlewareError::Fault)?; + let framing = if parts.status.is_success() { + MiddlewareFraming::JsonDocument + } else { + MiddlewareFraming::RawBytes + }; + Ok(MiddlewareResponse::new( + protocol.to_owned(), + parts.status.as_u16(), + request_headers(&parts.headers), + Box::new(BufferedBody(Some(MiddlewareFrame::new( + bytes, framing, true, + )))), + )) +} + +struct BufferedBody(Option); + +impl MiddlewareBody for BufferedBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async { Ok(self.0.take()) }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { drop(self) }) + } +} + +#[derive(Clone, Copy)] +pub(crate) enum ExpectedBody { + SingleJson, + SinglePayload, + Sse, +} + +impl ExpectedBody { + fn validate(self, frame: &MiddlewareFrame) -> Result<(), MiddlewareError> { + let valid = match self { + Self::SingleJson => { + frame.framing() == MiddlewareFraming::JsonDocument + && frame.terminal() + && serde_json::from_slice::(frame.bytes()).is_ok() + } + // 上游错误正文可能不是 JSON;只限定单文档交付,不改写原始错误字节。 + Self::SinglePayload => { + frame.terminal() + && (frame.framing() == MiddlewareFraming::RawBytes + || Self::SingleJson.validate(frame).is_ok()) + } + // 原生超大 SSE 帧允许按原字节片段交付;插件写入边界由 Runtime 校验。 + Self::Sse => frame.framing() == MiddlewareFraming::SseEvent, + }; + if valid { + Ok(()) + } else { + Err(MiddlewareError::InvalidState) + } + } +} + +/// 全部返回包装完成、首帧可读取后才提交原有 Core delivery barrier。 +pub(crate) async fn into_http_response( + response: MiddlewareResponse, + expected: ExpectedBody, +) -> Response { + let (protocol, status, headers, mut body, _) = response.into_parts(); + if protocol != "openai" { + return fail_before_commit(body, MiddlewareError::InvalidState).await; + } + let head = response_head(status, headers); + let (status, mut headers) = match head { + Ok(head) => head, + Err(error) => return fail_before_commit(body, error).await, + }; + let first = match body.next_frame().await { + Ok(first) => first, + Err(error) => return fail_before_commit(body, error).await, + }; + let empty = matches!(status, StatusCode::NO_CONTENT | StatusCode::NOT_MODIFIED); + let expected = if status.is_success() { + expected + } else { + ExpectedBody::SinglePayload + }; + let validation = match (&first, empty) { + (None, true) => Ok(()), + (Some(frame), false) => expected.validate(frame), + _ => Err(MiddlewareError::InvalidState), + }; + if let Err(error) = validation { + return fail_before_commit(body, error).await; + } + if let Some(frame) = &first { + // 短路响应没有下游 adapter 生成的头,传输类型仍由宿主按帧合同补齐。 + headers.entry(header::CONTENT_TYPE).or_insert_with(|| { + HeaderValue::from_static(match frame.framing() { + MiddlewareFraming::JsonDocument => "application/json", + MiddlewareFraming::SseEvent => "text/event-stream", + MiddlewareFraming::RawBytes => "application/octet-stream", + }) + }); + } + if matches!(expected, ExpectedBody::Sse) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-transform"), + ); + headers.insert("x-accel-buffering", HeaderValue::from_static("no")); + } + let exhausted = empty || !matches!(expected, ExpectedBody::Sse); + if !empty && exhausted { + match body.next_frame().await { + Ok(None) => {} + Ok(Some(_)) => return fail_before_commit(body, MiddlewareError::InvalidState).await, + Err(error) => return fail_before_commit(body, error).await, + } + } + if let Err(error) = body.commit_downstream(Some(status.as_u16())).await { + return fail_before_commit(body, error).await; + } + let delivery = HttpDelivery { + first, + body, + expected, + exhausted, + terminal_seen: empty, + }; + let keep_alive = matches!(expected, ExpectedBody::Sse); + let stream = Box::pin(futures::stream::try_unfold(delivery, HttpDelivery::next)); + // 固定同一条输出流;心跳不能取消并重建正在等待的 next_frame 或结算 future。 + let stream = futures::stream::unfold(stream, move |mut stream| async move { + let chunk = tokio::select! { + biased; + chunk = stream.next() => chunk?, + () = tokio::time::sleep(std::time::Duration::from_secs(15)), if keep_alive => { + Ok(Bytes::from_static(b": keep-alive\n\n")) + } + }; + Some((chunk, stream)) + }); + let mut response = Response::new(Body::from_stream(stream)); + *response.status_mut() = status; + *response.headers_mut() = headers; + response +} + +struct HttpDelivery { + first: Option, + body: Box, + expected: ExpectedBody, + exhausted: bool, + terminal_seen: bool, +} + +impl HttpDelivery { + async fn next(mut self) -> Result, MiddlewareError> { + let frame = match self.first.take() { + Some(frame) => Some(frame), + None if self.exhausted => None, + None => match self.body.next_frame().await { + Ok(frame) => frame, + Err(error) => { + self.body.close().await; + return Err(error); + } + }, + }; + if let Some(frame) = frame { + if self.terminal_seen || self.expected.validate(&frame).is_err() { + self.body.close().await; + return Err(MiddlewareError::InvalidState); + } + self.terminal_seen = frame.terminal(); + Ok(Some((frame.into_bytes(), self))) + } else { + self.body.close().await; + if self.terminal_seen { + Ok(None) + } else { + Err(MiddlewareError::InvalidState) + } + } + } +} + +fn response_head( + status: u16, + headers: Vec, +) -> Result<(StatusCode, HeaderMap), MiddlewareError> { + let status = StatusCode::from_u16(status).map_err(|_| MiddlewareError::InvalidState)?; + if status.is_informational() { + return Err(MiddlewareError::InvalidState); + } + let mut result = decode_headers(headers)?; + let connection_options = result + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>(); + // Content-Type 由协议 adapter 生成;其余字段在最终交付边界统一剥离认证与传输信息。 + let blocked = result + .keys() + .filter(|name| { + *name != header::CONTENT_TYPE + && !response_header_is_forwardable(name.as_str(), &connection_options) + }) + .cloned() + .collect::>(); + for name in blocked { + result.remove(name); + } + Ok((status, result)) +} + +async fn fail_before_commit(mut body: Box, error: MiddlewareError) -> Response { + let response = error_response(error); + let _ = body.record_client_status(response.status().as_u16()).await; + body.close().await; + response +} + +pub(crate) fn error_response(error: MiddlewareError) -> Response { + match error { + MiddlewareError::Gateway(error) => gateway_error_response(&error), + MiddlewareError::Engine(error) => { + gateway_error_response(&gateway_error_from_engine(&error)) + } + MiddlewareError::Provider(error) => { + gateway_error_response(&GatewayError::from_provider(&error)) + } + MiddlewareError::Rejected => openai_error_response( + StatusCode::FORBIDDEN, + "Request rejected by middleware", + "invalid_request_error", + "middleware_rejected", + ) + .into_response(), + MiddlewareError::Fault | MiddlewareError::InvalidState => openai_error_response( + StatusCode::BAD_GATEWAY, + "Request middleware failed", + "server_error", + "middleware_failed", + ) + .into_response(), + } +} diff --git a/backend/crates/gateway-api/src/openai/middleware/mod.rs b/backend/crates/gateway-api/src/openai/middleware/mod.rs new file mode 100644 index 000000000..bfc2efef2 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/middleware/mod.rs @@ -0,0 +1,11 @@ +//! OpenAI 请求洋葱链与最终传输交付;不拥有 Provider 选择或计量事实。 + +mod http; +mod request; +mod session; + +pub(crate) use http::{ExpectedBody, buffered_response, error_response, into_http_response}; +pub(crate) use request::{ + HttpMiddlewareInput, invoke_http_middleware, query_response, request_headers, request_parts, +}; +pub(crate) use session::{PendingExecution, finalize_session, pending_execution_response}; diff --git a/backend/crates/gateway-api/src/openai/middleware/request.rs b/backend/crates/gateway-api/src/openai/middleware/request.rs new file mode 100644 index 000000000..3be33c99c --- /dev/null +++ b/backend/crates/gateway-api/src/openai/middleware/request.rs @@ -0,0 +1,229 @@ +//! 请求身份冻结、单次 next 与调用代次保活;HTTP 编码在 http 子模块中完成。 + +use crate::openai::error::gateway_error_response; +use axum::{ + http::{HeaderMap, HeaderName, HeaderValue}, + response::Response, +}; +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_core::{ + engine::{ + execution::{ + AuthenticatedClient, ClientTransport, ExecutionService, PreparedRootExecution, + }, + extensions::ExtensionCallScope, + middleware::{ + FrozenMiddlewarePlan, MiddlewareBody, MiddlewareError, MiddlewareFrame, + MiddlewareHeader, MiddlewareMount, MiddlewareNext, MiddlewareRequest, + MiddlewareResponse, MiddlewareTarget, + }, + }, + lifecycle::CancellationToken, + operation::OperationKind, +}; +use std::{future::Future, sync::Arc}; + +use super::http::{ExpectedBody, buffered_response, error_response, into_http_response}; + +pub(crate) struct HttpMiddlewareInput { + pub endpoint: String, + pub protocol: String, + pub operation: Option, + pub transport: ClientTransport, + pub model_hint: Option, + pub headers: Vec, + pub body: Bytes, +} + +impl HttpMiddlewareInput { + pub(crate) fn query(endpoint: String, model_hint: Option, headers: &HeaderMap) -> Self { + Self { + endpoint, + protocol: "openai".to_owned(), + operation: None, + transport: ClientTransport::HttpJson, + model_hint, + headers: request_headers(headers), + body: Bytes::new(), + } + } +} + +/// 已鉴权只读查询共用入口;不重新鉴权、计量或创建 Provider attempt。 +pub(crate) async fn query_response( + execution: Arc, + client: AuthenticatedClient, + input: HttpMiddlewareInput, + terminal: F, +) -> Response +where + F: FnOnce(AuthenticatedClient) -> Fut + Send + 'static, + Fut: Future + Send + 'static, +{ + let prepared = match execution.prepare_verified_execution(client) { + Ok(prepared) => prepared, + Err(error) => return gateway_error_response(&error), + }; + let protocol = input.protocol.clone(); + let result = invoke_http_middleware( + execution, + prepared, + input, + Box::new(move |prepared, request| { + Box::pin(async move { + if request.protocol() != protocol || !request.body().is_empty() { + return Err(MiddlewareError::Rejected); + } + buffered_response(&protocol, terminal(prepared.client().clone()).await).await + }) + }), + ) + .await; + match result { + Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await, + Err(error) => error_response(error), + } +} + +pub(crate) type HttpMiddlewareTerminal = Box< + dyn FnOnce( + PreparedRootExecution, + MiddlewareRequest, + ) -> BoxFuture<'static, Result> + + Send, +>; + +pub(crate) async fn invoke_http_middleware( + execution: Arc, + prepared: PreparedRootExecution, + input: HttpMiddlewareInput, + terminal: HttpMiddlewareTerminal, +) -> Result { + let plan = execution.middleware_plan(&prepared); + let lifetime = RequestLifetime { + cancellation: prepared.cancellation(), + _plan: plan.clone(), + }; + let context = prepared.middleware_context( + MiddlewareTarget { + request_id: prepared.request_id().clone(), + mount: MiddlewareMount::Request, + attempt_index: None, + operation: input.operation, + endpoint: input.endpoint, + transport: input.transport, + provider: None, + model: input.model_hint, + account_id: None, + }, + ExtensionCallScope::default(), + ); + let request = MiddlewareRequest::new(input.protocol, input.headers, input.body); + let next = Box::new(HttpNext { prepared, terminal }); + let response = match plan { + Some(plan) => plan.handle(context, request, next).await?, + None => next.run(request).await?, + }; + let (protocol, status, headers, body, envelope) = response.into_parts(); + let mut response = MiddlewareResponse::new( + protocol, + status, + headers, + Box::new(RequestBody { body, lifetime }), + ); + if let Some(envelope) = envelope { + response = response.with_envelope(envelope); + } + Ok(response) +} + +struct HttpNext { + prepared: PreparedRootExecution, + terminal: HttpMiddlewareTerminal, +} + +impl MiddlewareNext for HttpNext { + fn run( + self: Box, + request: MiddlewareRequest, + ) -> BoxFuture<'static, Result> { + (self.terminal)(self.prepared, request) + } +} + +struct RequestLifetime { + cancellation: CancellationToken, + _plan: Option, +} + +impl Drop for RequestLifetime { + fn drop(&mut self) { + // 包括还在 next 中等待、客户端断开和提前关流;不启动第二份账本终结逻辑。 + self.cancellation.cancel(); + } +} + +struct RequestBody { + body: Box, + lifetime: RequestLifetime, +} + +impl MiddlewareBody for RequestBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + self.body.next_frame() + } + + fn commit_downstream( + &mut self, + status: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + self.body.commit_downstream(status) + } + + fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), MiddlewareError>> { + self.body.record_client_status(status) + } + + fn is_finalized(&self) -> bool { + self.body.is_finalized() + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { + let Self { body, lifetime } = *self; + body.close().await; + drop(lifetime); + }) + } +} + +/// 保留多值与非 UTF-8 header;是否向插件公开由 Runtime 按授权投影。 +pub(crate) fn request_headers(headers: &HeaderMap) -> Vec { + headers + .iter() + .map(|(name, value)| { + MiddlewareHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes())) + }) + .collect() +} + +/// 终点重新解码插件改写的请求;冻结认证身份不从这些 header 再次取得。 +pub(crate) fn request_parts( + request: MiddlewareRequest, +) -> Result<(String, HeaderMap, Bytes), MiddlewareError> { + let (protocol, headers, body) = request.into_parts(); + Ok((protocol, decode_headers(headers)?, body)) +} + +pub(super) fn decode_headers(headers: Vec) -> Result { + let mut result = HeaderMap::new(); + for header in headers { + let name = HeaderName::from_bytes(header.name().as_bytes()) + .map_err(|_| MiddlewareError::InvalidState)?; + let value = + HeaderValue::from_bytes(header.value()).map_err(|_| MiddlewareError::InvalidState)?; + result.append(name, value); + } + Ok(result) +} diff --git a/backend/crates/gateway-api/src/openai/middleware/session.rs b/backend/crates/gateway-api/src/openai/middleware/session.rs new file mode 100644 index 000000000..7d1ea484b --- /dev/null +++ b/backend/crates/gateway-api/src/openai/middleware/session.rs @@ -0,0 +1,159 @@ +//! 非流式响应在洋葱链返回后才提交;既有执行守卫保管唯一终结责任。 + +use futures::future::BoxFuture; +use gateway_core::engine::middleware::{ + MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareHeader, MiddlewareResponse, +}; + +use gateway_core::engine::execution::ExecutionSession; + +pub(crate) fn pending_execution_response( + protocol: String, + status: u16, + headers: Vec, + frame: MiddlewareFrame, + execution: PendingExecution, +) -> MiddlewareResponse { + MiddlewareResponse::new( + protocol, + status, + headers, + Box::new(BufferedExecutionBody { + frame: Some(frame), + execution, + commit_execution: (200..300).contains(&status), + }), + ) +} + +struct BufferedExecutionBody { + frame: Option, + execution: PendingExecution, + // 原生结果决定是否提交执行;外层改写状态不能把失败转成 Core 成功。 + commit_execution: bool, +} + +impl MiddlewareBody for BufferedExecutionBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + // EOF 只表示单文档已读完;外层仍须验证正文并调用 commit,不能在此取消会话。 + Box::pin(async { Ok(self.frame.take()) }) + } + + fn commit_downstream( + &mut self, + status: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + if !self.commit_execution { + if let (Some(status), Some(session)) = (status, self.execution.session_mut()) { + session.record_client_status(status).await?; + } + self.execution.cancel_and_finalize().await; + return Ok(()); + } + let Some(session) = self.execution.session_mut() else { + return Ok(()); + }; + if session.is_finalized() { + if let Some(status) = status { + session.record_client_status(status).await?; + } + } else { + session.commit_downstream(status).await?; + if !session.is_finalized() { + return Err(MiddlewareError::InvalidState); + } + } + self.execution.disarm(); + Ok(()) + }) + } + + fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + if let Some(session) = self.execution.session_mut() { + session.record_client_status(status).await?; + } + Ok(()) + }) + } + + fn is_finalized(&self) -> bool { + self.execution.is_finalized() + } + + fn close(mut self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { + self.execution.cancel_and_finalize().await; + }) + } +} + +pub(crate) async fn finalize_session(session: Box) { + if session.is_finalized() { + return; + } + session.cancel(); + session.detach_finalize().await; +} + +pub(crate) struct PendingExecution { + session: Option>, +} + +impl PendingExecution { + pub(crate) fn new(session: Box) -> Self { + Self { + session: Some(session), + } + } + + pub(crate) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> { + self.session.as_deref_mut() + } + + pub(crate) fn is_finalized(&self) -> bool { + self.session + .as_ref() + .is_none_or(|session| session.is_finalized()) + } + + pub(crate) async fn cancel_and_finalize(&mut self) { + if let Some(session) = self.session.take() { + finalize_session(session).await; + } + } + + pub(crate) async fn record_response_status( + &mut self, + response: axum::response::Response, + ) -> axum::response::Response { + if let Some(session) = self.session.as_mut() { + let _ = session + .record_client_status(response.status().as_u16()) + .await; + } + response + } + + pub(crate) fn disarm(&mut self) { + self.session = None; + } +} + +impl Drop for PendingExecution { + fn drop(&mut self) { + let Some(session) = self.session.take() else { + return; + }; + session.trace().record( + "downstream.cancelled", + serde_json::json!({"reason": "response_guard_dropped"}), + ); + session.cancel(); + let finalize = session.detach_finalize(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + drop(runtime.spawn(finalize)); + } + } +} diff --git a/backend/crates/gateway-api/src/openai/mod.rs b/backend/crates/gateway-api/src/openai/mod.rs index 8e529a56f..f50ca8146 100644 --- a/backend/crates/gateway-api/src/openai/mod.rs +++ b/backend/crates/gateway-api/src/openai/mod.rs @@ -4,6 +4,7 @@ pub mod auth; mod endpoint; pub mod error; pub mod images; +pub(crate) mod middleware; pub mod models; pub mod responses; pub mod router; @@ -12,7 +13,7 @@ pub(crate) mod service; mod usage; /// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。 -fn with_model_request_id( +pub(crate) fn with_model_request_id( mut response: axum::response::Response, request_id: &gateway_core::engine::ModelRequestId, ) -> axum::response::Response { diff --git a/backend/crates/gateway-api/src/openai/models.rs b/backend/crates/gateway-api/src/openai/models.rs index 300f4d0be..f8c9c41c0 100644 --- a/backend/crates/gateway-api/src/openai/models.rs +++ b/backend/crates/gateway-api/src/openai/models.rs @@ -6,11 +6,17 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; -use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile}; +use gateway_core::{ + engine::execution::AuthenticatedClient, + routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile}, +}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::ApiState; +use crate::{ + ApiState, + openai::middleware::{self, HttpMiddlewareInput}, +}; use super::{ auth::{authenticate_client, client_access_error_response}, @@ -32,11 +38,27 @@ pub(crate) async fn models( headers: HeaderMap, ) -> Response { let service = state.openai(); - let client = match authenticate_client(service, &headers) { + let client = match authenticate_client(service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; + middleware::query_response( + service.execution(), + client, + HttpMiddlewareInput::query(super::router::MODELS_PATH.to_owned(), None, &headers), + move |client| models_response(state, client, query), + ) + .await +} + +async fn models_response( + state: ApiState, + client: AuthenticatedClient, + query: ModelsQuery, +) -> Response { + let service = state.openai(); + if let Some(version) = query .client_version .as_deref() @@ -123,14 +145,33 @@ pub(crate) async fn model_detail( Path(model_id): Path, ) -> Response { let service = state.openai(); - let client = match authenticate_client(service, &headers) { + let client = match authenticate_client(service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; + middleware::query_response( + service.execution(), + client, + HttpMiddlewareInput::query( + format!("{}/{model_id}", super::router::MODELS_PATH), + Some(model_id.clone()), + &headers, + ), + move |client| async move { model_detail_response(&state, &client, model_id) }, + ) + .await +} + +fn model_detail_response( + state: &ApiState, + client: &AuthenticatedClient, + model_id: String, +) -> Response { + let service = state.openai(); let Ok(public_model) = PublicModelId::new(model_id) else { return model_not_found_response().into_response(); }; - if !service.contains_public_model(&client, &public_model) { + if !service.contains_public_model(client, &public_model) { return model_not_found_response().into_response(); } diff --git a/backend/crates/gateway-api/src/openai/responses/error.rs b/backend/crates/gateway-api/src/openai/responses/error.rs index 6a0d899d8..6bd8ea6ed 100644 --- a/backend/crates/gateway-api/src/openai/responses/error.rs +++ b/backend/crates/gateway-api/src/openai/responses/error.rs @@ -176,6 +176,9 @@ pub enum ResponseEncodeError { /// 协议原生流缺少可返回的终态 response object。 #[error("responses wire stream has no terminal response")] MissingWireTerminal, + /// 缺少完整终态输出,已交付的完成项也存在缺项或冲突,不能伪造完整结果。 + #[error("responses wire stream has inconsistent output items")] + InvalidOutputItems, /// 终态 response object 无法序列化为完整 HTTP JSON 响应。 #[error("responses wire terminal serialization failed")] Serialization, diff --git a/backend/crates/gateway-api/src/openai/responses/http.rs b/backend/crates/gateway-api/src/openai/responses/http.rs index 4bf2cfa6f..ca966ba90 100644 --- a/backend/crates/gateway-api/src/openai/responses/http.rs +++ b/backend/crates/gateway-api/src/openai/responses/http.rs @@ -1,43 +1,64 @@ //! OpenAI Responses HTTP 与 SSE adapter。 use std::collections::VecDeque; -use std::convert::Infallible; +use std::future::Future; use std::net::{IpAddr, SocketAddr}; +use std::sync::{Arc, OnceLock}; use std::time::Duration; use axum::{ - body::{Body, Bytes}, + body::Bytes, extract::{Extension, State, connect_info::ConnectInfo}, http::{ HeaderMap, HeaderName, HeaderValue, StatusCode, - header::{CACHE_CONTROL, CONTENT_TYPE, USER_AGENT}, + header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, + }, + response::Response, +}; +use futures::future::BoxFuture; +use gateway_core::engine::{ + CommitRequirement, EngineError, + execution::{ClientTransport, ExecutionSession, PreparedRootExecution, StartedExecution}, + middleware::{ + MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader, + MiddlewareResponse, }, - response::{IntoResponse, Response}, }; -use futures::{StreamExt, stream}; -use gateway_core::diagnostics::TraceContext; -use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution}; -use gateway_core::engine::{CommitRequirement, EngineError}; use gateway_core::error::{GatewayError, GatewayErrorKind}; use gateway_core::event::{ProviderEvent, ProviderResponseHeader}; -use gateway_core::lifecycle::ConnectionGuard; +use gateway_core::lifecycle::{CancellationToken, ConnectionGuard}; +use gateway_core::operation::OperationKind; use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_with_id}; +use tokio::time::Instant; use crate::ApiState; +use crate::openai::middleware::{ + ExpectedBody, HttpMiddlewareInput, PendingExecution, error_response, finalize_session, + into_http_response, invoke_http_middleware, pending_execution_response, request_headers, + request_parts, +}; +use crate::openai::service::OpenAiService; use crate::openai::{ auth::{authenticate_client, client_access_error_response}, error::{ engine_error_response, gateway_error_contract, gateway_error_from_engine, - gateway_error_response, protocol_error_response, runtime_unavailable_response, + gateway_error_response, protocol_error_response, }, }; use super::{ - OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError, - request::decode_request_with_headers, + OpenAiResponsesEncoder, ResponseEncodeError, + request::{DecodedResponsesRequest, decode_request_with_body, decode_request_with_headers}, + validation::{ResponseValidationFacts, ResponsesDeliveryValidator, validate_buffered_response}, }; -const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15); +const OPENAI_PROTOCOL: &str = "openai"; +const AUTHORIZATION_RECHECK_INTERVAL: Duration = Duration::from_secs(1); + +/// 管理页面模型桥在等待首帧和交付响应期间复核不可变插件目标。 +pub(crate) trait ResponseAuthorization: Send + Sync { + fn authorize(&self) -> BoxFuture<'_, Result<(), GatewayError>>; +} /// `POST /v1/responses`。 pub(crate) async fn responses( @@ -47,71 +68,302 @@ pub(crate) async fn responses( headers: HeaderMap, body: Bytes, ) -> Response { - let service = state.openai(); - let client = match authenticate_client(service, &headers) { + let service = state.openai().clone(); + let client = match authenticate_client(&service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; - let decoded = match decode_request_with_headers( - &body, - &headers, - client.snapshot().responses_max_decompressed_body_bytes(), - ) { - Ok(decoded) => decoded, - Err(error) => { - return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body()); - } - }; - let (client_ip, user_agent) = request_client_context( - &headers, - connect_info.map(|Extension(ConnectInfo(address))| address), - ); - let decoded = decoded.with_client_context(client_ip, user_agent); - let streaming = decoded.metadata().stream(); - let connection_guard = if streaming { - match service.try_register_connection() { - Ok(guard) => Some(guard), - Err(_) => return runtime_unavailable_response().into_response(), - } - } else { - None - }; - let started = match service - .start_response( - client, - decoded, - if streaming { - ClientTransport::HttpSse - } else { - ClientTransport::HttpJson - }, - "/v1/responses", - ) - .await - { - Ok(started) => started, + let maximum_body_bytes = client.snapshot().responses_max_decompressed_body_bytes(); + let (decoded, middleware_body) = + match decode_request_with_body(&body, &headers, maximum_body_bytes) { + Ok(decoded) => decoded, + Err(error) => { + return super::super::error::protocol_error_response( + StatusCode::BAD_REQUEST, + error.protocol_body(), + ); + } + }; + let execution = service.execution(); + let prepared = match execution.prepare_execution(client).await { + Ok(prepared) => prepared, Err(error) => return gateway_error_response(&error), }; - let trace = started.session.trace(); - trace.headers("client.request", serde_json::json!({ - "ingressRequestId": ingress_id.as_ref().and_then(|Extension(id)| id.header_value().to_str().ok()), - }), headers.iter().map(|(name, value)| (name.as_str(), value.as_bytes()))); - trace.capture("client.request.body", &body); - let request_id = started.request_id; - let StartedExecution { - stream, session, .. - } = started; - let response = if stream { - stream_execution_response(session, connection_guard).await + execute_prepared_responses( + service, + prepared, + ResponsesHttpRequest { + peer_address: connect_info.map(|Extension(ConnectInfo(address))| address), + ingress_id, + headers, + decoded, + middleware_body, + maximum_body_bytes, + }, + None, + ) + .await +} + +/// 两个 Responses 入口共享解码后的 HTTP 交付上下文,不携带认证策略。 +pub(crate) struct ResponsesHttpRequest { + pub peer_address: Option, + pub ingress_id: Option>, + pub headers: HeaderMap, + pub decoded: DecodedResponsesRequest, + pub middleware_body: Bytes, + pub maximum_body_bytes: usize, +} + +pub(crate) async fn execute_prepared_responses( + service: OpenAiService, + prepared: PreparedRootExecution, + request: ResponsesHttpRequest, + authorization: Option>, +) -> Response { + let ResponsesHttpRequest { + peer_address, + ingress_id, + headers, + decoded, + middleware_body, + maximum_body_bytes, + } = request; + let (client_ip, user_agent) = request_client_context(&headers, peer_address); + let stream = decoded.metadata().stream(); + let transport = if stream { + ClientTransport::HttpSse } else { - drop(connection_guard); - collect_execution_response(session).await + ClientTransport::HttpJson }; - crate::openai::with_model_request_id(response, &request_id) + let model_hint = Some(decoded.metadata().requested_model().to_owned()); + let execution = service.execution(); + let request_id = prepared.request_id().clone(); + let cancellation = prepared.cancellation(); + let started_request_id = Arc::new(OnceLock::new()); + let terminal_request_id = Arc::clone(&started_request_id); + let mut middleware_headers = headers.clone(); + // 中间件正文已经由入口按上限解压;传输编码和旧长度不能与改写后的正文混用。 + middleware_headers.remove(CONTENT_ENCODING); + middleware_headers.remove(CONTENT_LENGTH); + let input = HttpMiddlewareInput { + endpoint: crate::openai::router::RESPONSES_PATH.to_owned(), + protocol: OPENAI_PROTOCOL.to_owned(), + operation: Some(OperationKind::Generate), + transport, + model_hint, + headers: request_headers(&middleware_headers), + body: middleware_body, + }; + let terminal_service = service.clone(); + let validation = ResponseValidationFacts::default(); + let terminal_validation = validation.clone(); + let middleware = invoke_http_middleware( + execution, + prepared, + input, + Box::new(move |prepared, request| { + Box::pin(async move { + let (protocol, request_headers, request_body) = request_parts(request)?; + if protocol != OPENAI_PROTOCOL { + return Err(MiddlewareError::Rejected); + } + let decoded = decode_request_with_headers( + &request_body, + &request_headers, + maximum_body_bytes, + ) + .map_err(|_| MiddlewareError::Rejected)? + .with_client_context(client_ip, user_agent); + if decoded.metadata().stream() != stream { + return Err(MiddlewareError::Rejected); + } + let connection_guard = if stream { + Some( + terminal_service + .try_register_connection() + .map_err(|_| MiddlewareError::Fault)?, + ) + } else { + None + }; + let started = terminal_service + .start_prepared_response( + prepared, + decoded, + transport, + crate::openai::router::RESPONSES_PATH, + ) + .await + .map_err(MiddlewareError::Gateway)?; + let _ = terminal_request_id.set(started.request_id.clone()); + started.session.trace().headers( + "client.request", + serde_json::json!({ + "ingressRequestId": ingress_id.as_ref().and_then(|Extension(id)| id.header_value().to_str().ok()), + }), + request_headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_bytes())), + ); + started + .session + .trace() + .capture("client.request.body", &request_body); + execution_middleware_response(started, connection_guard, terminal_validation).await + }) + }), + ); + let middleware = if let Some(authorization) = authorization.as_ref() { + match await_with_authorization(middleware, authorization, &cancellation).await { + Ok(result) => result, + Err(error) => Err(MiddlewareError::Gateway(error)), + } + } else { + middleware.await + }; + let expected = if stream { + ExpectedBody::Sse + } else { + ExpectedBody::SingleJson + }; + let response = match middleware { + Ok(response) => { + let response = if let Some(authorization) = authorization { + authorized_middleware_response(response, authorization, cancellation) + } else { + response + }; + into_http_response( + validated_middleware_response(response, validation, stream), + expected, + ) + .await + } + Err(error) => error_response(error), + }; + super::super::with_model_request_id(response, started_request_id.get().unwrap_or(&request_id)) +} + +async fn await_with_authorization( + future: impl Future, + authorization: &Arc, + cancellation: &CancellationToken, +) -> Result { + tokio::pin!(future); + loop { + tokio::select! { + output = &mut future => return Ok(output), + () = tokio::time::sleep(AUTHORIZATION_RECHECK_INTERVAL) => { + if let Err(error) = authorization.authorize().await { + cancellation.cancel(); + return Err(error); + } + } + } + } +} + +fn authorized_middleware_response( + response: MiddlewareResponse, + authorization: Arc, + cancellation: CancellationToken, +) -> MiddlewareResponse { + let (protocol, status, headers, body, envelope) = response.into_parts(); + let mut response = MiddlewareResponse::new( + protocol, + status, + headers, + Box::new(AuthorizedResponseBody { + inner: Some(body), + authorization, + cancellation, + last_authorized_at: Instant::now(), + }), + ); + if let Some(envelope) = envelope { + response = response.with_envelope(envelope); + } + response +} + +struct AuthorizedResponseBody { + inner: Option>, + authorization: Arc, + cancellation: CancellationToken, + last_authorized_at: Instant, +} + +impl AuthorizedResponseBody { + async fn revoke(&mut self, error: GatewayError) -> MiddlewareError { + self.cancellation.cancel(); + if let Some(inner) = self.inner.take() { + inner.close().await; + } + MiddlewareError::Gateway(error) + } +} + +impl MiddlewareBody for AuthorizedResponseBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + loop { + let elapsed = self.last_authorized_at.elapsed(); + if elapsed >= AUTHORIZATION_RECHECK_INTERVAL { + if let Err(error) = self.authorization.authorize().await { + return Err(self.revoke(error).await); + } + self.last_authorized_at = Instant::now(); + continue; + } + let Some(inner) = self.inner.as_mut() else { + return Ok(None); + }; + let wait = AUTHORIZATION_RECHECK_INTERVAL - elapsed; + tokio::select! { + frame = inner.next_frame() => return frame, + () = tokio::time::sleep(wait) => {} + } + } + }) + } + + fn commit_downstream( + &mut self, + client_status_code: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + let Some(inner) = self.inner.as_mut() else { + return Box::pin(async { Err(MiddlewareError::InvalidState) }); + }; + inner.commit_downstream(client_status_code) + } + + fn record_client_status( + &mut self, + client_status_code: u16, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + let Some(inner) = self.inner.as_mut() else { + return Box::pin(async { Ok(()) }); + }; + inner.record_client_status(client_status_code) + } + + fn is_finalized(&self) -> bool { + self.inner.as_ref().is_none_or(|inner| inner.is_finalized()) + } + + fn close(mut self: Box) -> BoxFuture<'static, ()> { + let inner = self.inner.take(); + Box::pin(async move { + if let Some(inner) = inner { + inner.close().await; + } + }) + } } /// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。 -pub(in crate::openai) fn request_client_context( +pub(crate) fn request_client_context( headers: &HeaderMap, peer_address: Option, ) -> (Option, Option) { @@ -159,96 +411,331 @@ const fn is_private_or_loopback(address: IpAddr) -> bool { } } -/// 编码完整 canonical event 集合,并在完整 JSON 成功后提交下游。 -pub async fn collect_execution_response(session: Box) -> Response { +async fn execution_middleware_response( + started: StartedExecution, + connection_guard: Option>, + validation: ResponseValidationFacts, +) -> Result { + let StartedExecution { + stream, session, .. + } = started; + if stream { + streaming_execution_middleware_response(session, connection_guard, validation).await + } else { + drop(connection_guard); + buffered_execution_middleware_response(session, validation).await + } +} + +async fn buffered_execution_middleware_response( + session: Box, + validation: ResponseValidationFacts, +) -> Result { let mut execution = PendingExecution::new(session); - let Some(session) = execution.session_mut() else { - return internal_gateway_response("gateway response session is unavailable"); - }; - let events = match session.collect_uncommitted().await { + let collected = execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .collect_uncommitted() + .await; + let events = match collected { Ok(events) => events, Err(error) => { - let response_headers = session.response_headers().to_vec(); - let response = engine_error_response_with_headers(&error, &response_headers); - return execution.record_response_status(response).await; + let headers = execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .response_headers() + .to_vec(); + return guarded_engine_error_response(execution, &error, &headers).await; } }; - + let transformed = events.iter().any(ProviderEvent::middleware_transformed); let encoded = match encode_collected_events(&events) { - Ok(encoded) => encoded, + Ok(encoded) => Bytes::from(encoded), Err(error) => { let response = protocol_error_response(StatusCode::INTERNAL_SERVER_ERROR, error.protocol_body()); - let response = execution.record_response_status(response).await; - execution.cancel_and_finalize().await; - return response; + return guarded_http_response(execution, response).await; } }; - let response_headers = session.response_headers().to_vec(); + validation.observe_buffered(&events, &encoded); + let session = execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)?; session.trace().record( "downstream.encoded", serde_json::json!({"transport": "http_json", "bytes": encoded.len()}), ); session.trace().dump("downstream.body", &encoded); - let response = json_body_response(encoded, &response_headers); + let headers = response_headers(session.response_headers(), false); + Ok(pending_execution_response( + OPENAI_PROTOCOL.to_owned(), + StatusCode::OK.as_u16(), + headers, + MiddlewareFrame::new(encoded, MiddlewareFraming::JsonDocument, true) + .with_transformed(transformed), + execution, + )) +} - let Some(session) = execution.session_mut() else { - return internal_gateway_response("gateway response session is unavailable"); - }; - if let Err(error) = session - .commit_downstream(Some(StatusCode::OK.as_u16())) - .await +async fn streaming_execution_middleware_response( + session: Box, + connection_guard: Option>, + validation: ResponseValidationFacts, +) -> Result { + let mut body = StreamingExecutionBody::new(session, connection_guard, validation); + if let Err(error) = body.prime().await { + let headers = body.response_headers().to_vec(); + let execution = body.take_pending_execution()?; + return match error { + MiddlewareError::Engine(error) => { + guarded_engine_error_response(execution, &error, &headers).await + } + error => guarded_http_response(execution, error_response(error)).await, + }; + } + let headers = response_headers(body.response_headers(), true); + Ok(MiddlewareResponse::new( + OPENAI_PROTOCOL.to_owned(), + StatusCode::OK.as_u16(), + headers, + Box::new(body), + )) +} + +/// 直接驱动测试 session 的非流式交付;生产入口通过同一 MiddlewareResponse 路径。 +pub async fn collect_execution_response(session: Box) -> Response { + match buffered_execution_middleware_response(session, ResponseValidationFacts::default()).await { - execution.cancel_and_finalize().await; - let response = engine_error_response_with_headers(&error, &response_headers); - return execution.record_response_status(response).await; + Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await, + Err(error) => error_response(error), } - if !session.is_finalized() { - execution.cancel_and_finalize().await; - return internal_gateway_response("gateway response was not finalized after commit"); +} + +/// 直接驱动测试 session 的 SSE 交付;生产入口通过同一 MiddlewareResponse 路径。 +pub async fn stream_execution_response( + session: Box, + connection_guard: Option>, +) -> Response { + match streaming_execution_middleware_response( + session, + connection_guard, + ResponseValidationFacts::default(), + ) + .await + { + Ok(response) => into_http_response(response, ExpectedBody::Sse).await, + Err(error) => error_response(error), + } +} + +fn validated_middleware_response( + response: MiddlewareResponse, + facts: ResponseValidationFacts, + streaming: bool, +) -> MiddlewareResponse { + if !(200..300).contains(&response.status_code()) { + // 非成功响应沿用通用 SinglePayload 边界,不能套用成功 Responses 终态状态机。 + return response; + } + let (protocol, status, headers, body, envelope) = response.into_parts(); + let mut response = MiddlewareResponse::new( + protocol, + status, + headers, + Box::new(ValidatedResponsesBody { + inner: Some(body), + facts, + streaming, + validator: ResponsesDeliveryValidator::default(), + pending: VecDeque::new(), + committed: false, + }), + ); + if let Some(envelope) = envelope { + response = response.with_envelope(envelope); } - execution.disarm(); response } -#[derive(Debug)] -enum BufferedResponseEncodeError { - Canonical(ResponseEncodeError), - Json, +struct ValidatedResponsesBody { + inner: Option>, + facts: ResponseValidationFacts, + streaming: bool, + validator: ResponsesDeliveryValidator, + pending: VecDeque, + committed: bool, } -impl BufferedResponseEncodeError { - fn protocol_body(&self) -> ProtocolErrorBody { - match self { - Self::Canonical(error) => error.protocol_body(), - Self::Json => ResponseEncodeError::Serialization.protocol_body(), +impl ValidatedResponsesBody { + async fn fail_stream(&mut self) -> Option { + if let Some(inner) = self.inner.take() { + inner.close().await; } + let response_id = self + .validator + .response_id() + .map(str::to_owned) + .or_else(|| self.facts.response_id()); + self.pending.push_back(MiddlewareFrame::new( + Bytes::from(response_failed_sse_event_with_id( + response_id.as_deref(), + "server_error", + "middleware_failed", + "Response middleware returned an invalid stream.", + )), + MiddlewareFraming::SseEvent, + false, + )); + self.pending.push_back(done_frame()); + self.pending.pop_front() } } -fn encode_collected_events( - events: &[ProviderEvent], -) -> Result, BufferedResponseEncodeError> { - let mut encoder = OpenAiResponsesEncoder::new(); - for event in events { - encoder.push_sse(event); +impl MiddlewareBody for ValidatedResponsesBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + if let Some(frame) = self.pending.pop_front() { + return Ok(Some(frame)); + } + let Some(inner) = self.inner.as_mut() else { + return Ok(None); + }; + let frame = match inner.next_frame().await { + Ok(frame) => frame, + Err(error) if self.streaming && self.committed => { + let _ = error; + return Ok(self.fail_stream().await); + } + Err(error) => return Err(error), + }; + let Some(frame) = frame else { + if self.streaming && self.validator.finish_delivery().is_err() { + if self.committed { + return Ok(self.fail_stream().await); + } + return Err(MiddlewareError::InvalidState); + } + return Ok(None); + }; + let valid = if self.streaming { + self.validator + .validate_frame(frame.bytes(), frame.transformed(), &self.facts) + } else if frame.transformed() { + validate_buffered_response(&self.facts, frame.bytes()) + } else { + Ok(()) + }; + if valid.is_err() { + if self.streaming && self.committed { + return Ok(self.fail_stream().await); + } + return Err(MiddlewareError::InvalidState); + } + Ok(Some(frame)) + }) + } + + fn commit_downstream( + &mut self, + client_status_code: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + let Some(inner) = self.inner.as_mut() else { + return Err(MiddlewareError::InvalidState); + }; + inner.commit_downstream(client_status_code).await?; + self.committed = true; + Ok(()) + }) + } + + fn record_client_status( + &mut self, + client_status_code: u16, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + match self.inner.as_mut() { + Some(inner) => inner.record_client_status(client_status_code).await, + None => Ok(()), + } + }) + } + + fn is_finalized(&self) -> bool { + self.inner.as_ref().is_none_or(|inner| inner.is_finalized()) + } + + fn close(mut self: Box) -> BoxFuture<'static, ()> { + let inner = self.inner.take(); + Box::pin(async move { + if let Some(inner) = inner { + inner.close().await; + } + }) } - let response = encoder - .finish() - .map_err(BufferedResponseEncodeError::Canonical)?; - serde_json::to_vec(&response).map_err(|_| BufferedResponseEncodeError::Json) } -fn json_body_response(encoded: Vec, response_headers: &[ProviderResponseHeader]) -> Response { - let mut response = Response::new(Body::from(encoded)); - *response.status_mut() = StatusCode::OK; +fn encode_collected_events(events: &[ProviderEvent]) -> Result, ResponseEncodeError> { + let response = super::response::collect_response(events)?; + serde_json::to_vec(&response).map_err(|_| ResponseEncodeError::Serialization) +} + +async fn guarded_engine_error_response( + execution: PendingExecution, + error: &EngineError, + response_headers: &[ProviderResponseHeader], +) -> Result { + guarded_http_response( + execution, + engine_error_response_with_headers(error, response_headers), + ) + .await +} + +async fn guarded_http_response( + execution: PendingExecution, + response: Response, +) -> Result { + let (parts, body) = response.into_parts(); + let bytes = axum::body::to_bytes(body, usize::MAX) + .await + .map_err(|_| MiddlewareError::Fault)?; + Ok(pending_execution_response( + OPENAI_PROTOCOL.to_owned(), + parts.status.as_u16(), + request_headers(&parts.headers), + MiddlewareFrame::new(bytes, MiddlewareFraming::RawBytes, true), + execution, + )) +} + +fn engine_error_response_with_headers( + error: &EngineError, + response_headers: &[ProviderResponseHeader], +) -> Response { + let response = engine_error_response(error); + let failure_request_ids = ["x-request-id", "x-oai-request-id"] + .into_iter() + .flat_map(|name| { + response + .headers() + .get_all(name) + .iter() + .cloned() + .map(move |value| (name, value)) + }) + .collect::>(); + // 失败后仍须交付已采集的 turn state;只隔离 opening 身份,不丢弃会话状态。 + let mut response = apply_provider_response_headers(response, response_headers); + response.headers_mut().remove("x-request-id"); + response.headers_mut().remove("x-oai-request-id"); + for (name, value) in failure_request_ids { + response.headers_mut().append(name, value); + } response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - apply_response_headers(response, response_headers) } -fn apply_response_headers( +fn apply_provider_response_headers( mut response: Response, response_headers: &[ProviderResponseHeader], ) -> Response { @@ -268,406 +755,314 @@ fn apply_response_headers( response } -fn engine_error_response_with_headers( - error: &EngineError, - response_headers: &[ProviderResponseHeader], -) -> Response { - let response = engine_error_response(error); - let failure_request_ids: Vec<_> = ["x-request-id", "x-oai-request-id"] - .into_iter() - .flat_map(|name| { - response - .headers() - .get_all(name) - .iter() - .cloned() - .map(move |value| (name, value)) - }) - .collect(); - // 失败后仍须交付已采集的 turn state;只隔离 opening 身份,不丢弃会话状态。 - let mut response = apply_response_headers(response, response_headers); - response.headers_mut().remove("x-request-id"); - response.headers_mut().remove("x-oai-request-id"); - for (name, value) in failure_request_ids { - response.headers_mut().append(name, value); +fn response_headers( + provider_headers: &[ProviderResponseHeader], + streaming: bool, +) -> Vec { + let connection_options = super::response_connection_options(provider_headers); + let mut headers = Vec::new(); + headers.push(MiddlewareHeader::new( + CONTENT_TYPE.as_str(), + Bytes::from_static(if streaming { + b"text/event-stream" + } else { + b"application/json" + }), + )); + for header in provider_headers { + if !super::response_header_is_forwardable(header.name(), &connection_options) + || header.name().eq_ignore_ascii_case(CONTENT_TYPE.as_str()) + || HeaderName::from_bytes(header.name().as_bytes()).is_err() + || HeaderValue::from_bytes(header.value()).is_err() + { + continue; + } + headers.push(MiddlewareHeader::new( + header.name(), + Bytes::copy_from_slice(header.value()), + )); } - response + headers } -/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。 -pub async fn stream_execution_response( - session: Box, - connection_guard: Option>, -) -> Response { - let mut execution = PendingExecution::new(session); - let Some(session) = execution.session_mut() else { - return internal_gateway_response("gateway response session is unavailable"); - }; - let first = match session.next_event().await { - Ok(Some(event)) => event, - Ok(None) => { - let response = gateway_error_response(&GatewayError::new( - GatewayErrorKind::Internal, - "gateway response ended before its first event", - )); - let response = execution.record_response_status(response).await; - execution.cancel_and_finalize().await; - return response; - } - Err(error) => { - let response_headers = session.response_headers().to_vec(); - let response = engine_error_response_with_headers(&error, &response_headers); - return execution.record_response_status(response).await; - } - }; - let first_requirement = first.commit_requirement(); - let first_events = first.into_provider_events(); - if first_requirement != CommitRequirement::CommitBeforeDelivery { - let response = internal_gateway_response("gateway first event did not require commit"); - let response = execution.record_response_status(response).await; - execution.cancel_and_finalize().await; - return response; - } - let mut encoder = OpenAiResponsesEncoder::new(); - let mut frames = Vec::new(); - for event in &first_events { - frames.extend(encoder.push_sse(event)); - } - if frames.is_empty() { - let response = internal_gateway_response("gateway commit batch encoded no output"); - let response = execution.record_response_status(response).await; - execution.cancel_and_finalize().await; - return response; - } - let Some(session) = execution.session_mut() else { - return internal_gateway_response("gateway response session is unavailable"); - }; - let response_headers = session.response_headers().to_vec(); - if let Err(error) = session - .commit_downstream(Some(StatusCode::OK.as_u16())) - .await - { - execution.cancel_and_finalize().await; - let response = engine_error_response_with_headers(&error, &response_headers); - return execution.record_response_status(response).await; - } - let Some(session) = execution.into_session() else { - return internal_gateway_response("gateway response session is unavailable"); - }; - let mut state = ResponsesStreamState::new(session, encoder, frames, connection_guard); - if state.encoder.is_completed() { - state.finish_completed(Vec::new()).await; - } - let output = Box::pin(stream::unfold(state, |mut state| async move { - loop { - if let Some(chunk) = state.pending.pop_front() { - state.trace.dump("downstream.chunk", &chunk); - state.handed_off_bytes += chunk.len() as u64; - return Some((Ok::(chunk), state)); - } - if state.output_finished { - return None; - } - state.advance().await; - } - })); - // 保活只轮询已固定的输出 stream;不能取消并重建 advance/next_event future, - // 否则一次心跳就可能丢失正在等待的上游事件或执行终态清理。 - let body = Body::from_stream(stream::unfold(output, |mut output| async move { - let chunk = tokio::select! { - biased; - chunk = output.next() => chunk?, - () = tokio::time::sleep(SSE_KEEPALIVE_INTERVAL) => { - Ok(Bytes::from_static(b": keep-alive\n\n")) - } - }; - Some((chunk, output)) - })); - event_stream_response(body, &response_headers) -} - -fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]) -> Response { - let mut response = apply_response_headers(Response::new(body), response_headers); - *response.status_mut() = StatusCode::OK; - response - .headers_mut() - .insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); - response.headers_mut().insert( - CACHE_CONTROL, - HeaderValue::from_static("no-cache, no-transform"), - ); - response - .headers_mut() - .insert("x-accel-buffering", HeaderValue::from_static("no")); - response -} - -fn internal_gateway_response(message: &'static str) -> Response { - gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message)) -} - -pub(in crate::openai) struct PendingExecution { - session: Option>, -} - -impl PendingExecution { - pub(in crate::openai) fn new(session: Box) -> Self { - Self { - session: Some(session), - } - } - - pub(in crate::openai) fn session_mut( - &mut self, - ) -> Option<&mut (dyn ExecutionSession + 'static)> { - self.session.as_deref_mut() - } - - pub(in crate::openai) async fn cancel_and_finalize(&mut self) { - if let Some(session) = self.session.as_mut() { - session.cancel(); - if !session.is_finalized() { - let _ = session.next_event().await; - } - } - } - - pub(in crate::openai) async fn record_response_status( - &mut self, - response: Response, - ) -> Response { - if let Some(session) = self.session.as_mut() { - let _ = session - .record_client_status(response.status().as_u16()) - .await; - } - response - } - - pub(in crate::openai) fn disarm(&mut self) { - self.session = None; - } - - fn into_session(mut self) -> Option> { - self.session.take() - } -} - -impl Drop for PendingExecution { - fn drop(&mut self) { - let Some(session) = self.session.take() else { - return; - }; - if session.is_finalized() { - return; - } - session.trace().record( - "downstream.cancelled", - serde_json::json!({"reason": "response_guard_dropped"}), - ); - session.cancel(); - detach_finalize(session); - } -} - -struct ResponsesStreamState { - trace: TraceContext, - handed_off_bytes: u64, +struct StreamingExecutionBody { session: Option>, encoder: OpenAiResponsesEncoder, - pending: VecDeque, + validation: ResponseValidationFacts, + pending: VecDeque, + awaiting_terminal_eof: bool, output_finished: bool, - execution_terminal: bool, + batch_pending: bool, + transformed_pending: bool, + committed: bool, _connection_guard: Option>, } -impl ResponsesStreamState { +impl StreamingExecutionBody { fn new( session: Box, - encoder: OpenAiResponsesEncoder, - initial_frames: Vec, connection_guard: Option>, + validation: ResponseValidationFacts, ) -> Self { Self { - trace: session.trace(), - handed_off_bytes: 0, session: Some(session), - encoder, - pending: initial_frames.into_iter().collect(), + encoder: OpenAiResponsesEncoder::new(), + validation, + pending: VecDeque::new(), + awaiting_terminal_eof: false, output_finished: false, - execution_terminal: false, + batch_pending: false, + transformed_pending: false, + committed: false, _connection_guard: connection_guard, } } - async fn advance(&mut self) { - let next = match self.session.as_mut() { - Some(session) => session.next_event().await, - None => { - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway response session is unavailable", - )); - return; - } - }; - match next { - Ok(Some(event)) => { - let requirement = event.commit_requirement(); - let events = event.into_provider_events(); - if requirement != CommitRequirement::AlreadyCommitted { - self.cancel_execution().await; - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway requested another downstream commit", - )); - } else { - for event in events { - self.push_event(event).await; - if self.output_finished { - break; - } - } - } - } - Ok(None) => { - self.execution_terminal = self - .session - .as_ref() - .is_some_and(|session| session.is_finalized()); - if self.execution_terminal { - self.pending - .push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes())); - self.output_finished = true; - } else { - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway response ended without finalizing the execution", - )); - } - } - Err(error) => { - self.execution_terminal = self - .session - .as_ref() - .is_some_and(|session| session.is_finalized()); - if self.encoder.has_wire_failure() { - self.pending - .push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes())); - self.output_finished = true; - } else { - self.finish_with_gateway_error(gateway_error_from_engine(&error)); - } - } + async fn prime(&mut self) -> Result<(), MiddlewareError> { + self.fill_pending().await?; + if self.pending.is_empty() { + return Err(MiddlewareError::InvalidState); } + Ok(()) } - async fn push_event(&mut self, event: ProviderEvent) { - let frames = self.encoder.push_sse(&event); - if self.encoder.is_completed() { - self.finish_completed(frames).await; - } else { - self.pending.extend(frames); - } + fn response_headers(&self) -> &[ProviderResponseHeader] { + self.session + .as_ref() + .map_or(&[], |session| session.response_headers()) } - async fn finish_completed(&mut self, frames: Vec) { - let next = match self.session.as_mut() { - Some(session) => session.next_event().await, - None => { - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway response session is unavailable", - )); - return; + async fn fill_pending(&mut self) -> Result<(), MiddlewareError> { + while self.pending.is_empty() && !self.output_finished { + if self.awaiting_terminal_eof { + if !self.committed { + // 终态 wire 不能在首字节前被中间件整批移除。 + return Err(MiddlewareError::InvalidState); + } + self.verify_terminal_eof().await?; + self.pending.push_back(done_frame()); + self.output_finished = true; + continue; } - }; - match next { - Ok(None) - if self - .session - .as_ref() - .is_some_and(|session| session.is_finalized()) => - { - self.pending.extend(frames); - self.execution_terminal = true; - self.pending - .push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes())); + if self.batch_pending { + if !self.committed { + self.session + .as_mut() + .ok_or(MiddlewareError::InvalidState)? + .discard_pending_delivery() + .map_err(MiddlewareError::Engine)?; + } + self.batch_pending = false; + } + let result = self + .session + .as_mut() + .ok_or(MiddlewareError::InvalidState)? + .next_event() + .await; + let coordinated = match result { + Ok(Some(event)) => event, + Ok(None) if self.committed && self.is_finalized() => { + self.pending.push_back(done_frame()); + self.output_finished = true; + continue; + } + Ok(None) => return Err(invalid_execution_stream()), + Err(error) if self.committed => { + self.push_stream_error(gateway_error_from_engine(&error)); + continue; + } + Err(error) => return Err(MiddlewareError::Engine(error)), + }; + let expected = if self.committed { + CommitRequirement::AlreadyCommitted + } else { + CommitRequirement::CommitBeforeDelivery + }; + if coordinated.commit_requirement() != expected { + return Err(invalid_execution_stream()); + } + let mut encoded = VecDeque::new(); + for event in coordinated.into_provider_events() { + self.validation.observe_event(&event); + let transformed = self.transformed_pending || event.middleware_transformed(); + let frames = self.encoder.push_sse(&event); + if frames.is_empty() { + self.transformed_pending = transformed; + continue; + } + encoded.extend(frames.into_iter().map(|bytes| { + MiddlewareFrame::new(bytes, MiddlewareFraming::SseEvent, false) + .with_transformed(transformed) + })); + self.transformed_pending = false; + } + self.awaiting_terminal_eof = + self.encoder.is_completed() || self.encoder.has_wire_failure(); + self.batch_pending = !self.committed; + if encoded.is_empty() { + if !self.committed || self.awaiting_terminal_eof { + return Err(invalid_execution_stream()); + } + continue; + } + // 已提交的流在确认 Core 终结后才交付成功终态,不能先输出 completed 再报结算失败。 + if self.awaiting_terminal_eof && self.committed { + self.verify_terminal_eof().await?; + encoded.push_back(done_frame()); self.output_finished = true; } - Ok(None) => { - self.cancel_execution().await; - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway response was not finalized after its terminal event", - )); - } - Ok(Some(_)) => { - self.cancel_execution().await; - self.finish_with_gateway_error(GatewayError::new( - GatewayErrorKind::Internal, - "gateway response continued after its terminal event", - )); - } - Err(error) => { - self.execution_terminal = self - .session - .as_ref() - .is_some_and(|session| session.is_finalized()); - self.finish_with_gateway_error(gateway_error_from_engine(&error)); - } + self.pending = encoded; + } + Ok(()) + } + + async fn verify_terminal_eof(&mut self) -> Result<(), MiddlewareError> { + let result = self + .session + .as_mut() + .ok_or(MiddlewareError::InvalidState)? + .next_event() + .await; + match result { + Ok(None) if self.is_finalized() => Ok(()), + Err(_) if self.encoder.has_wire_failure() && self.is_finalized() => Ok(()), + Ok(_) => Err(invalid_execution_stream()), + Err(error) => Err(MiddlewareError::Engine(error)), } } - async fn cancel_execution(&mut self) { - if let Some(session) = self.session.as_mut() { - session.cancel(); - if !session.is_finalized() { - let _ = session.next_event().await; - } - self.execution_terminal = session.is_finalized(); - } - } - - fn finish_with_gateway_error(&mut self, error: GatewayError) { + fn push_stream_error(&mut self, error: GatewayError) { let (_, default_type, default_code) = gateway_error_contract(error.kind()); - self.pending - .push_back(Bytes::from(response_failed_sse_event_with_id( + self.pending.push_back(MiddlewareFrame::new( + Bytes::from(response_failed_sse_event_with_id( self.encoder.response_id(), error.client_error_type().unwrap_or(default_type), super::super::error::client_error_code( error.client_error_code().unwrap_or(default_code), ), error.client_message(), - ))); - self.pending - .push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes())); + )), + MiddlewareFraming::SseEvent, + false, + )); + self.pending.push_back(done_frame()); self.output_finished = true; } + + fn take_session_for_close(&mut self) -> Option> { + self.session.take() + } + + fn take_pending_execution(&mut self) -> Result { + self.take_session_for_close() + .map(PendingExecution::new) + .ok_or(MiddlewareError::InvalidState) + } } -impl Drop for ResponsesStreamState { +impl MiddlewareBody for StreamingExecutionBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + if let Some(frame) = self.pending.pop_front() { + return Ok(Some(frame)); + } + if self.output_finished { + return Ok(None); + } + if let Err(error) = self.fill_pending().await { + if !self.committed { + return Err(error); + } + let error = match error { + MiddlewareError::Gateway(error) => error, + MiddlewareError::Engine(error) => gateway_error_from_engine(&error), + _ => GatewayError::new( + GatewayErrorKind::Internal, + "gateway response stream could not be delivered", + ), + }; + self.push_stream_error(error); + } + Ok(self.pending.pop_front()) + }) + } + + fn commit_downstream( + &mut self, + client_status_code: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + if self.committed { + return Ok(()); + } + self.session + .as_mut() + .ok_or(MiddlewareError::InvalidState)? + .commit_downstream(client_status_code) + .await + .map_err(MiddlewareError::Engine)?; + self.committed = true; + self.batch_pending = false; + Ok(()) + }) + } + + fn record_client_status( + &mut self, + client_status_code: u16, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + let Some(session) = self.session.as_mut() else { + return Ok(()); + }; + session + .record_client_status(client_status_code) + .await + .map_err(MiddlewareError::Engine) + }) + } + + fn is_finalized(&self) -> bool { + self.session + .as_ref() + .is_none_or(|session| session.is_finalized()) + } + + fn close(mut self: Box) -> BoxFuture<'static, ()> { + let session = self.take_session_for_close(); + Box::pin(async move { + if let Some(session) = session { + finalize_session(session).await; + } + }) + } +} + +impl Drop for StreamingExecutionBody { fn drop(&mut self) { - self.trace.record("downstream.body.closed", serde_json::json!({ - "handedOffBytes": self.handed_off_bytes, - "pendingChunks": self.pending.len(), - "outputFinished": self.output_finished, "executionTerminal": self.execution_terminal, - })); - if self.execution_terminal { - return; - } - let Some(session) = self.session.take() else { + let Some(session) = self.take_session_for_close() else { return; }; session.cancel(); - // Body drop 后由 Core 继续状态机清理,HTTP body 生命周期不等待它。 - detach_finalize(session); + let finalize = session.detach_finalize(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + drop(runtime.spawn(finalize)); + } } } -fn detach_finalize(session: Box) { - let finalize = session.detach_finalize(); - if let Ok(runtime) = tokio::runtime::Handle::try_current() { - drop(runtime.spawn(finalize)); - } +fn done_frame() -> MiddlewareFrame { + MiddlewareFrame::new( + Bytes::from_static(DONE_SSE_FRAME.as_bytes()), + MiddlewareFraming::SseEvent, + true, + ) +} + +fn invalid_execution_stream() -> MiddlewareError { + MiddlewareError::Gateway(GatewayError::new( + GatewayErrorKind::Internal, + "gateway response stream violated its execution lifecycle", + )) } diff --git a/backend/crates/gateway-api/src/openai/responses/mod.rs b/backend/crates/gateway-api/src/openai/responses/mod.rs index daa0aa2de..c04ca9e85 100644 --- a/backend/crates/gateway-api/src/openai/responses/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/mod.rs @@ -4,12 +4,16 @@ mod error; mod http; mod request; mod response; +mod validation; pub mod websocket; pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError}; -pub(crate) use http::responses; -pub(super) use http::{PendingExecution, request_client_context}; +pub(crate) use http::{ + ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, + request_client_context, responses, +}; pub use http::{collect_execution_response, stream_execution_response}; +pub(crate) use request::decode_request_with_body; pub use request::{ ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata, decode_request_with_headers, @@ -19,6 +23,7 @@ pub(crate) use websocket::responses_websocket; pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context}; use gateway_core::event::ProviderResponseHeader; +pub(super) use gateway_protocol::openai::response_header_is_forwardable; pub(super) fn response_connection_options(headers: &[ProviderResponseHeader]) -> Vec { headers @@ -31,49 +36,3 @@ pub(super) fn response_connection_options(headers: &[ProviderResponseHeader]) -> .map(str::to_ascii_lowercase) .collect() } - -pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[String]) -> bool { - let name = name.trim().to_ascii_lowercase(); - if connection_options - .iter() - .any(|option| option.eq_ignore_ascii_case(&name)) - || name.starts_with("sec-websocket-") - { - return false; - } - - !matches!( - name.as_str(), - "connection" - | "keep-alive" - | "proxy-connection" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - | "content-length" - | "content-type" - | "content-encoding" - | "authorization" - | "x-api-key" - | "www-authenticate" - | "authentication-info" - | "proxy-authenticate" - | "proxy-authorization" - | "proxy-authentication-info" - | "cookie" - | "cookie2" - | "set-cookie" - | "set-cookie2" - | "chatgpt-account-id" - | "chatgpt-organization-id" - | "chatgpt-org-id" - | "chatgpt-project-id" - | "openai-organization" - | "openai-project" - | "x-openai-organization" - | "x-openai-project" - | "x-codex-installation-id" - | "x-codex-turn-metadata" - ) -} diff --git a/backend/crates/gateway-api/src/openai/responses/request.rs b/backend/crates/gateway-api/src/openai/responses/request.rs index 55f18aafa..fda633309 100644 --- a/backend/crates/gateway-api/src/openai/responses/request.rs +++ b/backend/crates/gateway-api/src/openai/responses/request.rs @@ -4,6 +4,7 @@ use std::{borrow::Cow, fmt, net::IpAddr}; use axum::http::HeaderMap; use base64::{Engine as _, engine::general_purpose::STANDARD}; +use bytes::Bytes; use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState}; use gateway_protocol::openai::{ X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER, @@ -333,6 +334,20 @@ pub fn decode_request_with_headers( decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers)) } +/// 解码请求并返回已经解除 HTTP content encoding 的中间件正文。 +/// +/// 外层中间件只接收有界的协议正文;terminal 会用中间件返回的正文重新解码, +/// 因而不能把压缩字节与已经移除的传输编码语义混用。 +pub(crate) fn decode_request_with_body( + body: &[u8], + headers: &HeaderMap, + max_decompressed_bytes: usize, +) -> Result<(DecodedResponsesRequest, Bytes), RequestDecodeError> { + let body = decompress_request_body(body, headers, max_decompressed_bytes)?; + let decoded = decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))?; + Ok((decoded, Bytes::copy_from_slice(&body))) +} + /// zstd 回溯窗口独立于输出上限,调整设置不能放大解码器内部窗口分配。 const MAX_ZSTD_WINDOW_LOG: u32 = 26; diff --git a/backend/crates/gateway-api/src/openai/responses/response.rs b/backend/crates/gateway-api/src/openai/responses/response.rs index fcfdad733..c0b93b451 100644 --- a/backend/crates/gateway-api/src/openai/responses/response.rs +++ b/backend/crates/gateway-api/src/openai/responses/response.rs @@ -1,5 +1,7 @@ //! Provider OpenAI Responses wire 到客户端 transport 的透明转发边界。 +use std::collections::BTreeMap; + use bytes::Bytes; use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent}; use gateway_protocol::openai::sse::{ @@ -141,12 +143,7 @@ impl OpenAiResponsesEncoder { } fn observe_wire(&mut self, wire: &ProtocolWireEvent) { - if let Some(response_id) = wire - .data() - .pointer("/response/id") - .or_else(|| wire.data().get("response_id")) - .and_then(Value::as_str) - { + if let Some(response_id) = wire_response_id(wire) { self.response_id = Some(response_id.to_owned()); } let effective_type = effective_event_type(wire); @@ -171,6 +168,72 @@ impl OpenAiResponsesEncoder { } } +/// 仅在非流式出口聚合同一响应的完成项,不让流式转发常驻保存输出内容。 +pub(super) fn collect_response(events: &[ProviderEvent]) -> Result { + let mut encoder = OpenAiResponsesEncoder::new(); + let mut response_id = None; + let mut items = BTreeMap::new(); + let mut invalid_items = false; + for wire in events.iter().filter_map(openai_wire) { + if let Some(id) = wire_response_id(wire) { + if response_id.is_some_and(|previous| previous != id) { + items.clear(); + invalid_items = false; + } + response_id = Some(id); + } + if !encoder.is_completed() + && effective_event_type(wire) == Some("response.output_item.done") + { + match ( + wire.data().get("output_index").and_then(Value::as_u64), + wire.data().get("item").filter(|item| item.is_object()), + ) { + (Some(index), Some(item)) => { + if let Some(previous) = items.insert(index, item) { + invalid_items |= previous != item; + } + } + _ => invalid_items = true, + } + } + encoder.observe_wire(wire); + } + let mut response = encoder.finish()?; + let Some(object) = response.as_object_mut() else { + return Ok(response); + }; + // Codex 可只在 output_item.done 交付内容,终态仅保留身份和 usage。 + // 有内容的终态仍是完整快照,不与 earlier done 或 canonical 增量拼接。 + if object + .get("output") + .is_some_and(|output| !output.as_array().is_some_and(Vec::is_empty)) + || (items.is_empty() && !invalid_items) + { + return Ok(response); + } + if invalid_items + || items + .keys() + .enumerate() + .any(|(expected, actual)| expected as u64 != *actual) + { + return Err(ResponseEncodeError::InvalidOutputItems); + } + object.insert( + "output".to_owned(), + Value::Array(items.into_values().cloned().collect()), + ); + Ok(response) +} + +fn wire_response_id(wire: &ProtocolWireEvent) -> Option<&str> { + wire.data() + .pointer("/response/id") + .or_else(|| wire.data().get("response_id")) + .and_then(Value::as_str) +} + fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> { event .wire_event() diff --git a/backend/crates/gateway-api/src/openai/responses/validation.rs b/backend/crates/gateway-api/src/openai/responses/validation.rs new file mode 100644 index 000000000..6c7f4aca4 --- /dev/null +++ b/backend/crates/gateway-api/src/openai/responses/validation.rs @@ -0,0 +1,698 @@ +//! 插件改写后的 OpenAI Responses 投递协议复核。 + +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, +}; + +use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent}; +use gateway_protocol::openai::sse::{parse_sse_events, sse_frame_is_done}; +use serde_json::{Map, Value}; + +const MAX_TRACKED_OUTPUT_ITEMS: usize = 4_096; +const MAX_IDENTIFIER_BYTES: usize = 1_024; + +/// 插件输出不能安全投递为 OpenAI Responses 协议。 +#[derive(Debug, Clone, Copy)] +pub(super) struct ResponseValidationError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalKind { + Completed, + Incomplete, + Failed, + Done, +} + +#[derive(Debug, Clone, Default)] +struct ExpectedResponseFacts { + response_id: Option, + terminal: Option, + invalid: bool, +} + +/// 仅保存最终协议复核所需的响应 ID 与终态类别,不缓存流正文。 +#[derive(Clone, Default)] +pub(super) struct ResponseValidationFacts(Arc>); + +impl ResponseValidationFacts { + pub(super) fn observe_event(&self, event: &ProviderEvent) { + let mut facts = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(wire) = event + .middleware_origin_wire() + .or_else(|| event.wire_event()) + .filter(|wire| wire.protocol() == "openai") + { + facts.observe_wire(wire); + } + for fact in event.canonical_facts() { + if let GatewayEvent::Started(metadata) = fact { + facts.observe_response_id(metadata.response_id()); + } + } + } + + pub(super) fn observe_buffered(&self, events: &[ProviderEvent], original: &[u8]) { + for event in events { + self.observe_event(event); + } + let Ok(value) = serde_json::from_slice::(original) else { + return; + }; + let mut facts = self + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Ok(response_id) = required_identifier(value.get("id")) { + facts.observe_response_id(response_id); + } + if let Some(terminal) = response_terminal(&value) { + facts.observe_terminal(terminal); + } + } + + fn snapshot(&self) -> ExpectedResponseFacts { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + pub(super) fn response_id(&self) -> Option { + self.snapshot().response_id + } +} + +impl ExpectedResponseFacts { + fn observe_wire(&mut self, wire: &ProtocolWireEvent) { + let event_type = wire + .event_type() + .or_else(|| wire.data().get("type").and_then(Value::as_str)); + self.observe_wire_value(event_type, wire.data()); + if !wire.has_json_data() + && let Some(raw) = wire.raw_sse_frame() + && let Ok(ParsedFrame::Event { event_type, value }) = ParsedFrame::parse(raw) + { + self.observe_wire_value(Some(&event_type), &value); + } + } + + fn observe_wire_value(&mut self, event_type: Option<&str>, value: &Value) { + if event_type == Some("response.created") + && let Some(response_id) = value + .get("response") + .and_then(|response| response.get("id")) + .and_then(Value::as_str) + { + self.observe_response_id(response_id); + } + if let Some(terminal) = terminal_kind(event_type) { + self.observe_terminal(terminal); + let response = value.get("response").unwrap_or(value); + if let Some(response_id) = response.get("id").and_then(Value::as_str) { + self.observe_response_id(response_id); + } + } + } + + fn observe_response_id(&mut self, response_id: &str) { + if response_id.is_empty() + || response_id.len() > MAX_IDENTIFIER_BYTES + || self + .response_id + .as_deref() + .is_some_and(|expected| expected != response_id) + { + self.invalid = true; + } else { + self.response_id + .get_or_insert_with(|| response_id.to_owned()); + } + } + + fn observe_terminal(&mut self, terminal: TerminalKind) { + if terminal == TerminalKind::Done { + return; + } + if self.terminal.is_some_and(|expected| expected != terminal) { + self.invalid = true; + } else { + self.terminal.get_or_insert(terminal); + } + } +} + +/// 只复核中间件确实改写过的完整 JSON;未经改写的原生快路保持原行为。 +pub(super) fn validate_buffered_response( + facts: &ResponseValidationFacts, + modified: &[u8], +) -> Result<(), ResponseValidationError> { + let expected = facts.snapshot(); + if expected.invalid { + return Err(ResponseValidationError); + } + let modified = + serde_json::from_slice::(modified).map_err(|_| ResponseValidationError)?; + let response_id = required_identifier(modified.get("id"))?; + let terminal = response_terminal(&modified).ok_or(ResponseValidationError)?; + if expected + .response_id + .as_deref() + .is_some_and(|expected| expected != response_id) + || expected + .terminal + .is_some_and(|expected| expected != terminal) + { + return Err(ResponseValidationError); + } + validate_terminal_response(&modified, response_id, terminal) +} + +/// 持有实际已交付给客户端的 Responses 流状态。 +/// +/// 原始前缀只旁路构建状态;直到插件首次改写或丢弃正文才启用严格拒绝,避免给 +/// 无策略原生热路增加新的兼容性条件。 +#[derive(Debug, Default)] +pub(super) struct ResponsesDeliveryValidator { + active: bool, + invalid_prefix: bool, + state: DeliveryState, +} + +impl ResponsesDeliveryValidator { + /// 复核实际交付帧;只有宿主标记为已改写时才激活严格模式。 + pub(super) fn validate_frame( + &mut self, + delivered: &[u8], + transformed: bool, + facts: &ResponseValidationFacts, + ) -> Result<(), ResponseValidationError> { + self.validate_parsed(ParsedFrame::parse(delivered), transformed, facts) + } + + /// WebSocket JSON message 与 SSE 共用同一响应顺序和关联校验。 + pub(super) fn validate_websocket_frame( + &mut self, + delivered: &[u8], + transformed: bool, + facts: &ResponseValidationFacts, + ) -> Result<(), ResponseValidationError> { + self.validate_parsed(ParsedFrame::parse_json(delivered), transformed, facts) + } + + fn validate_parsed( + &mut self, + parsed: Result, + transformed: bool, + facts: &ResponseValidationFacts, + ) -> Result<(), ResponseValidationError> { + if !self.active && !transformed { + if parsed.and_then(|frame| self.state.apply(frame)).is_err() { + self.invalid_prefix = true; + } + return Ok(()); + } + self.activate()?; + let parsed = parsed?; + let expected = facts.snapshot(); + if expected.invalid { + return Err(ResponseValidationError); + } + match &parsed { + ParsedFrame::Event { event_type, value } if event_type == "response.created" => { + let response_id = required_identifier( + value + .get("response") + .and_then(|response| response.get("id")), + )?; + if expected + .response_id + .as_deref() + .is_some_and(|expected| expected != response_id) + { + return Err(ResponseValidationError); + } + } + ParsedFrame::Event { event_type, value } + if terminal_kind(Some(event_type)).is_some() => + { + let terminal = terminal_kind(Some(event_type)).ok_or(ResponseValidationError)?; + let response = value.get("response").unwrap_or(value); + let response_id = required_identifier(response.get("id"))?; + if expected + .response_id + .as_deref() + .is_some_and(|expected| expected != response_id) + || expected + .terminal + .is_some_and(|expected| expected != terminal) + { + return Err(ResponseValidationError); + } + } + ParsedFrame::Event { .. } | ParsedFrame::Done => {} + } + self.state.apply(parsed) + } + + pub(super) fn response_id(&self) -> Option<&str> { + self.state.response_id.as_deref() + } + + pub(super) fn finish_delivery(&self) -> Result<(), ResponseValidationError> { + if !self.active || self.state.done { + Ok(()) + } else { + Err(ResponseValidationError) + } + } + + pub(super) fn finish_websocket_delivery(&self) -> Result<(), ResponseValidationError> { + if !self.active || self.state.terminal.is_some() { + Ok(()) + } else { + Err(ResponseValidationError) + } + } + + fn activate(&mut self) -> Result<(), ResponseValidationError> { + self.active = true; + if self.invalid_prefix { + Err(ResponseValidationError) + } else { + Ok(()) + } + } +} + +#[derive(Debug)] +enum ParsedFrame { + Event { event_type: String, value: Value }, + Done, +} + +impl ParsedFrame { + fn parse(bytes: &[u8]) -> Result { + let frame = std::str::from_utf8(bytes).map_err(|_| ResponseValidationError)?; + if sse_frame_is_done(frame) { + return Ok(Self::Done); + } + let mut events = parse_sse_events(frame).map_err(|_| ResponseValidationError)?; + if events.len() != 1 { + return Err(ResponseValidationError); + } + let event = events.pop().ok_or(ResponseValidationError)?; + let value = + serde_json::from_str::(&event.data).map_err(|_| ResponseValidationError)?; + let json_type = value.get("type").and_then(Value::as_str); + if let (Some(sse_type), Some(json_type)) = (event.event.as_deref(), json_type) + && sse_type != json_type + { + return Err(ResponseValidationError); + } + let event_type = event + .event + .or_else(|| json_type.map(ToOwned::to_owned)) + .filter(|value| !value.is_empty() && value.len() <= MAX_IDENTIFIER_BYTES) + .ok_or(ResponseValidationError)?; + if !value.is_object() { + return Err(ResponseValidationError); + } + Ok(Self::Event { event_type, value }) + } + + fn parse_json(bytes: &[u8]) -> Result { + let value = serde_json::from_slice::(bytes).map_err(|_| ResponseValidationError)?; + let event_type = required_identifier(value.get("type"))?.to_owned(); + if !value.is_object() { + return Err(ResponseValidationError); + } + Ok(Self::Event { event_type, value }) + } +} + +fn terminal_kind(event_type: Option<&str>) -> Option { + match event_type { + Some("response.completed") => Some(TerminalKind::Completed), + Some("response.incomplete") => Some(TerminalKind::Incomplete), + Some("response.failed" | "error") => Some(TerminalKind::Failed), + _ => None, + } +} + +fn response_terminal(response: &Value) -> Option { + match response.get("status").and_then(Value::as_str) { + Some("completed") => Some(TerminalKind::Completed), + Some("incomplete") => Some(TerminalKind::Incomplete), + Some("failed") => Some(TerminalKind::Failed), + _ => None, + } +} + +#[derive(Debug, Default)] +struct DeliveryState { + response_id: Option, + terminal: Option, + done: bool, + output_items: BTreeMap, + content_parts: BTreeSet<(u32, u32)>, +} + +impl DeliveryState { + fn apply(&mut self, frame: ParsedFrame) -> Result<(), ResponseValidationError> { + if self.done { + return Err(ResponseValidationError); + } + let ParsedFrame::Event { event_type, value } = frame else { + if self.terminal.is_none() { + return Err(ResponseValidationError); + } + self.done = true; + return Ok(()); + }; + match event_type.as_str() { + "response.created" => self.start(&value), + "response.in_progress" | "response.queued" => self.progress(&value), + "response.output_item.added" => self.output_item(&value, false), + "response.output_item.done" => self.output_item(&value, true), + "response.content_part.added" + | "response.reasoning_summary_part.added" + | "response.reasoning_part.added" => self.content_part(&value, false), + "response.content_part.done" + | "response.reasoning_summary_part.done" + | "response.reasoning_part.done" => self.content_part(&value, true), + "response.output_text.delta" + | "response.output_text.done" + | "response.refusal.delta" + | "response.refusal.done" + | "response.reasoning_summary_text.delta" + | "response.reasoning_summary_text.done" + | "response.reasoning_text.delta" + | "response.reasoning_text.done" => self.content_event(&value), + "response.function_call_arguments.delta" | "response.custom_tool_call_input.delta" => { + self.tool_arguments(&event_type, &value, false) + } + "response.function_call_arguments.done" | "response.custom_tool_call_input.done" => { + self.tool_arguments(&event_type, &value, true) + } + "response.completed" | "response.incomplete" | "response.failed" | "error" => { + self.finish_response(&event_type, &value) + } + _ => Ok(()), + } + } + + fn start(&mut self, value: &Value) -> Result<(), ResponseValidationError> { + if self.response_id.is_some() || self.terminal.is_some() { + return Err(ResponseValidationError); + } + let response = required_object(value.get("response"))?; + let response_id = required_identifier(response.get("id"))?; + if let Some(status) = response.get("status") { + let status = required_identifier(Some(status))?; + if !matches!(status, "queued" | "in_progress") { + return Err(ResponseValidationError); + } + } + self.response_id = Some(response_id.to_owned()); + Ok(()) + } + + fn progress(&self, value: &Value) -> Result<(), ResponseValidationError> { + self.require_active()?; + if let Some(response) = value.get("response") { + let response = required_object(Some(response))?; + self.validate_response_id(response.get("id"))?; + } + Ok(()) + } + + fn output_item(&mut self, value: &Value, done: bool) -> Result<(), ResponseValidationError> { + self.require_active()?; + self.validate_optional_event_response_id(value)?; + let index = required_index(value, "output_index")?; + let identity = OutputItemIdentity::parse(required_object(value.get("item"))?)?; + if let Some(existing) = self.output_items.get_mut(&index) { + if !done || existing.done || existing.identity != identity { + return Err(ResponseValidationError); + } + existing.done = true; + return Ok(()); + } + if self.output_items.len() >= MAX_TRACKED_OUTPUT_ITEMS + || self.output_items.values().any(|existing| { + !identity.id.is_empty() && existing.identity.id == identity.id + || existing.identity.call_id.is_some() + && existing.identity.call_id == identity.call_id + }) + { + return Err(ResponseValidationError); + } + self.output_items.insert( + index, + OutputItem { + identity, + done, + arguments_done: false, + }, + ); + Ok(()) + } + + fn content_part(&mut self, value: &Value, done: bool) -> Result<(), ResponseValidationError> { + self.require_active()?; + self.validate_optional_event_response_id(value)?; + let output_index = required_index(value, "output_index")?; + if !self.output_items.contains_key(&output_index) { + return Err(ResponseValidationError); + } + let content_index = required_index(value, "content_index")?; + let key = (output_index, content_index); + if done { + if !self.content_parts.remove(&key) { + return Err(ResponseValidationError); + } + } else if self.content_parts.len() >= MAX_TRACKED_OUTPUT_ITEMS + || !self.content_parts.insert(key) + { + return Err(ResponseValidationError); + } + Ok(()) + } + + fn content_event(&self, value: &Value) -> Result<(), ResponseValidationError> { + self.require_active()?; + self.validate_optional_event_response_id(value)?; + let key = ( + required_index(value, "output_index")?, + required_index(value, "content_index")?, + ); + if !self.content_parts.contains(&key) { + return Err(ResponseValidationError); + } + Ok(()) + } + + fn tool_arguments( + &mut self, + event_type: &str, + value: &Value, + done: bool, + ) -> Result<(), ResponseValidationError> { + self.require_active()?; + self.validate_optional_event_response_id(value)?; + let output_index = required_index(value, "output_index")?; + let item = self + .output_items + .get_mut(&output_index) + .ok_or(ResponseValidationError)?; + let expected_type = if event_type.starts_with("response.function_call") { + "function_call" + } else { + "custom_tool_call" + }; + if item.identity.kind != expected_type + || value.get("item_id").is_some_and(|id| { + required_identifier(Some(id)).ok() != Some(item.identity.id.as_str()) + }) + || value.get("call_id").is_some_and(|call_id| { + required_identifier(Some(call_id)).ok() != item.identity.call_id.as_deref() + }) + || done && item.arguments_done + { + return Err(ResponseValidationError); + } + item.arguments_done |= done; + Ok(()) + } + + fn finish_response( + &mut self, + event_type: &str, + value: &Value, + ) -> Result<(), ResponseValidationError> { + self.require_active()?; + if self.terminal.is_some() { + return Err(ResponseValidationError); + } + let terminal = terminal_kind(Some(event_type)).ok_or(ResponseValidationError)?; + let response = value + .get("response") + .or_else(|| (event_type == "error").then_some(value)) + .ok_or(ResponseValidationError)?; + let expected_id = self.response_id.as_deref().ok_or(ResponseValidationError)?; + validate_terminal_response(response, expected_id, terminal)?; + self.terminal = Some(terminal); + Ok(()) + } + + fn require_active(&self) -> Result<(), ResponseValidationError> { + if self.response_id.is_some() && self.terminal.is_none() { + Ok(()) + } else { + Err(ResponseValidationError) + } + } + + fn validate_response_id(&self, value: Option<&Value>) -> Result<(), ResponseValidationError> { + if required_identifier(value)? + == self.response_id.as_deref().ok_or(ResponseValidationError)? + { + Ok(()) + } else { + Err(ResponseValidationError) + } + } + + fn validate_optional_event_response_id( + &self, + value: &Value, + ) -> Result<(), ResponseValidationError> { + if let Some(response_id) = value.get("response_id") { + self.validate_response_id(Some(response_id))?; + } + Ok(()) + } +} + +#[derive(Debug)] +struct OutputItem { + identity: OutputItemIdentity, + done: bool, + arguments_done: bool, +} + +#[derive(Debug, PartialEq, Eq)] +struct OutputItemIdentity { + id: String, + kind: String, + call_id: Option, +} + +impl OutputItemIdentity { + fn parse(item: &Map) -> Result { + let kind = required_identifier(item.get("type"))?; + let known_item = matches!( + kind, + "message" + | "reasoning" + | "function_call" + | "custom_tool_call" + | "image_generation_call" + | "computer_call" + | "web_search_call" + ); + let id = item + .get("id") + .map(|value| required_identifier(Some(value))) + .transpose()? + .unwrap_or(""); + if known_item && id.is_empty() { + return Err(ResponseValidationError); + } + let call_id = item + .get("call_id") + .map(|value| required_identifier(Some(value)).map(ToOwned::to_owned)) + .transpose()?; + if matches!(kind, "function_call" | "custom_tool_call") + && (call_id.is_none() || required_identifier(item.get("name")).is_err()) + { + return Err(ResponseValidationError); + } + Ok(Self { + id: id.to_owned(), + kind: kind.to_owned(), + call_id, + }) + } +} + +fn validate_terminal_response( + response: &Value, + expected_id: &str, + terminal: TerminalKind, +) -> Result<(), ResponseValidationError> { + let response = required_object(Some(response))?; + if required_identifier(response.get("id"))? != expected_id { + return Err(ResponseValidationError); + } + let expected_status = match terminal { + TerminalKind::Completed => "completed", + TerminalKind::Incomplete => "incomplete", + TerminalKind::Failed => "failed", + TerminalKind::Done => return Err(ResponseValidationError), + }; + if required_identifier(response.get("status"))? != expected_status { + return Err(ResponseValidationError); + } + if terminal == TerminalKind::Failed { + if !response.get("error").is_some_and(Value::is_object) { + return Err(ResponseValidationError); + } + return Ok(()); + } + let output = response + .get("output") + .and_then(Value::as_array) + .ok_or(ResponseValidationError)?; + if output.len() > MAX_TRACKED_OUTPUT_ITEMS { + return Err(ResponseValidationError); + } + let mut ids = BTreeSet::new(); + let mut call_ids = BTreeSet::new(); + for item in output { + let identity = OutputItemIdentity::parse(required_object(Some(item))?)?; + if (!identity.id.is_empty() && !ids.insert(identity.id)) + || identity + .call_id + .is_some_and(|call_id| !call_ids.insert(call_id)) + { + return Err(ResponseValidationError); + } + } + Ok(()) +} + +fn required_object(value: Option<&Value>) -> Result<&Map, ResponseValidationError> { + value + .and_then(Value::as_object) + .ok_or(ResponseValidationError) +} + +fn required_identifier(value: Option<&Value>) -> Result<&str, ResponseValidationError> { + value + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= MAX_IDENTIFIER_BYTES) + .ok_or(ResponseValidationError) +} + +fn required_index(value: &Value, name: &str) -> Result { + value + .get(name) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or(ResponseValidationError) +} diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs index f472cafc7..c1f14fd3c 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/forward.rs @@ -1,18 +1,27 @@ -//! 已启动 Responses execution 到客户端 WebSocket wire event 的串行转发。 +//! Responses WebSocket 洋葱响应体与串行 transport 交付。 -use std::sync::Arc; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; use axum::http::StatusCode; -use gateway_core::engine::execution::StartedExecution; -use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError}; +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_core::engine::CommitRequirement; +use gateway_core::engine::execution::{ExecutionSession, StartedExecution}; +use gateway_core::engine::middleware::{ + MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader, + MiddlewareResponse, +}; use gateway_core::error::{GatewayError, GatewayErrorKind}; +use gateway_core::event::ProviderResponseHeader; use gateway_core::operation::ProviderSessionState; -use tokio::time::Instant; use crate::openai::error::{gateway_error_contract, gateway_error_from_engine}; +use crate::openai::middleware::PendingExecution; +use crate::openai::responses::validation::{ResponseValidationFacts, ResponsesDeliveryValidator}; use super::{ - super::{DecodedResponsesRequest, OpenAiResponsesEncoder, PendingExecution, ProtocolErrorBody}, + super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody}, connection::{FramePhase, ResponsesWebSocketConnection, WriteContext}, protocol::{error_event, initial_engine_error_event, response_metadata_event}, }; @@ -23,7 +32,7 @@ pub(super) enum ForwardOutcome { Disconnect, } -#[derive(Default)] +#[derive(Clone, Default)] pub(super) struct ConnectionReplaySnapshot { last_response_id: Option, provider_state: Option, @@ -49,99 +58,129 @@ impl ConnectionReplaySnapshot { } } -pub(super) async fn forward_execution( - connection: &mut ResponsesWebSocketConnection, +#[derive(Default)] +pub(super) struct ReplayCapture { + response_id: Option, + provider_state: Option, +} + +pub(super) type ReplayCaptureHandle = Arc>; + +pub(super) fn new_replay_capture() -> ReplayCaptureHandle { + Arc::new(Mutex::new(ReplayCapture::default())) +} + +pub(super) async fn execution_response( started: StartedExecution, - replay: &mut ConnectionReplaySnapshot, -) -> ForwardOutcome { + capture: ReplayCaptureHandle, + validation: ResponseValidationFacts, +) -> Result { + if !started.stream { + return Err(MiddlewareError::InvalidState); + } let request_id = Arc::::from(started.request_id.to_string()); - let streaming = started.stream; - let mut execution = PendingExecution::new(started.session); - if !streaming { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "WebSocket execution was not initialized as a stream", - ); - return send_gateway_error(connection, &error, &request_id).await; - } - let first = match next_active_input(connection, &mut execution).await { - ActiveInput::Event(Ok(Some(event))) => event, - ActiveInput::Event(Ok(None)) => { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway response ended before its first event", - ); - return send_gateway_error(connection, &error, &request_id).await; + let mut body = WebSocketExecutionBody::new( + started.session, + Arc::clone(&request_id), + Arc::clone(&capture), + validation, + ); + if let Err(error) = body.prime().await { + let headers = body.response_headers().to_vec(); + let status = middleware_error_status(&error); + let _ = body.record_client_status(status).await; + Box::new(body).close().await; + if let MiddlewareError::Engine(error) = error { + let response_headers = headers + .iter() + .map(|header| MiddlewareHeader::new(header.name(), header.value().clone())) + .collect(); + return Ok(MiddlewareResponse::new( + "openai".to_owned(), + status, + response_headers, + Box::new(SingleFrameBody(Some(MiddlewareFrame::new( + Bytes::from(initial_engine_error_event( + &error, + request_id.as_ref(), + &headers, + )), + MiddlewareFraming::JsonDocument, + true, + )))), + )); } - ActiveInput::Event(Err(error)) => { - return send_initial_engine_error(connection, &mut execution, &error, &request_id) - .await; + return Err(error); + } + let headers = body + .response_headers() + .iter() + .map(|header| MiddlewareHeader::new(header.name(), header.value().clone())) + .collect(); + Ok(MiddlewareResponse::new( + "openai".to_owned(), + 200, + headers, + Box::new(body), + )) +} + +struct SingleFrameBody(Option); + +impl MiddlewareBody for SingleFrameBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async { Ok(self.0.take()) }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { drop(self) }) + } +} + +pub(super) async fn forward_response( + connection: &mut ResponsesWebSocketConnection, + response: MiddlewareResponse, + request_id: Arc, + replay: &mut ConnectionReplaySnapshot, + capture: ReplayCaptureHandle, + validation: ResponseValidationFacts, +) -> ForwardOutcome { + let (protocol, status, headers, mut body, _) = response.into_parts(); + if protocol != "openai" || StatusCode::from_u16(status).is_err() { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; + } + let response_headers = headers + .into_iter() + .map(|header| { + let (name, value) = header.into_parts(); + ProviderResponseHeader::new(name, value) + }) + .collect::>(); + let first = match next_body_input(connection, body.as_mut()).await { + BodyInput::Frame(Ok(Some(frame))) => frame, + BodyInput::Frame(Ok(None)) => { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; } - ActiveInput::Disconnect => return ForwardOutcome::Disconnect, - }; - let requirement = first.commit_requirement(); - let mut first = first.into_provider_events(); - if requirement != CommitRequirement::CommitBeforeDelivery { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway first event did not require commit", - ); - return send_gateway_error(connection, &error, &request_id).await; - } - let mut encoder = OpenAiResponsesEncoder::new(); - let mut provider_state = None; - let mut first_messages = Vec::new(); - for event in &mut first { - if let Some(update) = event.take_session_update() { - provider_state = Some(update); + BodyInput::Frame(Err(error)) => { + return fail_body(connection, body, error, &request_id).await; } - first_messages.extend(encoder.push_websocket(event)); - } - if first_messages.is_empty() { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway commit batch encoded no output", - ); - return send_gateway_error(connection, &error, &request_id).await; - } - let Some(response_session) = execution.session_mut() else { - return ForwardOutcome::Disconnect; - }; - let response_headers = response_session.response_headers().to_vec(); - if let Err(error) = response_session.commit_downstream(None).await { - let error = gateway_error_from_engine(&error); - return send_gateway_error(connection, &error, &request_id).await; - } - let mut first_frame_written = false; - if encoder.is_completed() { - if let Err(outcome) = - confirm_completed_execution(connection, &mut execution, &request_id).await - { - return outcome; - } - let provider_terminal_at = Instant::now(); - commit_connection_replay(replay, &encoder, provider_state.take()); - let writes_succeeded = send_metadata( - connection, - &request_id, - response_metadata_event(&request_id, &response_headers), - ) - .await - && send_messages( - connection, - first_messages, - &request_id, - &mut first_frame_written, - true, - ) - .await; - if !writes_succeeded { - log_terminal_write_failure(connection, &request_id, provider_terminal_at.elapsed()); + BodyInput::Disconnect => { + detach_body(body); return ForwardOutcome::Disconnect; } - log_terminal_write_success(connection, &request_id, provider_terminal_at.elapsed()); - execution.disarm(); - return ForwardOutcome::Continue; + }; + if let Err(error) = validate_frame(&first) { + return fail_body(connection, body, error, &request_id).await; + } + let mut delivery_validator = ResponsesDeliveryValidator::default(); + if delivery_validator + .validate_websocket_frame(first.bytes(), first.transformed(), &validation) + .is_err() + { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; + } + if let Err(error) = body.commit_downstream(None).await { + return fail_body(connection, body, error, &request_id).await; } if !send_metadata( connection, @@ -149,206 +188,406 @@ pub(super) async fn forward_execution( response_metadata_event(&request_id, &response_headers), ) .await - || !send_messages( - connection, - first_messages, - &request_id, - &mut first_frame_written, - encoder.has_wire_failure(), - ) - .await { + detach_body(body); return ForwardOutcome::Disconnect; } + let mut first_frame_written = false; + let mut current = Some(first); + let mut terminal_seen = false; + let mut first_protocol_validated = true; loop { - match next_active_input(connection, &mut execution).await { - ActiveInput::Event(Ok(Some(delivery))) => { - let requirement = delivery.commit_requirement(); - let mut events = delivery.into_provider_events(); - if requirement != CommitRequirement::AlreadyCommitted { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway requested another downstream commit", - ); - return send_gateway_error(connection, &error, &request_id).await; - } - let mut messages = Vec::new(); - for event in &mut events { - if let Some(update) = event.take_session_update() { - provider_state = Some(update); - } - messages.extend(encoder.push_websocket(event)); - } - if encoder.is_completed() { - if let Err(outcome) = - confirm_completed_execution(connection, &mut execution, &request_id).await - { - return outcome; - } - let provider_terminal_at = Instant::now(); - commit_connection_replay(replay, &encoder, provider_state.take()); - if !send_messages( - connection, - messages, - &request_id, - &mut first_frame_written, - true, - ) - .await - { - log_terminal_write_failure( + let frame = match current.take() { + Some(frame) => frame, + None => match next_body_input(connection, body.as_mut()).await { + BodyInput::Frame(Ok(Some(frame))) => frame, + BodyInput::Frame(Ok(None)) if terminal_seen && body.is_finalized() => { + if delivery_validator.finish_websocket_delivery().is_err() { + return fail_body( connection, + body, + MiddlewareError::InvalidState, &request_id, - provider_terminal_at.elapsed(), - ); - return ForwardOutcome::Disconnect; + ) + .await; } - log_terminal_write_success( - connection, - &request_id, - provider_terminal_at.elapsed(), - ); - execution.disarm(); + { + let captured = capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(response_id) = captured.response_id.clone() { + replay.commit(response_id, captured.provider_state.clone()); + } + } + body.close().await; return ForwardOutcome::Continue; } - let terminal_batch = encoder.has_wire_failure(); - if !send_messages( - connection, - messages, - &request_id, - &mut first_frame_written, - terminal_batch, - ) - .await - { + BodyInput::Frame(Ok(None)) => { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id) + .await; + } + BodyInput::Frame(Err(error)) => { + return fail_body(connection, body, error, &request_id).await; + } + BodyInput::Disconnect => { + detach_body(body); return ForwardOutcome::Disconnect; } - } - ActiveInput::Event(Ok(None)) => { - if execution - .session_mut() - .is_some_and(|session| session.is_finalized()) - { - commit_connection_replay(replay, &encoder, provider_state.take()); - execution.disarm(); - return ForwardOutcome::Continue; - } - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway response ended without finalizing the execution", - ); - return send_gateway_error(connection, &error, &request_id).await; - } - ActiveInput::Event(Err(error)) => { - if encoder.has_wire_failure() { - execution.disarm(); - return ForwardOutcome::Continue; - } - let error = gateway_error_from_engine(&error); - return send_gateway_error(connection, &error, &request_id).await; - } - ActiveInput::Disconnect => return ForwardOutcome::Disconnect, + }, + }; + if terminal_seen || validate_frame(&frame).is_err() { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; } - } -} - -async fn send_initial_engine_error( - connection: &mut ResponsesWebSocketConnection, - execution: &mut PendingExecution, - error: &EngineError, - request_id: &Arc, -) -> ForwardOutcome { - let response_headers = execution - .session_mut() - .map(|session| session.response_headers().to_vec()) - .unwrap_or_default(); - if !response_headers.is_empty() - && !send_metadata( - connection, - request_id, - response_metadata_event(request_id, &response_headers), - ) - .await - { - return ForwardOutcome::Disconnect; - } - send_error_message( - connection, - initial_engine_error_event(error, request_id, &response_headers), - request_id, - ) - .await -} - -fn commit_connection_replay( - replay: &mut ConnectionReplaySnapshot, - encoder: &OpenAiResponsesEncoder, - provider_state: Option, -) { - if let Some(response_id) = encoder.response_id() { - replay.commit(response_id.to_owned(), provider_state); - } -} - -async fn confirm_completed_execution( - connection: &mut ResponsesWebSocketConnection, - execution: &mut PendingExecution, - request_id: &Arc, -) -> Result<(), ForwardOutcome> { - match next_active_input(connection, execution).await { - ActiveInput::Event(Ok(None)) - if execution - .session_mut() - .is_some_and(|session| session.is_finalized()) => + if first_protocol_validated { + first_protocol_validated = false; + } else if delivery_validator + .validate_websocket_frame(frame.bytes(), frame.transformed(), &validation) + .is_err() { - Ok(()) + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; } - ActiveInput::Event(Ok(None)) => { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway response was not finalized after its terminal event", - ); - Err(send_gateway_error(connection, &error, request_id).await) + let terminal = frame.terminal(); + let Ok(message) = String::from_utf8(frame.into_bytes().to_vec()) else { + return fail_body(connection, body, MiddlewareError::InvalidState, &request_id).await; + }; + let phase = match (first_frame_written, terminal) { + (false, true) => FramePhase::FirstAndTerminal, + (false, false) => FramePhase::First, + (true, true) => FramePhase::Terminal, + (true, false) => FramePhase::Data, + }; + if connection + .send_text(message, WriteContext::request(&request_id, phase)) + .await + .is_err() + { + detach_body(body); + return ForwardOutcome::Disconnect; } - ActiveInput::Event(Ok(Some(_))) => { - let error = GatewayError::new( - GatewayErrorKind::Internal, - "gateway response continued after its terminal event", - ); - Err(send_gateway_error(connection, &error, request_id).await) - } - ActiveInput::Event(Err(error)) => { - let error = gateway_error_from_engine(&error); - Err(send_gateway_error(connection, &error, request_id).await) - } - ActiveInput::Disconnect => Err(ForwardOutcome::Disconnect), + first_frame_written = true; + terminal_seen = terminal; } } -enum ActiveInput { - Event(Result, EngineError>), +fn detach_body(body: Box) { + // 客户端已离线时不能让连接 handler 等待可能仍在结算的请求;close future + // 继续持有唯一正文和执行守卫,保证已启动的费用与租约清理不会被取消。 + drop(tokio::spawn(body.close())); +} + +fn validate_frame(frame: &MiddlewareFrame) -> Result<(), MiddlewareError> { + if frame.framing() != MiddlewareFraming::JsonDocument + || serde_json::from_slice::(frame.bytes()).is_err() + { + return Err(MiddlewareError::InvalidState); + } + Ok(()) +} + +enum BodyInput { + Frame(Result, MiddlewareError>), Disconnect, } -async fn next_active_input( +async fn next_body_input( connection: &mut ResponsesWebSocketConnection, - execution: &mut PendingExecution, -) -> ActiveInput { - let Some(session) = execution.session_mut() else { - return ActiveInput::Disconnect; - }; - // 与 Codex stream_request 的连接锁一致:本轮结束前不消费下一条请求。 - // pump 继续接收有界业务帧和处理 Ping/Pong;退出通过独立通知取消本轮。 + body: &mut dyn MiddlewareBody, +) -> BodyInput { tokio::select! { biased; - _ = connection.wait_for_exit() => { - session.trace().record("downstream.cancelled", serde_json::json!({ - "reason": "websocket_connection_exit", "connectionId": connection.id(), - })); - ActiveInput::Disconnect - }, - event = session.next_event() => ActiveInput::Event(event), + _ = connection.wait_for_exit() => BodyInput::Disconnect, + frame = body.next_frame() => BodyInput::Frame(frame), + } +} + +async fn fail_body( + connection: &mut ResponsesWebSocketConnection, + mut body: Box, + error: MiddlewareError, + request_id: &Arc, +) -> ForwardOutcome { + let gateway = middleware_gateway_error(error); + let _ = body + .record_client_status(gateway_error_contract(gateway.kind()).0.as_u16()) + .await; + body.close().await; + send_gateway_error(connection, &gateway, request_id).await +} + +fn middleware_gateway_error(error: MiddlewareError) -> GatewayError { + match error { + MiddlewareError::Gateway(error) => error, + MiddlewareError::Engine(error) => gateway_error_from_engine(&error), + MiddlewareError::Provider(error) => GatewayError::from_provider(&error), + MiddlewareError::Rejected => GatewayError::new( + GatewayErrorKind::PolicyDenied, + "request middleware rejected the request", + ), + MiddlewareError::Fault | MiddlewareError::InvalidState => GatewayError::new( + GatewayErrorKind::Internal, + "request middleware returned an invalid response", + ), + } +} + +fn middleware_error_status(error: &MiddlewareError) -> u16 { + match error { + MiddlewareError::Gateway(error) => gateway_error_contract(error.kind()).0.as_u16(), + MiddlewareError::Engine(error) => { + gateway_error_contract(gateway_error_from_engine(error).kind()) + .0 + .as_u16() + } + MiddlewareError::Provider(error) => { + gateway_error_contract(GatewayError::from_provider(error).kind()) + .0 + .as_u16() + } + MiddlewareError::Rejected => StatusCode::FORBIDDEN.as_u16(), + MiddlewareError::Fault | MiddlewareError::InvalidState => { + StatusCode::INTERNAL_SERVER_ERROR.as_u16() + } + } +} + +pub(super) async fn send_middleware_error( + connection: &mut ResponsesWebSocketConnection, + error: MiddlewareError, + request_id: &Arc, +) -> ForwardOutcome { + send_gateway_error(connection, &middleware_gateway_error(error), request_id).await +} + +struct WebSocketExecutionBody { + execution: PendingExecution, + encoder: OpenAiResponsesEncoder, + validation: ResponseValidationFacts, + pending: VecDeque, + provider_state: Option, + request_id: Arc, + capture: ReplayCaptureHandle, + batch_pending: bool, + transformed_pending: bool, + awaiting_terminal_eof: bool, + committed: bool, + finished: bool, +} + +impl WebSocketExecutionBody { + fn new( + session: Box, + request_id: Arc, + capture: ReplayCaptureHandle, + validation: ResponseValidationFacts, + ) -> Self { + Self { + execution: PendingExecution::new(session), + encoder: OpenAiResponsesEncoder::new(), + validation, + pending: VecDeque::new(), + provider_state: None, + request_id, + capture, + batch_pending: false, + transformed_pending: false, + awaiting_terminal_eof: false, + committed: false, + finished: false, + } + } + + async fn prime(&mut self) -> Result<(), MiddlewareError> { + self.fill_pending().await?; + if self.pending.is_empty() { + return Err(MiddlewareError::InvalidState); + } + Ok(()) + } + + fn response_headers(&mut self) -> &[ProviderResponseHeader] { + self.execution + .session_mut() + .map_or(&[], |session| session.response_headers()) + } + + async fn fill_pending(&mut self) -> Result<(), MiddlewareError> { + while self.pending.is_empty() && !self.finished { + if self.awaiting_terminal_eof { + if !self.committed { + return Err(MiddlewareError::InvalidState); + } + let result = self + .execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .next_event() + .await; + match result { + Ok(None) if self.execution.is_finalized() => { + let mut capture = self + .capture + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + capture.response_id = self.encoder.response_id().map(str::to_owned); + capture.provider_state = self.provider_state.take(); + drop(capture); + self.execution.disarm(); + self.finished = true; + continue; + } + Err(_) if self.encoder.has_wire_failure() && self.execution.is_finalized() => { + self.execution.disarm(); + self.finished = true; + continue; + } + Ok(None | Some(_)) => return Err(MiddlewareError::InvalidState), + Err(error) => return Err(MiddlewareError::Engine(error)), + } + } + if self.batch_pending { + if !self.committed { + self.execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .discard_pending_delivery() + .map_err(MiddlewareError::Engine)?; + } + self.batch_pending = false; + } + let result = self + .execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .next_event() + .await; + let delivery = match result { + Ok(Some(delivery)) => delivery, + Ok(None) => return Err(MiddlewareError::InvalidState), + Err(error) if self.committed => { + self.push_gateway_error(gateway_error_from_engine(&error)); + continue; + } + Err(error) => return Err(MiddlewareError::Engine(error)), + }; + let expected = if self.committed { + CommitRequirement::AlreadyCommitted + } else { + CommitRequirement::CommitBeforeDelivery + }; + if delivery.commit_requirement() != expected { + return Err(MiddlewareError::InvalidState); + } + let mut messages = Vec::new(); + for mut event in delivery.into_provider_events() { + self.validation.observe_event(&event); + let transformed = self.transformed_pending || event.middleware_transformed(); + if let Some(update) = event.take_session_update() { + self.provider_state = Some(update); + } + let encoded = self.encoder.push_websocket(&event); + if encoded.is_empty() { + self.transformed_pending = transformed; + continue; + } + messages.extend(encoded.into_iter().map(|message| (message, transformed))); + self.transformed_pending = false; + } + let terminal = self.encoder.is_completed() || self.encoder.has_wire_failure(); + let last = messages.len().saturating_sub(1); + self.pending.extend(messages.into_iter().enumerate().map( + |(index, (message, transformed))| { + MiddlewareFrame::new( + Bytes::from(message), + MiddlewareFraming::JsonDocument, + terminal && index == last, + ) + .with_transformed(transformed) + }, + )); + self.awaiting_terminal_eof = terminal; + self.batch_pending = !self.committed; + if self.pending.is_empty() { + if terminal { + return Err(MiddlewareError::InvalidState); + } + continue; + } + } + Ok(()) + } + + fn push_gateway_error(&mut self, error: GatewayError) { + let (status, default_type, default_code) = gateway_error_contract(error.kind()); + self.pending.push_back(MiddlewareFrame::new( + Bytes::from(error_event( + status, + error.client_error_type().unwrap_or(default_type), + error.client_error_code().unwrap_or(default_code), + error.client_message(), + None, + Some(&self.request_id), + serde_json::Map::new(), + )), + MiddlewareFraming::JsonDocument, + true, + )); + self.finished = true; + } +} + +impl MiddlewareBody for WebSocketExecutionBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + if let Some(frame) = self.pending.pop_front() { + return Ok(Some(frame)); + } + if self.finished { + return Ok(None); + } + self.fill_pending().await?; + Ok(self.pending.pop_front()) + }) + } + + fn commit_downstream( + &mut self, + status: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + if self.committed { + return Ok(()); + } + self.execution + .session_mut() + .ok_or(MiddlewareError::InvalidState)? + .commit_downstream(status) + .await + .map_err(MiddlewareError::Engine)?; + self.committed = true; + self.batch_pending = false; + Ok(()) + }) + } + + fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async move { + let Some(session) = self.execution.session_mut() else { + return Ok(()); + }; + session + .record_client_status(status) + .await + .map_err(MiddlewareError::Engine) + }) + } + + fn is_finalized(&self) -> bool { + self.execution.is_finalized() + } + + fn close(mut self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { self.execution.cancel_and_finalize().await }) } } @@ -398,27 +637,15 @@ async fn send_error( param: Option<&str>, request_id: &Arc, ) -> ForwardOutcome { - send_error_message( - connection, - error_event( - status, - error_type, - code, - message, - param, - Some(request_id), - serde_json::Map::new(), - ), - request_id, - ) - .await -} - -async fn send_error_message( - connection: &mut ResponsesWebSocketConnection, - message: String, - request_id: &Arc, -) -> ForwardOutcome { + let message = error_event( + status, + error_type, + code, + message, + param, + Some(request_id), + serde_json::Map::new(), + ); if connection .send_text( message, @@ -446,61 +673,3 @@ async fn send_metadata( .await .is_ok() } - -async fn send_messages( - connection: &mut ResponsesWebSocketConnection, - messages: Vec, - request_id: &Arc, - first_frame_written: &mut bool, - terminal_batch: bool, -) -> bool { - let message_count = messages.len(); - for (index, message) in messages.into_iter().enumerate() { - let is_terminal = terminal_batch && index + 1 == message_count; - let phase = match (*first_frame_written, is_terminal) { - (false, true) => FramePhase::FirstAndTerminal, - (false, false) => FramePhase::First, - (true, true) => FramePhase::Terminal, - (true, false) => FramePhase::Data, - }; - if connection - .send_text(message, WriteContext::request(request_id, phase)) - .await - .is_err() - { - return false; - } - *first_frame_written = true; - } - true -} - -fn log_terminal_write_success( - connection: &ResponsesWebSocketConnection, - request_id: &Arc, - provider_terminal_to_write: std::time::Duration, -) { - tracing::info!( - target: "request_trace", stage = "downstream.terminal.written", - websocket_connection_id = connection.id(), - request_id = %request_id, - provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(), - terminal_frame_written = true, - "Provider execution terminal was written to the WebSocket transport" - ); -} - -fn log_terminal_write_failure( - connection: &ResponsesWebSocketConnection, - request_id: &Arc, - provider_terminal_to_write: std::time::Duration, -) { - tracing::info!( - target: "request_trace", stage = "downstream.terminal.write_failed", - websocket_connection_id = connection.id(), - request_id = %request_id, - provider_terminal_to_terminal_write_ms = provider_terminal_to_write.as_millis(), - provider_succeeded_but_terminal_write_failed = true, - "Provider execution succeeded before the terminal WebSocket frame write failed" - ); -} diff --git a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs index d34196c18..956ddfae4 100644 --- a/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/src/openai/responses/websocket/mod.rs @@ -18,14 +18,23 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; +use bytes::Bytes; use gateway_core::{ diagnostics::{TraceContext, diagnostic_json}, - engine::execution::{AuthenticatedClient, ClientTransport}, + engine::{ + execution::{AuthenticatedClient, ClientTransport}, + middleware::MiddlewareError, + }, lifecycle::{ConnectionGuard, ConnectionLifecycle}, + operation::OperationKind, }; use crate::{ ApiState, + openai::middleware::{ + HttpMiddlewareInput, invoke_http_middleware, request_headers as middleware_request_headers, + request_parts, + }, openai::{ auth::{authenticate_client, client_access_error_response}, error::runtime_unavailable_response, @@ -33,11 +42,14 @@ use crate::{ }, }; -use super::{http::request_client_context, request::OpenAiRequestHeaders}; +use super::{ + http::request_client_context, request::OpenAiRequestHeaders, + validation::ResponseValidationFacts, +}; use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext}; use forward::{ - ConnectionReplaySnapshot, ForwardOutcome, forward_execution, send_gateway_error, - send_protocol_error, + ConnectionReplaySnapshot, ForwardOutcome, execution_response, forward_response, + new_replay_capture, send_gateway_error, send_middleware_error, send_protocol_error, }; use protocol::connection_limit_event; pub use protocol::{ResponseCreateFrameError, decode_response_create_with_context}; @@ -53,7 +65,7 @@ pub(crate) async fn responses_websocket( websocket: WebSocketUpgrade, ) -> Response { let service = state.openai().clone(); - let client = match authenticate_client(&service, &headers) { + let client = match authenticate_client(&service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; @@ -101,7 +113,7 @@ impl ResponsesWebSocketAdapter { let connection_id = self.service.next_request_id().replacen("req_", "ws_", 1); TraceContext::new(&connection_id).headers( "client.connection.headers", - serde_json::json!({"transport": "websocket", "method": "GET", "path": "/v1/responses"}), + serde_json::json!({"transport": "websocket", "method": "GET", "path": crate::openai::router::RESPONSES_PATH}), raw_headers .iter() .map(|(name, value)| (name.as_str(), value.as_bytes())), @@ -113,6 +125,7 @@ impl ResponsesWebSocketAdapter { client_ip, user_agent, request_headers, + raw_headers, lifecycle: self.service.lifecycle(), connection_guard, }; @@ -134,6 +147,7 @@ struct ResponsesWebSocketSession { client_ip: Option, user_agent: Option, request_headers: OpenAiRequestHeaders, + raw_headers: HeaderMap, lifecycle: Arc, connection_guard: Box, } @@ -146,6 +160,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke client_ip, user_agent, request_headers, + raw_headers, lifecycle, connection_guard, } = session; @@ -184,7 +199,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke request_count = request_count.saturating_add(1); let correlation_id = Arc::::from(service.next_request_id()); let decoded = match decode_response_create_with_context(&payload, &request_headers) { - Ok(decoded) => decoded.with_client_context(client_ip, user_agent.clone()), + Ok(decoded) => decoded, Err(error) => { trace_rejected_request( &correlation_id, @@ -207,7 +222,6 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke continue; } }; - let decoded = replay.prepare(decoded); // deadline 与 Text 可能同时就绪;在任何上游执行开始前再次封住该竞争窗口。 if connection.is_expired() { trace_rejected_request( @@ -220,16 +234,9 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke expire_connection(&mut connection).await; break; } - let started = match service - .start_response( - client.clone(), - decoded, - ClientTransport::WebSocket, - "/v1/responses", - ) - .await - { - Ok(started) => started, + let execution = service.execution(); + let prepared = match execution.prepare_execution(client.clone()).await { + Ok(prepared) => prepared, Err(error) => { trace_rejected_request( &correlation_id, @@ -246,16 +253,96 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke continue; } }; - started.session.trace().record("client.connection", serde_json::json!({ - "transport": "websocket", "connectionId": connection.id(), "correlationId": correlation_id.as_ref(), - "requestIndex": request_count, - })); - started - .session - .trace() - .capture("client.request.body", payload.as_bytes()); - - if forward_execution(&mut connection, started, &mut replay).await + let request_id = Arc::::from(prepared.request_id().to_string()); + let capture = new_replay_capture(); + let validation = ResponseValidationFacts::default(); + let input = HttpMiddlewareInput { + endpoint: crate::openai::router::RESPONSES_PATH.to_owned(), + protocol: "openai".to_owned(), + operation: Some(OperationKind::Generate), + transport: ClientTransport::WebSocket, + model_hint: Some(decoded.metadata().requested_model().to_owned()), + headers: middleware_request_headers(&raw_headers), + body: Bytes::from(payload.clone()), + }; + let service_for_terminal = service.clone(); + let replay_for_terminal = replay.clone(); + let connection_id_for_terminal = connection.id().to_owned(); + let user_agent_for_terminal = user_agent.clone(); + let request_id_for_terminal = Arc::clone(&request_id); + let capture_for_terminal = Arc::clone(&capture); + let validation_for_terminal = validation.clone(); + let invoke = invoke_http_middleware( + execution, + prepared, + input, + Box::new(move |prepared, request| { + Box::pin(async move { + let (protocol, headers, body) = request_parts(request)?; + if protocol != "openai" { + return Err(MiddlewareError::Rejected); + } + let payload = + std::str::from_utf8(&body).map_err(|_| MiddlewareError::Rejected)?; + let request_headers = OpenAiRequestHeaders::from_headers(&headers) + .with_downstream_websocket_connection_id( + connection_id_for_terminal.clone(), + ); + let decoded = decode_response_create_with_context(payload, &request_headers) + .map_err(|_| MiddlewareError::Rejected)? + .with_client_context(client_ip, user_agent_for_terminal); + let decoded = replay_for_terminal.prepare(decoded); + let started = service_for_terminal + .start_prepared_response( + prepared, + decoded, + ClientTransport::WebSocket, + crate::openai::router::RESPONSES_PATH, + ) + .await + .map_err(MiddlewareError::Gateway)?; + started.session.trace().record( + "client.connection", + serde_json::json!({ + "transport": "websocket", + "connectionId": connection_id_for_terminal, + "correlationId": request_id_for_terminal.as_ref(), + "requestIndex": request_count, + }), + ); + started + .session + .trace() + .capture("client.request.body", &body); + execution_response(started, capture_for_terminal, validation_for_terminal).await + }) + }), + ); + let response = tokio::select! { + biased; + _ = connection.wait_for_exit() => break, + response = invoke => response, + }; + let response = match response { + Ok(response) => response, + Err(error) => { + if send_middleware_error(&mut connection, error, &request_id).await + == ForwardOutcome::Disconnect + { + break; + } + continue; + } + }; + if forward_response( + &mut connection, + response, + request_id, + &mut replay, + capture, + validation, + ) + .await == ForwardOutcome::Disconnect { break; diff --git a/backend/crates/gateway-api/src/openai/router.rs b/backend/crates/gateway-api/src/openai/router.rs index 32cf2ad64..b655b5301 100644 --- a/backend/crates/gateway-api/src/openai/router.rs +++ b/backend/crates/gateway-api/src/openai/router.rs @@ -16,16 +16,25 @@ use super::{ use crate::ApiState; +// 公开协议路径由 API 层统一定义;Core 使用操作类型,不根据这些路径分派业务。 +pub(super) const RESPONSES_PATH: &str = "/v1/responses"; +pub(super) const IMAGE_GENERATIONS_PATH: &str = "/v1/images/generations"; +pub(super) const IMAGE_EDITS_PATH: &str = "/v1/images/edits"; +pub(super) const SEARCH_PATH: &str = "/v1/alpha/search"; +pub(super) const MODELS_PATH: &str = "/v1/models"; +pub(super) const MODEL_DETAIL_PATH: &str = "/v1/models/{model_id}"; +pub(super) const USAGE_PATH: &str = "/v1/usage"; + /// 构造 OpenAI 客户端协议路由。 pub(crate) fn router() -> Router { Router::new() - .route("/v1/images/generations", post(image_generations)) - .route("/v1/images/edits", post(image_edits)) - .route("/v1/alpha/search", post(standalone_search)) - .route("/v1/responses", get(responses_websocket).post(responses)) - .route("/v1/models", get(models)) + .route(IMAGE_GENERATIONS_PATH, post(image_generations)) + .route(IMAGE_EDITS_PATH, post(image_edits)) + .route(SEARCH_PATH, post(standalone_search)) + .route(RESPONSES_PATH, get(responses_websocket).post(responses)) + .route(MODELS_PATH, get(models)) // 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。 - .route("/v1/models/{model_id}", get(model_detail)) + .route(MODEL_DETAIL_PATH, get(model_detail)) .merge(usage::router()) // OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝 // 上游本可接受的未来 payload。 diff --git a/backend/crates/gateway-api/src/openai/search/http.rs b/backend/crates/gateway-api/src/openai/search/http.rs index 449d2fd80..055cae166 100644 --- a/backend/crates/gateway-api/src/openai/search/http.rs +++ b/backend/crates/gateway-api/src/openai/search/http.rs @@ -8,15 +8,17 @@ use axum::{ http::HeaderMap, response::Response, }; +use gateway_core::engine::execution::ClientTransport; use gateway_core::error::{GatewayError, GatewayErrorKind}; -use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest}; +use gateway_core::operation::{Operation, OperationKind, RawJsonPayload, StandaloneSearchRequest}; use crate::ApiState; +use crate::openai::middleware::{HttpMiddlewareInput, request_headers}; use crate::openai::{ auth::{authenticate_client, client_access_error_response}, - endpoint::collect_raw_json_response, - error::gateway_error_response, + endpoint::provider_endpoint_response, responses::{OpenAiRequestHeaders, request_client_context}, + router::SEARCH_PATH, }; const OPENAI_PROTOCOL: &str = "openai"; @@ -29,7 +31,7 @@ pub(crate) async fn standalone_search( body: Bytes, ) -> Response { let service = state.openai(); - let client = match authenticate_client(service, &headers) { + let client = match authenticate_client(service, &headers).await { Ok(client) => client, Err(error) => return client_access_error_response(error), }; @@ -37,18 +39,23 @@ pub(crate) async fn standalone_search( &headers, connect_info.map(|Extension(ConnectInfo(address))| address), ); - let operation = match search_operation(body, &headers) { - Ok(operation) => operation, - Err(error) => return gateway_error_response(&error), - }; - let started = match service - .start_provider_endpoint(client, operation, client_ip, user_agent, "/v1/alpha/search") - .await - { - Ok(started) => started, - Err(error) => return gateway_error_response(&error), - }; - collect_raw_json_response(started).await + provider_endpoint_response( + service.clone(), + client, + HttpMiddlewareInput { + endpoint: SEARCH_PATH.to_owned(), + protocol: OPENAI_PROTOCOL.to_owned(), + operation: Some(OperationKind::Search), + transport: ClientTransport::HttpJson, + model_hint: None, + headers: request_headers(&headers), + body, + }, + client_ip, + user_agent, + search_operation, + ) + .await } fn search_operation(body: Bytes, headers: &HeaderMap) -> Result { diff --git a/backend/crates/gateway-api/src/openai/service.rs b/backend/crates/gateway-api/src/openai/service.rs index 874206cb9..c8d3e4527 100644 --- a/backend/crates/gateway-api/src/openai/service.rs +++ b/backend/crates/gateway-api/src/openai/service.rs @@ -3,14 +3,18 @@ use std::net::IpAddr; use std::sync::Arc; +use gateway_core::engine::authentication::ClientAuthenticationRequest; use gateway_core::engine::continuation::PreviousResponseId; use gateway_core::engine::execution::{ AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata, - ExecutionService, StartExecution, StartProviderExecution, StartedExecution, + ExecutionService, PreparedExecutionRequest, PreparedRootExecution, StartProviderExecution, + StartedExecution, }; use gateway_core::error::{GatewayError, GatewayErrorKind}; use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle}; -use gateway_core::routing::{ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId}; +use gateway_core::routing::{ + ProviderCatalogUnavailable, ProviderKind, PublicModelDescriptor, PublicModelId, +}; use uuid::Uuid; use super::auth::ClientApiKeyAuthError; @@ -24,6 +28,10 @@ pub(crate) struct OpenAiService { } impl OpenAiService { + pub(crate) fn execution(&self) -> Arc { + Arc::clone(&self.execution) + } + #[must_use] pub(crate) const fn new( execution: Arc, @@ -35,12 +43,23 @@ impl OpenAiService { } } - pub(crate) fn authenticate( + pub(crate) async fn authenticate( &self, - plaintext: &str, + request: ClientAuthenticationRequest, ) -> Result { self.execution - .authenticate(plaintext) + .authenticate_request(request) + .await + .map_err(map_authentication_error) + } + + pub(crate) async fn verify( + &self, + request: ClientAuthenticationRequest, + ) -> Result { + self.execution + .verify_request(request) + .await .map_err(map_authentication_error) } @@ -70,9 +89,9 @@ impl OpenAiService { self.execution.contains_public_model(client, model) } - pub(crate) async fn start_response( + pub(crate) async fn start_prepared_response( &self, - client: AuthenticatedClient, + prepared: PreparedRootExecution, request: DecodedResponsesRequest, transport: ClientTransport, endpoint: &'static str, @@ -92,55 +111,65 @@ impl OpenAiService { } }; self.execution - .start(StartExecution { - client, - public_model, - operation, - metadata: ExecutionRequestMetadata { - protocol: "openai".to_owned(), - endpoint: endpoint.to_owned(), - transport, - stream: metadata.stream(), - client_ip: metadata.client_ip(), - user_agent: metadata.user_agent().map(str::to_owned), - previous_response_id, + .start_prepared( + prepared, + PreparedExecutionRequest { + public_model, + operation, + metadata: ExecutionRequestMetadata { + protocol: "openai".to_owned(), + endpoint: endpoint.to_owned(), + transport, + stream: metadata.stream(), + client_ip: metadata.client_ip(), + user_agent: metadata.user_agent().map(str::to_owned), + previous_response_id, + }, }, - }) + ) .await } - pub(crate) async fn start_provider_endpoint( + pub(crate) async fn start_prepared_provider_endpoint( &self, - client: AuthenticatedClient, + prepared: PreparedRootExecution, operation: gateway_core::operation::Operation, client_ip: Option, user_agent: Option, - endpoint: &'static str, + endpoint: String, ) -> Result { - let provider = gateway_core::routing::ProviderKind::new("openai").map_err(|_| { + let provider = ProviderKind::new("openai").map_err(|_| { GatewayError::new( GatewayErrorKind::Internal, "OpenAI provider identifier is invalid", ) })?; self.execution - .start_provider_endpoint(StartProviderExecution { - client, + .start_prepared_provider_endpoint( + prepared, provider, + None, operation, - metadata: ExecutionRequestMetadata { + ExecutionRequestMetadata { protocol: "openai".to_owned(), - endpoint: endpoint.to_owned(), + endpoint, transport: ClientTransport::HttpJson, stream: false, client_ip, user_agent, previous_response_id: None, }, - }) + ) .await } + pub(crate) async fn start_bound_provider_endpoint( + &self, + request: StartProviderExecution, + ) -> Result { + self.execution.start_provider_endpoint(request).await + } + pub(crate) fn try_register_connection( &self, ) -> Result, ConnectionDraining> { @@ -162,5 +191,6 @@ const fn map_authentication_error(error: ClientAuthenticationError) -> ClientApi match error { ClientAuthenticationError::InvalidKey => ClientApiKeyAuthError::InvalidKey, ClientAuthenticationError::SnapshotUnavailable => ClientApiKeyAuthError::RuntimeUnavailable, + ClientAuthenticationError::ProviderUnavailable => ClientApiKeyAuthError::RuntimeUnavailable, } } diff --git a/backend/crates/gateway-api/src/openai/usage.rs b/backend/crates/gateway-api/src/openai/usage.rs index ffcdb8ac9..2af6e94c1 100644 --- a/backend/crates/gateway-api/src/openai/usage.rs +++ b/backend/crates/gateway-api/src/openai/usage.rs @@ -11,20 +11,24 @@ use axum::{ routing::get, }; use chrono::{DateTime, Utc}; -use gateway_core::metering::Decimal; +use gateway_core::{engine::execution::AuthenticatedClient, metering::Decimal}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::{ApiState, auth::SessionState as _}; +use crate::{ + ApiState, + auth::SessionState as _, + openai::middleware::{self as plugin_middleware, HttpMiddlewareInput}, +}; use super::{ - auth::{bearer_client_api_key, client_access_error_response}, + auth::{client_access_error_response, verify_client}, error::{missing_client_api_key_response, openai_error_response}, }; pub(super) fn router() -> Router { Router::new() - .route("/v1/usage", get(usage)) + .route(super::router::USAGE_PATH, get(usage)) .layer(middleware::map_response(no_store)) } @@ -37,11 +41,25 @@ async fn usage( headers: HeaderMap, uri: axum::http::Uri, ) -> Response { - let key = match bearer_client_api_key(&headers) { - Ok(key) => key, + let client = match verify_client(state.openai(), &headers).await { + Ok(client) => client, Err(error) => return client_access_error_response(error.into()), }; - // 范围只能来自 Bearer 身份,不接受调用者指定 Key、账号或时间窗口。 + plugin_middleware::query_response( + state.openai().execution(), + client, + HttpMiddlewareInput::query(super::router::USAGE_PATH.to_owned(), None, &headers), + move |client| usage_response(state, client, uri), + ) + .await +} + +async fn usage_response( + state: ApiState, + client: AuthenticatedClient, + uri: axum::http::Uri, +) -> Response { + // 范围只能来自宿主认证后的身份,不接受插件或调用者指定 Key、账号或时间窗口。 if Query::::try_from_uri(&uri).is_err() { return openai_error_response( StatusCode::BAD_REQUEST, @@ -51,7 +69,12 @@ async fn usage( ) .into_response(); } - let budget = match state.admin_services().key_usage().budget(key).await { + let budget = match state + .admin_services() + .key_usage() + .budget_for_client(client.policy().key_id()) + .await + { Ok(Some(budget)) => budget, Ok(None) => return missing_client_api_key_response().into_response(), Err(_) => { diff --git a/backend/crates/gateway-api/src/provider.rs b/backend/crates/gateway-api/src/provider.rs new file mode 100644 index 000000000..881823c3e --- /dev/null +++ b/backend/crates/gateway-api/src/provider.rs @@ -0,0 +1,465 @@ +//! Provider 自有操作的稳定 HTTP adapter。 + +use std::net::SocketAddr; + +use axum::{ + Router, + body::{Body, Bytes}, + extract::{ConnectInfo, DefaultBodyLimit, Extension, Path, State}, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, header}, + response::{IntoResponse, Response}, + routing::{MethodFilter, on, post}, +}; +use bytes::BytesMut; +use gateway_core::{ + engine::execution::{ + ClientTransport, ExecutionRequestMetadata, ExecutionSession, StartProviderExecution, + StartedExecution, + }, + event::{ProviderEvent, ProviderResponseHeader}, + operation::{ + Operation, ProviderHttpHeader, ProviderHttpMethod, ProviderHttpRequest, RawHttpPayload, + RawJsonPayload, TokenCountRequest, + }, + routing::{ProviderKind, UpstreamModelId}, +}; + +use crate::{ + ApiState, + openai::{ + auth::{authenticate_client, client_access_error_response}, + error::{engine_error_response, gateway_error_response, openai_error_response}, + middleware::PendingExecution, + responses::request_client_context, + with_model_request_id, + }, +}; + +const MAXIMUM_BODY_BYTES: usize = 8 * 1024 * 1024; +const MAXIMUM_RAW_BODY_CHUNK_BYTES: usize = 64 * 1024; +const TOKEN_COUNT_PROTOCOL: &str = "token-count"; +const PROVIDER_HTTP_PROTOCOL: &str = "provider-http"; + +pub(crate) fn router() -> Router { + Router::new() + .route( + "/v1/providers/{provider}/models/{model}/count_tokens", + post(count_tokens), + ) + // `get()` 会隐式接受 HEAD;这里必须只匹配明确声明的 GET/POST。 + .route( + "/v1/providers/{provider}/http/{endpoint}", + on(MethodFilter::GET.or(MethodFilter::POST), provider_http).head(provider_http_head), + ) + .layer(DefaultBodyLimit::max(MAXIMUM_BODY_BYTES)) +} + +async fn provider_http_head() -> StatusCode { + StatusCode::METHOD_NOT_ALLOWED +} + +async fn count_tokens( + State(state): State, + connect_info: Option>>, + Path((provider, model)): Path<(String, String)>, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let service = state.openai(); + let client = match authenticate_client(service, &headers).await { + Ok(client) => client, + Err(error) => return client_access_error_response(error), + }; + let provider = match ProviderKind::new(provider) { + Ok(provider) => provider, + Err(_) => return invalid_request("provider identifier is invalid"), + }; + let model = match UpstreamModelId::new(model) { + Ok(model) => model, + Err(_) => return invalid_request("provider model identifier is invalid"), + }; + if serde_json::from_slice::(&body).is_err() { + return invalid_request("token count request must be valid JSON"); + } + let payload = match RawJsonPayload::new(TOKEN_COUNT_PROTOCOL, body) { + Ok(payload) => payload, + Err(_) => return invalid_request("token count request is invalid"), + }; + let (client_ip, user_agent) = request_client_context( + &headers, + connect_info.map(|Extension(ConnectInfo(address))| address), + ); + let started = match service + .start_bound_provider_endpoint(StartProviderExecution { + client, + provider, + upstream_model: Some(model), + operation: Operation::CountTokens(TokenCountRequest::from_raw_json(payload)), + metadata: ExecutionRequestMetadata { + protocol: TOKEN_COUNT_PROTOCOL.to_owned(), + endpoint: uri.path().to_owned(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip, + user_agent, + previous_response_id: None, + }, + }) + .await + { + Ok(started) => started, + Err(error) => return gateway_error_response(&error), + }; + collect_provider_response(started, ResponseBodyKind::Json).await +} + +async fn provider_http( + State(state): State, + connect_info: Option>>, + Path((provider, endpoint)): Path<(String, String)>, + uri: Uri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Response { + if method == Method::GET && !body.is_empty() { + return invalid_request("GET provider HTTP operations cannot carry a request body"); + } + let method = match method { + Method::GET => ProviderHttpMethod::Get, + Method::POST => ProviderHttpMethod::Post, + _ => return invalid_request("provider HTTP method is unsupported"), + }; + let service = state.openai(); + let client = match authenticate_client(service, &headers).await { + Ok(client) => client, + Err(error) => return client_access_error_response(error), + }; + let provider = match ProviderKind::new(provider) { + Ok(provider) => provider, + Err(_) => return invalid_request("provider identifier is invalid"), + }; + let connection_options = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>(); + let request_headers = headers + .iter() + .filter(|(name, _)| request_header_is_forwardable(name, &connection_options)) + .map(|(name, value)| { + ProviderHttpHeader::new(name.as_str(), Bytes::copy_from_slice(value.as_bytes())) + }) + .collect(); + let payload = match RawHttpPayload::new(PROVIDER_HTTP_PROTOCOL, body) { + Ok(payload) => payload, + Err(_) => return invalid_request("provider HTTP request is invalid"), + }; + let operation = match ProviderHttpRequest::new( + endpoint, + method, + uri.query().map(str::to_owned), + request_headers, + payload, + ) { + Ok(request) => Operation::ProviderHttp(request), + Err(_) => return invalid_request("provider HTTP request is invalid"), + }; + let (client_ip, user_agent) = request_client_context( + &headers, + connect_info.map(|Extension(ConnectInfo(address))| address), + ); + let started = match service + .start_bound_provider_endpoint(StartProviderExecution { + client, + provider, + upstream_model: None, + operation, + metadata: ExecutionRequestMetadata { + protocol: PROVIDER_HTTP_PROTOCOL.to_owned(), + endpoint: uri.path().to_owned(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip, + user_agent, + previous_response_id: None, + }, + }) + .await + { + Ok(started) => started, + Err(error) => return gateway_error_response(&error), + }; + collect_provider_response(started, ResponseBodyKind::Opaque).await +} + +#[derive(Clone, Copy)] +enum ResponseBodyKind { + Json, + Opaque, +} + +async fn collect_provider_response(started: StartedExecution, kind: ResponseBodyKind) -> Response { + let request_id = started.request_id; + let response = collect_provider_session(started.session, kind).await; + with_model_request_id(response, &request_id) +} + +async fn collect_provider_session( + session: Box, + kind: ResponseBodyKind, +) -> Response { + let mut execution = PendingExecution::new(session); + let Some(session) = execution.session_mut() else { + return invalid_upstream_response(); + }; + let events = match session.collect_uncommitted().await { + Ok(events) => events, + Err(error) => { + let response = sanitize_response_headers(engine_error_response(&error)); + return execution.record_response_status(response).await; + } + }; + let Some(body) = response_body(events, kind) else { + let response = execution + .record_response_status(invalid_upstream_response()) + .await; + execution.cancel_and_finalize().await; + return response; + }; + let status = match session.response_status_code() { + None => StatusCode::OK, + Some(status) => match StatusCode::from_u16(status) { + Ok(status) if status.is_success() => status, + _ => { + let response = execution + .record_response_status(invalid_upstream_response()) + .await; + execution.cancel_and_finalize().await; + return response; + } + }, + }; + let response_headers = session.response_headers().to_vec(); + let response = body_response(body, status, &response_headers, kind); + let Some(session) = execution.session_mut() else { + return invalid_upstream_response(); + }; + if let Err(error) = session.commit_downstream(Some(status.as_u16())).await { + execution.cancel_and_finalize().await; + let response = sanitize_response_headers(engine_error_response(&error)); + return execution.record_response_status(response).await; + } + if !session.is_finalized() { + execution.cancel_and_finalize().await; + return invalid_upstream_response(); + } + execution.disarm(); + response +} + +fn response_body(events: Vec, kind: ResponseBodyKind) -> Option { + let expected_protocol = match kind { + ResponseBodyKind::Json => TOKEN_COUNT_PROTOCOL, + ResponseBodyKind::Opaque => PROVIDER_HTTP_PROTOCOL, + }; + let mut body = BytesMut::new(); + let mut raw_body_events = 0usize; + let mut empty_body_event = false; + for event in events { + let (_, wire) = event.into_parts(); + let Some(wire) = wire.filter(|wire| wire.protocol() == expected_protocol) else { + continue; + }; + let raw = match kind { + ResponseBodyKind::Json => wire.into_raw_json_body(), + ResponseBodyKind::Opaque => wire.into_raw_http_body(), + }?; + raw_body_events = raw_body_events.checked_add(1)?; + if matches!(kind, ResponseBodyKind::Json) && raw_body_events > 1 { + return None; + } + if matches!(kind, ResponseBodyKind::Opaque) && raw.len() > MAXIMUM_RAW_BODY_CHUNK_BYTES { + return None; + } + let total = body.len().checked_add(raw.len())?; + if total > MAXIMUM_BODY_BYTES + || empty_body_event + || (raw.is_empty() && (raw_body_events > 1 || !body.is_empty())) + { + return None; + } + if raw.is_empty() { + empty_body_event = true; + } + body.extend_from_slice(&raw); + } + if raw_body_events == 0 { + return None; + } + let body = body.freeze(); + if matches!(kind, ResponseBodyKind::Json) + && serde_json::from_slice::(&body).is_err() + { + return None; + } + Some(body) +} + +fn body_response( + body: Bytes, + status: StatusCode, + response_headers: &[ProviderResponseHeader], + kind: ResponseBodyKind, +) -> Response { + let length = body.len(); + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + apply_safe_response_headers(&mut response, response_headers); + if matches!(kind, ResponseBodyKind::Json) { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + } else if !response.headers().contains_key(header::CONTENT_TYPE) { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + } + if let Ok(length) = HeaderValue::from_str(&length.to_string()) { + response + .headers_mut() + .insert(header::CONTENT_LENGTH, length); + } + response +} + +fn apply_safe_response_headers( + response: &mut Response, + response_headers: &[ProviderResponseHeader], +) { + let connection_options = connection_options(response_headers); + for header in response_headers { + if !response_header_is_forwardable(header.name(), &connection_options) { + continue; + } + let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else { + continue; + }; + let Ok(value) = HeaderValue::from_bytes(header.value()) else { + continue; + }; + if name == header::CONTENT_TYPE { + response.headers_mut().insert(name, value); + } else { + response.headers_mut().append(name, value); + } + } +} + +fn sanitize_response_headers(mut response: Response) -> Response { + let connection_options = response + .headers() + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>(); + let blocked = response + .headers() + .keys() + .filter(|name| !response_header_is_forwardable(name.as_str(), &connection_options)) + .cloned() + .collect::>(); + for name in blocked { + response.headers_mut().remove(name); + } + response +} + +fn connection_options(headers: &[ProviderResponseHeader]) -> Vec { + headers + .iter() + .filter(|header| header.name().eq_ignore_ascii_case("connection")) + .filter_map(|header| std::str::from_utf8(header.value()).ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn request_header_is_forwardable(name: &HeaderName, connection_options: &[String]) -> bool { + let name = name.as_str(); + !matches!( + name, + "authorization" + | "proxy-authorization" + | "x-api-key" + | "api-key" + | "cookie" + | "host" + | "content-length" + | "transfer-encoding" + | "connection" + | "keep-alive" + | "upgrade" + | "trailer" + | "forwarded" + | "x-forwarded-for" + | "x-forwarded-host" + | "x-forwarded-proto" + | "cf-connecting-ip" + | "x-real-ip" + ) && !name.starts_with("proxy-") + && !connection_options.iter().any(|option| option == name) +} + +fn response_header_is_forwardable(name: &str, connection_options: &[String]) -> bool { + let name = name.trim().to_ascii_lowercase(); + !matches!( + name.as_str(), + "authorization" + | "proxy-authenticate" + | "www-authenticate" + | "set-cookie" + | "location" + | "content-length" + | "content-encoding" + | "transfer-encoding" + | "connection" + | "keep-alive" + | "upgrade" + | "trailer" + ) && !name.starts_with("proxy-") + && !connection_options.iter().any(|option| option == &name) +} + +fn invalid_request(message: &'static str) -> Response { + openai_error_response( + StatusCode::BAD_REQUEST, + message, + "invalid_request_error", + "invalid_provider_operation", + ) + .into_response() +} + +fn invalid_upstream_response() -> Response { + openai_error_response( + StatusCode::BAD_GATEWAY, + "The gateway could not forward the provider response.", + "server_error", + "invalid_upstream_response", + ) + .into_response() +} diff --git a/backend/crates/gateway-api/tests/admin/accounts/capabilities.rs b/backend/crates/gateway-api/tests/admin/accounts/capabilities.rs new file mode 100644 index 000000000..060a07ae1 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/accounts/capabilities.rs @@ -0,0 +1,95 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use gateway_api::admin; +use serde_json::{Value, json}; +use tower::ServiceExt as _; + +use super::super::{AdminTestFixture, AdminTestState}; + +#[tokio::test] +async fn provider_capabilities_require_admin_and_do_not_invent_unsupported_operations() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for authenticated in [false, true] { + let mut request = Request::builder() + .uri("/api/admin/accounts/providers") + .header("x-request-id", "provider-capabilities-test"); + if authenticated { + request = request.header(header::COOKIE, "cpr_session=valid-session"); + } + let response = admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + if authenticated { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + } + ); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + if authenticated { + let body = to_bytes(response.into_body(), 8192).await.unwrap(); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + value["data"], + json!([ + {"provider":"openai", "credentials":{"import":null,"login":null,"refresh":false,"export":false}}, + {"provider":"xai", "credentials":{"import":null,"login":null,"refresh":false,"export":false}} + ]) + ); + } + } +} + +#[test] +fn schema_and_completion_mode_are_preserved_in_the_api_contract() { + use gateway_admin::model::provider_capabilities::{ + AuthorizationCompletion, ProviderCredentialCapabilities, ProviderCredentialDescriptor, + ProviderLoginCapability, + }; + let schema = json!({"type":"object", "properties":{"code":{"type":"string", "writeOnly":true}}, "required":["code"]}); + for (completion, expected) in [ + (AuthorizationCompletion::Callback, "callback"), + (AuthorizationCompletion::Poll, "poll"), + ] { + let data = admin::accounts::AccountProviderData::from(ProviderCredentialDescriptor { + provider: gateway_core::routing::ProviderKind::new("custom").unwrap(), + capabilities: ProviderCredentialCapabilities { + login: Some(ProviderLoginCapability { + input_schema: schema.clone(), + completion, + }), + ..Default::default() + }, + }); + let value = serde_json::to_value(data).unwrap(); + assert_eq!( + value["credentials"]["login"], + json!({"inputSchema":schema, "completion":expected}) + ); + } +} + +#[test] +fn login_input_is_optional_but_must_be_a_bounded_object() { + use admin::accounts::StartAccountAuthorizationRequest; + let base = json!({"provider":"custom", "name":"account"}); + let request: StartAccountAuthorizationRequest = serde_json::from_value(base.clone()).unwrap(); + assert!(request.input.is_empty()); + request.validate().unwrap(); + for invalid in [Value::Null, json!([]), json!("token")] { + let mut value = base.clone(); + value["input"] = invalid; + assert!(serde_json::from_value::(value).is_err()); + } + let mut value = base; + value["input"] = json!({"token":"x".repeat(64 * 1024)}); + let request: StartAccountAuthorizationRequest = serde_json::from_value(value).unwrap(); + assert_eq!(request.validate().unwrap_err().field(), "input"); +} diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index a8871e4d2..24cb15625 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -7,6 +7,147 @@ use tower::ServiceExt as _; use super::super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn standalone_credential_rotation_route_is_not_exposed() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let response = admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/accounts/rotate") + .header(header::COOKIE, "cpr_session=valid-session") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn connection_update_requires_admin_and_validates_before_calling_the_service() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let input = serde_json::json!({ + "accountId":"acct_api", + "enabled":true, + "concurrencyLimit":null, + "weight":1, + "groupIds":[], + "connection":{"baseUrl":"https://api.example.invalid/v1", "transport":"http"} + }); + for (authenticated, transport, expected) in [ + (false, "http", StatusCode::UNAUTHORIZED), + (true, "invalid", StatusCode::BAD_REQUEST), + // 夹具没有凭据 Store,合法输入必须进入服务,不能按普通设置静默保存。 + (true, "http", StatusCode::SERVICE_UNAVAILABLE), + ] { + let mut input = input.clone(); + input["connection"]["transport"] = serde_json::json!(transport); + let mut request = Request::builder() + .method("POST") + .uri("/api/admin/accounts/update") + .header(header::CONTENT_TYPE, "application/json") + .header("x-request-id", "req_connection_update"); + if authenticated { + request = request.header(header::COOKIE, "cpr_session=valid-session"); + } + let response = admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::from(input.to_string())).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } +} + +#[test] +fn authorization_poll_response_preserves_every_committed_account() { + use gateway_admin::model::{ + Revision, + provider_credentials::{ + AuthorizationPollResult, AuthorizedAccount, CredentialBatchMutationResult, + }, + }; + let data = gateway_api::admin::accounts::AccountAuthorizationPollData::from( + AuthorizationPollResult::Complete(CredentialBatchMutationResult { + config_revision: Revision::new(3).unwrap(), + accounts: ["acct_first", "acct_second"] + .into_iter() + .map(|id| AuthorizedAccount { + account_id: gateway_core::account::ProviderAccountId::new(id).unwrap(), + credential_revision: Some(Revision::new(1).unwrap()), + }) + .collect(), + }), + ); + assert_eq!( + serde_json::to_value(data).unwrap(), + serde_json::json!({"status":"complete", "accountIds":["acct_first", "acct_second"]}) + ); +} + +#[tokio::test] +async fn authorization_poll_requires_admin_validates_input_and_preserves_pending_result() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (input, authenticated, expected) in [ + ( + serde_json::json!({"provider":"xai", "flowId":"flow-test"}), + false, + StatusCode::UNAUTHORIZED, + ), + ( + serde_json::json!({"provider":"xai", "flowId":""}), + true, + StatusCode::BAD_REQUEST, + ), + ( + serde_json::json!({"provider":"xai", "flowId":"flow-test", "accountId":"acct_forged"}), + true, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + serde_json::json!({"provider":"xai", "flowId":"flow-test", "callbackUrl":""}), + true, + StatusCode::BAD_REQUEST, + ), + ( + serde_json::json!({"provider":"xai", "flowId":"flow-test"}), + true, + StatusCode::OK, + ), + ] { + let mut request = Request::builder() + .method("POST") + .uri("/api/admin/accounts/oauth/poll") + .header(header::CONTENT_TYPE, "application/json") + .header("x-request-id", "req_login_poll"); + if authenticated { + request = request.header(header::COOKIE, "cpr_session=valid-session"); + } + let response = admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::from(input.to_string())).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let body = to_bytes(response.into_body(), 8192).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + if expected == StatusCode::OK { + assert_eq!( + value["data"], + serde_json::json!({"status":"pending", "retryAfterMs":1250}) + ); + } + } +} + #[tokio::test] async fn personal_info_requires_admin_and_a_valid_account_query() { let fixture = AdminTestFixture::new().await; diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 2d956e27c..90da58670 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -1,3 +1,4 @@ +mod capabilities; mod handlers; mod import_tasks; mod presenter; @@ -78,6 +79,34 @@ mod query { use gateway_api::admin::accounts::ListQuery; use serde_json::json; + #[test] + fn account_query_should_filter_every_explicit_provider_id_literally() { + for provider in ["all", "ALL", "example"] { + let query: ListQuery = serde_json::from_value(json!({ "provider": provider })).unwrap(); + assert_eq!( + query + .validate() + .unwrap() + .provider_kind + .as_ref() + .map(|kind| kind.as_str()), + Some(provider) + ); + } + } + + #[test] + fn account_query_should_only_omit_empty_provider_filters() { + for value in [ + json!({}), + json!({ "provider": "" }), + json!({ "provider": " " }), + ] { + let query: ListQuery = serde_json::from_value(value).unwrap(); + assert!(query.validate().unwrap().provider_kind.is_none()); + } + } + #[test] fn account_query_should_parse_provider_status_and_sort_once() { let query: ListQuery = serde_json::from_value(json!({ @@ -501,7 +530,7 @@ mod actions { AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery, AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest, AccountResetCreditConsumeRequest, AccountTestQuery, CompleteAccountAuthorizationRequest, - RotateAccountRequest, StartAccountAuthorizationRequest, + StartAccountAuthorizationRequest, UpdateAccountRequest, }; use gateway_core::{ account::ProviderAccountId, engine::probe::AccountProbeErrorSource, @@ -601,43 +630,81 @@ mod actions { } #[test] - fn api_key_rotation_settings_must_target_the_same_account_and_remain_valid() { + fn account_update_validates_connection_and_settings_together() { let mut request = json!({ - "provider": "openai", "accountId": "acct_api", - "baseUrl": "https://api.example.invalid/v1", - "transport": "http", - "settings": { - "accountId": "acct_api", - "enabled": true, - "concurrencyLimit": null, - "weight": 1, - "groupIds": [] - } + "connection": { + "baseUrl": "https://api.example.invalid/v1", + "transport": "http" + }, + "enabled": true, + "concurrencyLimit": null, + "weight": 1, + "groupIds": [] }); - serde_json::from_value::(request.clone()) + serde_json::from_value::(request.clone()) .expect("decode combined save") .validate() - .expect("blank replacement key preserves the existing key"); - request["settings"]["accountId"] = json!("acct_other"); + .expect("omitted replacement key preserves the existing key"); + request["connection"]["apiKey"] = json!(""); assert_eq!( - serde_json::from_value::(request.clone()) + serde_json::from_value::(request.clone()) .unwrap() .validate() .unwrap_err() .field(), - "settings.accountId" + "connection.apiKey" ); - request["settings"]["accountId"] = json!("acct_api"); - request["settings"]["concurrencyLimit"] = json!(0); + request["connection"]["apiKey"] = json!("test-replacement-key"); + request["concurrencyLimit"] = json!(0); assert!( - serde_json::from_value::(request) + serde_json::from_value::(request) .unwrap() .validate() .is_err() ); } + #[test] + fn account_connection_update_rejects_invalid_fields_and_generic_credentials() { + let request = json!({ + "accountId": "acct_api", + "enabled": true, + "concurrencyLimit": null, + "weight": 1, + "groupIds": [], + "connection": {"baseUrl": "https://api.example.invalid/v1", "transport": "http"} + }); + for (field, value, expected) in [ + ("baseUrl", "", "connection.baseUrl"), + ("transport", "websocket", "connection.transport"), + ("apiKey", "invalid key", "connection.apiKey"), + ] { + let mut invalid = request.clone(); + invalid["connection"][field] = json!(value); + assert_eq!( + serde_json::from_value::(invalid) + .unwrap() + .validate() + .unwrap_err() + .field(), + expected + ); + } + for field in [ + "accountId", + "provider", + "accessToken", + "refreshToken", + "data", + "expectedCredentialRevision", + ] { + let mut invalid = request.clone(); + invalid["connection"][field] = json!("unsupported"); + assert!(serde_json::from_value::(invalid).is_err()); + } + } + #[test] fn credential_recovery_requests_should_not_accept_client_revision_fences() { let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({ @@ -656,25 +723,6 @@ mod actions { })) .is_err() ); - - let rotation: RotateAccountRequest = serde_json::from_value(json!({ - "provider": "openai", - "accountId": "acct_1", - "accessToken": "header.payload.signature", - "refreshToken": "refresh-token", - "idToken": "id-header.id-payload.id-signature" - })) - .expect("decode rotation"); - assert!(rotation.validate().is_ok()); - assert!( - serde_json::from_value::(json!({ - "provider": "openai", - "accountId": "acct_1", - "expectedCredentialRevision": 1, - "accessToken": "header.payload.signature" - })) - .is_err() - ); } #[test] diff --git a/backend/crates/gateway-api/tests/admin/client_keys.rs b/backend/crates/gateway-api/tests/admin/client_keys.rs index 8b2e00da9..6c2ca4f45 100644 --- a/backend/crates/gateway-api/tests/admin/client_keys.rs +++ b/backend/crates/gateway-api/tests/admin/client_keys.rs @@ -432,8 +432,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() { .single() .expect("valid time"); let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), budget: Default::default(), id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"), name: "visible".to_owned(), @@ -574,11 +573,13 @@ async fn list_route_should_accept_the_full_nonzero_u16_page_size() { fn profile_override_distinguishes_omission_from_explicit_inheritance() { let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0}); let decode = |body| { - serde_json::from_value::(body) + let mut command = serde_json::from_value::(body) .unwrap() .into_command() - .unwrap() - .openai_client_profile_override + .unwrap(); + command + .request_profile_override_updates + .remove(&gateway_core::routing::ProviderKind::new("openai").expect("Provider kind")) }; assert_eq!(decode(payload.clone()), None); payload["openaiClientProfileOverride"] = json!(null); @@ -592,11 +593,13 @@ fn profile_override_distinguishes_omission_from_explicit_inheritance() { fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() { let mut payload = json!({"id":"key_profile", "name":"profile", "groupIds":[], "maxConcurrency":0, "requestsPerMinute":0}); let decode = |body| { - serde_json::from_value::(body) + let mut command = serde_json::from_value::(body) .unwrap() .into_command() - .unwrap() - .xai_client_profile_override + .unwrap(); + command + .request_profile_override_updates + .remove(&gateway_core::routing::ProviderKind::new("xai").expect("Provider kind")) }; assert_eq!(decode(payload.clone()), None); payload["xaiClientProfileOverride"] = json!(null); @@ -604,3 +607,58 @@ fn xai_profile_override_distinguishes_omission_from_explicit_inheritance() { payload["xaiClientProfileOverride"] = json!({"versionMode":"latest"}); assert!(decode(payload).unwrap().is_some()); } + +#[test] +fn generic_profile_overrides_accept_dynamic_providers_and_reject_legacy_conflicts() { + let profile = json!({"preset":"desktop"}); + let dynamic = json!({"preset":"managed"}); + let request: CreateClientKeyRequest = serde_json::from_value(json!({ + "name":"profile", + "groupIds":[], + "maxConcurrency":0, + "requestsPerMinute":0, + "providerRequestProfileOverrides": { + "openai": profile, + "provider.example": dynamic, + }, + "openaiClientProfileOverride": {"preset":"desktop"}, + })) + .unwrap(); + let command = request.into_command().unwrap(); + assert_eq!(command.request_profile_overrides.len(), 2); + assert!(command.request_profile_overrides.contains_key( + &gateway_core::routing::ProviderKind::new("provider.example").expect("Provider kind") + )); + + let conflict: CreateClientKeyRequest = serde_json::from_value(json!({ + "name":"profile", + "groupIds":[], + "maxConcurrency":0, + "requestsPerMinute":0, + "providerRequestProfileOverrides": {"openai":{"preset":"desktop"}}, + "openaiClientProfileOverride": {"preset":"cli"}, + })) + .unwrap(); + assert_eq!( + conflict.into_command().unwrap_err().field(), + "providerRequestProfileOverrides" + ); +} + +#[test] +fn generic_profile_override_clear_rejects_conflicting_legacy_update() { + let request: UpdateClientKeyRequest = serde_json::from_value(json!({ + "id":"key_profile", + "name":"profile", + "groupIds":[], + "maxConcurrency":0, + "requestsPerMinute":0, + "providerRequestProfileOverrides": {"xai":null}, + "xaiClientProfileOverride": {"preset":"desktop"}, + })) + .unwrap(); + assert_eq!( + request.into_command().unwrap_err().field(), + "providerRequestProfileOverrides" + ); +} diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index e7475c8cf..0989b4e99 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -1,3 +1,4 @@ +mod plugins; use std::{ collections::BTreeMap, sync::{ @@ -41,10 +42,10 @@ use gateway_admin::{ provider_credentials::{ AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialMutationResult, - CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport, - PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit, - PreparedCredentialImport, PreparedCredentialRotation, ProviderExport, - ProviderExportCredentialInput, ProviderModels, ProviderQuota, + CredentialRotationCommit, PrepareCredentialImport, PrepareCredentialRefresh, + PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport, + PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, + ProviderModels, ProviderQuota, }, settings::{ AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings, @@ -91,6 +92,8 @@ mod wire; pub(super) struct AdminTestFixture { pub services: AdminServices, + plugin_ports: Arc, + published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle, pub client_key: Arc>>, pub observations: Arc>, pub auth: Arc, @@ -150,6 +153,8 @@ impl AdminTestFixture { dashboard_summary_range: Arc::clone(&dashboard_summary_range), account: Arc::clone(&account), }); + let plugin_ports = Arc::new(plugins::TestPluginPorts::default()); + let published_snapshot = gateway_core::runtime::RuntimeSnapshotHandle::default(); let stores = AdminStorePorts::new( AdminAccountStorePorts::new( unused.clone(), @@ -162,6 +167,8 @@ impl AdminTestFixture { unused, settings.clone(), gateway_admin::ports::backup::BackupStorePorts::disabled(), + plugin_ports.clone(), + plugin_ports.clone(), ); let providers: Vec> = vec![ Arc::new(UnusedProvider::new("openai", Arc::clone(&provider_error))), @@ -176,8 +183,14 @@ impl AdminTestFixture { ClientConfig::default(), stores, gateway_admin::AdminRuntimePorts { + plugin_preparation: plugin_ports.clone(), + plugin_management: plugin_ports.clone(), + published_snapshot: published_snapshot.clone(), + plugin_inspector: plugin_ports.clone(), + plugin_distribution: plugin_ports.clone(), pricing_source: Arc::new(StaticPricingSource), - providers, + providers: gateway_admin::ports::provider::ProviderAdminRegistry::new(providers) + .unwrap(), snapshot: Arc::new(NoopSnapshot), account_probe: Arc::new(NoopProbe), proxy_probe: Arc::new(proxies::SuccessfulProbe), @@ -190,6 +203,8 @@ impl AdminTestFixture { .expect("initialize test admin services"); Self { services: bundle.services(), + plugin_ports, + published_snapshot, client_key, observations, auth, @@ -519,9 +534,16 @@ impl SettingsStore for MemorySettingsStore { _: &MutationContext, ) -> AdminStoreResult { let mut settings = self.settings.lock().expect("settings"); + let mut request_profiles = settings.request_profiles.clone(); + for (provider, profile) in command.request_profile_updates { + if let Some(profile) = profile { + request_profiles.insert(provider, profile); + } else { + request_profiles.remove(&provider); + } + } let updated = RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles, request_location_enabled: command.request_location_enabled, request_location: command.request_location, config_revision: next_revision(settings.config_revision), @@ -896,8 +918,7 @@ impl ClientKeyStore for MemoryClientKeyStore { let now = Utc::now(); Ok(Some(ClientKeySecret::new( ClientKeyRecord { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), budget: Default::default(), id: id.clone(), name: "revealed".to_owned(), @@ -969,6 +990,13 @@ impl ClientKeyVerifier for UnusedClientKeyVerifier { #[async_trait] impl AccountStore for UnusedStore { + async fn list_plugin_accounts( + &self, + _: gateway_admin::model::provider_credentials::PluginAccountListQuery, + ) -> AdminStoreResult { + Err(unavailable("plugin account list")) + } + async fn list_accounts( &self, _: AccountListQuery, @@ -1018,6 +1046,13 @@ impl AccountStore for UnusedStore { Err(unavailable("credential")) } + async fn credential_details_by_id( + &self, + _: &ProviderAccountId, + ) -> AdminStoreResult> { + Err(unavailable("plugin credential")) + } + async fn load_credentials_for_export( &self, _: &ProviderKind, @@ -1026,6 +1061,13 @@ impl AccountStore for UnusedStore { Err(unavailable("credential export")) } + async fn load_credential_for_plugin( + &self, + _: &ProviderAccountId, + ) -> AdminStoreResult> { + Err(unavailable("plugin credential export")) + } + async fn commit_credential_import( &self, _: CredentialImportCommit, @@ -1034,11 +1076,21 @@ impl AccountStore for UnusedStore { Err(unavailable("credential import")) } + async fn authorization_receipt( + &self, + _: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey, + ) -> AdminStoreResult< + Option, + > { + Ok(None) + } + async fn commit_authorization( &self, _: AuthorizationCommit, _: &MutationContext, - ) -> AdminStoreResult { + ) -> AdminStoreResult + { Err(unavailable("authorization")) } @@ -1189,6 +1241,10 @@ impl ObservabilityStore for UnusedStore { })) } + async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult> { + Ok(vec!["retired-provider".into()]) + } + async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult { self.observations .lock() @@ -1281,7 +1337,7 @@ impl ProviderAdmin for UnusedProvider { async fn account_unavailable(&self, _: &ProviderAccountId) {} - fn connection_test_operation( + async fn connection_test_operation( &self, _: &gateway_core::routing::UpstreamModelId, _: &str, @@ -1319,7 +1375,7 @@ impl ProviderAdmin for UnusedProvider { async fn start_authorization( &self, - _: PendingAuthorizationMutation, + _: gateway_admin::model::provider_credentials::PrepareAuthorization, ) -> Result { Err(unsupported_provider()) } @@ -1331,6 +1387,23 @@ impl ProviderAdmin for UnusedProvider { Err(unsupported_provider()) } + async fn poll_authorization( + &self, + _: gateway_admin::model::provider_credentials::PollAuthorization, + ) -> Result< + gateway_admin::model::provider_credentials::PreparedAuthorizationPoll, + ProviderAdminError, + > { + if let Some(error) = self.error.lock().expect("provider error").clone() { + return Err(error); + } + Ok( + gateway_admin::model::provider_credentials::PreparedAuthorizationPoll::Pending { + retry_after: std::time::Duration::from_millis(1250), + }, + ) + } + async fn prepare_rotation( &self, _: PrepareCredentialRotation, @@ -1387,6 +1460,7 @@ impl AccountProbe for NoopProbe { fn probe( &self, _: AccountProbeRequest, + _: Option>, ) -> BoxFuture<'_, Result> { Box::pin(async { panic!("unused account probe") }) } @@ -1411,6 +1485,7 @@ impl SystemOperations for UnusedSystem { async fn perform_update( &self, _: Option, + _: Arc, ) -> Result { Err(unavailable_system()) } @@ -1419,7 +1494,10 @@ impl SystemOperations for UnusedSystem { Err(unavailable_system()) } - async fn rollback(&self) -> Result { + async fn rollback( + &self, + _: Arc, + ) -> Result { Err(unavailable_system()) } @@ -1440,8 +1518,7 @@ fn test_runtime_settings() -> RuntimeSettings { ), ]); RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index d1d4b3b7b..1a4bd618c 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -10,6 +10,67 @@ use gateway_api::admin::observability::{ }; use serde_json::json; +#[tokio::test] +async fn usage_provider_filters_require_admin_and_validate_the_range() { + use crate::admin::{AdminTestFixture, AdminTestState}; + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use gateway_api::admin::observability; + use tower::ServiceExt as _; + + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let app = observability::router::().with_state(fixture.state()); + let anonymous = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/usage/providers") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + for query in [ + "startTime=invalid", + "startTime=2026-02-02T00:00:00Z&endTime=2026-02-01T00:00:00Z", + "startTime=2024-01-01T00:00:00Z&endTime=2026-01-01T00:00:00Z", + "provider=openai", + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/api/admin/usage/providers?{query}")) + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_provider_filter_invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{query}"); + } + let response = app + .oneshot( + Request::builder() + .uri("/api/admin/usage/providers?startTime=2026-02-01T00:00:00Z&endTime=2026-02-02T00:00:00Z") + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "req_provider_filter_valid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), 1024).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["data"], json!(["retired-provider"])); +} + #[test] fn usage_page_should_keep_terminal_camel_case_shape() { let data = PageData { diff --git a/backend/crates/gateway-api/tests/admin/plugins/management.rs b/backend/crates/gateway-api/tests/admin/plugins/management.rs new file mode 100644 index 000000000..33fa81e20 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/plugins/management.rs @@ -0,0 +1,680 @@ +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use futures::future::BoxFuture; +use gateway_core::{ + engine::{ + execution::{ + AuthenticatedClient, ClientAuthenticationError, ExecutionService, + PreparedRootExecution, StartExecution, StartProviderExecution, StartedExecution, + }, + middleware::{ + FrozenMiddlewarePlan, MiddlewareBody, MiddlewareContext, MiddlewareError, + MiddlewareFrame, MiddlewareFraming, MiddlewareHeader, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, + }, + }, + error::GatewayError, + lifecycle::CancellationToken, + policy::ClientApiKeyId, + routing::PublicModelId, + runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, +}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use tower::ServiceExt as _; + +use super::super::AdminTestFixture; + +#[derive(Debug, Clone, Copy)] +enum ModelReply { + Complete, + PendingResponse, + PendingBody, +} + +struct ModelExecution { + reply: ModelReply, + cancellation: Mutex>, + entered: Arc, + closed: Arc, +} + +impl ModelExecution { + fn new(reply: ModelReply) -> Arc { + Arc::new(Self { + reply, + cancellation: Mutex::new(None), + entered: Arc::default(), + closed: Arc::default(), + }) + } +} + +impl ExecutionService for ModelExecution { + fn authenticate(&self, _: &str) -> Result { + panic!("管理模型桥不能使用管理 Cookie 或 Bearer 作为 Client Key") + } + fn public_models(&self, _: &AuthenticatedClient) -> Vec { + vec![] + } + fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool { + true + } + fn prepare_plugin_execution( + &self, + key: &ClientApiKeyId, + ) -> BoxFuture<'_, Result> { + assert_eq!(key.as_str(), "client-key"); + Box::pin(async move { + let prepared = self + .prepare_execution(crate::openai::authenticated_client("fixture-client-key")) + .await?; + *self.cancellation.lock().unwrap() = Some(prepared.cancellation()); + Ok(prepared) + }) + } + fn middleware_plan(&self, _: &PreparedRootExecution) -> Option { + Some(FrozenMiddlewarePlan::new( + Arc::new(ModelMiddleware { + reply: self.reply, + entered: self.entered.clone(), + closed: self.closed.clone(), + }), + ExtensionSetReference::new( + ExtensionSetId::new("model-bridge-test".into()).unwrap(), + Arc::new(ModelLease), + ), + )) + } + fn start(&self, _: StartExecution) -> BoxFuture<'_, Result> { + panic!("测试中间件直接返回响应") + } + fn start_provider_endpoint( + &self, + _: StartProviderExecution, + ) -> BoxFuture<'_, Result> { + panic!("Responses 不走 Provider 端点") + } +} + +struct ModelLease; +impl ExtensionSetLease for ModelLease { + fn is_ready(&self) -> bool { + true + } +} + +#[derive(Debug)] +struct ModelMiddleware { + reply: ModelReply, + entered: Arc, + closed: Arc, +} + +impl MiddlewarePlan for ModelMiddleware { + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + _: Box, + ) -> BoxFuture<'static, Result> { + for header in request.headers() { + assert!(!["cookie", "authorization", "x-api-key"].contains(&header.name())); + } + assert!( + request + .headers() + .iter() + .any(|header| header.name() == "content-type") + ); + let reply = self.reply; + let closed = self.closed.clone(); + let entered = self.entered.clone(); + Box::pin(async move { + entered.notify_one(); + if matches!(reply, ModelReply::PendingResponse) { + return futures::future::pending().await; + } + let (bytes, framing, terminal) = if matches!(reply, ModelReply::PendingBody) { + ( + "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_bridge\",\"object\":\"response\",\"status\":\"in_progress\",\"output\":[]}}\n\n", + MiddlewareFraming::SseEvent, + false, + ) + } else { + ( + "{\"id\":\"resp_bridge\",\"object\":\"response\",\"status\":\"completed\",\"output\":[]}", + MiddlewareFraming::JsonDocument, + true, + ) + }; + Ok(MiddlewareResponse::new( + "openai".into(), + 200, + vec![ + MiddlewareHeader::new("set-cookie", "fixture=value".into()), + MiddlewareHeader::new("www-authenticate", "Bearer".into()), + ], + Box::new(ModelBody { + frame: Some(MiddlewareFrame::new(bytes.into(), framing, terminal)), + pending: !terminal, + closed, + }), + )) + }) + } +} + +struct ModelBody { + frame: Option, + pending: bool, + closed: Arc, +} + +impl MiddlewareBody for ModelBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + if let Some(frame) = self.frame.take() { + return Ok(Some(frame)); + } + if self.pending { + return futures::future::pending().await; + } + Ok(None) + }) + } + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { + self.closed.store(true, Ordering::SeqCst); + }) + } +} + +fn model_request(stream: bool) -> Request { + Request::post(format!("/api/admin/plugins/extensions/plugin-instance-diagnostic/{}/7/models/responses?clientKeyId=client-key", "a".repeat(64))) + .header("cookie", "cpr_session=management-session") + .header("authorization", "Bearer admin-fixture") + .header("x-api-key", format!("admin-{}", "a".repeat(64))) + .header("content-type", "application/json") + .body(Body::from(serde_json::json!({"model":"model-a", "input":"hello", "stream":stream}).to_string())).unwrap() +} + +#[tokio::test] +async fn model_bridge_executes_and_protects_request_and_response_headers() { + let fixture = published_fixture().await; + let execution = ModelExecution::new(ModelReply::Complete); + let response = crate::openai::api_router_with_admin_and_execution(fixture.services, execution) + .oneshot(model_request(false)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key("set-cookie")); + assert!(!response.headers().contains_key("www-authenticate")); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!(response.headers()["referrer-policy"], "no-referrer"); + assert_eq!(response.headers()["cache-control"], "no-store"); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&body).unwrap()["id"], + "resp_bridge" + ); +} + +#[tokio::test(start_paused = true)] +async fn model_bridge_revokes_while_waiting_for_the_first_response() { + let fixture = published_fixture().await; + let execution = ModelExecution::new(ModelReply::PendingResponse); + let router = + crate::openai::api_router_with_admin_and_execution(fixture.services, execution.clone()); + let request = tokio::spawn(router.oneshot(model_request(false))); + tokio::time::timeout( + std::time::Duration::from_secs(5), + execution.entered.notified(), + ) + .await + .expect("请求已进入模型执行链"); + *fixture.plugin_ports.instances.lock().unwrap() = Some(vec![]); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + assert_eq!( + request.await.unwrap().unwrap().status(), + StatusCode::FORBIDDEN + ); + assert!( + execution + .cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); +} + +#[tokio::test(start_paused = true)] +async fn model_bridge_revokes_and_closes_a_pending_stream() { + let fixture = published_fixture().await; + let execution = ModelExecution::new(ModelReply::PendingBody); + let response = + crate::openai::api_router_with_admin_and_execution(fixture.services, execution.clone()) + .oneshot(model_request(true)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = tokio::spawn(axum::body::to_bytes(response.into_body(), 4096)); + *fixture.plugin_ports.instances.lock().unwrap() = Some(vec![]); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + let body = body.await.unwrap().unwrap(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("response.failed")); + assert!(!body.contains("response.completed")); + assert!(body.ends_with("data: [DONE]\n\n")); + assert!( + execution + .cancellation + .lock() + .unwrap() + .as_ref() + .unwrap() + .is_cancelled() + ); + assert!(execution.closed.load(Ordering::SeqCst)); +} + +pub(super) fn resource_fixture( + path: &str, +) -> gateway_admin::model::plugins::management::PluginManagementResponse { + gateway_admin::model::plugins::management::PluginManagementResponse { + status: if path == "missing" { 404 } else { 200 }, + content_type: if path.ends_with(".css") { + "text/css" + } else { + "text/html" + } + .into(), + body: std::sync::Arc::from(path.as_bytes()), + } +} + +async fn published_fixture() -> AdminTestFixture { + use gateway_core::{ + account::{AccountSelectionPolicy, RotationStrategy}, + routing::{ConfigRevision, RuntimeSnapshot}, + runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, + }; + use std::{num::NonZeroU32, sync::Arc, time::Duration}; + struct Lease; + impl ExtensionSetLease for Lease { + fn is_ready(&self) -> bool { + true + } + } + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("management-session"); + fixture + .auth + .set_api_key(&format!("admin-{}", "a".repeat(64))); + fixture.published_snapshot.publish( + RuntimeSnapshot::new( + ConfigRevision::new(7).unwrap(), + AccountSelectionPolicy::new( + RotationStrategy::Smart, + NonZeroU32::new(1).unwrap(), + Duration::ZERO, + ), + vec![], + vec![], + vec![], + ) + .unwrap() + .with_extensions(Some(ExtensionSetReference::new( + ExtensionSetId::new("management-test".into()).unwrap(), + Arc::new(Lease), + ))), + ); + fixture +} + +async fn resource_request( + fixture: &AdminTestFixture, + method: &str, + uri: &str, + session: bool, + validators: &[&str], +) -> axum::response::Response { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("x-request-id", "management-cache-test"); + if session { + request = request.header("cookie", "cpr_session=management-session"); + } + for validator in validators { + request = request.header("if-none-match", *validator); + } + crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap() +} + +#[tokio::test] +async fn static_resources_revalidate_privately_after_authorization() { + use axum::body::to_bytes; + use gateway_admin::ports::plugins::PluginStore as _; + + let fixture = published_fixture().await; + let prefix = format!( + "/api/admin/plugins/extensions/plugin-instance-diagnostic/{}/7", + "a".repeat(64) + ); + let uri = format!("{prefix}/resources/ui/index.html"); + let first = resource_request(&fixture, "GET", &uri, true, &[]).await; + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(first.headers()["cache-control"], "private, no-cache"); + let etag = first.headers()["etag"].to_str().unwrap().to_owned(); + let csp = first.headers()["content-security-policy"].clone(); + assert_eq!( + to_bytes(first.into_body(), 1024).await.unwrap(), + "ui/index.html" + ); + + for validators in [ + vec![etag.as_str()], + vec!["*"], + vec!["\"other,tag\"", etag.as_str()], + vec!["\"other\"", &format!("W/{etag}")], + ] { + for method in ["GET", "HEAD"] { + let response = resource_request(&fixture, method, &uri, true, &validators).await; + assert_eq!( + response.status(), + StatusCode::NOT_MODIFIED, + "{validators:?}" + ); + assert_eq!(response.headers()["etag"], etag); + assert_eq!(response.headers()["cache-control"], "private, no-cache"); + assert_eq!(response.headers()["content-security-policy"], csp); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert!( + to_bytes(response.into_body(), 1024) + .await + .unwrap() + .is_empty() + ); + } + } + for validators in [vec!["\"other\""], vec!["invalid"]] { + assert_eq!( + resource_request(&fixture, "GET", &uri, true, &validators) + .await + .status(), + StatusCode::OK + ); + } + let changed = resource_request( + &fixture, + "GET", + &format!("{prefix}/resources/ui/other.css"), + true, + &[&etag], + ) + .await; + assert_eq!(changed.status(), StatusCode::OK); + assert_ne!(changed.headers()["etag"], etag); + let unauthenticated = resource_request(&fixture, "GET", &uri, false, &[&etag]).await; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + assert_eq!(unauthenticated.headers()["cache-control"], "no-store"); + + let original = fixture + .plugin_ports + .load_instances() + .await + .unwrap() + .instances; + for change in ["disable", "untrust", "revision", "version"] { + let mut instances = original.clone(); + match change { + "disable" => instances[0].enabled = false, + "untrust" => instances[0].trusted_process = false, + "revision" => instances[0].revision = gateway_admin::model::Revision::new(8).unwrap(), + _ => instances[0].artifact_sha256 = "b".repeat(64), + } + *fixture.plugin_ports.instances.lock().unwrap() = Some(instances); + let response = resource_request(&fixture, "GET", &uri, true, &[&etag]).await; + assert_eq!(response.status(), StatusCode::CONFLICT, "{change}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("etag")); + } +} + +#[tokio::test] +async fn public_resources_cache_but_dynamic_calls_callbacks_and_errors_do_not() { + let fixture = published_fixture().await; + let target = format!("plugin-instance-diagnostic/{}/7", "a".repeat(64)); + let public = format!("/plugins/resources/{target}/public/login.css"); + let first = resource_request(&fixture, "GET", &public, false, &[]).await; + assert_eq!(first.status(), StatusCode::OK); + let etag = first.headers()["etag"].to_str().unwrap(); + assert_eq!( + resource_request(&fixture, "GET", &public, false, &[etag]) + .await + .status(), + StatusCode::NOT_MODIFIED + ); + for (uri, status) in [ + ( + format!("/plugins/resources/{target}/ui/index.html"), + StatusCode::NOT_FOUND, + ), + ( + format!("/api/admin/plugins/extensions/{target}/resources/missing"), + StatusCode::NOT_FOUND, + ), + ( + format!("/api/admin/plugins/extensions/{target}/api/echo"), + StatusCode::OK, + ), + ( + format!("/plugins/callbacks/{target}/oauth?state=nonce"), + StatusCode::OK, + ), + ] { + let response = resource_request(&fixture, "GET", &uri, true, &["*"]).await; + assert_eq!(response.status(), status, "{uri}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response.headers().contains_key("etag")); + } + *fixture.plugin_ports.instances.lock().unwrap() = Some(vec![]); + let response = resource_request(&fixture, "GET", &public, false, &[etag]).await; + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(response.headers()["cache-control"], "no-store"); +} + +#[tokio::test] +async fn model_responses_requires_the_exact_authorized_target_before_execution() { + let fixture = published_fixture().await; + let target = format!("plugin-instance-diagnostic/{}/7", "a".repeat(64)); + let request = |target: &str, query: &str| { + Request::builder() + .method("POST") + .uri(format!( + "/api/admin/plugins/extensions/{target}/models/responses?{query}" + )) + .header("cookie", "cpr_session=management-session") + .header("content-type", "application/json") + .header("x-request-id", "management-model-request") + .body(Body::from( + serde_json::json!({"model":"model-a","input":"hello","stream":false}).to_string(), + )) + .unwrap() + }; + + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot(request(&target, "clientKeyId=client-key")) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "精确目标通过授权后应执行并返回结果" + ); + + for changed in [ + format!("plugin-instance-diagnostic/{}/8", "a".repeat(64)), + format!("plugin-instance-diagnostic/{}/7", "b".repeat(64)), + ] { + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot(request(&changed, "clientKeyId=client-key")) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + } + + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot(request( + &target, + "clientKeyId=client-key&accountId=forbidden", + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn protected_plugin_routes_require_admin_before_reading_bodies_but_public_resources_do_not() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("management-session"); + let prefix = "/api/admin/plugins/extensions/instance/sha256/1"; + for (method, uri) in [ + ("GET", "/api/admin/plugins/extensions".to_owned()), + ("GET", format!("{prefix}/resources/ui/index.html")), + ("POST", format!("{prefix}/api/echo")), + ("POST", format!("{prefix}/callback-tickets")), + ( + "POST", + format!("{prefix}/models/responses?clientKeyId=client-key"), + ), + ] { + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("x-request-id", "management-test") + .body(Body::from_stream(futures::stream::poll_fn( + |_| -> std::task::Poll>> { + panic!("未认证请求不能读取正文") + }, + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers()["cache-control"], "no-store"); + } + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::builder() + .uri("/plugins/resources/instance/sha256/1/ui/index.html") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::SERVICE_UNAVAILABLE, + "公开资源仍要求可用的发布视图,不能绕过实例授权" + ); + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("{prefix}/api/echo")) + .header("x-request-id", "management-limit") + .header("cookie", "cpr_session=management-session") + .header("content-type", "application/octet-stream") + .body(Body::from(vec![0_u8; 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("{prefix}/models/responses?clientKeyId=client-key")) + .header("x-request-id", "management-model-limit") + .header("cookie", "cpr_session=management-session") + .header("content-type", "application/json") + .body(Body::from(vec![0_u8; 8 * 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +#[tokio::test] +async fn public_callback_rejects_head_missing_duplicate_state_and_body_before_dispatch() { + let fixture = AdminTestFixture::new().await; + let prefix = "/plugins/callbacks/instance/sha256/1/oauth"; + for (method, query, body, expected) in [ + ("HEAD", "state=one", "", StatusCode::METHOD_NOT_ALLOWED), + ("GET", "code=one", "", StatusCode::BAD_REQUEST), + ("GET", "state=", "", StatusCode::BAD_REQUEST), + ("GET", "state=one&state=two", "", StatusCode::BAD_REQUEST), + ("GET", "state=one&%73tate=two", "", StatusCode::BAD_REQUEST), + ("GET", "state=one", "body", StatusCode::BAD_REQUEST), + // 合法 GET 没有管理 Cookie 仍进入服务;当前测试未发布视图,返回 503 而不是 401。 + ("GET", "state=one", "", StatusCode::SERVICE_UNAVAILABLE), + ] { + let response = crate::openai::api_router_with_admin_and_execution( + fixture.services.clone(), + ModelExecution::new(ModelReply::Complete), + ) + .oneshot( + Request::builder() + .method(method) + .uri(format!("{prefix}?{query}")) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{method} {query}"); + } +} diff --git a/backend/crates/gateway-api/tests/admin/plugins/mod.rs b/backend/crates/gateway-api/tests/admin/plugins/mod.rs new file mode 100644 index 000000000..384e3cb51 --- /dev/null +++ b/backend/crates/gateway-api/tests/admin/plugins/mod.rs @@ -0,0 +1,1013 @@ +use async_trait::async_trait; +use gateway_admin::model::plugins::distribution::{ + DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential, + SourceCredentialInfo, +}; +use gateway_admin::model::plugins::distribution::{PluginSourceBinding, PluginUpdateSource}; +use gateway_admin::{ + model::{ + AdminError, MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactIcon, + PluginArtifactIconResource, PluginArtifactMetadata, PluginArtifactMutation, + PluginIconTheme, PluginSource, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration, + PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, + PluginStateRecord, PluginStateTransition, PluginStateWrite, PutPluginState, + }, + }, + }, + ports::{ + plugins::{ + PluginPackageInspector, PluginStateStore, PluginStateStoreError, + PluginStateStoreErrorKind, PluginStateStoreResult, PluginStore, + }, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use std::{collections::BTreeMap, sync::Arc}; + +mod management; + +#[test] +fn artifact_metadata_wire_exposes_v2_identity_and_contributions() { + use gateway_admin::model::plugins::{ + PluginArtifactIcon, PluginArtifactIconVariants, PluginArtifactMetadata, PluginContribution, + }; + + let metadata = PluginArtifactMetadata { + plugin_id: "test.example".into(), + version: "1.0.0".into(), + name: "example".into(), + display_name: "Example".into(), + publisher: "test".into(), + author: None, + description: "fixture".into(), + license: "MIT".into(), + sha256: "a".repeat(64), + platforms: vec!["linux-x86_64".into()], + icon: Some(PluginArtifactIcon::Themed(PluginArtifactIconVariants { + light: "assets/icon-light.png".into(), + dark: "assets/icon-dark.webp".into(), + })), + contributes: BTreeMap::from([( + "middleware".into(), + PluginContribution { + id: "test.example.middleware".into(), + version: 1, + stages: vec!["attempt".into()], + input_formats: vec!["openai".into()], + output_formats: vec!["openai".into()], + }, + )]), + requested_permissions: vec![], + configuration_schema: serde_json::json!({}), + secret_fields: vec![], + state_namespaces: vec![], + }; + let value = serde_json::to_value(metadata).expect("v2 artifact metadata JSON"); + assert_eq!(value["displayName"], "Example"); + assert_eq!(value["publisher"], "test"); + assert!(value["author"].is_null()); + assert_eq!(value["icon"]["light"], "assets/icon-light.png"); + assert_eq!(value["icon"]["dark"], "assets/icon-dark.webp"); + assert_eq!( + value["contributes"]["middleware"]["id"], + "test.example.middleware" + ); + assert!(value.get("capabilities").is_none()); + assert!(value.get("permissionDescriptions").is_none()); +} + +#[test] +fn instance_binding_wire_uses_contribution_without_capability_alias() { + use gateway_admin::model::plugins::instances::PluginCapabilityBinding; + + let binding = serde_json::from_value::(serde_json::json!({ + "contribution": "test.example.usage", + "stage": "observation", + "order": 0, + "failurePolicy": "observe" + })) + .expect("v2 contribution binding"); + assert_eq!(binding.contribution, "test.example.usage"); + + assert!( + serde_json::from_value::(serde_json::json!({ + "capability": "usage", + "stage": "observation", + "order": 0, + "failurePolicy": "observe" + })) + .is_err(), + "legacy capability binding must be rejected" + ); +} + +#[tokio::test] +async fn artifact_icon_is_admin_only_digest_bound_and_hardened() { + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let digest = "f".repeat(64); + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/plugins/artifacts/{digest}/icon?theme=light" + )) + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_icon") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 1024).await.unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + assert_eq!(headers["content-type"], "image/png"); + assert_eq!(headers["x-content-type-options"], "nosniff"); + assert_eq!( + headers["content-security-policy"], + "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'" + ); + assert_eq!( + headers["cache-control"], + "private, max-age=31536000, immutable" + ); + assert_eq!(body.as_ref(), b"icon fixture"); + + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/plugins/artifacts/{digest}/icon?theme=dark" + )) + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_svg_icon") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "image/svg+xml"); + assert_eq!( + response.headers()["content-security-policy"], + headers["content-security-policy"] + ); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + response.headers()["cache-control"], + headers["cache-control"] + ); + let body = to_bytes(response.into_body(), 1024).await.unwrap(); + assert_eq!( + body.as_ref(), + br#""# + ); + + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/plugins/artifacts/{digest}/icon?theme=light" + )) + .header("x-request-id", "req_plugin_icon_unauthorized") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!( + "/api/admin/plugins/artifacts/{}/icon?theme=light", + "e".repeat(64) + )) + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_icon_missing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn distribution_rate_limit_keeps_its_safe_owner_message() { + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let direct = fixture + .services + .plugins() + .query_release( + GithubReleaseQuery { + repository: "rate-limit/fixture".to_owned(), + tag: None, + allow_prerelease: false, + }, + &[], + None, + ) + .await + .expect_err("fixture release query should be rate limited"); + assert_eq!( + direct.kind(), + gateway_admin::model::AdminErrorKind::RateLimited + ); + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/plugins/releases/query") + .header("content-type", "application/json") + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_distribution_rate_limit") + .body(Body::from( + serde_json::json!({ + "query": { + "repository": "rate-limit/fixture", + "tag": null, + "allowPrerelease": false + }, + "credentialIds": [] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + assert!(response.headers().get("retry-after").is_none()); + let body = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("rate limited response body"); + assert_eq!( + status, + StatusCode::TOO_MANY_REQUESTS, + "{}", + String::from_utf8_lossy(&body) + ); + assert_eq!( + serde_json::from_slice::(&body).expect("rate limited response JSON"), + serde_json::json!({ + "code": 42902, + "message": "插件来源限流,请稍后重试", + "data": null + }) + ); +} + +#[tokio::test] +async fn instance_list_serializes_safe_runtime_diagnostics() { + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let direct = fixture + .services + .plugins() + .instances() + .await + .expect("direct plugin instance projection"); + assert_eq!(direct.len(), 1); + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri("/api/admin/plugins/instances") + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_instance_diagnostics") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 64 * 1024) + .await + .expect("response body"); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + let value: serde_json::Value = serde_json::from_slice(&body).expect("response json"); + let instance = &value["data"][0]; + assert_eq!(instance["running"], false); + assert_eq!(instance["runtime"]["status"], "preparation_failed"); + assert_eq!(instance["runtime"]["actualRevision"], 6); + assert_eq!( + instance["runtime"]["drainingRevisions"], + serde_json::json!([5]) + ); + assert_eq!(instance["runtime"]["retainedContinuations"], 1); + assert_eq!(instance["runtime"]["failure"]["code"], "unavailable"); + assert_eq!( + instance["runtime"]["failure"]["message"], + "插件候选准备失败" + ); + assert!(instance["runtime"].get("configuration").is_none()); +} + +#[tokio::test] +async fn plugin_upload_endpoints_require_admin_and_bound_raw_bodies() { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for path in ["artifacts/upload", "artifacts/upload/verify"] { + for authenticated in [false, true] { + let mut request = Request::builder() + .method("POST") + .uri(format!("/api/admin/plugins/{path}")) + .header("x-request-id", "req_plugin_upload_limit") + .header("content-type", "application/octet-stream"); + if authenticated { + request = request.header("cookie", "cpr_session=valid-session"); + } + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + request + .body(Body::from(vec![0_u8; 32 * 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + if authenticated { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::UNAUTHORIZED + }, + "{path}" + ); + } + } +} + +#[tokio::test] +async fn artifact_acceptance_returns_the_flat_install_contract() { + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/plugins/artifacts/accept") + .header("content-type", "application/json") + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_accept") + .body(Body::from(format!(r#"{{"sha256":"{}"}}"#, "a".repeat(64)))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + assert_eq!( + status, + StatusCode::CREATED, + "{}", + String::from_utf8_lossy(&body) + ); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let result = &value["data"]; + assert_eq!(result["configRevision"], 7); + assert_eq!(result["defaultInstanceId"], serde_json::Value::Null); + assert_eq!(result["configurationRequired"], false); + assert_eq!(result["artifact"]["metadata"]["sha256"], "a".repeat(64)); + assert_eq!( + result["artifact"]["metadata"]["permissionDescriptions"][0]["permission"], + "models" + ); + assert_eq!( + result["artifact"]["metadata"]["permissionDescriptions"][0]["label"], + "当前宿主标签" + ); + assert!(result["artifact"]["acceptedAt"].is_string()); + assert!(result.get("mutation").is_none()); +} + +#[tokio::test] +async fn removed_instance_policy_fields_and_extra_accept_fields_are_rejected() { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (path, body) in [ + ( + "/api/admin/plugins/instances", + serde_json::json!({ + "name":"Example", + "artifactSha256":"a".repeat(64), + "enabled":false, + "trustedProcess":true, + "configuration":{}, + "grants":[], + "bindings":[] + }), + ), + ( + "/api/admin/plugins/artifacts/accept", + serde_json::json!({"sha256":"a".repeat(64),"trusted":true}), + ), + ] { + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .header("cookie", "cpr_session=valid-session") + .header("x-request-id", "req_plugin_strict_input") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "{path}" + ); + } +} + +#[tokio::test] +async fn plugin_mutations_require_admin_and_validate_json_on_static_post_routes() { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for path in [ + "artifacts/accept", + "artifacts/delete", + "artifacts/install", + "releases/query", + "updates/check", + "artifacts/verify", + "source-credentials", + "source-credentials/delete", + "update-sources", + "instances", + "instances/update", + "instances/rollback", + "instances/switch-version", + "instances/disable", + "instances/delete", + ] { + for authenticated in [false, true] { + let mut request = Request::builder() + .method("POST") + .uri(format!("/api/admin/plugins/{path}")) + .header("content-type", "application/json") + .header("x-request-id", "req_plugin_routes"); + if authenticated { + request = request.header("cookie", "cpr_session=valid-session"); + } + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::from("{}")).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + if authenticated { + // 字段缺失沿用 AdminJson 的结构校验合同。 + StatusCode::UNPROCESSABLE_ENTITY + } else { + StatusCode::UNAUTHORIZED + }, + "{path}" + ); + assert_eq!(response.headers()["cache-control"], "no-store"); + } + } +} + +#[derive(Default)] +pub(super) struct TestPluginPorts { + instances: + std::sync::Mutex>>, +} + +#[async_trait] +impl gateway_admin::ports::plugin_management::PluginManagement for TestPluginPorts { + async fn authorize_models( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> Result<(), AdminError> { + Ok(()) + } + + async fn start_callback( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: gateway_admin::model::plugins::management::StartPluginManagementCallback, + _: &gateway_admin::model::auth::AdminRequestContext, + ) -> Result + { + Err(AdminError::not_found("unused plugin fixture")) + } + async fn callback( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: &str, + _: gateway_admin::model::plugins::management::PluginManagementRequest, + ) -> Result + { + Ok(management::resource_fixture("ui/index.html")) + } + async fn views( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + ) -> Result, AdminError> + { + Ok(Vec::new()) + } + async fn resource( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + path: &str, + public: bool, + ) -> Result + { + if public && path != "public/login.css" { + return Err(AdminError::not_found("resource is not public")); + } + Ok(management::resource_fixture(path)) + } + async fn handle( + &self, + _: &gateway_core::runtime::extensions::ExtensionSetReference, + _: &gateway_admin::model::plugins::management::PluginManagementTarget, + _: gateway_admin::model::plugins::management::PluginManagementRequest, + ) -> Result + { + Ok(management::resource_fixture("api/echo")) + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginPreparation for TestPluginPorts { + async fn configuration_ready( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + _: &gateway_admin::model::plugins::PluginArtifactMetadata, + ) -> Result { + Ok(true) + } + + async fn validate( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + async fn prepare( + &self, + _: gateway_admin::model::Revision, + _: gateway_admin::model::plugins::instances::PluginInstanceSnapshot, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + + async fn runtime_diagnostics( + &self, + snapshot: &gateway_admin::model::plugins::instances::PluginInstanceSnapshot, + _: Option, + _: Option<&gateway_core::runtime::extensions::ExtensionSetReference>, + ) -> Option> + { + Some( + snapshot + .instances + .iter() + .map(|instance| { + ( + instance.id.clone(), + gateway_admin::model::plugins::instances::PluginInstanceRuntime { + status: gateway_admin::model::plugins::instances::PluginInstanceRuntimeStatus::PreparationFailed, + actual_revision: Some(6), + actual_artifact_sha256: Some("b".repeat(64)), + failure: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeFailure { + code: "unavailable".into(), + message: "插件候选准备失败".into(), + }), + draining_revisions: vec![5], + retained_continuations: 1, + }, + ) + }) + .collect(), + ) + } +} + +fn unavailable() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin", + "unused plugin fixture", + ) +} + +fn icon_artifact(digest: &str) -> InspectedPluginArtifact { + InspectedPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: "test.icon".into(), + version: "1.0.0".into(), + name: "icon".into(), + display_name: "Icon fixture".into(), + publisher: "test".into(), + author: None, + description: "fixture".into(), + license: "MIT".into(), + sha256: digest.into(), + platforms: vec!["linux-x86_64".into()], + icon: (digest.as_bytes()[0] == b'f') + .then(|| PluginArtifactIcon::Path("assets/icon.png".into())), + contributes: BTreeMap::new(), + requested_permissions: Vec::new(), + configuration_schema: serde_json::json!({}), + secret_fields: Vec::new(), + state_namespaces: Vec::new(), + }, + archive: b"icon archive fixture".as_slice().into(), + } +} + +fn accepted_artifact() -> InstalledPluginArtifact { + let mut metadata = icon_artifact(&"a".repeat(64)).metadata; + metadata.requested_permissions = vec!["models".into()]; + InstalledPluginArtifact { + metadata, + source: PluginSource::Upload, + installed_at: chrono::Utc::now(), + accepted_at: Some(chrono::Utc::now()), + } +} + +fn state_unavailable() -> PluginStateStoreResult { + Err(PluginStateStoreError::new( + PluginStateStoreErrorKind::Unavailable, + )) +} + +#[async_trait] +impl PluginStateStore for TestPluginPorts { + async fn load_owner( + &self, + _: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + state_unavailable() + } + async fn get( + &self, + _: &PluginStateOwner, + _: &str, + _: &str, + ) -> PluginStateStoreResult> { + state_unavailable() + } + async fn put( + &self, + _: &PluginStateOwner, + _: PutPluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn delete( + &self, + _: &PluginStateOwner, + _: DeletePluginState, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn transition_required( + &self, + _: &str, + _: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn begin_transition( + &self, + _: &str, + _: Revision, + _: &str, + _: PluginStateConfiguration, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn migration_batch( + &self, + _: &str, + _: &str, + _: u32, + ) -> PluginStateStoreResult { + state_unavailable() + } + async fn apply_migration_batch( + &self, + _: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + state_unavailable() + } + async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> { + state_unavailable() + } +} + +#[async_trait] +impl PluginStore for TestPluginPorts { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn load_instances( + &self, + ) -> AdminStoreResult { + Ok( + gateway_admin::model::plugins::instances::PluginInstanceSnapshot { + config_revision: Revision::new(7).unwrap(), + instances: self.instances.lock().unwrap().clone().unwrap_or_else(|| { + vec![gateway_admin::model::plugins::instances::PluginInstance { + id: "plugin-instance-diagnostic".into(), + name: "Diagnostic fixture".into(), + artifact_sha256: "a".repeat(64), + enabled: true, + trusted_process: true, + configuration: serde_json::json!({}), + secrets: BTreeMap::new(), + grants: Vec::new(), + bindings: Vec::new(), + revision: Revision::new(7).unwrap(), + }] + }), + }, + ) + } + async fn save_instance( + &self, + _: gateway_admin::model::plugins::instances::PluginInstance, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn list_update_sources(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list_source_credentials(&self) -> AdminStoreResult> { + Ok(Vec::new()) + } + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(unavailable()) + } + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + + async fn list_artifacts(&self) -> AdminStoreResult> { + Ok(vec![accepted_artifact()]) + } + async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + if matches!(digest.as_bytes().first(), Some(b'e' | b'f')) && digest.len() == 64 { + return Ok(icon_artifact(digest)); + } + Err(unavailable()) + } + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unavailable()) + } + async fn accept_artifact( + &self, + digest: &str, + _: &MutationContext, + ) -> AdminStoreResult { + if digest != "a".repeat(64) { + return Err(unavailable()); + } + Ok(PluginArtifactMutation { + config_revision: Revision::new(7).unwrap(), + artifact: accepted_artifact(), + }) + } + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + Err(unavailable()) + } +} + +#[async_trait] +impl PluginPackageInspector for TestPluginPorts { + fn permission_descriptions( + &self, + permissions: &[String], + ) -> Vec { + permissions + .iter() + .map( + |permission| gateway_admin::model::plugins::PluginPermissionDescription { + permission: permission.clone(), + label: "当前宿主标签".into(), + description: "当前宿主说明".into(), + }, + ) + .collect() + } + + async fn inspect( + &self, + _: Arc<[u8]>, + _: Option, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } + + async fn icon( + &self, + _: Arc<[u8]>, + expected_sha256: String, + theme: PluginIconTheme, + ) -> Result, AdminError> { + Ok( + (expected_sha256 == "f".repeat(64)).then(|| PluginArtifactIconResource { + content_type: match theme { + PluginIconTheme::Light => "image/png", + PluginIconTheme::Dark => "image/svg+xml", + } + .into(), + body: match theme { + PluginIconTheme::Light => b"icon fixture".to_vec(), + PluginIconTheme::Dark => { + br#""#.to_vec() + } + }, + }), + ) + } +} + +#[async_trait] +impl gateway_admin::ports::plugins::PluginDistribution for TestPluginPorts { + fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> { + Err(AdminError::invalid("unused plugin fixture")) + } + + fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> { + Err(AdminError::invalid("unused plugin fixture")) + } + async fn query_release( + &self, + query: GithubReleaseQuery, + _: Vec, + _: Option, + ) -> Result { + if query.repository == "rate-limit/fixture" { + return Err(AdminError::new( + gateway_admin::model::AdminErrorKind::RateLimited, + "插件来源限流,请稍后重试", + )); + } + Err(AdminError::invalid("unused plugin fixture")) + } + async fn download( + &self, + _: RemotePluginLocation, + _: Vec, + _: Option, + ) -> Result { + Err(AdminError::invalid("unused plugin fixture")) + } +} + +#[tokio::test] +async fn version_plan_requires_admin_and_an_explicit_target() { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; + let fixture = super::AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for authenticated in [false, true] { + let mut request = Request::builder() + .uri("/api/admin/plugins/instances/version-plan") + .header("x-request-id", "version-plan"); + if authenticated { + request = request.header("cookie", "cpr_session=valid-session"); + } + let response = gateway_api::admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + response.status(), + if authenticated { + StatusCode::BAD_REQUEST + } else { + StatusCode::UNAUTHORIZED + } + ); + assert_eq!(response.headers()["cache-control"], "no-store"); + } +} diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index 72a4b8762..ca4fb5aae 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -67,6 +67,24 @@ fn update_body() -> Value { }) } +fn insert_orphan_profile(fixture: &AdminTestFixture) { + fixture + .settings + .settings + .lock() + .expect("settings") + .request_profiles + .insert( + gateway_core::routing::ProviderKind::new("plugin.orphan").expect("provider kind"), + gateway_core::account::OpaqueProviderData::new( + json!({"preset":"legacy"}) + .as_object() + .expect("profile object") + .clone(), + ), + ); +} + #[test] fn settings_request_should_reject_unknown_rotation_strategy() { let mut body = update_body(); @@ -111,8 +129,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { use gateway_core::routing::{PublicModelId, UpstreamModelId}; let settings = RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -157,6 +174,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { assert_eq!( value, json!({ + "providerRequestProfiles": {}, "openaiClientProfile": null, "xaiClientProfile": null, "requestLocationEnabled": false, @@ -212,8 +230,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .cloned() .collect(); let settings = RuntimeSettings { - openai_client_profile: None, - xai_client_profile: None, + request_profiles: Default::default(), request_location_enabled: false, request_location: Default::default(), config_revision: Revision::new(7).expect("revision"), @@ -262,6 +279,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { .cloned() .collect(); let mut expected_fields = request_fields; + expected_fields.insert("providerRequestProfiles".to_owned()); expected_fields.insert("openaiClientProfile".to_owned()); expected_fields.insert("xaiClientProfile".to_owned()); expected_fields.insert("updatedAt".to_owned()); @@ -305,6 +323,26 @@ async fn settings_get_should_preserve_global_model_mappings() { ); } +#[tokio::test] +async fn client_profile_providers_should_come_from_provider_capabilities() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let response = app(fixture.state()) + .oneshot(request( + Method::GET, + "/api/admin/settings/client-profiles", + None, + )) + .await + .expect("client profile providers response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response_json(response).await["data"], + json!({"providers": []}) + ); +} + #[tokio::test] async fn settings_post_should_replace_global_model_mappings() { let fixture = AdminTestFixture::new().await; @@ -664,6 +702,130 @@ fn global_profile_can_be_omitted_but_cannot_be_cleared() { } } +#[tokio::test] +async fn generic_global_profiles_decode_dynamic_providers_and_reject_legacy_conflicts() { + let mut body = update_body(); + body["providerRequestProfiles"] = json!({ + "openai":{"preset":"desktop"}, + "provider.example":{"preset":"managed"}, + }); + body["openaiClientProfile"] = json!({"preset":"desktop"}); + let decoded = serde_json::from_value::(body.clone()).unwrap(); + assert!( + decoded + .provider_request_profiles + .contains_key("provider.example") + ); + + body["openaiClientProfile"] = json!({"preset":"cli"}); + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn settings_update_preserves_an_unchanged_orphan_profile() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + insert_orphan_profile(&fixture); + let mut body = update_body(); + body["providerRequestProfiles"] = json!({ + "plugin.orphan":{"preset":"legacy"} + }); + + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + let status = response.status(); + let data = response_json(response).await["data"].clone(); + + assert_eq!( + (status, &data["providerRequestProfiles"]["plugin.orphan"]), + (StatusCode::OK, &json!({"preset":"legacy"})), + ); +} + +#[tokio::test] +async fn settings_update_rejects_a_modified_orphan_profile() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + insert_orphan_profile(&fixture); + let mut body = update_body(); + body["providerRequestProfiles"] = json!({ + "plugin.orphan":{"preset":"changed"} + }); + + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn settings_update_rejects_a_new_unknown_profile() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let mut body = update_body(); + body["providerRequestProfiles"] = json!({ + "plugin.unknown":{"preset":"new"} + }); + + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn settings_update_explicitly_removes_an_orphan_profile() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + insert_orphan_profile(&fixture); + let mut body = update_body(); + body["providerRequestProfiles"] = json!({"plugin.orphan":null}); + + let response = app(fixture.state()) + .oneshot(request( + Method::POST, + "/api/admin/settings/update", + Some(body), + )) + .await + .unwrap(); + let status = response.status(); + let data = response_json(response).await["data"].clone(); + + assert_eq!( + (status, data["providerRequestProfiles"].get("plugin.orphan"),), + (StatusCode::OK, None), + ); +} + fn custom_pricing() -> Value { json!({"multiplierBps":12500,"bands":{"standard":{"input":"3","output":"12","cacheRead":"0","cacheWrite":"0"}}}) } diff --git a/backend/crates/gateway-api/tests/admin/system.rs b/backend/crates/gateway-api/tests/admin/system.rs index c488cd39b..b3f77b3bd 100644 --- a/backend/crates/gateway-api/tests/admin/system.rs +++ b/backend/crates/gateway-api/tests/admin/system.rs @@ -147,6 +147,7 @@ impl SystemOperations for ProgressSystem { async fn perform_update( &self, target: Option, + _: Arc, ) -> Result { Ok(SystemOperationAccepted::Update { operation_id: "update-1".to_owned(), @@ -174,7 +175,10 @@ impl SystemOperations for ProgressSystem { }) } - async fn rollback(&self) -> Result { + async fn rollback( + &self, + _: Arc, + ) -> Result { Err(unavailable_system()) } diff --git a/backend/crates/gateway-api/tests/admin/wire.rs b/backend/crates/gateway-api/tests/admin/wire.rs index e1cc4f006..d6176709d 100644 --- a/backend/crates/gateway-api/tests/admin/wire.rs +++ b/backend/crates/gateway-api/tests/admin/wire.rs @@ -36,6 +36,7 @@ mod error { AdminErrorCode::NOT_FOUND, AdminErrorCode::CONFLICT, AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS, + AdminErrorCode::RATE_LIMITED, AdminErrorCode::SETTINGS_PERSIST, AdminErrorCode::INTERNAL, AdminErrorCode::USAGE_RECORD_ACCOUNTS, @@ -48,8 +49,8 @@ mod error { assert_eq!( actual, [ - 40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 50000, 50001, - 50002, 50201, 50202, 50301, + 40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 42902, 50000, + 50001, 50002, 50201, 50202, 50301, ] ); } @@ -189,6 +190,11 @@ mod response { StatusCode::TOO_MANY_REQUESTS, 42901, ), + ( + AdminError::rate_limited(), + StatusCode::TOO_MANY_REQUESTS, + 42902, + ), ( AdminError::conflict("conflict"), StatusCode::CONFLICT, @@ -228,4 +234,33 @@ mod response { assert!(body["data"].is_null()); } } + + #[tokio::test] + async fn generic_and_login_rate_limits_should_keep_distinct_wire_contracts() { + let generic = AdminError::rate_limited().into_response(); + assert_eq!(generic.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(generic.headers().get("retry-after").is_none()); + assert_eq!( + response_json(generic).await, + json!({ + "code": 42902, + "message": "请求过于频繁,请稍后重试", + "data": null + }) + ); + + let login = AdminError::too_many_login_attempts() + .with_retry_after(17) + .into_response(); + assert_eq!(login.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(login.headers()["retry-after"], "17"); + assert_eq!( + response_json(login).await, + json!({ + "code": 42901, + "message": "登录尝试过多,请稍后重试", + "data": null + }) + ); + } } diff --git a/backend/crates/gateway-api/tests/architecture.rs b/backend/crates/gateway-api/tests/architecture.rs index 668b1266e..d2bc63c04 100644 --- a/backend/crates/gateway-api/tests/architecture.rs +++ b/backend/crates/gateway-api/tests/architecture.rs @@ -34,6 +34,7 @@ fn source_tree_should_match_frozen_machine_manifest() { actual.sort(); let mut expected = vec![ "src/admin/account_groups.rs", + "src/admin/accounts/capabilities.rs", "src/admin/accounts/credentials.rs", "src/admin/accounts/handlers.rs", "src/admin/accounts/import_tasks.rs", @@ -50,6 +51,11 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/admin/observability/query.rs", "src/admin/observability/routes.rs", "src/admin/observability/wire.rs", + "src/admin/plugins/artifacts.rs", + "src/admin/plugins/distribution.rs", + "src/admin/plugins/instances.rs", + "src/admin/plugins/management.rs", + "src/admin/plugins/mod.rs", "src/admin/presenter.rs", "src/admin/proxies.rs", "src/admin/settings.rs", @@ -61,6 +67,10 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/key_usage/query.rs", "src/key_usage/presenter.rs", "src/lib.rs", + "src/openai/middleware/http.rs", + "src/openai/middleware/mod.rs", + "src/openai/middleware/request.rs", + "src/openai/middleware/session.rs", "src/openai/auth.rs", "src/openai/error.rs", "src/openai/images/http.rs", @@ -73,6 +83,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/openai/responses/mod.rs", "src/openai/responses/request.rs", "src/openai/responses/response.rs", + "src/openai/responses/validation.rs", "src/openai/responses/websocket/connection.rs", "src/openai/responses/websocket/forward.rs", "src/openai/responses/websocket/mod.rs", @@ -82,6 +93,7 @@ fn source_tree_should_match_frozen_machine_manifest() { "src/openai/search/mod.rs", "src/openai/service.rs", "src/openai/usage.rs", + "src/provider.rs", "src/session_cookie.rs", ]; expected.sort_unstable(); @@ -96,6 +108,7 @@ fn test_tree_should_match_frozen_rust_mirror() { actual.sort(); let mut expected = vec![ "tests/admin/account_groups.rs", + "tests/admin/accounts/capabilities.rs", "tests/admin/accounts/handlers.rs", "tests/admin/accounts/import_tasks.rs", "tests/admin/accounts/mod.rs", @@ -107,6 +120,8 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/admin/observability/mod.rs", "tests/admin/observability/query.rs", "tests/admin/observability/response.rs", + "tests/admin/plugins/management.rs", + "tests/admin/plugins/mod.rs", "tests/admin/proxies.rs", "tests/admin/settings.rs", "tests/admin/system.rs", @@ -118,6 +133,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/key_usage/mod.rs", "tests/key_usage/fixtures.rs", "tests/main.rs", + "tests/openai/middleware/mod.rs", "tests/openai/auth.rs", "tests/openai/error.rs", "tests/openai/images/mod.rs", @@ -134,6 +150,7 @@ fn test_tree_should_match_frozen_rust_mirror() { "tests/openai/router.rs", "tests/openai/search/mod.rs", "tests/openai/usage.rs", + "tests/provider.rs", ]; expected.sort_unstable(); diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index 56200d723..3fe5e372a 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -17,8 +17,7 @@ pub(super) async fn fixture() -> AdminTestFixture { let fixture = key_fixture().await; let now = Utc::now(); *fixture.client_key.lock().unwrap() = Some(ClientKeyRecord { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), id: ClientApiKeyId::new("key-42").unwrap(), name: "Development".to_owned(), label: Some("private-sentinel".to_owned()), diff --git a/backend/crates/gateway-api/tests/main.rs b/backend/crates/gateway-api/tests/main.rs index 4d0e55378..b13aac930 100644 --- a/backend/crates/gateway-api/tests/main.rs +++ b/backend/crates/gateway-api/tests/main.rs @@ -4,6 +4,7 @@ mod auth; mod health; mod key_usage; mod openai; +mod provider; mod support; #[tokio::test] @@ -18,6 +19,7 @@ async fn api_should_serve_resolved_assets_with_relative_environment_override() { fs::create_dir_all(directory.join("deploy")).expect("config directory"); fs::create_dir_all(directory.join("web/dist")).expect("asset directory"); fs::write(directory.join("web/dist/index.html"), "resolved-web-assets").expect("index"); + fs::write(directory.join("web/dist/favicon.svg"), "").expect("favicon"); let output = Command::new(std::env::current_exe().expect("test executable")) .args([ "--exact", @@ -47,13 +49,33 @@ async fn api_should_serve_resolved_assets_with_relative_environment_override() { .resolve_and_validate(&std::path::Path::new(&directory).join("deploy")) .expect("resolved API config"); let admin = admin::AdminTestFixture::new().await; - let response = openai::api_router_with_config(admin.services, config) + let router = openai::api_router_with_config(admin.services, config); + let response = router + .clone() .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .expect("static response"); assert_eq!(response.status(), axum::http::StatusCode::OK); + assert_eq!(response.headers()["cache-control"], "no-cache"); let body = axum::body::to_bytes(response.into_body(), 1024) .await .unwrap(); assert_eq!(body.as_ref(), b"resolved-web-assets"); + for (uri, expected_body) in [ + ("/favicon.svg", ""), + ("/assets/missing.svg", "resolved-web-assets"), + ("/plugins/one/main", "resolved-web-assets"), + ] { + let response = router + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK, "{uri}"); + assert_eq!(response.headers()["cache-control"], "no-cache", "{uri}"); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert_eq!(body.as_ref(), expected_body.as_bytes(), "{uri}"); + } } diff --git a/backend/crates/gateway-api/tests/openai/auth.rs b/backend/crates/gateway-api/tests/openai/auth.rs index 5d31ad170..731e8fe28 100644 --- a/backend/crates/gateway-api/tests/openai/auth.rs +++ b/backend/crates/gateway-api/tests/openai/auth.rs @@ -1,9 +1,120 @@ -use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use axum::http::{ + HeaderMap, HeaderValue, Method, StatusCode, + header::{self, AUTHORIZATION}, +}; +use futures::future::BoxFuture; use gateway_api::openai::auth::{ ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client, }; -use gateway_core::policy::CodexClientKind; +use gateway_core::{ + engine::{ + authentication::ClientAuthenticationRequest, + execution::{ + AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution, + StartProviderExecution, StartedExecution, + }, + }, + error::{GatewayError, GatewayErrorKind}, + policy::CodexClientKind, + routing::PublicModelId, +}; +use tower::ServiceExt as _; + +struct EnvelopeAuthentication { + client: AuthenticatedClient, + calls: AtomicUsize, +} + +impl ExecutionService for EnvelopeAuthentication { + fn authenticate(&self, _: &str) -> Result { + Err(ClientAuthenticationError::InvalidKey) + } + + fn authenticate_request( + &self, + request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + assert_eq!(request.authorization(), "External controlled-credential"); + let debug = format!("{request:?}"); + for private in [ + "controlled-credential", + "controlled-fixture", + "private-client", + "192.0.2.9", + ] { + assert!(!debug.contains(private)); + } + self.calls.fetch_add(1, Ordering::SeqCst); + let client = self.client.clone(); + Box::pin(async move { Ok(client) }) + } + + fn public_models(&self, _: &AuthenticatedClient) -> Vec { + Vec::new() + } + + fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool { + false + } + + fn start(&self, _: StartExecution) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Internal, + "authentication envelope test must not execute", + )) + }) + } + + fn start_provider_endpoint( + &self, + _: StartProviderExecution, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Internal, + "authentication envelope test must not execute", + )) + }) + } +} + +#[tokio::test] +async fn plugin_authentication_envelope_contains_only_authorization() { + let fixture = crate::admin::AdminTestFixture::new().await; + let execution = Arc::new(EnvelopeAuthentication { + client: super::authenticated_client("unused-native-key"), + calls: AtomicUsize::new(0), + }); + let app = super::api_router_with_admin_and_execution(fixture.services, execution.clone()); + let mut request = crate::support::empty_request(Method::GET, "/v1/models"); + request.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_static("External controlled-credential"), + ); + request.headers_mut().insert( + header::COOKIE, + HeaderValue::from_static("admin_session=controlled-fixture"), + ); + request.headers_mut().insert( + header::USER_AGENT, + HeaderValue::from_static("private-client"), + ); + request + .headers_mut() + .insert("x-forwarded-for", HeaderValue::from_static("192.0.2.9")); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(execution.calls.load(Ordering::SeqCst), 1); +} #[test] fn bearer_client_api_key_should_reject_missing_authorization() { diff --git a/backend/crates/gateway-api/tests/openai/images/mod.rs b/backend/crates/gateway-api/tests/openai/images/mod.rs index be14ecd8b..bdf4eee91 100644 --- a/backend/crates/gateway-api/tests/openai/images/mod.rs +++ b/backend/crates/gateway-api/tests/openai/images/mod.rs @@ -19,7 +19,7 @@ use gateway_core::error::{ ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind, }; use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader}; -use gateway_core::operation::{ImageRequestKind, Operation}; +use gateway_core::operation::{ImageRequestKind, Operation, OperationKind}; use gateway_core::routing::PublicModelId; use gateway_core::upstream::UpstreamSendState; use serde_json::{Value, json}; @@ -44,7 +44,9 @@ struct ImageExecution { client: AuthenticatedClient, captured: Arc>>, committed_statuses: Arc>>, + error_statuses: Arc>>, fail_with_upstream_response: bool, + middleware: Option>, } impl ImageExecution { @@ -53,7 +55,9 @@ impl ImageExecution { client: authenticated_client("sk_images_test"), captured: Arc::new(Mutex::new(Vec::new())), committed_statuses: Arc::new(Mutex::new(Vec::new())), + error_statuses: Arc::new(Mutex::new(Vec::new())), fail_with_upstream_response: false, + middleware: None, }) } @@ -62,7 +66,9 @@ impl ImageExecution { client: authenticated_client("sk_images_test"), captured: Arc::new(Mutex::new(Vec::new())), committed_statuses: Arc::new(Mutex::new(Vec::new())), + error_statuses: Arc::new(Mutex::new(Vec::new())), fail_with_upstream_response: true, + middleware: None, }) } @@ -79,6 +85,13 @@ impl ImageExecution { } impl ExecutionService for ImageExecution { + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + self.middleware.as_ref().map(|plan| plan.frozen()) + } + fn authenticate( &self, plaintext: &str, @@ -139,6 +152,7 @@ impl ExecutionService for ImageExecution { Bytes::from_static(b"42"), )], committed_statuses: Arc::clone(&self.committed_statuses), + error_statuses: Arc::clone(&self.error_statuses), finalized: Arc::new(AtomicBool::new(false)), fail_with_upstream_response: self.fail_with_upstream_response, }), @@ -151,6 +165,7 @@ struct ImageSession { response: Option, response_headers: Vec, committed_statuses: Arc>>, + error_statuses: Arc>>, finalized: Arc, fail_with_upstream_response: bool, } @@ -216,8 +231,14 @@ impl ExecutionSession for ImageSession { }) } - fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> { - Box::pin(async { Ok(()) }) + fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), EngineError>> { + Box::pin(async move { + self.error_statuses + .lock() + .expect("error status lock") + .push(status); + Ok(()) + }) } fn is_finalized(&self) -> bool { @@ -237,29 +258,43 @@ impl ExecutionSession for ImageSession { #[tokio::test] async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directions() { - let execution = ImageExecution::new(); + let mut execution = ImageExecution::new(); + let middleware = Arc::new(crate::openai::middleware::RequestMiddleware { + expected_operation: Some(OperationKind::GenerateImage), + committed_statuses: Some(execution.committed_statuses.clone()), + ..Default::default() + }); + Arc::get_mut(&mut execution).unwrap().middleware = Some(middleware.clone()); let router = api_router(execution.clone()).await; let cases = [ ( "/v1/images/generations", ImageRequestKind::Generation, + "application/json", br#"{ "model":"gpt-image-future", "prompt":"a lighthouse", "future":9007199254740993 }"#.as_slice(), ), ( "/v1/images/edits", ImageRequestKind::Edit, + "application/json", br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate stays raw"}"#.as_slice(), ), + ( + "/v1/images/edits", + ImageRequestKind::Edit, + "multipart/form-data; boundary=image-boundary", + b"--image-boundary\r\nContent-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n\x89PNG\x00\xff\r\n--image-boundary--\r\n".as_slice(), + ), ]; - for (path, _, body) in &cases { + for (path, _, content_type, body) in &cases { let response = router .clone() .oneshot( Request::post(*path) .header(AUTHORIZATION, "Bearer sk_images_test") .header("x-request-id", "caller-image-request") - .header("content-type", "application/json") + .header("content-type", *content_type) .header("x-codex-image-turn-id", "turn_image_route") .header("session-id", "root-image-session") .header("thread-id", "child-image-thread") @@ -269,6 +304,7 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio .await .expect("image response"); assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-request-middleware"], "applied"); assert_eq!(response.headers()["x-request-id"], "req_images_test"); assert_eq!( response.headers()["x-gateway-request-id"], @@ -286,7 +322,15 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio let captured = execution.captured(); assert_eq!(captured.len(), cases.len()); - for (captured, (endpoint, kind, body)) in captured.iter().zip(cases.iter()) { + assert_eq!( + *middleware.endpoints.lock().unwrap(), + vec![ + "/v1/images/generations", + "/v1/images/edits", + "/v1/images/edits" + ] + ); + for (captured, (endpoint, kind, _, body)) in captured.iter().zip(cases.iter()) { assert_eq!(captured.provider, "openai"); assert_eq!(captured.endpoint, *endpoint); assert_eq!(captured.transport, ClientTransport::HttpJson); @@ -306,7 +350,7 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio #[tokio::test] async fn image_route_should_return_the_exact_upstream_error_response() { let execution = ImageExecution::failing(); - let response = api_router(execution) + let response = api_router(execution.clone()) .await .oneshot( Request::post("/v1/images/edits") @@ -341,4 +385,9 @@ async fn image_route_should_return_the_exact_upstream_error_response() { body.as_ref(), br#"{ "error":{"message":"future image validation"}, "future":9007199254740993 }"# ); + assert!(execution.committed_statuses().is_empty()); + assert_eq!( + *execution.error_statuses.lock().expect("error status lock"), + vec![422] + ); } diff --git a/backend/crates/gateway-api/tests/openai/middleware/mod.rs b/backend/crates/gateway-api/tests/openai/middleware/mod.rs new file mode 100644 index 000000000..0ccef5e77 --- /dev/null +++ b/backend/crates/gateway-api/tests/openai/middleware/mod.rs @@ -0,0 +1,231 @@ +use std::{ + collections::VecDeque, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_core::{ + engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareBody, MiddlewareContext, MiddlewareError, MiddlewareFrame, + MiddlewareFraming, MiddlewareHeader, MiddlewareMount, MiddlewareNext, MiddlewarePlan, + MiddlewareRequest, MiddlewareResponse, + }, + operation::OperationKind, + runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}, +}; + +/// OpenAI API 入口共用的中间件夹具;授权和 RPC 由 Runtime 行为测试覆盖。 +#[derive(Debug, Default)] +pub(super) struct RequestMiddleware { + pub(super) endpoints: Mutex>, + pub(super) live_leases: Arc, + pub(super) expected_operation: Option, + pub(super) committed_statuses: Option>>>, + pub(super) response_actions: Mutex>, + pub(super) response_headers: Vec, + pub(super) short_circuit_json: Option, +} + +#[derive(Debug, Clone)] +pub(super) enum ResponseFrameAction { + Continue, + Replace(Bytes), + Drop, +} + +impl RequestMiddleware { + pub(super) fn frozen(self: &Arc) -> FrozenMiddlewarePlan { + self.live_leases.fetch_add(1, Ordering::SeqCst); + FrozenMiddlewarePlan::new( + self.clone(), + ExtensionSetReference::new( + ExtensionSetId::new("request-middleware".to_owned()).unwrap(), + Arc::new(RequestLease(self.live_leases.clone())), + ), + ) + } + + #[must_use] + pub(super) fn with_response_actions(self, actions: Vec) -> Self { + Self { + response_actions: Mutex::new(actions.into()), + ..self + } + } + + #[must_use] + pub(super) fn with_response_headers(self, headers: Vec) -> Self { + Self { + response_headers: headers, + ..self + } + } + + #[must_use] + pub(super) fn with_json_short_circuit(self, body: Bytes) -> Self { + Self { + short_circuit_json: Some(body), + ..self + } + } +} + +struct RequestLease(Arc); + +impl ExtensionSetLease for RequestLease { + fn is_ready(&self) -> bool { + true + } +} + +impl Drop for RequestLease { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +impl MiddlewarePlan for RequestMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Request); + assert_eq!(context.operation(), self.expected_operation); + assert!(context.attempt_index().is_none()); + assert!(context.account_id().is_none()); + self.endpoints + .lock() + .unwrap() + .push(context.endpoint().to_owned()); + let statuses = self.committed_statuses.clone(); + let actions = std::mem::take(&mut *self.response_actions.lock().unwrap()); + let response_headers = self.response_headers.clone(); + let short_circuit_json = self.short_circuit_json.clone(); + Box::pin(async move { + if let Some(body) = short_circuit_json { + return Ok(MiddlewareResponse::new( + "openai".to_owned(), + 200, + Vec::new(), + Box::new(SingleFrameBody(Some(MiddlewareFrame::new( + body, + MiddlewareFraming::JsonDocument, + true, + )))), + )); + } + let count = statuses.as_ref().map(|values| values.lock().unwrap().len()); + let response = next.run(request).await?; + assert_eq!( + statuses.as_ref().map(|values| values.lock().unwrap().len()), + count + ); + let (protocol, status, mut headers, body, envelope) = response.into_parts(); + headers.push(MiddlewareHeader::new( + "x-request-middleware", + Bytes::from_static(b"applied"), + )); + headers.extend(response_headers); + let mut response = MiddlewareResponse::new( + protocol, + status, + headers, + Box::new(ResponseMiddlewareBody { + body, + actions, + transformed_pending: false, + }), + ); + if let Some(envelope) = envelope { + response = response.with_envelope(envelope); + } + Ok(response) + }) + } +} + +struct SingleFrameBody(Option); + +impl MiddlewareBody for SingleFrameBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async { Ok(self.0.take()) }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { drop(self) }) + } +} + +struct ResponseMiddlewareBody { + body: Box, + actions: VecDeque, + transformed_pending: bool, +} + +impl MiddlewareBody for ResponseMiddlewareBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + loop { + let Some(frame) = self.body.next_frame().await? else { + return Ok(None); + }; + let action = self + .actions + .pop_front() + .unwrap_or(ResponseFrameAction::Continue); + let transformed = self.transformed_pending || frame.transformed(); + let (bytes, framing, terminal, envelope) = frame.into_parts(); + match action { + ResponseFrameAction::Continue => { + self.transformed_pending = false; + let mut frame = MiddlewareFrame::new(bytes, framing, terminal) + .with_transformed(transformed); + if let Some(envelope) = envelope { + frame = frame.with_envelope(envelope); + } + return Ok(Some(frame)); + } + ResponseFrameAction::Replace(bytes) => { + self.transformed_pending = false; + let mut frame = + MiddlewareFrame::new(bytes, framing, terminal).with_transformed(true); + if let Some(envelope) = envelope { + frame = frame.with_envelope(envelope); + } + return Ok(Some(frame)); + } + ResponseFrameAction::Drop if envelope.is_some() => { + return Err(MiddlewareError::InvalidState); + } + ResponseFrameAction::Drop => self.transformed_pending = true, + } + } + }) + } + + fn commit_downstream( + &mut self, + status: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + self.body.commit_downstream(status) + } + + fn record_client_status(&mut self, status: u16) -> BoxFuture<'_, Result<(), MiddlewareError>> { + self.body.record_client_status(status) + } + + fn is_finalized(&self) -> bool { + self.body.is_finalized() + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + let Self { body, .. } = *self; + body.close() + } +} diff --git a/backend/crates/gateway-api/tests/openai/mod.rs b/backend/crates/gateway-api/tests/openai/mod.rs index 63950002f..bb1f8dbb8 100644 --- a/backend/crates/gateway-api/tests/openai/mod.rs +++ b/backend/crates/gateway-api/tests/openai/mod.rs @@ -2,6 +2,7 @@ mod auth; mod endpoint; mod error; mod images; +mod middleware; mod models; mod responses; mod router; @@ -62,21 +63,63 @@ pub(super) fn api_router_with_admin(admin: gateway_admin::AdminServices) -> axum ) } +pub(super) fn api_router_with_admin_and_execution( + admin: gateway_admin::AdminServices, + execution: Arc, +) -> axum::Router { + api_router_with_config_and_execution( + admin, + gateway_api::ApiConfig { + asset_directory: std::env::temp_dir(), + cors_allowed_origins: Vec::new(), + request_timeout_seconds: None, + request_id_header: "x-request-id".to_owned(), + }, + execution, + ) +} + +pub(super) fn api_router_with_admin_and_client( + admin: gateway_admin::AdminServices, + plaintext: &str, + client_key_id: &str, +) -> axum::Router { + let execution = Arc::new(DefaultExecutionService::new( + RuntimeSnapshotHandle::new(snapshot_with_client_key(plaintext, "openai", client_key_id)), + Arc::new(UnusedExecutionStore), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(IgnoredClientApiKeyUsage), + )); + api_router_with_admin_and_execution(admin, execution) +} + pub(super) fn api_router_with_config( admin: gateway_admin::AdminServices, config: gateway_api::ApiConfig, +) -> axum::Router { + let execution = Arc::new(DefaultExecutionService::new( + RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")), + Arc::new(UnusedExecutionStore), + ProviderRegistry::default(), + Arc::new(UnusedAdmissions), + Arc::new(UnusedCircuits), + Arc::new(UnusedContinuation), + Arc::new(IgnoredClientApiKeyUsage), + )); + api_router_with_config_and_execution(admin, config, execution) +} + +fn api_router_with_config_and_execution( + admin: gateway_admin::AdminServices, + config: gateway_api::ApiConfig, + execution: Arc, ) -> axum::Router { gateway_api::initialize( config, - Arc::new(DefaultExecutionService::new( - RuntimeSnapshotHandle::new(snapshot("unused-client-route-key", "openai")), - Arc::new(UnusedExecutionStore), - ProviderRegistry::default(), - Arc::new(UnusedAdmissions), - Arc::new(UnusedCircuits), - Arc::new(UnusedContinuation), - Arc::new(IgnoredClientApiKeyUsage), - )), + execution, admin, Vec::new(), Arc::new(EmptyWorkerHealth), @@ -191,6 +234,14 @@ impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage { } fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot { + snapshot_with_client_key(plaintext, provider_name, "key_api_test") +} + +fn snapshot_with_client_key( + plaintext: &str, + provider_name: &str, + client_key_id: &str, +) -> RuntimeSnapshot { let provider = ProviderKind::new(provider_name).expect("provider"); let account_directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( ProviderAccountId::new("acct_api_test").expect("account ID"), @@ -219,7 +270,7 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot { }) .collect(), vec![ClientPolicy::new( - ClientApiKeyId::new("key_api_test").expect("key ID"), + ClientApiKeyId::new(client_key_id).expect("key ID"), PlaintextClientApiKey::new(plaintext).expect("plaintext key"), Arc::new(FrozenAccountScope::new( Arc::clone(&account_directory), diff --git a/backend/crates/gateway-api/tests/openai/models.rs b/backend/crates/gateway-api/tests/openai/models.rs index 0df1bc7be..60bd9383b 100644 --- a/backend/crates/gateway-api/tests/openai/models.rs +++ b/backend/crates/gateway-api/tests/openai/models.rs @@ -4,6 +4,7 @@ use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header::AUTHORIZATION}, }; +use bytes::Bytes; use futures::future::BoxFuture; use gateway_core::engine::execution::{ AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution, @@ -23,6 +24,7 @@ pub(super) struct ModelsExecution { presentation: Option, native: Option, ProviderCatalogUnavailable>>, requested_versions: Mutex>, + middleware: Option>, } impl ModelsExecution { @@ -32,6 +34,7 @@ impl ModelsExecution { presentation: None, native: None, requested_versions: Mutex::default(), + middleware: None, }) } @@ -68,6 +71,7 @@ impl ModelsExecution { presentation: Some(presentation), native: None, requested_versions: Mutex::default(), + middleware: None, }) } @@ -77,6 +81,7 @@ impl ModelsExecution { presentation: None, native: None, requested_versions: Mutex::default(), + middleware: None, }) } @@ -90,6 +95,7 @@ impl ModelsExecution { presentation: None, native: None, requested_versions: Mutex::default(), + middleware: None, }) } @@ -108,6 +114,15 @@ impl ModelsExecution { } impl ExecutionService for ModelsExecution { + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + self.middleware + .as_ref() + .map(|middleware| middleware.frozen()) + } + fn client_model_catalog<'a>( &'a self, _client: &'a AuthenticatedClient, @@ -181,6 +196,81 @@ fn authorized_request(path: &str) -> Request { .expect("build models request") } +#[tokio::test] +async fn model_queries_enter_the_same_chain_after_auth_and_hold_generation_until_body_end() { + use std::sync::atomic::Ordering; + + let middleware = Arc::new(crate::openai::middleware::RequestMiddleware::default()); + let mut execution = ModelsExecution::new(); + Arc::get_mut(&mut execution).unwrap().middleware = Some(middleware.clone()); + let app = api_router(execution).await; + let unauthenticated = Request::get("/v1/models").body(Body::empty()).unwrap(); + assert_eq!( + app.clone().oneshot(unauthenticated).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + assert!(middleware.endpoints.lock().unwrap().is_empty()); + + for (path, status) in [ + ("/v1/models", StatusCode::OK), + ("/v1/models/model-a", StatusCode::OK), + ("/v1/models/hidden-model", StatusCode::NOT_FOUND), + ] { + let response = app.clone().oneshot(authorized_request(path)).await.unwrap(); + assert_eq!(response.status(), status); + assert_eq!(response.headers()["x-request-middleware"], "applied"); + assert_eq!(middleware.live_leases.load(Ordering::SeqCst), 1); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!body.is_empty()); + assert_eq!(middleware.live_leases.load(Ordering::SeqCst), 0); + } + assert_eq!( + *middleware.endpoints.lock().unwrap(), + [ + "/v1/models", + "/v1/models/model-a", + "/v1/models/hidden-model" + ] + ); +} + +#[tokio::test] +async fn model_query_middleware_short_circuit_sets_json_content_type_without_calling_terminal() { + let middleware = Arc::new( + crate::openai::middleware::RequestMiddleware::default().with_json_short_circuit( + Bytes::from_static(br#"{"object":"list","data":[],"source":"middleware"}"#), + ), + ); + let mut execution = ModelsExecution::new(); + Arc::get_mut(&mut execution).unwrap().middleware = Some(middleware); + + let response = api_router(execution.clone()) + .await + .oneshot(authorized_request( + "/v1/models?client_version=0.154.0-alpha.3", + )) + .await + .expect("short-circuit response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("short-circuit body"); + assert_eq!( + serde_json::from_slice::(&body).expect("short-circuit JSON"), + serde_json::json!({"object":"list","data":[],"source":"middleware"}) + ); + assert!( + execution + .requested_versions + .lock() + .expect("versions") + .is_empty(), + "terminal catalog lookup must not run after middleware short circuit" + ); +} + #[tokio::test] async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug() { let original = serde_json::json!({ @@ -204,6 +294,7 @@ async fn native_catalog_preserves_complete_objects_and_only_rewrites_alias_slug( presentation: None, native: Some(Ok(vec![entry("gpt-native"), entry("my-alias")])), requested_versions: Mutex::default(), + middleware: None, }); let response = api_router(execution.clone()) .await @@ -236,6 +327,7 @@ async fn native_catalog_failure_does_not_publish_a_synthetic_or_empty_success() presentation: None, native: Some(Err(ProviderCatalogUnavailable)), requested_versions: Mutex::default(), + middleware: None, }); let response = api_router(execution) .await diff --git a/backend/crates/gateway-api/tests/openai/responses/http.rs b/backend/crates/gateway-api/tests/openai/responses/http.rs index 74cb5fa78..7c43ef435 100644 --- a/backend/crates/gateway-api/tests/openai/responses/http.rs +++ b/backend/crates/gateway-api/tests/openai/responses/http.rs @@ -3,7 +3,7 @@ use std::io::Read; use std::net::{IpAddr, SocketAddr}; use std::sync::{ Arc, Mutex, - atomic::{AtomicBool, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }; use std::time::Duration; @@ -23,6 +23,7 @@ use gateway_core::engine::execution::{ AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession, StartExecution, StartProviderExecution, StartedExecution, }; +use gateway_core::engine::middleware::{FrozenMiddlewarePlan, MiddlewareHeader}; use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, ModelRequestId}; use gateway_core::error::{ ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, @@ -41,15 +42,24 @@ use serde_json::{Value, json}; use gateway_api::openai::responses::{collect_execution_response, stream_execution_response}; use tower::ServiceExt; +use crate::openai::middleware::{RequestMiddleware, ResponseFrameAction}; use crate::openai::{ api_router, authenticated_client_for_provider, authenticated_client_with_min_versions, }; +fn responses_middleware() -> RequestMiddleware { + RequestMiddleware { + expected_operation: Some(OperationKind::Generate), + ..RequestMiddleware::default() + } +} + #[derive(Default)] struct Trace { events: Mutex>, client_statuses: Mutex>, cancelled: AtomicBool, + detached_finalizations: AtomicUsize, } impl Trace { @@ -82,6 +92,10 @@ impl Trace { fn is_cancelled(&self) -> bool { self.cancelled.load(Ordering::Acquire) } + + fn detached_finalizations(&self) -> usize { + self.detached_finalizations.load(Ordering::Acquire) + } } enum NextStep { @@ -335,6 +349,11 @@ impl ExecutionSession for FakeSession { &self.response_headers } + fn discard_pending_delivery(&mut self) -> Result<(), EngineError> { + self.trace.push("discard"); + Ok(()) + } + fn commit_downstream( &mut self, client_status_code: Option, @@ -372,6 +391,9 @@ impl ExecutionSession for FakeSession { fn detach_finalize(mut self: Box) -> BoxFuture<'static, ()> { Box::pin(async move { + self.trace + .detached_finalizations + .fetch_add(1, Ordering::AcqRel); if !self.finalized { let _ = self.next_event().await; } @@ -835,6 +857,268 @@ async fn streaming_encodes_first_frame_before_commit_and_http_delivery() { std::mem::forget(response); } +#[tokio::test] +async fn streaming_dropped_unknown_initial_batch_discards_without_committing() { + let trace = Arc::new(Trace::default()); + let dropped_raw = Bytes::from_static( + b"event: response.future_metadata\ndata: {\"type\":\"response.future_metadata\",\"dropped\":true}\n\n", + ); + let dropped = ProviderEvent::wire( + ProtocolWireEvent::raw_sse("openai", dropped_raw.clone()).expect("future SSE event"), + ); + let session = FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery_provider( + dropped, + CommitRequirement::CommitBeforeDelivery, + )), + NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), + NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)), + NextStep::FinalizeSuccess, + ], + ); + let middleware = Arc::new(responses_middleware().with_response_actions(vec![ + ResponseFrameAction::Drop, + ResponseFrameAction::Continue, + ResponseFrameAction::Continue, + ResponseFrameAction::Continue, + ])); + + let response = response_with_middleware("openai", session, true, Some(middleware)).await; + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read filtered SSE response"); + + assert!(!body.as_ref().starts_with(dropped_raw.as_ref())); + assert!(!String::from_utf8_lossy(&body).contains("\"dropped\":true")); + assert!(String::from_utf8_lossy(&body).contains("response.created")); + assert!(String::from_utf8_lossy(&body).contains("response.completed")); + assert!(body.as_ref().ends_with(b"data: [DONE]\n\n")); + assert_eq!(trace.client_statuses(), vec![200]); + assert_eq!( + trace.snapshot(), + vec![ + "next_event", + "discard", + "next_event", + "commit", + "next_event", + "next_end" + ] + ); + assert_eq!(trace.detached_finalizations(), 0); +} + +#[tokio::test] +async fn streaming_modified_sse_rejects_out_of_order_tool_events_without_replay() { + let trace = Arc::new(Trace::default()); + let added = super::openai_wire_event( + vec![], + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "item_tool", "type": "function_call", + "call_id": "call_tool", "name": "lookup" + } + }), + ); + let out_of_order = encode_sse_event( + "response.function_call_arguments.done", + &json!({ + "type": "response.function_call_arguments.done", + "output_index": 0, "item_id": "item_tool", + "call_id": "call_tool", "arguments": "{}" + }) + .to_string(), + ); + let session = FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), + NextStep::Event(delivery_provider( + added, + CommitRequirement::AlreadyCommitted, + )), + NextStep::FinalizeCancelled, + ], + ); + let middleware = Arc::new(responses_middleware().with_response_actions(vec![ + ResponseFrameAction::Continue, + ResponseFrameAction::Replace(Bytes::from(out_of_order)), + ])); + + let response = response_with_middleware("openai", session, true, Some(middleware)).await; + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read rejected transformed SSE"); + tokio::task::yield_now().await; + let body = String::from_utf8(body.to_vec()).expect("gateway SSE is UTF-8"); + + assert_eq!(body.matches("event: response.created").count(), 1); + assert!(!body.contains("response.function_call_arguments.done")); + assert!(body.contains("response.failed")); + assert!(body.ends_with("data: [DONE]\n\n")); + assert!(trace.is_cancelled()); + assert_eq!( + trace.snapshot(), + vec!["next_event", "commit", "next_event", "cancel_finalize"] + ); +} + +#[tokio::test] +async fn streaming_modified_sse_rejects_wrong_tool_association_without_replay() { + let trace = Arc::new(Trace::default()); + let added = super::openai_wire_event( + vec![], + "response.output_item.added", + json!({ + "type": "response.output_item.added", + "output_index": 0, + "item": { + "id": "item_tool", "type": "function_call", + "call_id": "call_tool", "name": "lookup" + } + }), + ); + let arguments = super::openai_wire_event( + vec![], + "response.function_call_arguments.delta", + json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, "item_id": "item_tool", + "call_id": "call_tool", "delta": "{}" + }), + ); + let wrong_association = encode_sse_event( + "response.function_call_arguments.delta", + &json!({ + "type": "response.function_call_arguments.delta", + "output_index": 0, "item_id": "item_tool", + "call_id": "call_other", "delta": "{}" + }) + .to_string(), + ); + let session = FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), + NextStep::Event(delivery_provider( + added, + CommitRequirement::AlreadyCommitted, + )), + NextStep::Event(delivery_provider( + arguments, + CommitRequirement::AlreadyCommitted, + )), + NextStep::FinalizeCancelled, + ], + ); + let middleware = Arc::new(responses_middleware().with_response_actions(vec![ + ResponseFrameAction::Continue, + ResponseFrameAction::Continue, + ResponseFrameAction::Replace(Bytes::from(wrong_association)), + ])); + + let response = response_with_middleware("openai", session, true, Some(middleware)).await; + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read rejected transformed SSE"); + tokio::task::yield_now().await; + let body = String::from_utf8(body.to_vec()).expect("gateway SSE is UTF-8"); + + assert!(!body.contains("call_other")); + assert!(body.contains("response.failed")); + assert_eq!(body.matches("event: response.created").count(), 1); + assert_eq!( + trace.snapshot(), + vec![ + "next_event", + "commit", + "next_event", + "next_event", + "cancel_finalize" + ] + ); +} + +#[tokio::test] +async fn streaming_modified_sse_cannot_change_terminal_classification() { + let trace = Arc::new(Trace::default()); + let incomplete = encode_sse_event( + "response.incomplete", + &json!({ + "type": "response.incomplete", + "response": { + "id": "resp_test", "model": "public-model", + "status": "incomplete", "output": [] + } + }) + .to_string(), + ); + let session = FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)), + NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)), + NextStep::FinalizeSuccess, + ], + ); + let middleware = Arc::new(responses_middleware().with_response_actions(vec![ + ResponseFrameAction::Continue, + ResponseFrameAction::Replace(Bytes::from(incomplete)), + ])); + + let response = response_with_middleware("openai", session, true, Some(middleware)).await; + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read terminal-class rejection"); + tokio::task::yield_now().await; + let body = String::from_utf8(body.to_vec()).expect("gateway SSE is UTF-8"); + + assert!(body.contains("response.failed")); + assert!(!body.contains("response.incomplete")); + assert_eq!(body.matches("event: response.created").count(), 1); + assert_eq!( + trace.snapshot(), + vec!["next_event", "commit", "next_event", "next_end"] + ); + assert!(!trace.is_cancelled()); +} + +#[tokio::test] +async fn streaming_terminal_drop_fails_before_commit() { + let trace = Arc::new(Trace::default()); + let session = FakeSession::streaming( + Arc::clone(&trace), + vec![ + NextStep::Event(delivery( + completed(), + CommitRequirement::CommitBeforeDelivery, + )), + NextStep::FinalizeCancelled, + ], + ); + let middleware = + Arc::new(responses_middleware().with_response_actions(vec![ResponseFrameAction::Drop])); + + let response = response_with_middleware("openai", session, true, Some(middleware)).await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read middleware error response"); + assert_eq!( + serde_json::from_slice::(&body).expect("middleware error JSON")["error"]["code"], + "middleware_failed" + ); + assert_eq!(trace.client_statuses(), vec![502]); + assert!(trace.is_cancelled()); + assert!(!trace.snapshot().contains(&"commit")); +} + #[tokio::test(start_paused = true)] async fn streaming_should_keep_alive_during_silent_compaction_without_restarting_execution() { let trace = Arc::new(Trace::default()); @@ -922,6 +1206,7 @@ async fn dropping_stream_during_keepalive_should_cancel_and_finalize_execution() trace.snapshot(), vec!["next_event", "commit", "wait_event", "cancel_finalize"] ); + assert_eq!(trace.detached_finalizations(), 1); } #[tokio::test(start_paused = true)] @@ -1396,6 +1681,7 @@ async fn streaming_success_should_emit_terminal_event_and_done_marker() { "next_end", ] ); + assert_eq!(trace.detached_finalizations(), 0); } #[tokio::test] @@ -1464,6 +1750,7 @@ async fn streaming_completed_event_without_finalized_execution_should_fail_close "cancel_finalize" ] ); + assert_eq!(trace.detached_finalizations(), 1); } #[tokio::test] @@ -1910,6 +2197,257 @@ async fn buffered_response_commits_only_after_complete_json_is_encoded() { assert!(!trace.is_cancelled()); } +#[tokio::test] +async fn buffered_response_collects_completed_output_items_without_rewriting_streams() { + let message = json!({ + "id": "msg_test", "type": "message", "role": "assistant", "status": "completed", + "content": [{"type": "output_text", "text": "完整文本", "annotations": []}], + "future_item_field": {"keep": true} + }); + let tool = json!({ + "id": "fc_test", "type": "function_call", "call_id": "call_test", + "name": "calculate", "arguments": "{\"value\":42}", "status": "completed" + }); + let terminal = json!({ + "id": "resp_test", "status": "completed", "output": [], + "usage": {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}, + "future_terminal_field": {"keep": true} + }); + let mut events = vec![provider_event_for_fact(started())]; + // 完成顺序不决定输出顺序;相同完成项重传不能产生重复正文。 + for (index, item) in [(1, &tool), (0, &message), (0, &message)] { + events.push(super::openai_wire_event( + vec![], + "response.output_item.done", + json!({"type": "response.output_item.done", "output_index": index, "item": item}), + )); + } + events.push(super::openai_wire_event( + vec![], + "response.completed", + json!({"type": "response.completed", "response": terminal}), + )); + let mut sse = super::OpenAiResponsesEncoder::new(); + let mut websocket = super::OpenAiResponsesEncoder::new(); + for event in &events { + let frames = sse.push_sse(event); + let messages = websocket.push_websocket(event); + let wire = event.wire_event().unwrap(); + let parsed = parse_sse_events(std::str::from_utf8(&frames.concat()).unwrap()).unwrap(); + assert_eq!( + serde_json::from_str::(&parsed[0].data).unwrap(), + *wire.data() + ); + assert_eq!( + serde_json::from_str::(&messages[0]).unwrap(), + *wire.data() + ); + } + assert_eq!(sse.finish().unwrap(), terminal); + assert_eq!(websocket.finish().unwrap(), terminal); + + let trace = Arc::new(Trace::default()); + let session = FakeSession::buffered_provider(Arc::clone(&trace), events); + let response = collect_execution_response(Box::new(session)).await; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let mut expected = terminal; + expected["output"] = json!([message, tool]); + assert_eq!(serde_json::from_slice::(&body).unwrap(), expected); + assert_eq!(trace.snapshot(), ["collect", "commit"]); + assert!(!trace.is_cancelled()); +} + +#[tokio::test] +async fn buffered_response_keeps_authoritative_terminal_and_supports_omitted_output() { + let item = json!({"id": "rs_test", "type": "reasoning", "encrypted_content": "opaque"}); + for (terminal, expected_output) in [ + ( + json!({"id": "resp_test", "output": [{"type": "future", "result": "terminal"}]}), + json!([{"type": "future", "result": "terminal"}]), + ), + ( + json!({"id": "resp_test", "status": "incomplete", "incomplete_details": {"reason": "max_output_tokens"}}), + json!([item]), + ), + ] { + let trace = Arc::new(Trace::default()); + let event_type = if terminal["status"] == "incomplete" { + "response.incomplete" + } else { + "response.completed" + }; + let session = FakeSession::buffered_provider( + Arc::clone(&trace), + vec![ + provider_event_for_fact(started()), + super::openai_wire_event( + vec![], + "response.output_item.done", + json!({"output_index": 0, "item": item}), + ), + super::openai_wire_event(vec![], event_type, json!({"response": terminal})), + ], + ); + let response = collect_execution_response(Box::new(session)).await; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let mut expected = terminal; + expected["output"] = expected_output; + assert_eq!(serde_json::from_slice::(&body).unwrap(), expected); + assert_eq!(trace.snapshot(), ["collect", "commit"]); + } +} + +#[tokio::test] +async fn buffered_response_rejects_incomplete_or_conflicting_output_items_before_commit() { + let item = + json!({"id": "msg_test", "type": "message", "content": [{"text": "private-output"}]}); + for done_items in [ + vec![json!({"output_index": 1, "item": item})], + vec![json!({"output_index": u64::MAX, "item": item})], + vec![json!({"output_index": -1, "item": item})], + vec![json!({"item": item})], + vec![json!({"output_index": 0, "item": []})], + vec![ + json!({"output_index": 0, "item": item}), + json!({"output_index": 0, "item": {"type": "different"}}), + ], + ] { + let trace = Arc::new(Trace::default()); + let mut events = vec![provider_event_for_fact(started())]; + events.extend( + done_items + .into_iter() + .map(|item| super::openai_wire_event(vec![], "response.output_item.done", item)), + ); + events.push(super::openai_wire_event( + vec![], + "response.completed", + json!({"response": {"id": "resp_test", "output": []}}), + )); + let response = collect_execution_response(Box::new(FakeSession::buffered_provider( + Arc::clone(&trace), + events, + ))) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["error"]["code"], "invalid_upstream_response"); + assert!(!String::from_utf8_lossy(&body).contains("private-output")); + assert!(!trace.snapshot().contains(&"commit")); + assert!(trace.is_cancelled()); + } +} + +#[tokio::test] +async fn buffered_response_never_merges_items_from_a_different_wire_response() { + for conflicting_event in [ + json!({"output_index": 0, "item": {"type": "message", "content": [{"text": "earlier-response"}]}}), + json!({"response_id": "foreign-response", "output_index": 0, "item": {"type": "message", "content": [{"text": "foreign-response"}]}}), + ] { + let trace = Arc::new(Trace::default()); + let terminal_id = if conflicting_event.get("response_id").is_some() { + "resp_test" + } else { + "new-response" + }; + let terminal = json!({"id": terminal_id, "status": "completed", "output": []}); + let events = vec![ + provider_event_for_fact(started()), + super::openai_wire_event(vec![], "response.output_item.done", conflicting_event), + super::openai_wire_event(vec![], "response.completed", json!({"response": terminal})), + ]; + let response = collect_execution_response(Box::new(FakeSession::buffered_provider( + Arc::clone(&trace), + events, + ))) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap(), terminal); + assert_eq!(trace.snapshot(), ["collect", "commit"]); + } +} + +#[tokio::test] +async fn buffered_response_middleware_replaces_body_and_filters_unsafe_headers() { + let trace = Arc::new(Trace::default()); + let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]); + let middleware = Arc::new( + responses_middleware() + .with_response_actions(vec![ResponseFrameAction::Replace( + Bytes::from_static( + b"{\"id\":\"resp_test\",\"status\":\"completed\",\"output\":[],\"future_policy_field\":true}", + ), + )]) + .with_response_headers(vec![ + MiddlewareHeader::new("x-policy-result", Bytes::from_static(b"replaced")), + MiddlewareHeader::new("authorization", Bytes::from_static(b"hidden")), + ]), + ); + + let response = response_with_middleware("openai", session, false, Some(middleware)).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["x-policy-result"], "replaced"); + assert!(response.headers().get(AUTHORIZATION).is_none()); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read policy JSON body"); + assert_eq!( + serde_json::from_slice::(&body).expect("policy response JSON"), + json!({ + "id": "resp_test", "status": "completed", "output": [], + "future_policy_field": true + }) + ); + assert_eq!(trace.client_statuses(), vec![200]); + assert_eq!(trace.snapshot(), vec!["collect", "commit"]); +} + +#[tokio::test] +async fn buffered_response_middleware_rejects_missing_terminal_fields_or_duplicate_tool_identity() { + for replacement in [ + json!({"id": "resp_test", "output": []}), + json!({"id": "resp_test", "status": "incomplete", "output": []}), + json!({ + "id": "resp_test", "status": "completed", "output": [ + { + "id": "item_a", "type": "function_call", "call_id": "call_same", + "name": "first", "arguments": "{}" + }, + { + "id": "item_b", "type": "function_call", "call_id": "call_same", + "name": "second", "arguments": "{}" + } + ] + }), + ] { + let trace = Arc::new(Trace::default()); + let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()]); + let middleware = Arc::new(responses_middleware().with_response_actions(vec![ + ResponseFrameAction::Replace(Bytes::from( + serde_json::to_vec(&replacement).expect("replacement JSON"), + )), + ])); + + let response = response_with_middleware("openai", session, false, Some(middleware)).await; + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read middleware error response"); + assert_eq!( + serde_json::from_slice::(&body).expect("middleware error JSON")["error"]["code"], + "middleware_failed" + ); + assert_eq!(trace.client_statuses(), vec![502]); + assert!(!trace.snapshot().contains(&"commit")); + assert!(trace.is_cancelled()); + assert_eq!(trace.snapshot(), vec!["collect", "cancel_finalize"]); + } +} + #[tokio::test] async fn buffered_response_forwards_long_ordinary_provider_header_values() { let trace = Arc::new(Trace::default()); @@ -2007,6 +2545,7 @@ const MODEL_REQUEST_ID: &str = "req_model_correlation"; struct SessionExecution { client: AuthenticatedClient, session: Mutex>>, + middleware: Option, } #[tokio::test] @@ -2040,6 +2579,7 @@ async fn chatgpt_remote_responses_should_only_skip_missing_desktop_versions() { NextStep::FinalizeSuccess, ], )))), + middleware: None, }); let mut request = Request::post("/v1/responses") .header(AUTHORIZATION, "Bearer sk_remote_test") @@ -2128,6 +2668,13 @@ impl ExecutionService for SessionExecution { true } + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + self.middleware.clone() + } + fn start( &self, request: StartExecution, @@ -2154,10 +2701,20 @@ async fn response( provider: &str, session: FakeSession, streaming: bool, +) -> axum::response::Response { + response_with_middleware(provider, session, streaming, None).await +} + +async fn response_with_middleware( + provider: &str, + session: FakeSession, + streaming: bool, + middleware: Option>, ) -> axum::response::Response { let execution = Arc::new(SessionExecution { client: authenticated_client_for_provider("sk_correlation_test", provider), session: Mutex::new(Some(Box::new(session))), + middleware: middleware.map(|middleware| middleware.frozen()), }); api_router(execution) .await diff --git a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs index fadb243b1..02d31ed91 100644 --- a/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs +++ b/backend/crates/gateway-api/tests/openai/responses/websocket/mod.rs @@ -1030,7 +1030,7 @@ impl Provider for ChargedWebSocketProvider { } async fn execute( - &self, + self: Arc, request: ProviderRequest, _: AttemptContext, ) -> Result { diff --git a/backend/crates/gateway-api/tests/openai/search/mod.rs b/backend/crates/gateway-api/tests/openai/search/mod.rs index aab238ebf..0bddb7ef3 100644 --- a/backend/crates/gateway-api/tests/openai/search/mod.rs +++ b/backend/crates/gateway-api/tests/openai/search/mod.rs @@ -16,7 +16,7 @@ use gateway_core::error::{ ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind, }; use gateway_core::event::ProviderResponseHeader; -use gateway_core::operation::Operation; +use gateway_core::operation::{Operation, OperationKind}; use gateway_core::routing::PublicModelId; use gateway_core::upstream::UpstreamSendState; use serde_json::{Value, json}; @@ -41,6 +41,7 @@ struct SearchExecution { captured: Mutex>, committed_statuses: Arc>>, fail_with_upstream_response: bool, + middleware: Option>, } impl SearchExecution { @@ -50,6 +51,7 @@ impl SearchExecution { captured: Mutex::new(Vec::new()), committed_statuses: Arc::new(Mutex::new(Vec::new())), fail_with_upstream_response, + middleware: None, }) } @@ -59,6 +61,13 @@ impl SearchExecution { } impl ExecutionService for SearchExecution { + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + self.middleware.as_ref().map(|plan| plan.frozen()) + } + fn authenticate( &self, plaintext: &str, @@ -151,7 +160,13 @@ impl ExecutionService for SearchExecution { #[tokio::test] async fn search_route_should_preserve_request_and_success_response_bytes() { - let execution = SearchExecution::new(false); + let mut execution = SearchExecution::new(false); + let middleware = Arc::new(crate::openai::middleware::RequestMiddleware { + expected_operation: Some(OperationKind::Search), + committed_statuses: Some(execution.committed_statuses.clone()), + ..Default::default() + }); + Arc::get_mut(&mut execution).unwrap().middleware = Some(middleware.clone()); let request_body = br#"{ "id":"search-session", "model":"gpt-future", "commands":{"search_query":[{"q":"private query"}]}, "future":1, "future":2 }"#; let turn_metadata = r#"{"session_id":"session","thread_id":"thread","turn_id":"turn","future":true}"#; @@ -171,6 +186,11 @@ async fn search_route_should_preserve_request_and_success_response_bytes() { .expect("search response"); assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-request-middleware"], "applied"); + assert_eq!( + *middleware.endpoints.lock().unwrap(), + vec!["/v1/alpha/search"] + ); assert_eq!(response.headers()["x-request-id"], "req_search_test"); assert_eq!( response.headers()["x-gateway-request-id"], diff --git a/backend/crates/gateway-api/tests/openai/usage.rs b/backend/crates/gateway-api/tests/openai/usage.rs index 7faa10b1d..d19dd482d 100644 --- a/backend/crates/gateway-api/tests/openai/usage.rs +++ b/backend/crates/gateway-api/tests/openai/usage.rs @@ -1,12 +1,27 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + use axum::{ Router, http::{Method, StatusCode, header}, response::Response, }; use chrono::{Duration, Utc}; +use futures::future::BoxFuture; use gateway_core::{ engine::budget::{ClientBudgetLimits, ClientBudgetStatus}, + engine::{ + authentication::ClientAuthenticationRequest, + execution::{ + AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution, + StartProviderExecution, StartedExecution, + }, + }, + error::{GatewayError, GatewayErrorKind}, policy::ClientApiKeyId, + routing::PublicModelId, }; use serde_json::json; use tower::ServiceExt as _; @@ -41,7 +56,7 @@ async fn fixture() -> (AdminTestFixture, Router) { weekly_resets_at: Some((Utc::now() + Duration::days(7)).into()), }; *fixture.client_key.lock().unwrap() = Some(key); - let app = super::api_router_with_admin(fixture.services.clone()); + let app = super::api_router_with_admin_and_client(fixture.services.clone(), KEY, "key-42"); (fixture, app) } @@ -83,6 +98,106 @@ async fn usage_returns_current_key_budget_without_exposing_private_data_or_writi assert!(fixture.observations.lock().unwrap().summaries.is_empty()); } +struct FrontendUsageAuthentication { + client: AuthenticatedClient, + authentication_calls: AtomicUsize, + verification_calls: AtomicUsize, + middleware: Arc, +} + +impl ExecutionService for FrontendUsageAuthentication { + fn middleware_plan( + &self, + _: &gateway_core::engine::execution::PreparedRootExecution, + ) -> Option { + Some(self.middleware.frozen()) + } + + fn authenticate(&self, _: &str) -> Result { + Err(ClientAuthenticationError::InvalidKey) + } + + fn authenticate_request( + &self, + _: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + self.authentication_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Err(ClientAuthenticationError::InvalidKey) }) + } + + fn verify_request( + &self, + request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + assert_eq!(request.authorization(), "External controlled-fixture"); + self.verification_calls.fetch_add(1, Ordering::SeqCst); + let client = self.client.clone(); + Box::pin(async move { Ok(client) }) + } + + fn public_models(&self, _: &AuthenticatedClient) -> Vec { + Vec::new() + } + + fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool { + false + } + + fn start(&self, _: StartExecution) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Internal, + "usage route must not start an execution", + )) + }) + } + + fn start_provider_endpoint( + &self, + _: StartProviderExecution, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Internal, + "usage route must not start a provider execution", + )) + }) + } +} + +#[tokio::test] +async fn usage_uses_the_entry_authentication_identity_without_recording_request_usage() { + let fixture = key_fixture().await; + let id = ClientApiKeyId::new("key_api_test").unwrap(); + let key = fixture + .services + .client_keys() + .reveal(&id) + .await + .unwrap() + .record; + *fixture.client_key.lock().unwrap() = Some(key); + let execution = Arc::new(FrontendUsageAuthentication { + client: super::authenticated_client("unused-native-key"), + authentication_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + middleware: Arc::new(crate::openai::middleware::RequestMiddleware::default()), + }); + let app = + super::api_router_with_admin_and_execution(fixture.services.clone(), execution.clone()); + + let response = query(&app, "/v1/usage", Some("External controlled-fixture")).await; + assert_eq!(response.headers()["x-request-middleware"], "applied"); + assert_eq!( + *execution.middleware.endpoints.lock().unwrap(), + ["/v1/usage"] + ); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(execution.verification_calls.load(Ordering::SeqCst), 1); + assert_eq!(execution.authentication_calls.load(Ordering::SeqCst), 0); +} + #[tokio::test] async fn usage_remains_readable_when_exhausted_and_never_returns_negative_remaining() { let (fixture, app) = fixture().await; diff --git a/backend/crates/gateway-api/tests/provider.rs b/backend/crates/gateway-api/tests/provider.rs new file mode 100644 index 000000000..ec821607e --- /dev/null +++ b/backend/crates/gateway-api/tests/provider.rs @@ -0,0 +1,474 @@ +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + time::SystemTime, +}; + +use axum::{ + body::{Body, to_bytes}, + http::{Method, Request, StatusCode, header::AUTHORIZATION}, +}; +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_core::{ + engine::execution::{ + AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession, + StartExecution, StartProviderExecution, StartedExecution, + }, + engine::{CoordinatedEvent, EngineError, ModelRequestId}, + error::{GatewayError, GatewayErrorKind}, + event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader}, + operation::{Operation, ProviderHttpMethod}, + routing::PublicModelId, +}; +use tower::ServiceExt as _; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CapturedOperation { + CountTokens { + provider: String, + model: String, + protocol: String, + endpoint: String, + body: Bytes, + }, + Http { + provider: String, + endpoint_id: String, + method: ProviderHttpMethod, + query: Option, + protocol: String, + route: String, + headers: Vec<(String, Bytes)>, + body: Bytes, + }, +} + +struct ProviderExecution { + client: AuthenticatedClient, + captured: Mutex>, +} + +impl ProviderExecution { + fn new() -> Arc { + Arc::new(Self { + client: crate::openai::authenticated_client_for_provider( + "sk_provider_operation_test", + "local-echo", + ), + captured: Mutex::new(Vec::new()), + }) + } + + fn captured(&self) -> Vec { + self.captured.lock().expect("capture lock").clone() + } +} + +impl ExecutionService for ProviderExecution { + fn authenticate( + &self, + plaintext: &str, + ) -> Result { + (plaintext == "sk_provider_operation_test") + .then(|| self.client.clone()) + .ok_or(ClientAuthenticationError::InvalidKey) + } + + fn public_models(&self, _: &AuthenticatedClient) -> Vec { + Vec::new() + } + + fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool { + false + } + + fn start(&self, _: StartExecution) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Internal, + "provider operation test must use a provider endpoint", + )) + }) + } + + fn start_provider_endpoint( + &self, + request: StartProviderExecution, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let (captured, response, status) = match request.operation { + Operation::CountTokens(count) => { + let model = request + .upstream_model + .expect("token count model binding") + .as_str() + .to_owned(); + ( + CapturedOperation::CountTokens { + provider: request.provider.as_str().to_owned(), + model, + protocol: request.metadata.protocol, + endpoint: request.metadata.endpoint, + body: count.payload().body().clone(), + }, + SessionResponse::Json(Bytes::from_static( + br#"{ "input_tokens": 2, "tokenizer":"local_whitespace_v1" }"#, + )), + StatusCode::OK.as_u16(), + ) + } + Operation::ProviderHttp(http) => { + let status = if http.endpoint() == "redirect-fixture" { + StatusCode::FOUND + } else { + StatusCode::OK + }; + ( + CapturedOperation::Http { + provider: request.provider.as_str().to_owned(), + endpoint_id: http.endpoint().to_owned(), + method: http.method(), + query: http.query().map(str::to_owned), + protocol: request.metadata.protocol, + route: request.metadata.endpoint, + headers: http + .headers() + .iter() + .map(|header| (header.name().to_owned(), header.value().clone())) + .collect(), + body: http.payload().body().clone(), + }, + SessionResponse::Opaque(match http.endpoint() { + "oversized-fragment-fixture" => { + vec![Bytes::from(vec![0; 64 * 1024 + 1])] + } + "aggregate-overflow-fixture" => { + vec![Bytes::from_static(&[0; 64 * 1024]); 129] + } + "missing-body-fixture" => Vec::new(), + "empty-body-fixture" => vec![Bytes::new()], + _ => vec![ + Bytes::from_static(b"\0raw-provider-"), + Bytes::from_static(b"body\xff"), + ], + }), + status.as_u16(), + ) + } + _ => { + return Err(GatewayError::new( + GatewayErrorKind::Internal, + "unexpected provider operation", + )); + } + }; + self.captured.lock().expect("capture lock").push(captured); + Ok(StartedExecution { + request_id: ModelRequestId::new("req_provider_operation_test").expect("request ID"), + created_at: SystemTime::now(), + stream: false, + session: Box::new(ProviderSession { + response: Some(response), + status, + finalized: AtomicBool::new(false), + }), + }) + }) + } +} + +enum SessionResponse { + Json(Bytes), + Opaque(Vec), +} + +struct ProviderSession { + response: Option, + status: u16, + finalized: AtomicBool, +} + +impl ExecutionSession for ProviderSession { + fn next_event(&mut self) -> BoxFuture<'_, Result, EngineError>> { + Box::pin(async { unreachable!("buffered provider response does not stream") }) + } + + fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result, EngineError>> { + Box::pin(async move { + match self.response.take().expect("single response") { + SessionResponse::Json(body) => Ok(vec![ProviderEvent::wire( + ProtocolWireEvent::raw_json("token-count", body).expect("JSON protocol"), + )]), + SessionResponse::Opaque(chunks) => Ok(chunks + .into_iter() + .map(|body| { + ProviderEvent::wire( + ProtocolWireEvent::raw_http_body("provider-http", body) + .expect("HTTP protocol"), + ) + }) + .collect()), + } + }) + } + + fn response_headers(&self) -> &[ProviderResponseHeader] { + static HEADERS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| { + vec![ + ProviderResponseHeader::new( + "content-type", + Bytes::from_static(b"application/x-provider"), + ), + ProviderResponseHeader::new("content-length", Bytes::from_static(b"9999")), + ProviderResponseHeader::new("set-cookie", Bytes::from_static(b"secret=1")), + ProviderResponseHeader::new( + "location", + Bytes::from_static(b"https://evil.invalid"), + ), + ProviderResponseHeader::new("connection", Bytes::from_static(b"x-hop")), + ProviderResponseHeader::new("x-hop", Bytes::from_static(b"secret")), + ProviderResponseHeader::new("x-provider-safe", Bytes::from_static(b"kept")), + ] + }); + HEADERS.as_slice() + } + + fn response_status_code(&self) -> Option { + Some(self.status) + } + + fn commit_downstream(&mut self, _: Option) -> BoxFuture<'_, Result<(), EngineError>> { + Box::pin(async move { + self.finalized.store(true, Ordering::Release); + Ok(()) + }) + } + + fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> { + Box::pin(async { Ok(()) }) + } + + fn is_finalized(&self) -> bool { + self.finalized.load(Ordering::Acquire) + } + + fn cancel(&self) { + self.finalized.store(true, Ordering::Release); + } + + fn detach_finalize(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { + self.finalized.store(true, Ordering::Release); + }) + } +} + +#[tokio::test] +async fn token_count_should_bind_provider_model_and_preserve_exact_json() { + let execution = ProviderExecution::new(); + let request_body = Bytes::from_static(br#"{ "input":"one two", "future":9007199254740993 }"#); + let response = crate::openai::api_router(execution.clone()) + .await + .oneshot( + Request::post("/v1/providers/local-echo/models/local-echo/count_tokens") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::from(request_body.clone())) + .expect("token count request"), + ) + .await + .expect("token count response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/json"); + assert_eq!(response.headers()["content-length"], "56"); + assert_eq!(response.headers()["x-provider-safe"], "kept"); + assert!(!response.headers().contains_key("set-cookie")); + assert!(!response.headers().contains_key("location")); + assert!(!response.headers().contains_key("x-hop")); + let body = to_bytes(response.into_body(), 1024).await.expect("body"); + assert_eq!( + body.as_ref(), + br#"{ "input_tokens": 2, "tokenizer":"local_whitespace_v1" }"# + ); + assert_eq!( + execution.captured(), + vec![CapturedOperation::CountTokens { + provider: "local-echo".to_owned(), + model: "local-echo".to_owned(), + protocol: "token-count".to_owned(), + endpoint: "/v1/providers/local-echo/models/local-echo/count_tokens".to_owned(), + body: request_body, + }] + ); +} + +#[tokio::test] +async fn provider_http_should_strip_sensitive_headers_and_preserve_opaque_bytes() { + let execution = ProviderExecution::new(); + let request_body = Bytes::from_static(b"\x00input\xff"); + let response = crate::openai::api_router(execution.clone()) + .await + .oneshot( + Request::post("/v1/providers/local-echo/http/inspect?mode=future") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .header("x-api-key", "must-not-reach-plugin") + .header("cookie", "must-not-reach-plugin=1") + .header("connection", "x-hop-client") + .header("x-hop-client", "must-not-reach-plugin") + .header("x-safe-client", "kept") + .body(Body::from(request_body.clone())) + .expect("provider HTTP request"), + ) + .await + .expect("provider HTTP response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], "application/x-provider"); + assert_eq!(response.headers()["content-length"], "19"); + assert!(!response.headers().contains_key("set-cookie")); + assert!(!response.headers().contains_key("location")); + let body = to_bytes(response.into_body(), 1024).await.expect("body"); + assert_eq!(body.as_ref(), b"\0raw-provider-body\xff"); + + let captured = execution.captured(); + let [CapturedOperation::Http { headers, .. }] = captured.as_slice() else { + panic!("HTTP operation captured"); + }; + assert!( + headers + .iter() + .any(|(name, value)| { name == "x-safe-client" && value.as_ref() == b"kept" }) + ); + assert!(!headers.iter().any(|(name, _)| { + matches!( + name.as_str(), + "authorization" | "x-api-key" | "cookie" | "connection" | "x-hop-client" + ) + })); + assert_eq!( + captured[0], + CapturedOperation::Http { + provider: "local-echo".to_owned(), + endpoint_id: "inspect".to_owned(), + method: ProviderHttpMethod::Post, + query: Some("mode=future".to_owned()), + protocol: "provider-http".to_owned(), + route: "/v1/providers/local-echo/http/inspect".to_owned(), + headers: headers.clone(), + body: request_body, + } + ); +} + +#[tokio::test] +async fn provider_http_should_reject_head_and_get_bodies_before_execution() { + let execution = ProviderExecution::new(); + let router = crate::openai::api_router(execution.clone()).await; + let head = router + .clone() + .oneshot( + Request::builder() + .method(Method::HEAD) + .uri("/v1/providers/local-echo/http/inspect") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::empty()) + .expect("HEAD request"), + ) + .await + .expect("HEAD response"); + assert_eq!(head.status(), StatusCode::METHOD_NOT_ALLOWED); + + let get = router + .oneshot( + Request::get("/v1/providers/local-echo/http/inspect") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::from("not-empty")) + .expect("GET request"), + ) + .await + .expect("GET response"); + assert_eq!(get.status(), StatusCode::BAD_REQUEST); + assert!(execution.captured().is_empty()); +} + +#[tokio::test] +async fn token_count_should_reject_invalid_json_before_execution() { + let execution = ProviderExecution::new(); + let response = crate::openai::api_router(execution.clone()) + .await + .oneshot( + Request::post("/v1/providers/local-echo/models/local-echo/count_tokens") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::from("{not-json")) + .expect("token count request"), + ) + .await + .expect("token count response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(execution.captured().is_empty()); +} + +#[tokio::test] +async fn provider_http_should_fail_closed_on_non_success_success_envelope() { + let execution = ProviderExecution::new(); + let response = crate::openai::api_router(execution) + .await + .oneshot( + Request::get("/v1/providers/local-echo/http/redirect-fixture") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::empty()) + .expect("provider HTTP request"), + ) + .await + .expect("provider HTTP response"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert!(!response.headers().contains_key("location")); +} + +#[tokio::test] +async fn provider_http_should_enforce_fragment_aggregate_and_body_presence_bounds() { + let empty = crate::openai::api_router(ProviderExecution::new()) + .await + .oneshot( + Request::get("/v1/providers/local-echo/http/empty-body-fixture") + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::empty()) + .expect("empty provider HTTP request"), + ) + .await + .expect("empty provider HTTP response"); + assert_eq!(empty.status(), StatusCode::OK); + assert_eq!(empty.headers()["content-length"], "0"); + assert!( + to_bytes(empty.into_body(), 1) + .await + .expect("empty body") + .is_empty() + ); + + for endpoint in [ + "oversized-fragment-fixture", + "aggregate-overflow-fixture", + "missing-body-fixture", + ] { + let execution = ProviderExecution::new(); + let response = crate::openai::api_router(execution) + .await + .oneshot( + Request::get(format!("/v1/providers/local-echo/http/{endpoint}")) + .header(AUTHORIZATION, "Bearer sk_provider_operation_test") + .body(Body::empty()) + .expect("provider HTTP request"), + ) + .await + .expect("provider HTTP response"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "{endpoint}"); + } +} diff --git a/backend/crates/gateway-api/tests/support.rs b/backend/crates/gateway-api/tests/support.rs index ea4e9af88..0e6adcefa 100644 --- a/backend/crates/gateway-api/tests/support.rs +++ b/backend/crates/gateway-api/tests/support.rs @@ -111,6 +111,7 @@ impl SystemOperations for VersionSystem { async fn perform_update( &self, _: Option, + _: std::sync::Arc, ) -> Result { unreachable!("client route must not perform updates") } @@ -119,7 +120,10 @@ impl SystemOperations for VersionSystem { unreachable!("client route must not request update status") } - async fn rollback(&self) -> Result { + async fn rollback( + &self, + _: std::sync::Arc, + ) -> Result { unreachable!("client route must not roll back") } diff --git a/backend/crates/gateway-core/src/account/scope.rs b/backend/crates/gateway-core/src/account/scope.rs index 941b04562..effdafbb5 100644 --- a/backend/crates/gateway-core/src/account/scope.rs +++ b/backend/crates/gateway-core/src/account/scope.rs @@ -251,6 +251,9 @@ pub struct FrozenAccountScope { request_profiles: BTreeMap, directory: Arc, client_scope: ClientRoutingScope, + provider_kinds: Arc>, + allowed_account_ids: Option>>, + excluded_account_ids: Arc>, } impl FrozenAccountScope { @@ -282,23 +285,80 @@ impl FrozenAccountScope { } #[must_use] - pub const fn new( - directory: Arc, - client_scope: ClientRoutingScope, - ) -> Self { + pub fn new(directory: Arc, client_scope: ClientRoutingScope) -> Self { + let provider_kinds = match &client_scope { + ClientRoutingScope::AllAccounts => Arc::new(directory.providers_with_accounts.clone()), + ClientRoutingScope::Restricted { provider_kinds, .. } => Arc::clone(provider_kinds), + }; Self { disable_fast: false, request_profiles: BTreeMap::new(), directory, client_scope, + provider_kinds, + allowed_account_ids: None, + excluded_account_ids: Arc::default(), } } + /// 在已经冻结的 Key 范围内施加一次请求局部的 Provider/账号交集。 + /// + /// 空集合表示该维度不额外收窄;非空集合即使没有有效交集也保持为空, + /// 不能回退为父请求的完整范围。 + #[must_use] + pub fn restricted_to( + &self, + providers: &BTreeSet, + accounts: &BTreeSet, + ) -> Self { + let mut restricted = self.clone(); + let mut provider_kinds = self.provider_kinds.as_ref().clone(); + if !providers.is_empty() { + provider_kinds.retain(|provider| providers.contains(provider)); + } + if !accounts.is_empty() { + let allowed_account_ids = accounts + .iter() + .filter(|account| self.allows(account)) + .cloned() + .collect::>(); + provider_kinds.retain(|provider| { + allowed_account_ids.iter().any(|account| { + self.directory + .account(account) + .is_some_and(|entry| entry.provider_kind() == provider) + }) + }); + restricted.allowed_account_ids = Some(Arc::new(allowed_account_ids)); + } + restricted.provider_kinds = Arc::new(provider_kinds); + restricted + } + + /// 排除父 attempt 当前已经持有的账号,避免嵌套调用形成账号 lease 自等待。 + #[must_use] + pub fn excluding_account(&self, account: ProviderAccountId) -> Self { + let mut restricted = self.clone(); + let mut excluded = self.excluded_account_ids.as_ref().clone(); + excluded.insert(account); + restricted.excluded_account_ids = Arc::new(excluded); + restricted + } + #[must_use] pub fn allows(&self, account_id: &ProviderAccountId) -> bool { let Some(account) = self.directory.account(account_id) else { return false; }; + if self.excluded_account_ids.contains(account_id) + || !self.provider_kinds.contains(account.provider_kind()) + || self + .allowed_account_ids + .as_ref() + .is_some_and(|accounts| !accounts.contains(account_id)) + { + return false; + } match &self.client_scope { ClientRoutingScope::AllAccounts => true, ClientRoutingScope::Restricted { @@ -322,17 +382,23 @@ impl FrozenAccountScope { /// 目录按整个授权账号池过滤,不能只看用于获取元数据的账号政策。 #[must_use] pub fn allows_provider_model(&self, provider: &ProviderKind, upstream_model: &str) -> bool { - self.directory.accounts.iter().any(|(id, account)| { - account.provider_kind() == provider && self.allows_model(id, upstream_model) - }) + self.provider_kinds.contains(provider) + && self.directory.accounts.iter().any(|(id, account)| { + account.provider_kind() == provider && self.allows_model(id, upstream_model) + }) } #[must_use] pub fn provider_kinds(&self) -> &BTreeSet { - match &self.client_scope { - ClientRoutingScope::AllAccounts => self.directory.providers_with_accounts(), - ClientRoutingScope::Restricted { provider_kinds, .. } => provider_kinds, - } + &self.provider_kinds + } + + /// 返回冻结目录中账号所属 Provider;调用方仍须另行检查 [`Self::allows`]。 + #[must_use] + pub fn account_provider(&self, account_id: &ProviderAccountId) -> Option<&ProviderKind> { + self.directory + .account(account_id) + .map(RuntimeAccount::provider_kind) } #[must_use] diff --git a/backend/crates/gateway-core/src/account/selection.rs b/backend/crates/gateway-core/src/account/selection.rs index 2169a4d18..80fa79ec5 100644 --- a/backend/crates/gateway-core/src/account/selection.rs +++ b/backend/crates/gateway-core/src/account/selection.rs @@ -454,6 +454,7 @@ pub enum AccountSchedulingBlocker { pub enum PreferredAccountSelection { NotRequested, Hit, + OverriddenByPolicy, Missing, Blocked(AccountSchedulingBlocker), } @@ -482,6 +483,52 @@ impl<'a> AccountSelection<'a> { pub struct AccountSelector; impl AccountSelector { + /// 返回调度策略可见的全部合格候选;权重层授权由调用策略的宿主适配器裁剪。 + #[must_use] + pub(crate) fn policy_candidates<'a>( + &self, + candidates: &'a [AccountCandidate], + context: &AccountSelectionContext, + ) -> Vec<&'a AccountCandidate> { + candidates + .iter() + .filter(|candidate| self.scheduling_blocker(candidate, context).is_none()) + .collect() + } + + /// 对插件返回的 ID 再执行同一资格判断,并保留原有亲和遥测结果。 + #[must_use] + pub(crate) fn select_policy_candidate<'a>( + &self, + candidates: &'a [AccountCandidate], + context: &AccountSelectionContext, + account_id: &ProviderAccountId, + ) -> Option> { + let candidate = candidates + .iter() + .find(|candidate| candidate.account.id() == account_id)?; + if self.scheduling_blocker(candidate, context).is_some() { + return None; + } + let highest_weight = candidates + .iter() + .filter(|candidate| self.scheduling_blocker(candidate, context).is_none()) + .map(|candidate| candidate.account.weight()) + .max()?; + let (preferred, _) = self.preferred_decision(candidates, context, highest_weight); + let preferred = if preferred == PreferredAccountSelection::Hit + && context.preferred_account.as_ref() != Some(account_id) + { + PreferredAccountSelection::OverriddenByPolicy + } else { + preferred + }; + Some(AccountSelection { + candidate, + preferred, + }) + } + /// 汇总与本次调度约束一致的并发容量,供请求级观测使用。 #[must_use] pub fn capacity_snapshot( @@ -538,30 +585,14 @@ impl AccountSelector { .iter() .map(|candidate| candidate.account.weight()) .max()?; - let preferred = if let Some(preferred) = context.preferred_account.as_ref() { - match candidates - .iter() - .find(|candidate| candidate.account.id() == preferred) - { - Some(candidate) => match self.scheduling_blocker(candidate, context) { - Some(blocker) => PreferredAccountSelection::Blocked(blocker), - None if !context.preferred_account_overrides_weight - && candidate.account.weight() < highest_weight => - { - PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight) - } - None => { - return Some(AccountSelection { - candidate, - preferred: PreferredAccountSelection::Hit, - }); - } - }, - None => PreferredAccountSelection::Missing, - } - } else { - PreferredAccountSelection::NotRequested - }; + let (preferred, preferred_candidate) = + self.preferred_decision(candidates, context, highest_weight); + if let Some(candidate) = preferred_candidate { + return Some(AccountSelection { + candidate, + preferred: PreferredAccountSelection::Hit, + }); + } eligible.retain(|candidate| candidate.account.weight() == highest_weight); let candidate = match context.policy.strategy() { @@ -615,6 +646,35 @@ impl AccountSelector { }) } + fn preferred_decision<'a>( + &self, + candidates: &'a [AccountCandidate], + context: &AccountSelectionContext, + highest_weight: super::AccountWeight, + ) -> (PreferredAccountSelection, Option<&'a AccountCandidate>) { + let Some(preferred) = context.preferred_account.as_ref() else { + return (PreferredAccountSelection::NotRequested, None); + }; + let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.account.id() == preferred) + else { + return (PreferredAccountSelection::Missing, None); + }; + match self.scheduling_blocker(candidate, context) { + Some(blocker) => (PreferredAccountSelection::Blocked(blocker), None), + None if !context.preferred_account_overrides_weight + && candidate.account.weight() < highest_weight => + { + ( + PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight), + None, + ) + } + None => (PreferredAccountSelection::Hit, Some(candidate)), + } + } + /// 只有本地并发/调度间隔可等待;账号权限、失效、额度与上游冷却仍立即排除。 #[must_use] pub fn wait_candidates( diff --git a/backend/crates/gateway-core/src/engine/authentication.rs b/backend/crates/gateway-core/src/engine/authentication.rs new file mode 100644 index 000000000..750610a7a --- /dev/null +++ b/backend/crates/gateway-core/src/engine/authentication.rs @@ -0,0 +1,154 @@ +//! 数据面入口认证计划;插件只产生 principal,Core 解析既有 Client Key 策略。 + +use std::{ + collections::BTreeMap, + fmt, + sync::{Arc, RwLock, Weak}, +}; + +use futures::future::BoxFuture; + +use crate::{ + policy::{ClientApiKeyId, PlaintextClientApiKey}, + runtime::extensions::{ExtensionSetId, ExtensionSetReference}, +}; + +pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024; + +/// API 从数据面 Authorization 头构造的有界认证信封。 +#[derive(Clone, PartialEq, Eq)] +pub struct ClientAuthenticationRequest { + authorization: Arc, + native_bearer: Option, +} + +impl ClientAuthenticationRequest { + /// 创建入口认证信封;HTTP 语法与缺失头仍由 API owner 负责。 + pub fn new(authorization: impl Into) -> Result { + let authorization = authorization.into(); + if authorization.is_empty() + || authorization.len() > MAXIMUM_AUTHORIZATION_BYTES + || !authorization.is_ascii() + || authorization.chars().any(char::is_control) + { + return Err(ClientAuthenticationRequestError); + } + let native_bearer = authorization + .strip_prefix("Bearer ") + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok()); + Ok(Self { + authorization: authorization.into(), + native_bearer, + }) + } + + /// 原生调用方显式构造 Bearer 信封;仍经过同一长度与字符校验。 + pub fn bearer(plaintext: &str) -> Result { + Self::new(format!("Bearer {plaintext}")) + } + + #[must_use] + pub fn authorization(&self) -> &str { + &self.authorization + } + + #[must_use] + pub(crate) const fn native_bearer(&self) -> Option<&PlaintextClientApiKey> { + self.native_bearer.as_ref() + } +} + +impl fmt::Debug for ClientAuthenticationRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientAuthenticationRequest") + .field("authorization", &"[REDACTED]") + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("client authorization is invalid")] +pub struct ClientAuthenticationRequestError; + +/// 插件认证结果不携带宿主执行身份。 +#[derive(Clone, PartialEq, Eq)] +pub enum FrontendAuthenticationDecision { + Authenticated { principal: String }, + NotMatched, + Rejected, +} + +impl fmt::Debug for FrontendAuthenticationDecision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Authenticated { .. } => formatter + .debug_struct("Authenticated") + .field("principal", &"[REDACTED]") + .finish(), + Self::NotMatched => formatter.write_str("NotMatched"), + Self::Rejected => formatter.write_str("Rejected"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("frontend authentication provider is unavailable")] +pub struct FrontendAuthenticationError; + +/// 一个发布代次中唯一的入口认证计划。 +pub trait FrontendAuthenticationPlan: Send + Sync { + fn authenticate<'a>( + &'a self, + request: &'a ClientAuthenticationRequest, + ) -> BoxFuture<'a, Result>; + + /// 映射由管理员配置冻结,插件结果不能选择 Key ID。 + fn client_key_id(&self, principal: &str) -> Option; + + /// 独占计划不允许 `NotMatched` 回退原生 Bearer。 + fn exclusive(&self) -> bool; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("frontend authentication generation is already registered")] +pub struct FrontendAuthenticationRegistrationError; + +/// 按已发布集合解析的非拥有索引;旧代次由快照和在途调用保活。 +#[derive(Clone, Default)] +pub struct FrontendAuthenticationExtensionIndex { + sets: Arc>>>, +} + +impl FrontendAuthenticationExtensionIndex { + pub fn register( + &self, + id: ExtensionSetId, + plan: Arc, + ) -> Result, FrontendAuthenticationRegistrationError> { + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, plan| plan.strong_count() > 0); + if sets.contains_key(&id) { + return Err(FrontendAuthenticationRegistrationError); + } + sets.insert(id, Arc::downgrade(&plan)); + Ok(plan) + } + + #[must_use] + pub fn resolve( + &self, + generation: &ExtensionSetReference, + ) -> Option> { + self.sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(generation.id()) + .and_then(Weak::upgrade) + } +} diff --git a/backend/crates/gateway-core/src/engine/coordinator.rs b/backend/crates/gateway-core/src/engine/coordinator.rs index a7c85ae4c..36b9c72eb 100644 --- a/backend/crates/gateway-core/src/engine/coordinator.rs +++ b/backend/crates/gateway-core/src/engine/coordinator.rs @@ -5,12 +5,14 @@ use crate::diagnostics::TraceContext; use serde_json::json; use std::collections::{BTreeMap, BTreeSet}; -use std::future::Future; use std::num::NonZeroU32; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; -use super::observation::ResponseObservation; +use super::nested::ExecutionEffects; +use super::observation::{ + RequestObservationDispatch, ResponseObservation, WebSocketResponseAttempt, +}; use crate::engine::continuation::{ ContinuationBinding, NativeContinuationPin, NativeContinuationScope, PreviousResponseId, }; @@ -49,6 +51,83 @@ enum AccountSelection { Diagnostic(crate::account::ProviderAccountId), } +pub(super) struct CoordinationExtensions { + continuation: Option, + observation: Option, + request_policy: Option, + execution_effects: Option>, + execution_effects_baseline: usize, + middleware: Option, + account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, + endpoint: String, + client_transport: super::execution::ClientTransport, + extension_scope: super::extensions::ExtensionCallScope, +} + +impl CoordinationExtensions { + pub(super) fn new( + continuation: Option, + observation: Option, + ) -> Self { + Self { + continuation, + observation, + request_policy: None, + execution_effects: None, + execution_effects_baseline: 0, + middleware: None, + account_group_ids: Arc::from([]), + endpoint: String::new(), + client_transport: super::execution::ClientTransport::InternalProbe, + extension_scope: super::extensions::ExtensionCallScope::default(), + } + } + + #[must_use] + pub(super) fn with_extension_scope( + mut self, + extension_scope: super::extensions::ExtensionCallScope, + ) -> Self { + self.extension_scope = extension_scope; + self + } + + #[must_use] + pub(super) fn with_request_policy( + mut self, + request_policy: Option, + ) -> Self { + self.request_policy = request_policy; + self + } + + #[must_use] + pub(super) fn with_execution_effects( + mut self, + execution_effects: Option>, + execution_effects_baseline: usize, + ) -> Self { + self.execution_effects = execution_effects; + self.execution_effects_baseline = execution_effects_baseline; + self + } + + #[must_use] + pub(super) fn with_middleware( + mut self, + middleware: Option, + account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, + endpoint: String, + client_transport: super::execution::ClientTransport, + ) -> Self { + self.middleware = middleware; + self.account_group_ids = account_group_ids; + self.endpoint = endpoint; + self.client_transport = client_transport; + self + } +} + impl AccountSelection { fn required_account(&self) -> Option<&crate::account::ProviderAccountId> { match self { @@ -92,7 +171,27 @@ where operation, plan, AccountSelection::Scheduled(required_account), - continuation, + CoordinationExtensions::new(continuation, None), + cancellation, + ) + .await + } + + pub(super) async fn start_observed( + &self, + request: NewModelRequest, + operation: Operation, + plan: RoutingPlan, + required_account: Option, + extensions: CoordinationExtensions, + cancellation: CancellationToken, + ) -> Result, EngineError> { + self.start_with_account_selection( + request, + operation, + plan, + AccountSelection::Scheduled(required_account), + extensions, cancellation, ) .await @@ -115,7 +214,7 @@ where operation, plan, AccountSelection::Diagnostic(required_account), - continuation, + CoordinationExtensions::new(continuation, None), cancellation, ) .await @@ -127,9 +226,21 @@ where operation: Operation, plan: RoutingPlan, account_selection: AccountSelection, - continuation: Option, + extensions: CoordinationExtensions, cancellation: CancellationToken, ) -> Result, EngineError> { + let CoordinationExtensions { + continuation, + observation: request_observation, + request_policy, + execution_effects, + execution_effects_baseline, + middleware, + account_group_ids, + endpoint, + client_transport, + extension_scope, + } = extensions; let request_id = request.id.clone(); let client_api_key_ref = request.client_api_key_ref.clone(); let timing_started_at = Instant::now(); @@ -169,6 +280,7 @@ where client_api_key_ref, concurrency_wait_budget: ConcurrencyWaitBudget::default(), observation: ResponseObservation::new(timing_started_at), + request_observation, budget_prior_attempts_usd: Decimal::ZERO, budget_attempt_already_counted: false, trace, @@ -183,12 +295,21 @@ where request_persisted: false, operation, plan, + request_policy, + execution_effects, + execution_effects_baseline, + middleware, + account_group_ids, + endpoint, + client_transport, + extension_scope, account_selection, continuation, continuation_attempt, account_state_owner, cancellation, attempts: 0, + websocket_observation_sequence: 0, routing_attempts: 0, candidate_index, excluded_accounts: BTreeSet::new(), @@ -275,6 +396,7 @@ pub struct ResponseExecutionSession { client_api_key_ref: crate::policy::ClientApiKeyId, concurrency_wait_budget: ConcurrencyWaitBudget, observation: ResponseObservation, + request_observation: Option, budget_prior_attempts_usd: Decimal, budget_attempt_already_counted: bool, trace: TraceContext, @@ -285,6 +407,14 @@ pub struct ResponseExecutionSession { request_persisted: bool, operation: Operation, plan: RoutingPlan, + request_policy: Option, + execution_effects: Option>, + execution_effects_baseline: usize, + middleware: Option, + account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, + endpoint: String, + client_transport: super::execution::ClientTransport, + extension_scope: super::extensions::ExtensionCallScope, account_selection: AccountSelection, continuation: Option, continuation_attempt: ContinuationAttempt, @@ -292,6 +422,8 @@ pub struct ResponseExecutionSession { cancellation: CancellationToken, /// 所有实际上游 attempt 数;包含同账号传输重试,作为持久化序号。 attempts: u32, + /// WebSocket 观察序列在同一逻辑请求内按实际上游 wire 单调递增。 + websocket_observation_sequence: u64, /// 路由预算只统计正常选号/账号恢复,不被 Provider-owned 传输预算消耗。 routing_attempts: u32, candidate_index: usize, @@ -371,18 +503,14 @@ where } loop { match self.pull().await? { - PullOutcome::Event(event) => { - if self.downstream_committed_at.is_some() { - return Ok(Some(CoordinatedEvent::single( - event, - CommitRequirement::AlreadyCommitted, - ))); - } - self.delivery_pending = true; - return Ok(Some(CoordinatedEvent::single( - event, - CommitRequirement::CommitBeforeDelivery, - ))); + PullOutcome::Events(events) => { + let requirement = if self.downstream_committed_at.is_some() { + CommitRequirement::AlreadyCommitted + } else { + self.delivery_pending = true; + CommitRequirement::CommitBeforeDelivery + }; + return CoordinatedEvent::try_batch(events, requirement).map(Some); } PullOutcome::AttemptDiscarded => {} PullOutcome::TerminalFailure { @@ -427,7 +555,7 @@ where let mut events = Vec::new(); loop { match self.pull().await? { - PullOutcome::Event(event) => events.push(event), + PullOutcome::Events(next) => events.extend(next), PullOutcome::AttemptDiscarded => events.clear(), PullOutcome::TerminalFailure { error, send_state, .. @@ -485,6 +613,20 @@ where Ok(()) } + /// 仅当 HTTP 流插件明确丢弃了整个尚未提交的非终态批次时释放交付屏障。 + /// 原始 Provider facts 已经观察,不回滚计量,也不把丢弃误记为客户端 commit。 + pub fn discard_pending_delivery(&mut self) -> Result<(), EngineError> { + if !self.delivery_pending + || self.downstream_committed_at.is_some() + || self.pending_terminal_failure.is_some() + || self.is_finalized() + { + return Err(EngineError::InvalidDeliveryState); + } + self.delivery_pending = false; + Ok(()) + } + /// 在 HTTP adapter 已确定首字节前错误响应后补写最终状态。 /// /// # Errors @@ -684,10 +826,34 @@ where if event.wire_event().is_some() && !event.has_canonical_facts() { self.observe_wire_event().await; } + if self.request_observation.is_some() && event.wire_event().is_some() { + let websocket_attempt = self + .current + .as_ref() + .and_then(websocket_observation_attempt); + self.observe_websocket_response(&event, websocket_attempt); + } if !event.has_client_event() { continue; } - return Ok(PullOutcome::Event(event)); + let terminal = event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))); + let translated = self + .current + .as_mut() + .ok_or(EngineError::NoActiveAttempt)? + .stream + .translate_native_response(event, terminal); + match translated { + Ok(events) if events.is_empty() => continue, + Ok(events) => return Ok(PullOutcome::Events(events)), + Err(error) => { + self.finish_provider_error(&error).await?; + return Err(provider_engine_error(error)); + } + } } PollBoundary::Item(Some(Err(error))) => { match self.handle_stream_error(error).await? { @@ -797,17 +963,21 @@ where .ok_or_else(|| EngineError::ProviderNotRegistered { provider: candidate.provider().as_str().to_owned(), })?; - if !self.request_profiles.contains_key(candidate.provider()) - && let Some(configuration) = self + if !self.request_profiles.contains_key(candidate.provider()) { + let profile = match self .plan .account_scope() .request_profile(candidate.provider()) - { - match provider.resolve_request_profile(configuration) { - Ok(profile) => { + { + Some(configuration) => provider.resolve_request_profile(configuration).map(Some), + None => provider.default_request_profile(), + }; + match profile { + Ok(Some(profile)) => { self.request_profiles .insert(candidate.provider().clone(), profile); } + Ok(None) => {} Err(error) => { self.finish_provider_error(&error).await?; return Err(provider_engine_error(error)); @@ -822,6 +992,15 @@ where .with_request_location(self.plan.request_location().cloned()) .with_concurrency_wait_budget(self.concurrency_wait_budget.clone()) .with_timing_started_at(self.observation.timing_started_at) + .with_request_policy(self.request_policy.clone()) + .with_execution_effects(self.execution_effects.as_ref().map(Arc::clone)) + .with_middleware( + self.middleware.clone(), + Arc::clone(&self.account_group_ids), + self.endpoint.clone(), + self.client_transport, + ) + .with_extension_scope(self.extension_scope.clone()) .with_trace(self.trace.clone()), next_attempt, self.deadline, @@ -850,15 +1029,15 @@ where }), ); let provider_request = ProviderRequest::new(self.operation.clone(), candidate.clone()); - let stream = match poll_provider( + let provider_boundary = poll_provider( provider, provider_request, context, self.cancellation.clone(), self.deadline, ) - .await - { + .await; + let stream = match provider_boundary { ProviderBoundary::Cancelled => { self.finish_interruption(&EngineError::Cancelled).await?; return Err(EngineError::Cancelled); @@ -886,6 +1065,7 @@ where | ProviderErrorKind::ConcurrencyQueueFull | ProviderErrorKind::ConcurrencyQueueTimeout ) && error.send_state() == UpstreamSendState::NotSent + && self.current_send_state() == UpstreamSendState::NotSent && matches!( self.continuation_attempt, ContinuationAttempt::None | ContinuationAttempt::ReplayAny @@ -1177,15 +1357,25 @@ where record_trace_error(&self.trace.attempt(self.attempts), &error); let mut atomic_client_events = error.take_atomic_client_events(); let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?; + if self.request_observation.is_some() { + let websocket_attempt = websocket_observation_attempt(¤t); + for event in atomic_client_events + .iter() + .filter(|event| event.wire_event().is_some()) + { + self.observe_websocket_response(event, websocket_attempt.clone()); + } + } self.record_provider_failure(current.metadata.provider().clone(), error.kind()); - // attempt_send_state 是本 attempt 自身的发送事实,驱动重试门; - // 持久化与终态用请求级水位,二者不可混用(水位会把早先 attempt 的 - // sent 传染给本 attempt,从而错误放行/拦截重试)。 + // attempt_send_state 是本 attempt 自身的发送事实;共享 effect 单独作为 + // 一票否决的重试门。持久化与终态用请求级水位,不能把早先 Provider attempt + // 的 sent 传染给当前 attempt,但任何已观测外部副作用都必须阻止重放。 let attempt_send_state = if current.send_observed { UpstreamSendState::Sent } else { error.send_state() }; + let execution_effect_observed = self.execution_effect_observed(); let send_state = self.raise_send_watermark(attempt_send_state); if self.request_persisted { best_effort_store_write( @@ -1198,13 +1388,15 @@ where .await; } let provider_proved_replay_safe = provider_proved_replay_safe(&error); - let continuation_retry = self.prepare_continuation_retry( - ¤t, - &error, - attempt_send_state, - provider_proved_replay_safe, - ); - let account_rotation_retry = self.account_selection.required_account().is_none() + let continuation_retry = !execution_effect_observed + && self.prepare_continuation_retry( + ¤t, + &error, + attempt_send_state, + provider_proved_replay_safe, + ); + let account_rotation_retry = !execution_effect_observed + && self.account_selection.required_account().is_none() && self.continuation_attempt == ContinuationAttempt::None && self.downstream_committed_at.is_none() && !self.delivery_pending @@ -1218,14 +1410,16 @@ where Some(crate::error::PreDeliveryRetry::SameAccountTransportRetry { retry_index, delay, - }) if self.downstream_committed_at.is_none() + }) if !execution_effect_observed + && self.downstream_committed_at.is_none() && !self.delivery_pending && attempt_send_state != UpstreamSendState::Ambiguous => { Some((AttemptTransport::Retry(retry_index), delay)) } Some(crate::error::PreDeliveryRetry::SameAccountTransportFallback) - if self.downstream_committed_at.is_none() + if !execution_effect_observed + && self.downstream_committed_at.is_none() && !self.delivery_pending && attempt_send_state != UpstreamSendState::Ambiguous => { @@ -1233,7 +1427,8 @@ where } _ => None, }; - let ordinary_retry = self.account_selection.required_account().is_none() + let ordinary_retry = !execution_effect_observed + && self.account_selection.required_account().is_none() && self.continuation_attempt == ContinuationAttempt::None && self.downstream_committed_at.is_none() && !self.delivery_pending @@ -1261,7 +1456,8 @@ where } _ => None, }; - let same_account_retry = error.retries_same_account() + let same_account_retry = !execution_effect_observed + && error.retries_same_account() && provider_proved_replay_safe && self.downstream_committed_at.is_none() && !self.delivery_pending @@ -1281,6 +1477,7 @@ where "sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry, "ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(), "transientRetry": transient_retry.is_some(), + "executionEffectObserved": execution_effect_observed, "delayMs": transient_retry.or(transport_recovery.map(|(_, delay)| delay)).map(duration_ms), "downstreamCommitted": self.downstream_committed_at.is_some(), "sendState": format!("{attempt_send_state:?}"), @@ -1381,6 +1578,24 @@ where } } + fn observe_websocket_response( + &mut self, + event: &ProviderEvent, + attempt: Option, + ) { + let Some(observer) = self.request_observation.clone() else { + return; + }; + let Some(wire) = event.wire_event().cloned() else { + return; + }; + let Some(attempt) = attempt else { + return; + }; + self.websocket_observation_sequence = self.websocket_observation_sequence.saturating_add(1); + observer.websocket_response(attempt, self.websocket_observation_sequence, wire); + } + fn prepare_continuation_retry( &mut self, current: &CurrentAttempt, @@ -1446,6 +1661,7 @@ where fn prepare_unavailable_native_continuation_replay(&mut self, error: &ProviderError) -> bool { if self.account_selection.required_account().is_some() || self.continuation_attempt != ContinuationAttempt::Native + || self.current_send_state() != UpstreamSendState::NotSent || !matches!( error.kind(), ProviderErrorKind::NoEligibleAccount @@ -1717,6 +1933,23 @@ where async fn persist_finalization(&mut self, finalization: ModelRequestFinalization) { self.finalized_at = Some(finalization.completed_at); + let observed_provider = self + .current + .as_ref() + .map(|current| current.metadata.provider().clone()) + .or_else(|| { + self.provider_attempt_outcomes + .last() + .map(|outcome| outcome.provider_kind().clone()) + }); + let observer = self.request_observation.clone(); + let observation = observer.as_ref().map(|observer| { + observer.finalization( + &finalization, + observed_provider, + self.current.as_ref().map(|current| ¤t.metadata), + ) + }); let mut persisted = self.request_persisted; // 首次合并写失败后不能以零 attempt 补行;建流前也只有确定未发送的请求可按零次收敛。 let request = if !persisted @@ -1748,6 +1981,9 @@ where ) .await; } + if let (Some(observer), Some(observation)) = (observer, observation) { + observer.dispatch(observation); + } persisted }))); self.resume_finalization().await; @@ -1818,13 +2054,25 @@ where } else { UpstreamSendState::NotSent }; + let observed = if self.execution_effect_observed() { + escalate_send_state(observed, UpstreamSendState::Ambiguous) + } else { + observed + }; escalate_send_state(self.send_state_watermark, observed) } + fn execution_effect_observed(&self) -> bool { + self.execution_effects + .as_ref() + .is_some_and(|effects| effects.epoch() != self.execution_effects_baseline) + } + /// 抬升并返回请求级发送水位;attempt 间切换(`current` 被取走)后, /// 后续终态沿用已达到的最高档,不会把已落库的 `sent` 写回 `not_sent`。 fn raise_send_watermark(&mut self, observed: UpstreamSendState) -> UpstreamSendState { - self.send_state_watermark = escalate_send_state(self.send_state_watermark, observed); + let observed = escalate_send_state(self.current_send_state(), observed); + self.send_state_watermark = observed; self.send_state_watermark } @@ -1872,7 +2120,7 @@ async fn best_effort_store_write( } enum PullOutcome { - Event(ProviderEvent), + Events(Vec), AttemptDiscarded, TerminalFailure { events: Vec, @@ -1985,6 +2233,16 @@ async fn poll_provider( } } +fn websocket_observation_attempt(current: &CurrentAttempt) -> Option { + (current.metadata.transport().as_str() == "websocket").then(|| { + WebSocketResponseAttempt::new( + current.metadata.provider().clone(), + current.metadata.provider_account_id().clone(), + current.index, + ) + }) +} + fn initial_continuation_attempt( operation: &Operation, plan: &RoutingPlan, diff --git a/backend/crates/gateway-core/src/engine/execution.rs b/backend/crates/gateway-core/src/engine/execution.rs index 2b1c90b91..f7b1a13c2 100644 --- a/backend/crates/gateway-core/src/engine/execution.rs +++ b/backend/crates/gateway-core/src/engine/execution.rs @@ -1,10 +1,13 @@ //! 数据面执行用例:认证、准入、路由、continuation、circuit 与会话生命周期。 -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::net::IpAddr; use std::num::NonZeroU32; -use std::sync::Arc; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicUsize, Ordering}, +}; use std::time::{Duration, Instant, SystemTime}; use futures::{FutureExt, future::BoxFuture, pin_mut, select_biased}; @@ -15,12 +18,32 @@ use crate::concurrency::{CapacityWait, ConcurrencyWaitBudget, ConcurrencyWaitQue use crate::engine::admission::{ ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest, }; +use crate::engine::authentication::{ + ClientAuthenticationRequest, FrontendAuthenticationDecision, + FrontendAuthenticationExtensionIndex, +}; use crate::engine::budget::{ClientBudgetCharge, ClientBudgetPort}; use crate::engine::continuation::{ ContinuationBinding, NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreErrorKind, PreviousResponseId, }; -use crate::engine::coordinator::ResponseExecutionSession; +use crate::engine::coordinator::{CoordinationExtensions, ResponseExecutionSession}; +use crate::engine::extensions::ExtensionCallScope; +use crate::engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareAuthority, MiddlewareContext, MiddlewareExtensionIndex, + MiddlewareTarget, +}; +use crate::engine::nested::{ + AffinityLookupPort, AffinityLookupRequest, AffinityLookupResult, BoundModelExecutionBinding, + BoundModelExecutionRequest, ExecutionEffects, NestedModelExecutionPort, + NestedModelExecutionRequest, +}; +use crate::engine::observation::{ + FrozenRequestObservationContext, RequestObservationDispatch, RequestObserverExtensionIndex, +}; +use crate::engine::policy::{ + ModelRouteDecision, RequestPolicyContext, RequestPolicyExtensionIndex, +}; use crate::engine::probe::{ AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest, AccountProbeResult, AccountProbeUpstreamResponse, @@ -37,14 +60,18 @@ use crate::identity::ProviderKind; use crate::lifecycle::CancellationToken; use crate::operation::{Operation, ProviderSessionState}; use crate::policy::{ClientApiKeyId, ClientPolicy}; +use crate::provider_ports::{ProviderSessionAffinityPort, ProviderStoreErrorKind}; use crate::routing::{ - ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId, RoutingContext, - RuntimeSnapshot, UpstreamModelId, + FrozenAccountScope, ProviderCatalogUnavailable, PublicModelDescriptor, PublicModelId, + RoutingContext, RuntimeSnapshot, UpstreamModelId, }; -use crate::runtime::RuntimeSnapshotHandle; +use crate::runtime::{RuntimeSnapshotHandle, RuntimeSnapshotPublisher}; const MODEL_REQUEST_DEADLINE: Duration = Duration::from_secs(10 * 60); const COORDINATION_TIMEOUT: Duration = Duration::from_millis(100); +const MAX_NESTED_DEPTH: usize = 4; +const MAX_NESTED_EXECUTIONS: usize = 16; +const MAX_CONCURRENT_NESTED_EXECUTIONS: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClientTransport { @@ -52,6 +79,7 @@ pub enum ClientTransport { HttpSse, WebSocket, InternalProbe, + InternalPlugin, } impl ClientTransport { @@ -62,6 +90,7 @@ impl ClientTransport { Self::HttpSse => "http_sse", Self::WebSocket => "websocket", Self::InternalProbe => "internal", + Self::InternalPlugin => "internal_plugin", } } } @@ -82,6 +111,7 @@ pub struct ExecutionRequestMetadata { pub struct AuthenticatedClient { snapshot: Arc, policy: ClientPolicy, + authentication: Option, } impl AuthenticatedClient { @@ -112,6 +142,8 @@ pub enum ClientAuthenticationError { InvalidKey, #[error("runtime snapshot is unavailable")] SnapshotUnavailable, + #[error("frontend authentication provider is unavailable")] + ProviderUnavailable, } pub struct StartExecution { @@ -121,24 +153,134 @@ pub struct StartExecution { pub metadata: ExecutionRequestMetadata, } +/// 入口中间件开始前冻结的根请求身份与生命周期。 +/// +/// 中间件短路时本值直接释放;调用 `next` 后必须原样交回 +/// [`ExecutionService::start_prepared`],不能重新认证为另一个 Key。 +pub struct PreparedRootExecution { + client: AuthenticatedClient, + request_id: ModelRequestId, + started_at: SystemTime, + deadline_at: SystemTime, + cancellation: CancellationToken, + execution_effects: Arc, + execution_effects_baseline: usize, +} + +impl PreparedRootExecution { + fn new(client: AuthenticatedClient) -> Result { + let started_at = SystemTime::now(); + let deadline_at = started_at + .checked_add(MODEL_REQUEST_DEADLINE) + .ok_or_else(|| { + GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + })?; + let execution_effects = Arc::new(ExecutionEffects::default()); + let execution_effects_baseline = execution_effects.epoch(); + Ok(Self { + client, + request_id: new_request_id()?, + started_at, + deadline_at, + cancellation: CancellationToken::new(), + execution_effects, + execution_effects_baseline, + }) + } + + #[must_use] + pub const fn request_id(&self) -> &ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn client(&self) -> &AuthenticatedClient { + &self.client + } + + #[must_use] + pub const fn started_at(&self) -> SystemTime { + self.started_at + } + + #[must_use] + pub const fn deadline_at(&self) -> SystemTime { + self.deadline_at + } + + #[must_use] + pub fn cancellation(&self) -> CancellationToken { + self.cancellation.clone() + } + + /// 以冻结的入口身份补齐 Runtime-only binding 事实;调用位置事实由 API/Core 提供。 + #[must_use] + pub fn middleware_context( + &self, + mut target: MiddlewareTarget, + extension_scope: ExtensionCallScope, + ) -> MiddlewareContext { + target.request_id = self.request_id.clone(); + let account_group_ids = self + .client + .policy + .account_scope() + .routing_snapshot() + .groups_snapshot() + .iter() + .map(|group| group.id().clone()) + .collect::>(); + MiddlewareContext::new( + target, + MiddlewareAuthority { + client_key_id: self.client.policy.key_id().clone(), + account_group_ids: Arc::from(account_group_ids), + cancellation: self.cancellation.clone(), + deadline: self.deadline_at, + extension_scope, + execution_effects: Some(Arc::clone(&self.execution_effects)), + }, + ) + } +} + +/// 入口中间件调用 `next` 后交给 Core 的已解码执行输入。 +pub struct PreparedExecutionRequest { + pub public_model: PublicModelId, + pub operation: Operation, + pub metadata: ExecutionRequestMetadata, +} + /// 启动一个由协议 adapter 明确绑定到 Provider 自有端点的请求。 pub struct StartProviderExecution { pub client: AuthenticatedClient, pub provider: ProviderKind, + /// Provider 自有模型端点可固定上游模型;非模型端点保持 `None`。 + pub upstream_model: Option, pub operation: Operation, pub metadata: ExecutionRequestMetadata, } enum ExecutionTarget { Model(PublicModelId), - ProviderEndpoint(ProviderKind), + ProviderEndpoint { + provider: ProviderKind, + upstream_model: Option, + }, } impl ExecutionTarget { + fn public_model(&self) -> Option<&PublicModelId> { + match self { + Self::Model(model) => Some(model), + Self::ProviderEndpoint { .. } => None, + } + } + fn into_public_model(self) -> Option { match self { Self::Model(model) => Some(model), - Self::ProviderEndpoint(_) => None, + Self::ProviderEndpoint { .. } => None, } } } @@ -150,6 +292,193 @@ struct PendingStartExecution { metadata: ExecutionRequestMetadata, } +struct AuthorizedExecution { + account_scope: Arc, + deadline_at: SystemTime, + cancellation: CancellationToken, + extension_scope: ExtensionCallScope, + required_provider: Option, + required_account: Option, + nested: Option, + bound: Option, + graph: Option>, + execution_effects_baseline: usize, + nested_permit: Option, +} + +struct ExecutionStartGuard { + admission: ExecutionAdmission, + active_request: Option, + concurrency_wait_budget: ConcurrencyWaitBudget, + admission_decision_ms: Option, +} + +struct PreparedExecutionStart { + request_id: ModelRequestId, + started_at: SystemTime, + plan: crate::routing::RoutingPlan, + extensions: CoordinationExtensions, + authorization: AuthorizedExecution, + guard: ExecutionStartGuard, +} + +struct NestedExecutionFacts { + parent_request_id: ModelRequestId, + initiating_plugin_instance_id: String, +} + +struct BoundExecutionFacts { + initiating_plugin_instance_id: String, +} + +struct NestedExecutionGraph { + total_started: AtomicUsize, + active: AtomicUsize, + effects: Arc, +} + +impl NestedExecutionGraph { + fn new(effects: Arc) -> Self { + Self { + total_started: AtomicUsize::new(0), + active: AtomicUsize::new(0), + effects, + } + } + + fn acquire(self: &Arc) -> Result { + if self + .active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < MAX_CONCURRENT_NESTED_EXECUTIONS).then_some(active + 1) + }) + .is_err() + { + return Err(GatewayError::new( + GatewayErrorKind::ConcurrencyQueueFull, + "nested model execution concurrency is exhausted", + )); + } + if self + .total_started + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |total| { + (total < MAX_NESTED_EXECUTIONS).then_some(total + 1) + }) + .is_err() + { + self.active.fetch_sub(1, Ordering::AcqRel); + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "nested model execution budget is exhausted", + )); + } + Ok(NestedExecutionPermit { + graph: Arc::clone(self), + armed: true, + }) + } +} + +struct NestedExecutionPermit { + graph: Arc, + armed: bool, +} + +impl NestedExecutionPermit { + fn release(mut self) { + if self.armed { + self.graph.active.fetch_sub(1, Ordering::AcqRel); + self.armed = false; + } + } +} + +impl Drop for NestedExecutionPermit { + fn drop(&mut self) { + if self.armed { + self.graph.active.fetch_sub(1, Ordering::AcqRel); + } + } +} + +struct ActiveRequestAuthority { + client: AuthenticatedClient, + account_scope: Arc, + deadline_at: SystemTime, + cancellation: CancellationToken, + extension_scope: ExtensionCallScope, + graph: Arc, +} + +type ActiveRequestRegistry = Arc>>>; + +struct ActiveRequestLease { + registry: ActiveRequestRegistry, + request_id: ModelRequestId, + authority: Arc, +} + +/// 管理页或 CLI 一次调用持有的显式模型执行身份。 +/// +/// 结构不暴露 Key 明文;Runtime 只能把它原样交回 Core 发起模型请求。 +#[derive(Clone)] +pub struct BoundModelExecutionContext { + authority: Arc, +} + +struct BoundModelExecutionAuthority { + client: AuthenticatedClient, + account_scope: Arc, + deadline_at: SystemTime, + cancellation: CancellationToken, + extension_scope: ExtensionCallScope, + graph: Arc, + initiating_plugin_instance_id: String, +} + +impl fmt::Debug for BoundModelExecutionContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundModelExecutionContext") + .field("key_id", &self.authority.client.policy.key_id()) + .field( + "initiating_plugin_instance_id", + &self.authority.initiating_plugin_instance_id, + ) + .field("deadline_at", &self.authority.deadline_at) + .finish_non_exhaustive() + } +} + +impl Drop for BoundModelExecutionAuthority { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + +impl ActiveRequestLease { + fn release(self) { + drop(self); + } +} + +impl Drop for ActiveRequestLease { + fn drop(&mut self) { + self.authority.cancellation.cancel(); + let mut active = self + .registry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if active + .get(&self.request_id) + .and_then(Weak::upgrade) + .is_some_and(|authority| Arc::ptr_eq(&authority, &self.authority)) + { + active.remove(&self.request_id); + } + } +} + pub struct StartedExecution { pub request_id: ModelRequestId, pub created_at: SystemTime, @@ -167,6 +496,9 @@ pub trait ExecutionSession: Send { fn response_status_code(&self) -> Option { None } + fn discard_pending_delivery(&mut self) -> Result<(), EngineError> { + Err(EngineError::InvalidDeliveryState) + } fn commit_downstream( &mut self, client_status_code: Option, @@ -188,6 +520,24 @@ pub trait ExecutionService: Send + Sync { &self, plaintext: &str, ) -> Result; + fn authenticate_request( + &self, + request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let plaintext = request + .native_bearer() + .ok_or(ClientAuthenticationError::InvalidKey)?; + self.authenticate(plaintext.expose_for_auth()) + }) + } + /// 只验证入口认证信封,不记录 Key 使用事实或执行推理准入。 + fn verify_request( + &self, + _request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async { Err(ClientAuthenticationError::InvalidKey) }) + } fn public_models(&self, client: &AuthenticatedClient) -> Vec; fn client_model_catalog<'a>( &'a self, @@ -198,6 +548,80 @@ pub trait ExecutionService: Send + Sync { Box::pin(async { Err(ProviderCatalogUnavailable) }) } fn contains_public_model(&self, client: &AuthenticatedClient, model: &PublicModelId) -> bool; + + /// 重新鉴权并冻结本次根请求身份、请求 ID、deadline 与取消域。 + fn prepare_execution( + &self, + client: AuthenticatedClient, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { PreparedRootExecution::new(client) }) + } + + /// 由已经验证管理会话与插件 models 域的宿主入口选择 Key。 + /// 不向页面提供明文,也不跳过普通请求的扩展、准入和计量。 + fn prepare_plugin_execution( + &self, + _client_key_id: &ClientApiKeyId, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Err(GatewayError::new( + GatewayErrorKind::Unauthorized, + "plugin model entry is unavailable", + )) + }) + } + + /// 为已经通过 `verify_request` 的只读入口建立中间件生命周期,不重复写 Key 使用事实。 + fn prepare_verified_execution( + &self, + client: AuthenticatedClient, + ) -> Result { + PreparedRootExecution::new(client) + } + + /// 解析与准备阶段冻结的发布代次一致的中间件计划。 + fn middleware_plan(&self, _prepared: &PreparedRootExecution) -> Option { + None + } + + /// 消费一次已准备身份并进入原有 Core 路由、准入、attempt 与结算路径。 + fn start_prepared( + &self, + prepared: PreparedRootExecution, + request: PreparedExecutionRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.start(StartExecution { + client: prepared.client, + public_model: request.public_model, + operation: request.operation, + metadata: request.metadata, + }) + .await + }) + } + + /// 消费一次已准备身份,并进入 Provider 自有端点的原有准入与结算路径。 + fn start_prepared_provider_endpoint( + &self, + prepared: PreparedRootExecution, + provider: ProviderKind, + upstream_model: Option, + operation: Operation, + metadata: ExecutionRequestMetadata, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.start_provider_endpoint(StartProviderExecution { + client: prepared.client, + provider, + upstream_model, + operation, + metadata, + }) + .await + }) + } + fn start( &self, request: StartExecution, @@ -268,8 +692,6 @@ pub trait ProviderCircuitPort: Send + Sync { pub struct DefaultExecutionService { snapshots: RuntimeSnapshotHandle, - coordinator: Arc>, - probe_coordinator: Arc>, /// probe 自身走 transient store,探测失败仍写入持久 store 的 ops_events。 observations: Arc, providers: ProviderRegistry, @@ -279,6 +701,13 @@ pub struct DefaultExecutionService { continuation: Arc, client_api_key_usage: Arc, budget: Option>, + request_observers: Option, + request_policies: Option, + middlewares: Option, + frontend_authentication: Option, + session_affinity: Option>, + snapshot_refresh: Option>, + active_requests: ActiveRequestRegistry, } impl DefaultExecutionService { @@ -292,15 +721,9 @@ impl DefaultExecutionService { continuation: Arc, client_api_key_usage: Arc, ) -> Self { - let observations = Arc::clone(&execution); - let engine = GatewayEngine::::new(execution, providers.clone()); - let transient: Arc = Arc::new(TransientExecutionStore); - let probe_engine = GatewayEngine::::new(transient, providers.clone()); Self { snapshots, - coordinator: Arc::new(AttemptCoordinator::new(engine)), - probe_coordinator: Arc::new(AttemptCoordinator::new(probe_engine)), - observations, + observations: execution, providers, admissions, admission_waiting: ConcurrencyWaitQueue::default(), @@ -308,6 +731,13 @@ impl DefaultExecutionService { continuation, client_api_key_usage, budget: None, + request_observers: None, + request_policies: None, + middlewares: None, + frontend_authentication: None, + session_affinity: None, + snapshot_refresh: None, + active_requests: Arc::default(), } } @@ -317,10 +747,55 @@ impl DefaultExecutionService { self } + #[must_use] + pub fn with_request_observers(mut self, observers: RequestObserverExtensionIndex) -> Self { + self.request_observers = Some(observers); + self + } + + #[must_use] + pub fn with_request_policies(mut self, policies: RequestPolicyExtensionIndex) -> Self { + self.request_policies = Some(policies); + self + } + + #[must_use] + pub fn with_middlewares(mut self, middlewares: MiddlewareExtensionIndex) -> Self { + self.middlewares = Some(middlewares); + self + } + + #[must_use] + pub fn with_frontend_authentication( + mut self, + authentication: FrontendAuthenticationExtensionIndex, + ) -> Self { + self.frontend_authentication = Some(authentication); + self + } + + #[must_use] + pub fn with_session_affinity( + mut self, + session_affinity: Arc, + ) -> Self { + self.session_affinity = Some(session_affinity); + self + } + + /// CLI command-plane 没有后台订阅;每次绑定显式身份前主动编译最新事实。 + #[must_use] + pub fn with_snapshot_refresh(mut self, refresh: Arc) -> Self { + self.snapshot_refresh = Some(refresh); + self + } + fn authenticate_without_usage( &self, plaintext: &str, ) -> Result { + let authentication = ClientAuthenticationRequest::bearer(plaintext) + .map_err(|_| ClientAuthenticationError::InvalidKey)?; let snapshot = self .snapshots .acquire() @@ -333,7 +808,59 @@ impl DefaultExecutionService { .find(|policy| policy.authorize().is_ok()) .cloned() .ok_or(ClientAuthenticationError::InvalidKey)?; - Ok(AuthenticatedClient { snapshot, policy }) + Ok(AuthenticatedClient { + snapshot, + policy, + authentication: Some(authentication), + }) + } + + async fn authenticate_request_without_usage( + &self, + authentication: ClientAuthenticationRequest, + ) -> Result { + let snapshot = self + .snapshots + .acquire() + .map_err(|_| ClientAuthenticationError::SnapshotUnavailable)?; + let frontend = self + .frontend_authentication + .as_ref() + .and_then(|index| snapshot.extensions().and_then(|set| index.resolve(set))); + let policy = if let Some(frontend) = frontend { + match frontend + .authenticate(&authentication) + .await + .map_err(|_| ClientAuthenticationError::ProviderUnavailable)? + { + FrontendAuthenticationDecision::Authenticated { principal } => { + let id = frontend + .client_key_id(&principal) + .ok_or(ClientAuthenticationError::InvalidKey)?; + snapshot + .client_policy(&id) + .filter(|policy| policy.authorize().is_ok()) + .cloned() + .ok_or(ClientAuthenticationError::InvalidKey)? + } + FrontendAuthenticationDecision::Rejected => { + return Err(ClientAuthenticationError::InvalidKey); + } + FrontendAuthenticationDecision::NotMatched if frontend.exclusive() => { + return Err(ClientAuthenticationError::InvalidKey); + } + FrontendAuthenticationDecision::NotMatched => { + native_policy(&snapshot, &authentication)? + } + } + } else { + native_policy(&snapshot, &authentication)? + }; + Ok(AuthenticatedClient { + snapshot, + policy, + authentication: Some(authentication), + }) } async fn start_inner(&self, request: StartExecution) -> Result { @@ -343,12 +870,13 @@ impl DefaultExecutionService { operation, metadata, } = request; - self.start_inner_with_target(PendingStartExecution { - client, - target: ExecutionTarget::Model(public_model), + let prepared = self.prepare_root_execution_inner(client).await?; + self.start_prepared_with_target( + prepared, + ExecutionTarget::Model(public_model), operation, metadata, - }) + ) .await } @@ -359,204 +887,497 @@ impl DefaultExecutionService { let StartProviderExecution { client, provider, + upstream_model, operation, metadata, } = request; - self.start_inner_with_target(PendingStartExecution { - client, - target: ExecutionTarget::ProviderEndpoint(provider), + let prepared = self.prepare_root_execution_inner(client).await?; + self.start_prepared_with_target( + prepared, + ExecutionTarget::ProviderEndpoint { + provider, + upstream_model, + }, operation, metadata, - }) + ) .await } - async fn start_inner_with_target( + async fn prepare_root_execution_inner( &self, - mut request: PendingStartExecution, - ) -> Result { + mut client: AuthenticatedClient, + ) -> Result { // 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。 - request.client = self - .authenticate(request.client.policy.plaintext_key().expose_for_auth()) - .map_err(|error| match error { - ClientAuthenticationError::InvalidKey => { - GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid") - } - ClientAuthenticationError::SnapshotUnavailable => GatewayError::new( - GatewayErrorKind::Internal, - "runtime snapshot is unavailable", - ), - })?; - let started_at = SystemTime::now(); - let deadline_at = started_at - .checked_add(MODEL_REQUEST_DEADLINE) - .ok_or_else(|| { - GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") - })?; - let request_id = new_request_id()?; - let routing_context = self - .route_context(request.client.policy.account_scope().provider_kinds()) - .await?; - let account_scope = Arc::clone(request.client.policy.account_scope()); - let plan = match &request.target { - ExecutionTarget::ProviderEndpoint(provider) => { - request.client.snapshot.plan_provider_endpoint( - provider, - &request.operation, - account_scope, - &routing_context, - ) - } - ExecutionTarget::Model(public_model) => request.client.snapshot.plan( - public_model, - &request.operation, - account_scope, - &routing_context, - ), - } - .map_err(map_routing_error)?; - let continuation = match request.metadata.previous_response_id.as_ref() { - Some(previous) => { - let resolve = self - .continuation - .resolve(request.client.policy.key_id(), previous) - .fuse(); - let timeout = Delay::new(COORDINATION_TIMEOUT).fuse(); - pin_mut!(resolve, timeout); - let pin = select_biased! { - result = resolve => match result { - Ok(pin) => pin, - Err(error) - if error.kind() == NativeContinuationStoreErrorKind::Unavailable => - { - tracing::debug!( - request_id = request_id.as_str(), - %error, - "Continuation affinity 查询失败,退化为外部续接" - ); - None - } - Err(error) - if error.kind() - == NativeContinuationStoreErrorKind::OwnershipMismatch => - { - return Err(GatewayError::new( - GatewayErrorKind::PolicyDenied, - "continuation does not belong to this client API key", - )); - } - Err(error) => { - tracing::warn!( - request_id = request_id.as_str(), - %error, - "Continuation affinity 记录无效,已拒绝续接" - ); - return Err(GatewayError::new( - GatewayErrorKind::Internal, - "continuation state is invalid", - )); - } - }, - _ = timeout => None, - }; - match pin { - Some(pin) if !pin.matches_client(request.client.policy.key_id()) => { - return Err(GatewayError::new( - GatewayErrorKind::PolicyDenied, - "continuation does not belong to this client API key", - )); - } - Some(pin) if !request.client.policy.account_scope().allows(pin.account()) => { - return Err(GatewayError::new( - GatewayErrorKind::PolicyDenied, - "continuation account is outside the client account scope", - )); - } - Some(pin) - if !plan - .candidates() - .iter() - .any(|candidate| candidate.provider() == pin.provider()) => - { - return Err(GatewayError::new( - GatewayErrorKind::NoAvailableProvider, - "continuation provider is not available", - )); - } - Some(pin) => { - attach_continuation_session_state(&mut request.operation, &pin); - ContinuationBinding::Pinned(pin) - } - None => ContinuationBinding::External(previous.clone()), - } - } - None => { - return self - .start_without_continuation( - request, - request_id, - started_at, - deadline_at, - plan, - None, - ) - .await; - } - }; - self.start_without_continuation( - request, + let expected_key_id = client.policy.key_id().clone(); + let authentication = client.authentication.clone().ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::Internal, + "bound execution identity cannot enter the public start path", + ) + })?; + client = self + .authenticate_request_without_usage(authentication) + .await + .and_then(|client| { + (client.policy.key_id() == &expected_key_id) + .then_some(client) + .ok_or(ClientAuthenticationError::InvalidKey) + }) + .map_err(authentication_gateway_error)?; + self.client_api_key_usage + .record_used(client.policy().key_id()); + PreparedRootExecution::new(client) + } + + async fn start_prepared_with_target( + &self, + prepared: PreparedRootExecution, + target: ExecutionTarget, + operation: Operation, + metadata: ExecutionRequestMetadata, + ) -> Result { + let PreparedRootExecution { + client, request_id, started_at, deadline_at, - plan, - Some(continuation), + cancellation, + execution_effects, + execution_effects_baseline, + } = prepared; + let authorization = AuthorizedExecution { + account_scope: Arc::clone(client.policy.account_scope()), + deadline_at, + cancellation, + extension_scope: ExtensionCallScope::default(), + required_provider: None, + required_account: None, + nested: None, + bound: None, + graph: client + .snapshot + .extensions() + .map(|_| Arc::new(NestedExecutionGraph::new(execution_effects))), + execution_effects_baseline, + nested_permit: None, + }; + self.start_authorized( + PendingStartExecution { + client, + target, + operation, + metadata, + }, + authorization, + started_at, + Some(request_id), ) .await } + async fn start_authorized( + &self, + mut request: PendingStartExecution, + mut authorization: AuthorizedExecution, + started_at: SystemTime, + request_id: Option, + ) -> Result { + let request_id = request_id.map_or_else(new_request_id, Ok)?; + if authorization.cancellation.is_cancelled() { + return Err(GatewayError::new( + GatewayErrorKind::Cancelled, + "parent request was cancelled", + )); + } + if authorization + .deadline_at + .duration_since(SystemTime::now()) + .unwrap_or_default() + .is_zero() + { + return Err(GatewayError::new( + GatewayErrorKind::Timeout, + "request deadline elapsed", + )); + } + let account_group_ids = authorization + .account_scope + .routing_snapshot() + .groups_snapshot() + .iter() + .map(|group| group.id().clone()) + .collect::>(); + let middleware = self.middlewares.as_ref().and_then(|middlewares| { + let generation = request.client.snapshot.extensions()?.clone(); + middlewares.resolve(&generation) + }); + let request_policy = self.request_policies.as_ref().and_then(|policies| { + let generation = request.client.snapshot.extensions()?.clone(); + let plan = policies.resolve(&generation)?; + Some( + RequestPolicyContext::new( + plan, + generation, + request_id.clone(), + request.client.policy.key_id().clone(), + account_group_ids.clone(), + ) + .with_extension_scope(authorization.extension_scope.clone()) + .with_execution_effects(Arc::clone(&authorization.graph.as_ref()?.effects)), + ) + }); + // 路由插件可以调用 host.model/host.affinity;仅这条扩展路径必须在首次 RPC + // 前取得父 Key 准入。无策略的原生路由仍保持“先路由、后准入”的既有顺序。 + let mut start_guard = if request_policy.is_some() { + Some( + self.prepare_execution_start(&request, &request_id, &mut authorization) + .await?, + ) + } else { + None + }; + let request_observation = self.request_observers.as_ref().and_then(|observers| { + let generation = request.client.snapshot.extensions()?.clone(); + let plan = observers.resolve(&generation)?; + Some(RequestObservationDispatch::new( + plan, + generation, + FrozenRequestObservationContext::new( + request_id.clone(), + request.client.snapshot.revision(), + request.client.policy.key_id().clone(), + account_group_ids.clone(), + request.operation.kind(), + request.target.public_model().cloned(), + authorization.extension_scope.clone(), + ), + )) + }); + let budget_key_id = request.client.policy.key_id().clone(); + let result = async { + let mut routing_context = self + .route_context(authorization.account_scope.provider_kinds()) + .await?; + if let Some(provider) = authorization.required_provider.clone() { + if !authorization.account_scope.provider_kinds().contains(&provider) { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "nested provider is outside the frozen account scope", + )); + } + routing_context.required_provider = Some(provider); + } + let account_scope = Arc::clone(&authorization.account_scope); + let plan = match &request.target { + ExecutionTarget::ProviderEndpoint { + provider, + upstream_model, + } => request.client.snapshot.plan_provider_endpoint( + provider, + upstream_model.as_ref(), + &request.operation, + account_scope, + &routing_context, + ), + ExecutionTarget::Model(public_model) => { + let mut target_model = public_model.clone(); + let mut target_context = routing_context.clone(); + if let Some(policy) = &request_policy { + let available = request + .client + .snapshot + .available_providers(&account_scope, &routing_context); + match policy + .route_model(request.operation.clone(), public_model.clone(), available) + .await + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "model routing policy failed", + ) + })? { + ModelRouteDecision::Unhandled => {} + ModelRouteDecision::Reject => { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model routing policy rejected the request", + )); + } + ModelRouteDecision::Route { provider, model } => { + if provider.is_none() && model.is_none() { + return Err(GatewayError::new( + GatewayErrorKind::Internal, + "model routing policy returned an empty target", + )); + } + if let Some(provider) = provider { + if authorization + .required_provider + .as_ref() + .is_some_and(|required| required != &provider) + || !authorization + .account_scope + .provider_kinds() + .contains(&provider) + { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model routing policy exceeded the frozen provider scope", + )); + } + target_context.required_provider = Some(provider); + } + if let Some(model) = model { + target_model = model; + } + } + } + } + request.client.snapshot.plan( + &target_model, + &request.operation, + account_scope, + &target_context, + ) + } + } + .map_err(map_routing_error)?; + let continuation = match request.metadata.previous_response_id.as_ref() { + Some(previous) => { + let resolve = self + .continuation + .resolve(request.client.policy.key_id(), previous) + .fuse(); + let timeout = Delay::new(COORDINATION_TIMEOUT).fuse(); + pin_mut!(resolve, timeout); + let pin = select_biased! { + result = resolve => match result { + Ok(pin) => pin, + Err(error) + if error.kind() == NativeContinuationStoreErrorKind::Unavailable => + { + tracing::debug!(request_id = request_id.as_str(), %error, "Continuation affinity 查询失败,退化为外部续接"); + None + } + Err(error) + if error.kind() == NativeContinuationStoreErrorKind::OwnershipMismatch => + { + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation does not belong to this client API key")); + } + Err(error) => { + tracing::warn!(request_id = request_id.as_str(), %error, "Continuation affinity 记录无效,已拒绝续接"); + return Err(GatewayError::new(GatewayErrorKind::Internal, "continuation state is invalid")); + } + }, + _ = timeout => None, + }; + Some(match pin { + Some(pin) if !pin.matches_client(request.client.policy.key_id()) => { + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation does not belong to this client API key")); + } + Some(pin) if !authorization.account_scope.allows(pin.account()) => { + return Err(GatewayError::new(GatewayErrorKind::PolicyDenied, "continuation account is outside the client account scope")); + } + Some(pin) + if !plan.candidates().iter().any(|candidate| candidate.provider() == pin.provider()) => + { + return Err(GatewayError::new(GatewayErrorKind::NoAvailableProvider, "continuation provider is not available")); + } + Some(pin) => { + attach_continuation_session_state(&mut request.operation, &pin); + ContinuationBinding::Pinned(pin) + } + None => ContinuationBinding::External(previous.clone()), + }) + } + None => None, + }; + if start_guard.is_none() { + start_guard = Some( + self.prepare_execution_start(&request, &request_id, &mut authorization) + .await?, + ); + } + let execution_effects = authorization + .graph + .as_ref() + .map(|graph| Arc::clone(&graph.effects)); + let extensions = + CoordinationExtensions::new(continuation, request_observation.clone()) + .with_request_policy(request_policy) + .with_execution_effects( + execution_effects, + authorization.execution_effects_baseline, + ) + .with_middleware( + middleware, + Arc::from(account_group_ids.clone()), + request.metadata.endpoint.clone(), + request.metadata.transport, + ) + .with_extension_scope(authorization.extension_scope.clone()); + self.start_without_continuation( + request, + PreparedExecutionStart { + request_id: request_id.clone(), + started_at, + plan, + extensions, + authorization, + guard: start_guard + .take() + .expect("execution start guard was prepared"), + }, + ) + .await + } + .await; + if let (Some(observation), Err(error)) = (&request_observation, &result) { + observation.reject(error); + } + if result.is_err() + && let Some(guard) = start_guard.take() + { + guard + .release_failed(self.budget.as_deref(), request_id, budget_key_id) + .await; + } + result + } + + async fn prepare_execution_start( + &self, + request: &PendingStartExecution, + request_id: &ModelRequestId, + authorization: &mut AuthorizedExecution, + ) -> Result { + let concurrency_wait_budget = ConcurrencyWaitBudget::default(); + let (admission, admission_decision_ms) = if authorization.nested.is_some() { + let permit = authorization.nested_permit.take().ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::Internal, + "nested execution admission is unavailable", + ) + })?; + (ExecutionAdmission::Nested(permit), None) + } else { + let admission_started_at = Instant::now(); + let admission = self + .acquire_client_admission( + &request.client, + request_id, + authorization.deadline_at, + &concurrency_wait_budget, + ) + .await?; + let admission = if let Some(permit) = authorization.nested_permit.take() { + ExecutionAdmission::Bound(admission, permit) + } else { + ExecutionAdmission::Client(admission) + }; + (admission, Some(duration_ms(admission_started_at.elapsed()))) + }; + if let Some(budget) = &self.budget + && let Err(error) = budget.admit(request.client.policy.key_id().clone()).await + { + admission.release().await; + return Err(error); + } + let active_request = authorization.graph.as_ref().map(|graph| { + self.register_active_request( + request_id.clone(), + ActiveRequestAuthority { + client: request.client.clone(), + account_scope: Arc::clone(&authorization.account_scope), + deadline_at: authorization.deadline_at, + // 回收嵌套调用权限只取消子作用域,父请求仍需完成响应流终态加工。 + cancellation: authorization.cancellation.child_token(), + extension_scope: authorization.extension_scope.clone(), + graph: Arc::clone(graph), + }, + ) + }); + Ok(ExecutionStartGuard { + admission, + active_request, + concurrency_wait_budget, + admission_decision_ms, + }) + } + async fn start_without_continuation( &self, request: PendingStartExecution, - request_id: ModelRequestId, - started_at: SystemTime, - deadline_at: SystemTime, - plan: crate::routing::RoutingPlan, - continuation: Option, + prepared: PreparedExecutionStart, ) -> Result { + let PreparedExecutionStart { + request_id, + started_at, + plan, + extensions, + authorization, + guard: start_guard, + } = prepared; let PendingStartExecution { client, target, operation, metadata, } = request; - let admission_started_at = Instant::now(); - let concurrency_wait_budget = ConcurrencyWaitBudget::default(); - let admission = self - .acquire_client_admission(&client, &request_id, deadline_at, &concurrency_wait_budget) - .await?; - let admission_decision_ms = duration_ms(admission_started_at.elapsed()); - if let Some(budget) = &self.budget - && let Err(error) = budget.admit(client.policy.key_id().clone()).await - { - admission.release().await; - return Err(error); - } + let providers = self + .providers + .for_extensions(client.snapshot.extensions()) + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "provider generation is unavailable", + ) + })?; + let ExecutionStartGuard { + admission, + active_request, + concurrency_wait_budget, + admission_decision_ms, + } = start_guard; let observation = plan .candidates() .first() .map_or_else(Default::default, |candidate| { - self.providers.request_observation( + providers.request_observation( candidate.provider(), &operation, client.policy.key_id(), ) }); + let (request_kind, subagent_kind) = if let Some(nested) = authorization.nested.as_ref() { + ( + Some("plugin_child_model".to_owned()), + Some(nested.initiating_plugin_instance_id.clone()), + ) + } else if let Some(bound) = authorization.bound.as_ref() { + ( + Some("plugin_bound_model".to_owned()), + Some(bound.initiating_plugin_instance_id.clone()), + ) + } else { + (observation.request_kind, observation.subagent_kind) + }; + if let Some(nested) = authorization.nested.as_ref() { + tracing::debug!( + parent_request_id = nested.parent_request_id.as_str(), + child_request_id = request_id.as_str(), + plugin_instance_id = nested.initiating_plugin_instance_id, + depth = authorization.extension_scope.len(), + "插件子模型请求已继承父请求身份与 deadline" + ); + } let new_request = NewModelRequest { id: request_id.clone(), client_api_key_id: Some(client.policy.key_id().clone()), client_api_key_ref: client.policy.key_id().clone(), config_revision: plan.config_revision(), - routing: client.policy.account_scope().routing_snapshot(), + routing: authorization.account_scope.routing_snapshot(), protocol: metadata.protocol, operation: operation.kind(), endpoint: metadata.endpoint, @@ -566,24 +1387,25 @@ impl DefaultExecutionService { user_agent: metadata.user_agent, reasoning_effort: observation.reasoning_effort, reasoning_preset: observation.reasoning_preset, - request_kind: observation.request_kind, - subagent_kind: observation.subagent_kind, + request_kind, + subagent_kind, compact: observation.compact, continuation: observation.continuation, image_generation_requested: operation.image_generation_requested(), - admission_decision_ms: Some(admission_decision_ms), + admission_decision_ms, started_at, - deadline_at, + deadline_at: authorization.deadline_at, }; - let core = match self - .coordinator - .start( + let coordinator = + AttemptCoordinator::new(GatewayEngine::new(self.observations.clone(), providers)); + let core = match coordinator + .start_observed( new_request, operation, plan, - None, - continuation, - CancellationToken::new(), + authorization.required_account, + extensions, + authorization.cancellation, ) .await { @@ -601,6 +1423,9 @@ impl DefaultExecutionService { ) .await; } + if let Some(active_request) = active_request { + active_request.release(); + } admission.release().await; return Err(gateway_error_from_engine(&error)); } @@ -612,6 +1437,7 @@ impl DefaultExecutionService { session: Box::new(DefaultExecutionSession::new( core, admission, + active_request, Arc::clone(&self.circuits), Arc::clone(&self.continuation), self.budget.clone(), @@ -619,6 +1445,346 @@ impl DefaultExecutionService { }) } + fn register_active_request( + &self, + request_id: ModelRequestId, + authority: ActiveRequestAuthority, + ) -> ActiveRequestLease { + let authority = Arc::new(authority); + let mut active = self + .active_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = active.insert(request_id.clone(), Arc::downgrade(&authority)); + debug_assert!(previous.is_none(), "model request IDs must be unique"); + drop(active); + ActiveRequestLease { + registry: Arc::clone(&self.active_requests), + request_id, + authority, + } + } + + fn active_request( + &self, + request_id: &ModelRequestId, + ) -> Result, GatewayError> { + let authority = self + .active_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(request_id) + .and_then(Weak::upgrade) + .ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "parent model request is not active", + ) + })?; + if authority.cancellation.is_cancelled() { + return Err(GatewayError::new( + GatewayErrorKind::Cancelled, + "parent request was cancelled", + )); + } + if authority + .deadline_at + .duration_since(SystemTime::now()) + .unwrap_or_default() + .is_zero() + { + return Err(GatewayError::new( + GatewayErrorKind::Timeout, + "parent request deadline elapsed", + )); + } + Ok(authority) + } + + async fn start_nested_inner( + &self, + request: NestedModelExecutionRequest, + ) -> Result { + let NestedModelExecutionRequest { + parent_request_id, + initiating_plugin_instance_id, + public_model, + operation, + mut metadata, + provider, + account, + parent_account, + } = request; + let parent = self.active_request(&parent_request_id)?; + if parent.extension_scope.len() >= MAX_NESTED_DEPTH { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "nested model execution depth is exhausted", + )); + } + let extension_scope = parent + .extension_scope + .extending(initiating_plugin_instance_id.clone()) + .ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "recursive plugin model execution is not allowed", + ) + })?; + let (account_scope, required_provider) = restrict_model_execution_scope( + parent.account_scope.as_ref(), + provider, + account.as_ref(), + parent_account.as_ref(), + )?; + let nested_permit = parent.graph.acquire()?; + // Core.start 之后的路由/Provider 可以继续调用外部系统;在进入子执行前即按 + // 可能已出站记水位,不能让父 attempt 随后的 `not_sent` 触发重放。 + parent.graph.effects.observe(); + let execution_effects_baseline = parent.graph.effects.epoch(); + metadata.endpoint = "host.model".to_owned(); + metadata.transport = ClientTransport::InternalPlugin; + metadata.client_ip = None; + metadata.user_agent = None; + let authorization = AuthorizedExecution { + account_scope, + deadline_at: parent.deadline_at, + cancellation: parent.cancellation.child_token(), + extension_scope, + required_provider, + required_account: account, + nested: Some(NestedExecutionFacts { + parent_request_id, + initiating_plugin_instance_id, + }), + bound: None, + graph: Some(Arc::clone(&parent.graph)), + execution_effects_baseline, + nested_permit: Some(nested_permit), + }; + self.start_authorized( + PendingStartExecution { + client: parent.client.clone(), + target: ExecutionTarget::Model(public_model), + operation, + metadata, + }, + authorization, + SystemTime::now(), + None, + ) + .await + } + + async fn client_for_key_id( + &self, + client_key_id: &ClientApiKeyId, + ) -> Result { + if let Some(refresh) = &self.snapshot_refresh { + refresh.refresh().await.map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "current runtime snapshot is unavailable", + ) + })?; + } + let snapshot = self.snapshots.acquire().map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "current runtime snapshot is unavailable", + ) + })?; + let policy = snapshot + .client_policy(client_key_id) + .cloned() + .ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::Unauthorized, + "client API key no longer exists", + ) + })?; + policy.authorize().map_err(|_| { + GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled") + })?; + Ok(AuthenticatedClient { + snapshot, + policy, + authentication: None, + }) + } + + async fn bind_bound_model_inner( + &self, + binding: BoundModelExecutionBinding, + ) -> Result { + if binding.timeout.is_zero() { + return Err(GatewayError::new( + GatewayErrorKind::InvalidRequest, + "bound model execution timeout is invalid", + )); + } + let client = self.client_for_key_id(&binding.client_key_id).await?; + let deadline_at = SystemTime::now() + .checked_add(binding.timeout.min(MODEL_REQUEST_DEADLINE)) + .ok_or_else(|| { + GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid") + })?; + if binding.extension_scope.len() >= MAX_NESTED_DEPTH { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "plugin model execution depth exceeded", + )); + } + let extension_scope = binding + .extension_scope + .extending(binding.initiating_plugin_instance_id.clone()) + .ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::Internal, + "bound plugin execution scope is invalid", + ) + })?; + let account_scope = Arc::clone(client.policy.account_scope()); + Ok(BoundModelExecutionContext { + authority: Arc::new(BoundModelExecutionAuthority { + client, + account_scope, + deadline_at, + cancellation: binding.cancellation, + extension_scope, + graph: Arc::new(NestedExecutionGraph::new(Arc::new( + ExecutionEffects::default(), + ))), + initiating_plugin_instance_id: binding.initiating_plugin_instance_id, + }), + }) + } + + async fn start_bound_model_inner( + &self, + request: BoundModelExecutionRequest, + ) -> Result { + let BoundModelExecutionRequest { + context, + public_model, + operation, + mut metadata, + provider, + account, + } = request; + let authority = &context.authority; + if authority.cancellation.is_cancelled() { + return Err(GatewayError::new( + GatewayErrorKind::Cancelled, + "bound plugin invocation was cancelled", + )); + } + if authority + .deadline_at + .duration_since(SystemTime::now()) + .unwrap_or_default() + .is_zero() + { + return Err(GatewayError::new( + GatewayErrorKind::Timeout, + "bound plugin invocation deadline elapsed", + )); + } + let (account_scope, required_provider) = restrict_model_execution_scope( + authority.account_scope.as_ref(), + provider, + account.as_ref(), + None, + )?; + let permit = authority.graph.acquire()?; + let execution_effects_baseline = authority.graph.effects.epoch(); + metadata.endpoint = "host.model".to_owned(); + metadata.transport = ClientTransport::InternalPlugin; + metadata.client_ip = None; + metadata.user_agent = None; + self.client_api_key_usage + .record_used(authority.client.policy.key_id()); + self.start_authorized( + PendingStartExecution { + client: authority.client.clone(), + target: ExecutionTarget::Model(public_model), + operation, + metadata, + }, + AuthorizedExecution { + account_scope, + deadline_at: authority.deadline_at, + cancellation: authority.cancellation.child_token(), + extension_scope: authority.extension_scope.clone(), + required_provider, + required_account: account, + nested: None, + bound: Some(BoundExecutionFacts { + initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(), + }), + graph: Some(Arc::clone(&authority.graph)), + execution_effects_baseline, + nested_permit: Some(permit), + }, + SystemTime::now(), + None, + ) + .await + } + + async fn lookup_affinity_inner( + &self, + request: AffinityLookupRequest, + ) -> Result, GatewayError> { + let AffinityLookupRequest { + parent_request_id, + provider, + key, + } = request; + let parent = self.active_request(&parent_request_id)?; + if !parent.account_scope.provider_kinds().contains(&provider) { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "affinity provider is outside the frozen authorization scope", + )); + } + let affinity = self.session_affinity.as_ref().ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "provider session affinity is unavailable", + ) + })?; + let account = { + let load = affinity.load(&provider, &key).fuse(); + let timeout = Delay::new(COORDINATION_TIMEOUT).fuse(); + pin_mut!(load, timeout); + select_biased! { + result = load => result.map_err(|error| match error.kind() { + ProviderStoreErrorKind::Unavailable => GatewayError::new( + GatewayErrorKind::ProviderInfrastructureUnavailable, + "provider session affinity is temporarily unavailable", + ), + ProviderStoreErrorKind::InvalidData | ProviderStoreErrorKind::Conflict => { + GatewayError::new(GatewayErrorKind::Internal, "provider session affinity is invalid") + } + })?, + _ = timeout => return Err(GatewayError::new( + GatewayErrorKind::ProviderInfrastructureUnavailable, + "provider session affinity lookup timed out", + )), + } + }; + let Some(account) = account else { + return Ok(None); + }; + if !parent.account_scope.allows(&account) + || parent.account_scope.account_provider(&account) != Some(&provider) + { + return Ok(None); + } + Ok(Some(AffinityLookupResult::new(provider, account))) + } + async fn acquire_client_admission( &self, client: &AuthenticatedClient, @@ -743,6 +1909,7 @@ impl DefaultExecutionService { async fn probe_inner( &self, request: AccountProbeRequest, + frozen: Option>, ) -> Result { let AccountProbeRequest { account_id, @@ -755,12 +1922,14 @@ impl DefaultExecutionService { account_id: account_id.clone(), upstream_model: upstream_model.clone(), }; - let snapshot = self.snapshots.acquire().map_err(|_| { - GatewayError::new( - GatewayErrorKind::Internal, - "runtime snapshot is unavailable", - ) - })?; + let snapshot = frozen + .map_or_else(|| self.snapshots.acquire(), Ok) + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "runtime snapshot is unavailable", + ) + })?; let public_model = PublicModelId::new(upstream_model.as_str().to_owned()).map_err(|_| { GatewayError::new(GatewayErrorKind::Unsupported, "requested model is invalid") @@ -770,12 +1939,7 @@ impl DefaultExecutionService { ..RoutingContext::default() }; let plan = snapshot - .plan( - &public_model, - &operation, - snapshot.all_account_scope(), - &routing_context, - ) + .plan_diagnostic(&public_model, &operation, &routing_context) .map_err(map_routing_error)?; let started_at = SystemTime::now(); let deadline_at = started_at @@ -810,8 +1974,18 @@ impl DefaultExecutionService { started_at, deadline_at, }; - let mut session = match self - .probe_coordinator + let providers = self + .providers + .for_extensions(snapshot.extensions()) + .map_err(|_| { + GatewayError::new( + GatewayErrorKind::Internal, + "provider generation is unavailable", + ) + })?; + let transient: Arc = Arc::new(TransientExecutionStore); + let coordinator = AttemptCoordinator::new(GatewayEngine::new(transient, providers)); + let mut session = match coordinator .start_diagnostic( new_request, operation, @@ -993,6 +2167,25 @@ impl ExecutionService for DefaultExecutionService { Ok(client) } + fn authenticate_request( + &self, + request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let client = self.authenticate_request_without_usage(request).await?; + self.client_api_key_usage + .record_used(client.policy().key_id()); + Ok(client) + }) + } + + fn verify_request( + &self, + request: ClientAuthenticationRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(self.authenticate_request_without_usage(request)) + } + fn public_models(&self, client: &AuthenticatedClient) -> Vec { client .snapshot @@ -1007,10 +2200,14 @@ impl ExecutionService for DefaultExecutionService { ) -> BoxFuture<'a, Result, ProviderCatalogUnavailable>> { Box::pin(async move { let scope = client.policy.account_scope(); + let providers = self + .providers + .for_extensions(client.snapshot.extensions()) + .map_err(|_| ProviderCatalogUnavailable)?; let mut result = Vec::new(); let mut seen = BTreeSet::new(); for kind in scope.provider_kinds() { - let provider = self.providers.get(kind).ok_or(ProviderCatalogUnavailable)?; + let provider = providers.get(kind).ok_or(ProviderCatalogUnavailable)?; let Some(models) = provider .query_client_model_catalog(scope, protocol, client_version) .await? @@ -1084,6 +2281,69 @@ impl ExecutionService for DefaultExecutionService { .contains_public_model_for_scope(model, client.policy.account_scope()) } + fn prepare_execution( + &self, + client: AuthenticatedClient, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { self.prepare_root_execution_inner(client).await }) + } + + fn middleware_plan(&self, prepared: &PreparedRootExecution) -> Option { + let generation = prepared.client.snapshot.extensions()?.clone(); + self.middlewares.as_ref()?.resolve(&generation) + } + + fn prepare_plugin_execution( + &self, + client_key_id: &ClientApiKeyId, + ) -> BoxFuture<'_, Result> { + let client_key_id = client_key_id.clone(); + Box::pin(async move { + let client = self.client_for_key_id(&client_key_id).await?; + self.client_api_key_usage + .record_used(client.policy.key_id()); + PreparedRootExecution::new(client) + }) + } + + fn start_prepared( + &self, + prepared: PreparedRootExecution, + request: PreparedExecutionRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.start_prepared_with_target( + prepared, + ExecutionTarget::Model(request.public_model), + request.operation, + request.metadata, + ) + .await + }) + } + + fn start_prepared_provider_endpoint( + &self, + prepared: PreparedRootExecution, + provider: ProviderKind, + upstream_model: Option, + operation: Operation, + metadata: ExecutionRequestMetadata, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + self.start_prepared_with_target( + prepared, + ExecutionTarget::ProviderEndpoint { + provider, + upstream_model, + }, + operation, + metadata, + ) + .await + }) + } + fn start( &self, request: StartExecution, @@ -1099,6 +2359,71 @@ impl ExecutionService for DefaultExecutionService { } } +impl NestedModelExecutionPort for DefaultExecutionService { + fn models( + &self, + context: BoundModelExecutionContext, + protocol: String, + client_version: String, + ) -> BoxFuture<'_, Result, GatewayError>> { + Box::pin(async move { + let authority = &context.authority; + let remaining = authority + .deadline_at + .duration_since(SystemTime::now()) + .unwrap_or_default(); + let cancellation = authority.cancellation.cancelled().fuse(); + let timeout = Delay::new(remaining).fuse(); + let catalog = self + .client_model_catalog(&authority.client, &protocol, &client_version) + .fuse(); + pin_mut!(cancellation, timeout, catalog); + let models = select_biased! { + () = cancellation => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")), + () = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")), + result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?, + }; + Ok(models + .into_iter() + .map(|model| match model { + PublicModelDescriptor::Native { model, .. } => model, + PublicModelDescriptor::Adapted(profile) => profile.model().clone(), + }) + .collect()) + }) + } + + fn bind( + &self, + binding: BoundModelExecutionBinding, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { self.bind_bound_model_inner(binding).await }) + } + + fn start( + &self, + request: NestedModelExecutionRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { self.start_nested_inner(request).await }) + } + + fn start_bound( + &self, + request: BoundModelExecutionRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { self.start_bound_model_inner(request).await }) + } +} + +impl AffinityLookupPort for DefaultExecutionService { + fn lookup( + &self, + request: AffinityLookupRequest, + ) -> BoxFuture<'_, Result, GatewayError>> { + Box::pin(async move { self.lookup_affinity_inner(request).await }) + } +} + impl ClientKeyVerifier for DefaultExecutionService { fn verify_client_key( &self, @@ -1113,8 +2438,9 @@ impl AccountProbe for DefaultExecutionService { fn probe( &self, request: AccountProbeRequest, + snapshot: Option>, ) -> BoxFuture<'_, Result> { - Box::pin(async move { self.probe_inner(request).await }) + Box::pin(async move { self.probe_inner(request, snapshot).await }) } } @@ -1153,9 +2479,55 @@ impl Drop for AdmissionLease { } } +enum ExecutionAdmission { + Client(AdmissionLease), + Nested(NestedExecutionPermit), + Bound(AdmissionLease, NestedExecutionPermit), +} + +impl ExecutionAdmission { + async fn release(self) { + match self { + Self::Client(admission) => admission.release().await, + Self::Nested(permit) => permit.release(), + Self::Bound(admission, permit) => { + admission.release().await; + permit.release(); + } + } + } +} + +impl ExecutionStartGuard { + async fn release_failed( + self, + budget: Option<&dyn ClientBudgetPort>, + request_id: ModelRequestId, + key_id: ClientApiKeyId, + ) { + if let Some(active_request) = self.active_request { + active_request.release(); + } + if let Some(budget) = budget { + settle_budget( + budget, + ClientBudgetCharge { + key_id, + request_id, + amount_usd: crate::metering::Decimal::ZERO, + completed_at: SystemTime::now(), + }, + ) + .await; + } + self.admission.release().await; + } +} + struct DefaultExecutionSession { core: ResponseExecutionSession, - admission: Option, + admission: Option, + active_request: Option, cleanup: Option>, circuits: Arc, continuation: Arc, @@ -1167,7 +2539,8 @@ struct DefaultExecutionSession { impl DefaultExecutionSession { fn new( core: ResponseExecutionSession, - admission: AdmissionLease, + admission: ExecutionAdmission, + active_request: Option, circuits: Arc, continuation: Arc, budget: Option>, @@ -1175,6 +2548,7 @@ impl DefaultExecutionSession { Self { core, admission: Some(admission), + active_request, cleanup: None, circuits, continuation, @@ -1188,6 +2562,9 @@ impl DefaultExecutionSession { if self.core.is_finalized() && let Some(admission) = self.admission.take() { + if let Some(active_request) = self.active_request.take() { + active_request.release(); + } let budget = self.budget.take(); let charge = self.core.budget_charge(); // 在首次 await 前把完整清理责任留在会话内。事件等待被取消后,后续 poll @@ -1241,6 +2618,7 @@ impl DefaultExecutionSession { impl Drop for DefaultExecutionSession { fn drop(&mut self) { self.core.cancel(); + drop(self.active_request.take()); } } @@ -1281,6 +2659,10 @@ impl ExecutionSession for DefaultExecutionSession { self.core.response_status_code() } + fn discard_pending_delivery(&mut self) -> Result<(), EngineError> { + self.core.discard_pending_delivery() + } + fn commit_downstream( &mut self, client_status_code: Option, @@ -1306,7 +2688,10 @@ impl ExecutionSession for DefaultExecutionSession { } fn is_finalized(&self) -> bool { - self.core.is_finalized() && self.admission.is_none() && self.cleanup.is_none() + self.core.is_finalized() + && self.admission.is_none() + && self.active_request.is_none() + && self.cleanup.is_none() } fn cancel(&self) { @@ -1394,6 +2779,87 @@ fn constant_time_equal(left: &str, right: &str) -> bool { == 0 } +fn restrict_model_execution_scope( + parent_scope: &FrozenAccountScope, + provider: Option, + account: Option<&ProviderAccountId>, + parent_account: Option<&ProviderAccountId>, +) -> Result<(Arc, Option), GatewayError> { + let mut account_scope = parent_scope.clone(); + if let Some(parent_account) = parent_account { + if !parent_scope.allows(parent_account) { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "parent attempt account is outside the frozen account scope", + )); + } + if account == Some(parent_account) { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "nested execution cannot wait for the parent attempt account", + )); + } + account_scope = account_scope.excluding_account(parent_account.clone()); + } + let required_provider = match account { + Some(account) => { + if !account_scope.allows(account) { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model account is outside the frozen authorization scope", + )); + } + let account_provider = account_scope + .account_provider(account) + .cloned() + .ok_or_else(|| { + GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model account is unavailable in the frozen snapshot", + ) + })?; + if provider + .as_ref() + .is_some_and(|provider| provider != &account_provider) + { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model provider and account do not match", + )); + } + Some(account_provider) + } + None => provider, + }; + if required_provider + .as_ref() + .is_some_and(|provider| !account_scope.provider_kinds().contains(provider)) + || account_scope.provider_kinds().is_empty() + { + return Err(GatewayError::new( + GatewayErrorKind::PolicyDenied, + "model provider is outside the frozen authorization scope", + )); + } + Ok((Arc::new(account_scope), required_provider)) +} + +fn native_policy( + snapshot: &RuntimeSnapshot, + authentication: &ClientAuthenticationRequest, +) -> Result { + let plaintext = authentication + .native_bearer() + .ok_or(ClientAuthenticationError::InvalidKey)? + .expose_for_auth(); + snapshot + .client_policies() + .filter(|policy| constant_time_equal(plaintext, policy.plaintext_key().expose_for_auth())) + .find(|policy| policy.authorize().is_ok()) + .cloned() + .ok_or(ClientAuthenticationError::InvalidKey) +} + fn duration_ms(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } @@ -1403,6 +2869,22 @@ fn new_request_id() -> Result { .map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "failed to allocate request ID")) } +fn authentication_gateway_error(error: ClientAuthenticationError) -> GatewayError { + match error { + ClientAuthenticationError::InvalidKey => { + GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid") + } + ClientAuthenticationError::SnapshotUnavailable => GatewayError::new( + GatewayErrorKind::Internal, + "runtime snapshot is unavailable", + ), + ClientAuthenticationError::ProviderUnavailable => GatewayError::new( + GatewayErrorKind::Internal, + "frontend authentication provider is unavailable", + ), + } +} + fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError { match error { crate::validation::RoutingError::ModelNotFound { @@ -1422,6 +2904,10 @@ fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError { GatewayErrorKind::NoAvailableProvider, "no provider can execute this request", ), + crate::validation::RoutingError::UnsupportedProviderEndpoint { .. } => GatewayError::new( + GatewayErrorKind::Unsupported, + "the selected provider does not support this operation", + ), _ => GatewayError::new( GatewayErrorKind::Internal, "runtime routing configuration is invalid", diff --git a/backend/crates/gateway-core/src/engine/extensions.rs b/backend/crates/gateway-core/src/engine/extensions.rs new file mode 100644 index 000000000..c7f504564 --- /dev/null +++ b/backend/crates/gateway-core/src/engine/extensions.rs @@ -0,0 +1,104 @@ +//! 执行器按发布集合查找;索引不持有当前代次,也不延长旧代次寿命。 + +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, RwLock, Weak}, +}; + +use crate::{identity::ProviderKind, runtime::extensions::ExtensionSetId}; + +use super::provider::{Provider, ProviderRegistry, RegistryError}; + +/// 一次请求已经进入过的插件实例集合。 +/// +/// Core 把它随子请求传播给 Provider、策略与观察边界;Runtime 只据此跳过对应 +/// 实例,不能自行扩大授权或改变路由。集合同时用于拒绝 A → B → A 间接递归。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ExtensionCallScope { + instance_ids: Arc>, +} + +impl ExtensionCallScope { + #[must_use] + pub fn contains(&self, instance_id: &str) -> bool { + self.instance_ids.contains(instance_id) + } + + #[must_use] + pub fn len(&self) -> usize { + self.instance_ids.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.instance_ids.is_empty() + } + + /// 返回加入当前发起实例后的新作用域;重复实例表示间接递归。 + #[must_use] + pub fn extending(&self, instance_id: String) -> Option { + if self.contains(&instance_id) { + return None; + } + let mut instance_ids = self.instance_ids.as_ref().clone(); + instance_ids.insert(instance_id); + Some(Self { + instance_ids: Arc::new(instance_ids), + }) + } +} + +#[derive(Clone)] +pub struct ProviderExtensionIndex { + base: Arc>>, + sets: Arc>>>, +} + +impl ProviderExtensionIndex { + #[must_use] + pub fn new(base: ProviderRegistry) -> Self { + Self { + base: base.providers, + sets: Arc::default(), + } + } + + /// 准备阶段检查原生与插件身份冲突;返回值由候选集合保活。 + pub fn register( + &self, + id: ExtensionSetId, + providers: impl IntoIterator>, + ) -> Result, RegistryError> { + let registry = Arc::new(ProviderRegistry::new( + self.base.values().cloned().chain(providers), + )?); + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, set| set.strong_count() > 0); + if sets.contains_key(&id) { + return Err(RegistryError::DuplicateGeneration); + } + sets.insert(id, Arc::downgrade(®istry)); + Ok(registry) + } + + /// 组合根使用的解析入口;请求仍须显式提供冻结的集合引用。 + #[must_use] + pub fn registry(&self) -> ProviderRegistry { + ProviderRegistry { + providers: self.base.clone(), + extensions: Some(self.clone()), + lease: None, + } + } + + pub(super) fn resolve(&self, id: &ExtensionSetId) -> Option> { + self.sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(id) + .and_then(Weak::upgrade) + } +} diff --git a/backend/crates/gateway-core/src/engine/middleware.rs b/backend/crates/gateway-core/src/engine/middleware.rs new file mode 100644 index 000000000..066bfff5a --- /dev/null +++ b/backend/crates/gateway-core/src/engine/middleware.rs @@ -0,0 +1,728 @@ +//! 数据面请求/响应的统一洋葱中间件端口。 +//! +//! 本模块只承载宿主内部组合合同。插件 RPC 如何表示 `next` 和正文句柄由 +//! Runtime/SDK 负责;身份、路由、账号租约、发送事实、重试、计量与结算仍由 Core +//! 持有,不能通过本端口交给插件解释。 + +use std::collections::BTreeMap; +use std::fmt; +use std::num::NonZeroU32; +use std::sync::{Arc, RwLock, Weak}; +use std::time::SystemTime; + +use bytes::Bytes; +use futures::future::BoxFuture; + +use crate::account::{ProviderAccountId, scope::AccountGroupId}; +use crate::engine::execution::ClientTransport; +use crate::engine::extensions::ExtensionCallScope; +use crate::engine::nested::ExecutionEffects; +use crate::engine::provider::ProviderCallMetadata; +use crate::engine::{EngineError, ModelRequestId}; +use crate::error::{GatewayError, ProviderError}; +use crate::event::ProviderEvent; +use crate::identity::ProviderKind; +use crate::lifecycle::CancellationToken; +use crate::operation::OperationKind; +use crate::policy::ClientApiKeyId; +use crate::runtime::extensions::{ExtensionSetId, ExtensionSetReference}; + +/// 中间件在一次逻辑请求中的挂载位置。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MiddlewareMount { + /// 客户端请求外层;一次逻辑请求只执行一次。 + Request, + /// 受管 Provider attempt 内层;每次真实 retry 都重新建立。 + Attempt, +} + +/// 中间件正文块的宿主边界。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MiddlewareFraming { + /// 一个完整 JSON document。 + JsonDocument, + /// 一个完整 SSE event,包含终止该事件的空行。 + SseEvent, + /// 不承诺 UTF-8 或消息边界的原始字节。 + RawBytes, +} + +/// 允许进入中间件合同的多值 HTTP 头。 +#[derive(Clone, PartialEq, Eq)] +pub struct MiddlewareHeader { + name: String, + value: Bytes, +} + +impl MiddlewareHeader { + #[must_use] + pub fn new(name: impl Into, value: Bytes) -> Self { + Self { + name: name.into(), + value, + } + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub const fn value(&self) -> &Bytes { + &self.value + } + + #[must_use] + pub fn into_parts(self) -> (String, Bytes) { + (self.name, self.value) + } +} + +impl fmt::Debug for MiddlewareHeader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareHeader") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +/// 一次中间件调用可修改的请求事实。 +#[derive(Clone, PartialEq, Eq)] +pub struct MiddlewareRequest { + protocol: String, + headers: Vec, + body: Bytes, +} + +impl MiddlewareRequest { + #[must_use] + pub fn new(protocol: impl Into, headers: Vec, body: Bytes) -> Self { + Self { + protocol: protocol.into(), + headers, + body, + } + } + + #[must_use] + pub fn protocol(&self) -> &str { + &self.protocol + } + + #[must_use] + pub fn headers(&self) -> &[MiddlewareHeader] { + &self.headers + } + + #[must_use] + pub const fn body(&self) -> &Bytes { + &self.body + } + + #[must_use] + pub fn into_parts(self) -> (String, Vec, Bytes) { + (self.protocol, self.headers, self.body) + } +} + +impl fmt::Debug for MiddlewareRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareRequest") + .field("protocol", &self.protocol) + .field("headers", &self.headers) + .field("body", &"") + .finish() + } +} + +/// 中间件逐次拉取的完整正文边界。 +pub struct MiddlewareFrame { + bytes: Bytes, + framing: MiddlewareFraming, + terminal: bool, + transformed: bool, + envelope: Option, +} + +impl MiddlewareFrame { + #[must_use] + pub const fn new(bytes: Bytes, framing: MiddlewareFraming, terminal: bool) -> Self { + Self { + bytes, + framing, + terminal, + transformed: false, + envelope: None, + } + } + + #[must_use] + pub const fn bytes(&self) -> &Bytes { + &self.bytes + } + + #[must_use] + pub const fn framing(&self) -> MiddlewareFraming { + self.framing + } + + #[must_use] + pub const fn terminal(&self) -> bool { + self.terminal + } + + /// 返回正文是否经过至少一个中间件的替换、展开或丢弃映射。 + /// + /// 该事实只在宿主进程内传播,不进入插件 RPC wire,插件不能自行伪造。 + #[must_use] + pub const fn transformed(&self) -> bool { + self.transformed + } + + /// 继承宿主确认的改写事实;`false` 不能清除内层已经记录的改写。 + #[must_use] + pub fn with_transformed(mut self, transformed: bool) -> Self { + self.transformed |= transformed; + self + } + + #[must_use] + pub fn into_bytes(self) -> Bytes { + self.bytes + } + + /// 拆出插件可见正文与只能由宿主原样搬运的事实信封。 + /// + /// Runtime 在一对多转换时必须把信封附到最后一个输出;这样 `Completed` 不会在 + /// 前置输出交付前提前终结。信封不能被读取、序列化、复制或伪造。 + #[must_use] + pub fn into_parts( + self, + ) -> ( + Bytes, + MiddlewareFraming, + bool, + Option, + ) { + (self.bytes, self.framing, self.terminal, self.envelope) + } + + /// 把宿主持有的事实信封附到改写后的最后一个输出。 + #[must_use] + pub fn with_envelope(mut self, envelope: MiddlewareFrameEnvelope) -> Self { + self.envelope = Some(envelope); + self + } + + pub(crate) fn from_provider_event( + bytes: Bytes, + framing: MiddlewareFraming, + terminal: bool, + event: ProviderEvent, + ) -> Self { + let transformed = event.middleware_transformed(); + Self { + bytes, + framing, + terminal, + transformed, + envelope: Some(MiddlewareFrameEnvelope { event }), + } + } + + pub(crate) fn into_provider_parts( + self, + ) -> (Bytes, MiddlewareFraming, bool, Option) { + ( + self.bytes, + self.framing, + self.terminal, + self.envelope.map(|envelope| envelope.event), + ) + } +} + +impl fmt::Debug for MiddlewareFrame { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareFrame") + .field("bytes", &"") + .field("framing", &self.framing) + .field("terminal", &self.terminal) + .field("transformed", &self.transformed) + .field("has_host_envelope", &self.envelope.is_some()) + .finish() + } +} + +/// Runtime 可移动但不能检查或构造的 Core 事实信封。 +/// +/// 该类型故意不实现 `Clone`,防止同一 usage/cost 在一对多响应中被复制。 +pub struct MiddlewareFrameEnvelope { + event: ProviderEvent, +} + +impl fmt::Debug for MiddlewareFrameEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareFrameEnvelope") + .finish_non_exhaustive() + } +} + +/// 洋葱返回路径上的惰性正文。 +/// +/// 实现必须让 `Drop` 与 [`Self::close`] 都回收底层流。请求 finalization 继续委托 +/// 原有 [`crate::engine::execution::ExecutionSession`],本端口不建立第二套清理器。 +pub trait MiddlewareBody: Send { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>>; + + /// 在最外层输出准备完成后提交原有 Core delivery barrier。中间件实现只能向内委托。 + fn commit_downstream( + &mut self, + _client_status_code: Option, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async { Ok(()) }) + } + + /// 首字节前失败时记录最终返回给客户端的状态。中间件实现只能向内委托。 + fn record_client_status( + &mut self, + _client_status_code: u16, + ) -> BoxFuture<'_, Result<(), MiddlewareError>> { + Box::pin(async { Ok(()) }) + } + + /// 没有调用 `next` 的短路正文视为无需 Core 结算;调用过 `next` 的包装器必须委托。 + fn is_finalized(&self) -> bool { + true + } + + fn close(self: Box) -> BoxFuture<'static, ()>; +} + +/// `next` 或短路返回的响应头与惰性正文。 +pub struct MiddlewareResponse { + protocol: String, + status_code: u16, + headers: Vec, + body: Box, + envelope: Option, +} + +impl MiddlewareResponse { + #[must_use] + pub fn new( + protocol: String, + status_code: u16, + headers: Vec, + body: Box, + ) -> Self { + Self { + protocol, + status_code, + headers, + body, + envelope: None, + } + } + + #[must_use] + pub fn protocol(&self) -> &str { + &self.protocol + } + + #[must_use] + pub const fn status_code(&self) -> u16 { + self.status_code + } + + #[must_use] + pub fn headers(&self) -> &[MiddlewareHeader] { + &self.headers + } + + #[must_use] + pub fn into_parts( + self, + ) -> ( + String, + u16, + Vec, + Box, + Option, + ) { + ( + self.protocol, + self.status_code, + self.headers, + self.body, + self.envelope, + ) + } + + /// 搬运宿主 terminal 的不透明来源事实;Runtime 不得序列化或自行构造。 + #[must_use] + pub fn with_envelope(mut self, envelope: MiddlewareResponseEnvelope) -> Self { + self.envelope = Some(envelope); + self + } + + pub(crate) fn with_provider_metadata(mut self, metadata: ProviderCallMetadata) -> Self { + self.envelope = Some(MiddlewareResponseEnvelope { metadata }); + self + } +} + +impl fmt::Debug for MiddlewareResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareResponse") + .field("protocol", &self.protocol) + .field("status_code", &self.status_code) + .field("headers", &self.headers) + .field("body", &"") + .field("has_host_envelope", &self.envelope.is_some()) + .finish() + } +} + +/// Attempt terminal 的 Core 来源事实;中间件只能随响应原样搬运。 +pub struct MiddlewareResponseEnvelope { + metadata: ProviderCallMetadata, +} + +impl MiddlewareResponseEnvelope { + pub(crate) fn into_provider_metadata(self) -> ProviderCallMetadata { + self.metadata + } +} + +impl fmt::Debug for MiddlewareResponseEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareResponseEnvelope") + .finish_non_exhaustive() + } +} + +/// Core 已冻结、但不会序列化敏感身份的调用事实。 +#[derive(Clone)] +pub struct MiddlewareContext { + request_id: ModelRequestId, + mount: MiddlewareMount, + attempt_index: Option, + operation: Option, + endpoint: String, + transport: ClientTransport, + provider: Option, + model: Option, + account_id: Option, + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, + cancellation: CancellationToken, + deadline: SystemTime, + extension_scope: ExtensionCallScope, + execution_effects: Option>, +} + +impl MiddlewareContext { + #[must_use] + pub fn new(target: MiddlewareTarget, authority: MiddlewareAuthority) -> Self { + let MiddlewareTarget { + request_id, + mount, + attempt_index, + operation, + endpoint, + transport, + provider, + model, + account_id, + } = target; + let MiddlewareAuthority { + client_key_id, + account_group_ids, + cancellation, + deadline, + extension_scope, + execution_effects, + } = authority; + Self { + request_id, + mount, + attempt_index, + operation, + endpoint, + transport, + provider, + model, + account_id, + client_key_id, + account_group_ids, + cancellation, + deadline, + extension_scope, + execution_effects, + } + } + + #[must_use] + pub const fn request_id(&self) -> &ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn mount(&self) -> MiddlewareMount { + self.mount + } + + #[must_use] + pub const fn attempt_index(&self) -> Option { + self.attempt_index + } + + #[must_use] + pub const fn operation(&self) -> Option { + self.operation + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + #[must_use] + pub const fn transport(&self) -> ClientTransport { + self.transport + } + + #[must_use] + pub const fn provider(&self) -> Option<&ProviderKind> { + self.provider.as_ref() + } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + + #[must_use] + pub const fn account_id(&self) -> Option<&ProviderAccountId> { + self.account_id.as_ref() + } + + /// 只供宿主 Runtime 匹配冻结 binding;不得序列化给插件。 + #[must_use] + pub const fn client_key_id(&self) -> &ClientApiKeyId { + &self.client_key_id + } + + /// 只供宿主 Runtime 匹配冻结 binding;不得序列化给插件。 + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub const fn cancellation(&self) -> &CancellationToken { + &self.cancellation + } + + #[must_use] + pub const fn deadline(&self) -> SystemTime { + self.deadline + } + + #[must_use] + pub const fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + + /// 只供受信 Runtime 记录本次中间件调用已经产生的外部副作用,不进入插件 wire。 + #[must_use] + pub fn execution_effects(&self) -> Option> { + self.execution_effects.as_ref().map(Arc::clone) + } +} + +/// 不含授权主体的调用位置与目标事实。 +pub struct MiddlewareTarget { + pub request_id: ModelRequestId, + pub mount: MiddlewareMount, + pub attempt_index: Option, + pub operation: Option, + pub endpoint: String, + pub transport: ClientTransport, + pub provider: Option, + pub model: Option, + pub account_id: Option, +} + +/// Runtime 只用于授权求交的冻结主体;字段不得进入插件 wire。 +pub struct MiddlewareAuthority { + pub client_key_id: ClientApiKeyId, + pub account_group_ids: Arc<[AccountGroupId]>, + pub cancellation: CancellationToken, + pub deadline: SystemTime, + pub extension_scope: ExtensionCallScope, + pub execution_effects: Option>, +} + +impl fmt::Debug for MiddlewareContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("MiddlewareContext") + .field("request_id", &self.request_id) + .field("mount", &self.mount) + .field("attempt_index", &self.attempt_index) + .field("operation", &self.operation) + .field("endpoint", &self.endpoint) + .field("transport", &self.transport) + .field("provider", &self.provider) + .field("model", &self.model) + .field("account_id", &self.account_id) + .field("deadline", &self.deadline) + .finish_non_exhaustive() + } +} + +/// 中间件链调用错误。原有域错误保持原类型,不能被插件故障改写为“未发送”。 +#[derive(Debug)] +pub enum MiddlewareError { + Rejected, + Fault, + InvalidState, + Gateway(GatewayError), + Engine(EngineError), + Provider(ProviderError), +} + +impl fmt::Display for MiddlewareError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Rejected => "middleware rejected the request", + Self::Fault => "middleware call failed", + Self::InvalidState => "middleware state is invalid", + Self::Gateway(_) => "gateway middleware terminal failed", + Self::Engine(_) => "execution middleware terminal failed", + Self::Provider(_) => "provider middleware terminal failed", + }) + } +} + +impl std::error::Error for MiddlewareError {} + +impl From for MiddlewareError { + fn from(error: GatewayError) -> Self { + Self::Gateway(error) + } +} + +impl From for MiddlewareError { + fn from(error: EngineError) -> Self { + Self::Engine(error) + } +} + +impl From for MiddlewareError { + fn from(error: ProviderError) -> Self { + Self::Provider(error) + } +} + +/// 绑定当前调用且只能消费一次的宿主续体。 +pub trait MiddlewareNext: Send { + fn run( + self: Box, + request: MiddlewareRequest, + ) -> BoxFuture<'static, Result>; +} + +/// 一个发布代次冻结的统一数据面中间件计划。 +pub trait MiddlewarePlan: Send + Sync + fmt::Debug { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result>; +} + +/// 调用期间同时保活发布代次与中间件计划。 +#[derive(Clone)] +pub struct FrozenMiddlewarePlan { + plan: Arc, + _generation: ExtensionSetReference, +} + +impl FrozenMiddlewarePlan { + #[must_use] + pub fn new(plan: Arc, generation: ExtensionSetReference) -> Self { + Self { + plan, + _generation: generation, + } + } + + pub fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + self.plan.handle(context, request, next) + } +} + +impl fmt::Debug for FrozenMiddlewarePlan { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FrozenMiddlewarePlan") + .finish_non_exhaustive() + } +} + +/// 同一发布代次不能被静默替换。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("middleware generation is already registered")] +pub struct MiddlewareRegistrationError; + +/// 按请求冻结的发布集合解析中间件计划;索引不延长旧代次寿命。 +#[derive(Clone, Default)] +pub struct MiddlewareExtensionIndex { + sets: Arc>>>, +} + +impl MiddlewareExtensionIndex { + pub fn register( + &self, + id: ExtensionSetId, + plan: Arc, + ) -> Result, MiddlewareRegistrationError> { + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, plan| plan.strong_count() > 0); + if sets.contains_key(&id) { + return Err(MiddlewareRegistrationError); + } + sets.insert(id, Arc::downgrade(&plan)); + Ok(plan) + } + + #[must_use] + pub fn resolve(&self, generation: &ExtensionSetReference) -> Option { + self.sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(generation.id()) + .and_then(Weak::upgrade) + .map(|plan| FrozenMiddlewarePlan::new(plan, generation.clone())) + } +} diff --git a/backend/crates/gateway-core/src/engine/mod.rs b/backend/crates/gateway-core/src/engine/mod.rs index f5f6c6ab1..5ae8b40a7 100644 --- a/backend/crates/gateway-core/src/engine/mod.rs +++ b/backend/crates/gateway-core/src/engine/mod.rs @@ -1,11 +1,16 @@ //! 模型请求生命周期、单行持久化 port 与 commit/send/cancellation 边界。 pub mod admission; +pub mod authentication; pub mod budget; pub mod continuation; pub mod coordinator; pub mod execution; -mod observation; +pub mod extensions; +pub mod middleware; +pub mod nested; +pub mod observation; +pub mod policy; pub mod probe; pub mod provider; @@ -21,7 +26,10 @@ use std::time::{Duration, Instant, SystemTime}; use async_trait::async_trait; use thiserror::Error; -use crate::account::{AccountSelectionPolicy, ProviderAccountId}; +use crate::account::{ + AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelectionPolicy, + ProviderAccountId, +}; use crate::engine::continuation::{ContinuationBinding, NativeContinuationPin}; use crate::error::{ GatewayError, ProviderConnectionObservation, ProviderError, ProviderErrorKind, StoreError, @@ -323,6 +331,13 @@ pub struct RequestAttemptContext { timing_started_at: Instant, trace: crate::diagnostics::TraceContext, concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget, + request_policy: Option, + execution_effects: Option>, + middleware: Option, + account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, + endpoint: String, + client_transport: execution::ClientTransport, + extension_scope: extensions::ExtensionCallScope, } impl RequestAttemptContext { @@ -368,6 +383,13 @@ impl RequestAttemptContext { timing_started_at: Instant::now(), trace: crate::diagnostics::TraceContext::default(), concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget::default(), + request_policy: None, + execution_effects: None, + middleware: None, + account_group_ids: Arc::from([]), + endpoint: String::new(), + client_transport: execution::ClientTransport::InternalProbe, + extension_scope: extensions::ExtensionCallScope::default(), } } @@ -394,6 +416,45 @@ impl RequestAttemptContext { self } + /// 附着与发布视图同代次的请求策略;诊断与无插件路径保持 `None`。 + #[must_use] + pub fn with_request_policy(mut self, policy: Option) -> Self { + self.request_policy = policy; + self + } + + /// 附着本次逻辑请求共享的外部副作用水位;不会传给 Provider 或插件 wire。 + #[must_use] + pub fn with_execution_effects( + mut self, + effects: Option>, + ) -> Self { + self.execution_effects = effects; + self + } + + /// 附着与路由快照同代次的 attempt 中间件及可信绑定事实。 + #[must_use] + pub fn with_middleware( + mut self, + middleware: Option, + account_group_ids: Arc<[crate::account::scope::AccountGroupId]>, + endpoint: String, + client_transport: execution::ClientTransport, + ) -> Self { + self.middleware = middleware; + self.account_group_ids = account_group_ids; + self.endpoint = endpoint; + self.client_transport = client_transport; + self + } + + #[must_use] + pub fn with_extension_scope(mut self, extension_scope: extensions::ExtensionCallScope) -> Self { + self.extension_scope = extension_scope; + self + } + #[must_use] pub const fn request_id(&self) -> &ModelRequestId { &self.request_id @@ -409,6 +470,36 @@ impl RequestAttemptContext { pub const fn timing_started_at(&self) -> Instant { self.timing_started_at } + + #[must_use] + pub const fn request_policy(&self) -> Option<&policy::RequestPolicyContext> { + self.request_policy.as_ref() + } + + #[must_use] + pub const fn extension_scope(&self) -> &extensions::ExtensionCallScope { + &self.extension_scope + } + + #[must_use] + pub const fn middleware(&self) -> Option<&middleware::FrozenMiddlewarePlan> { + self.middleware.as_ref() + } + + #[must_use] + pub fn account_group_ids(&self) -> &[crate::account::scope::AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + #[must_use] + pub const fn client_transport(&self) -> execution::ClientTransport { + self.client_transport + } } /// Provider 每次执行可见的 request-local context。 @@ -515,6 +606,60 @@ impl AttemptContext { self.request.timing_started_at() } + /// 返回同一次请求冻结的插件策略上下文。 + #[must_use] + pub const fn request_policy_context(&self) -> Option<&policy::RequestPolicyContext> { + self.request.request_policy() + } + + #[must_use] + pub const fn extension_scope(&self) -> &extensions::ExtensionCallScope { + self.request.extension_scope() + } + + /// 在已选账号并持有其 lease 后,以 owned terminal 执行本次 retry 的中间件链。 + /// + /// terminal 返回的 stream 仍须保持 cold;中间件不能取得 credential、发送状态、 + /// canonical usage/cost 或结算所有权。 + pub async fn execute_middleware( + &self, + operation: crate::operation::Operation, + provider: ProviderKind, + model: Option, + account_id: ProviderAccountId, + terminal: provider::ProviderMiddlewareTerminal, + ) -> Result { + let context = middleware::MiddlewareContext::new( + middleware::MiddlewareTarget { + request_id: self.request.request_id.clone(), + mount: middleware::MiddlewareMount::Attempt, + attempt_index: Some(self.attempt_index), + operation: Some(operation.kind()), + endpoint: self.request.endpoint.clone(), + transport: self.request.client_transport, + provider: Some(provider), + model, + account_id: Some(account_id), + }, + middleware::MiddlewareAuthority { + client_key_id: self.request.client_api_key_ref.clone(), + account_group_ids: Arc::clone(&self.request.account_group_ids), + cancellation: self.cancellation.clone(), + deadline: self.deadline, + extension_scope: self.request.extension_scope.clone(), + execution_effects: self.request.execution_effects.as_ref().map(Arc::clone), + }, + ); + provider::execute_attempt_middleware( + self.request.middleware.as_ref(), + context, + operation, + self.request.client_transport, + terminal, + ) + .await + } + #[must_use] pub const fn attempt_index(&self) -> NonZeroU32 { self.attempt_index @@ -590,6 +735,24 @@ impl AttemptContext { pub const fn cancellation(&self) -> &CancellationToken { &self.cancellation } + + /// 使用冻结策略选择账号;没有匹配策略时委托现有 `AccountSelector`。 + pub async fn select_account<'a>( + &self, + provider: &ProviderKind, + model: Option<&str>, + candidates: &'a [AccountCandidate], + context: &AccountSelectionContext, + ) -> Result>, policy::AccountPolicyError> { + match self.request.request_policy() { + Some(policy) => { + policy + .select_account(self.attempt_index, provider, model, candidates, context) + .await + } + None => Ok(crate::account::AccountSelector.select(candidates, context)), + } + } } /// 创建唯一 `model_requests` 行所需的入口事实。 diff --git a/backend/crates/gateway-core/src/engine/nested.rs b/backend/crates/gateway-core/src/engine/nested.rs new file mode 100644 index 000000000..dbb34daa0 --- /dev/null +++ b/backend/crates/gateway-core/src/engine/nested.rs @@ -0,0 +1,203 @@ +//! 插件宿主模型调用与亲和查询的中立 Core 端口。 +//! +//! Runtime 只传递宿主签发的父请求关联和业务目标;调用方身份、 +//! 快照、准入、续写资格与计费始终由 [`super::execution::DefaultExecutionService`] +//! 持有和复核。 + +use std::{ + fmt, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; + +use futures::future::BoxFuture; + +use crate::{ + account::ProviderAccountId, error::GatewayError, identity::ProviderKind, + lifecycle::CancellationToken, operation::Operation, policy::ClientApiKeyId, + provider_ports::ProviderSessionAffinityKey, routing::PublicModelId, +}; + +use super::{ + ModelRequestId, + execution::{BoundModelExecutionContext, ExecutionRequestMetadata, StartedExecution}, +}; + +/// 同一请求图内不可逆外部副作用的单调水位。 +/// +/// 受信 Runtime 只在受管子执行或外部 HTTP 已发送、或无法证明未发送时调用 +/// [`Self::observe`]。它不承载费用或插件输入;Coordinator 用每次执行自己的基线 +/// 阻止把随后 Provider 的 `not_sent` 错当作可重放。 +#[derive(Debug, Default)] +pub struct ExecutionEffects(AtomicUsize); + +impl ExecutionEffects { + pub fn observe(&self) { + self.0.fetch_add(1, Ordering::AcqRel); + } + + pub(crate) fn epoch(&self) -> usize { + self.0.load(Ordering::Acquire) + } +} + +/// 插件发起的一个子模型请求。 +/// +/// `parent_request_id` 与 `initiating_plugin_instance_id` 都来自宿主保存的 +/// `CallContext`,不能接受插件自行构造的关联事实。 +pub struct NestedModelExecutionRequest { + pub parent_request_id: ModelRequestId, + pub initiating_plugin_instance_id: String, + pub public_model: PublicModelId, + pub operation: Operation, + pub metadata: ExecutionRequestMetadata, + pub provider: Option, + pub account: Option, + /// 发起回调时父 attempt 已持有的账号。该事实由宿主从 `CallContext` 注入, + /// 用于阻止子请求在父请求等待其返回时再次等待同一账号 lease。 + pub parent_account: Option, +} + +/// 管理页或 CLI 为一次插件调用显式绑定的模型执行身份。 +/// +/// Key ID 由已获模型域访问的调用选择,发起实例由宿主确定。Core 在绑定时冻结当前 +/// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费。 +pub struct BoundModelExecutionBinding { + pub client_key_id: ClientApiKeyId, + pub initiating_plugin_instance_id: String, + pub timeout: Duration, + pub cancellation: CancellationToken, + /// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实。 + pub extension_scope: super::extensions::ExtensionCallScope, +} + +impl fmt::Debug for BoundModelExecutionBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundModelExecutionBinding") + .field("client_key_id", &self.client_key_id) + .field( + "initiating_plugin_instance_id", + &self.initiating_plugin_instance_id, + ) + .field("timeout", &self.timeout) + .finish_non_exhaustive() + } +} + +/// 显式身份作用域内的一次模型请求。 +pub struct BoundModelExecutionRequest { + pub context: BoundModelExecutionContext, + pub public_model: PublicModelId, + pub operation: Operation, + pub metadata: ExecutionRequestMetadata, + pub provider: Option, + pub account: Option, +} + +impl fmt::Debug for BoundModelExecutionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BoundModelExecutionRequest") + .field("context", &self.context) + .field("public_model", &self.public_model) + .field("operation", &self.operation.kind()) + .field("provider", &self.provider) + .field("account", &self.account) + .finish_non_exhaustive() + } +} + +impl fmt::Debug for NestedModelExecutionRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NestedModelExecutionRequest") + .field("parent_request_id", &self.parent_request_id) + .field( + "initiating_plugin_instance_id", + &self.initiating_plugin_instance_id, + ) + .field("public_model", &self.public_model) + .field("operation", &self.operation.kind()) + .field("provider", &self.provider) + .field("account", &self.account) + .field("parent_account", &self.parent_account) + .finish_non_exhaustive() + } +} + +/// 嵌套模型执行只进入 Core 的既有执行会话;Runtime 不实现重试、commit 或计费。 +pub trait NestedModelExecutionPort: Send + Sync { + fn models( + &self, + context: BoundModelExecutionContext, + protocol: String, + client_version: String, + ) -> BoxFuture<'_, Result, GatewayError>>; + + /// 为单次受保护管理调用或 CLI 命令冻结显式执行身份与共享调用图。 + fn bind( + &self, + binding: BoundModelExecutionBinding, + ) -> BoxFuture<'_, Result>; + + fn start( + &self, + request: NestedModelExecutionRequest, + ) -> BoxFuture<'_, Result>; + + fn start_bound( + &self, + request: BoundModelExecutionRequest, + ) -> BoxFuture<'_, Result>; +} + +/// 带父请求身份与授权范围的会话亲和查询。 +pub struct AffinityLookupRequest { + pub parent_request_id: ModelRequestId, + pub provider: ProviderKind, + pub key: ProviderSessionAffinityKey, +} + +impl fmt::Debug for AffinityLookupRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AffinityLookupRequest") + .field("parent_request_id", &self.parent_request_id) + .field("provider", &self.provider) + .field("key", &self.key) + .finish_non_exhaustive() + } +} + +/// 只公开后续请求可作为偏好提交、但仍须 Core 再次复核的亲和提示。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AffinityLookupResult { + provider: ProviderKind, + account: ProviderAccountId, +} + +impl AffinityLookupResult { + #[must_use] + pub const fn new(provider: ProviderKind, account: ProviderAccountId) -> Self { + Self { provider, account } + } + + #[must_use] + pub const fn provider(&self) -> &ProviderKind { + &self.provider + } + + #[must_use] + pub const fn account(&self) -> &ProviderAccountId { + &self.account + } +} + +/// 会话亲和查询失败沿用公开网关错误分类,不暴露响应句柄或归属细节。 +pub trait AffinityLookupPort: Send + Sync { + fn lookup( + &self, + request: AffinityLookupRequest, + ) -> BoxFuture<'_, Result, GatewayError>>; +} diff --git a/backend/crates/gateway-core/src/engine/observation.rs b/backend/crates/gateway-core/src/engine/observation.rs index 1df1c1b44..b572fe02e 100644 --- a/backend/crates/gateway-core/src/engine/observation.rs +++ b/backend/crates/gateway-core/src/engine/observation.rs @@ -1,10 +1,751 @@ -//! 单次响应的计量、时间与响应身份事实;重试丢弃时统一清理。 +//! 单次响应事实与请求终态扩展派发;重试丢弃时统一清理。 -use super::ModelRequestTimings; -use crate::event::GatewayEvent; -use crate::event::ProviderResponseObservation; -use crate::metering::{CostEstimate, CostSource, Usage}; -use std::time::Instant; +use std::{ + collections::BTreeMap, + num::NonZeroU32, + sync::{ + Arc, RwLock, Weak, + atomic::{AtomicBool, Ordering}, + }, + time::{Instant, SystemTime}, +}; + +use super::{ + ExecutionOutcome, ModelRequestFinalization, ModelRequestId, ModelRequestTimings, + extensions::ExtensionCallScope, +}; +use crate::{ + account::ProviderAccountId, + error::{GatewayError, GatewayErrorKind}, + event::{GatewayEvent, ProtocolWireEvent, ProviderResponseObservation}, + identity::ProviderKind, + metering::{CostEstimate, CostSource, Usage}, + operation::OperationKind, + policy::ClientApiKeyId, + routing::{AccountGroupId, ConfigRevision, PublicModelId}, + runtime::extensions::{ExtensionSetId, ExtensionSetReference}, + upstream::UpstreamSendState, +}; + +/// 一条 WebSocket 响应事件所属的实际上游 attempt 身份。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebSocketResponseAttempt { + provider: ProviderKind, + account_id: ProviderAccountId, + attempt_index: NonZeroU32, +} + +impl WebSocketResponseAttempt { + /// 固定一条响应事件所属的实际上游 attempt。 + #[must_use] + pub const fn new( + provider: ProviderKind, + account_id: ProviderAccountId, + attempt_index: NonZeroU32, + ) -> Self { + Self { + provider, + account_id, + attempt_index, + } + } + + #[must_use] + pub const fn provider(&self) -> &ProviderKind { + &self.provider + } + + #[must_use] + pub const fn account_id(&self) -> &ProviderAccountId { + &self.account_id + } + + #[must_use] + pub const fn attempt_index(&self) -> NonZeroU32 { + self.attempt_index + } +} + +/// 实际上游 WebSocket 响应事件的只读观察。 +/// +/// Key 与账号组只用于 Runtime 匹配冻结绑定,不得进入插件 wire;`wire` 保留策略加工前 +/// 的 Provider 原始事实,由 Runtime 再按正文读取授权投影。 +#[derive(Clone, PartialEq)] +pub struct WebSocketResponseObservation { + event_id: String, + request_id: ModelRequestId, + config_revision: ConfigRevision, + client_key_id: Option, + account_group_ids: Arc<[AccountGroupId]>, + extension_scope: ExtensionCallScope, + operation: OperationKind, + requested_model: Option, + attempt: WebSocketResponseAttempt, + sequence: u64, + wire: ProtocolWireEvent, +} + +impl WebSocketResponseObservation { + /// 创建一个尚未附加冻结 Key/组/公开模型范围的实际上游事件。 + #[must_use] + pub fn new( + request_id: ModelRequestId, + config_revision: ConfigRevision, + operation: OperationKind, + attempt: WebSocketResponseAttempt, + sequence: u64, + wire: ProtocolWireEvent, + ) -> Self { + Self { + event_id: format!( + "{}:websocket:{}:{sequence}", + request_id.as_str(), + attempt.attempt_index().get() + ), + request_id, + config_revision, + client_key_id: None, + account_group_ids: Arc::from([]), + extension_scope: ExtensionCallScope::default(), + operation, + requested_model: None, + attempt, + sequence, + wire, + } + } + + #[must_use] + pub fn with_client_scope( + mut self, + client_key_id: ClientApiKeyId, + mut account_group_ids: Vec, + ) -> Self { + account_group_ids.sort(); + account_group_ids.dedup(); + self.client_key_id = Some(client_key_id); + self.account_group_ids = account_group_ids.into(); + self + } + + #[must_use] + pub fn with_requested_model(mut self, model: PublicModelId) -> Self { + self.requested_model = Some(model); + self + } + + #[must_use] + pub fn with_extension_scope(mut self, extension_scope: ExtensionCallScope) -> Self { + self.extension_scope = extension_scope; + self + } + + #[must_use] + pub fn suppresses_plugin(&self, instance_id: &str) -> bool { + self.extension_scope.contains(instance_id) + } + + #[must_use] + pub const fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + + #[must_use] + pub fn event_id(&self) -> &str { + &self.event_id + } + + #[must_use] + pub const fn request_id(&self) -> &ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn config_revision(&self) -> ConfigRevision { + self.config_revision + } + + #[must_use] + pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> { + self.client_key_id.as_ref() + } + + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub const fn operation(&self) -> OperationKind { + self.operation + } + + #[must_use] + pub const fn requested_model(&self) -> Option<&PublicModelId> { + self.requested_model.as_ref() + } + + #[must_use] + pub const fn provider(&self) -> &ProviderKind { + self.attempt.provider() + } + + #[must_use] + pub const fn account_id(&self) -> &ProviderAccountId { + self.attempt.account_id() + } + + #[must_use] + pub const fn attempt_index(&self) -> NonZeroU32 { + self.attempt.attempt_index() + } + + #[must_use] + pub const fn sequence(&self) -> u64 { + self.sequence + } + + #[must_use] + pub const fn wire(&self) -> &ProtocolWireEvent { + &self.wire + } +} + +/// 插件可观察的请求终态;拒绝表示请求没有进入 Provider 执行。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestObservationOutcome { + Succeeded, + Failed, + Rejected, + Cancelled, + Incomplete, +} + +/// Core 在业务结果确定后产生的一次最终观察,不包含原始正文或凭据。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RequestObservation { + event_id: String, + request_id: ModelRequestId, + config_revision: ConfigRevision, + client_key_id: Option, + account_group_ids: Arc<[AccountGroupId]>, + extension_scope: ExtensionCallScope, + operation: OperationKind, + requested_model: Option, + provider: Option, + account_id: Option, + upstream_model: Option, + response_model: Option, + service_tier: Option, + outcome: RequestObservationOutcome, + send_state: UpstreamSendState, + attempt_count: u32, + client_status_code: Option, + upstream_status_code: Option, + error_kind: Option, + retry_after_ms: Option, + usage: Usage, + cost: CostEstimate, + timings: ModelRequestTimings, + completed_at: SystemTime, +} + +impl RequestObservation { + /// 创建一个不带 Provider、模型、用量或错误详情的最终事实。 + #[must_use] + pub fn new( + request_id: ModelRequestId, + config_revision: ConfigRevision, + operation: OperationKind, + outcome: RequestObservationOutcome, + send_state: UpstreamSendState, + completed_at: SystemTime, + ) -> Self { + Self { + event_id: format!("{}:terminal", request_id.as_str()), + request_id, + config_revision, + client_key_id: None, + account_group_ids: Arc::from([]), + extension_scope: ExtensionCallScope::default(), + operation, + requested_model: None, + provider: None, + account_id: None, + upstream_model: None, + response_model: None, + service_tier: None, + outcome, + send_state, + attempt_count: 0, + client_status_code: None, + upstream_status_code: None, + error_kind: None, + retry_after_ms: None, + usage: Usage::new(), + cost: CostEstimate::unavailable(), + timings: ModelRequestTimings::default(), + completed_at, + } + } + + #[must_use] + pub fn with_client_scope( + mut self, + client_key_id: ClientApiKeyId, + mut account_group_ids: Vec, + ) -> Self { + account_group_ids.sort(); + account_group_ids.dedup(); + self.client_key_id = Some(client_key_id); + self.account_group_ids = account_group_ids.into(); + self + } + + #[must_use] + pub fn with_requested_model(mut self, model: PublicModelId) -> Self { + self.requested_model = Some(model); + self + } + + #[must_use] + pub fn with_extension_scope(mut self, extension_scope: ExtensionCallScope) -> Self { + self.extension_scope = extension_scope; + self + } + + #[must_use] + pub fn suppresses_plugin(&self, instance_id: &str) -> bool { + self.extension_scope.contains(instance_id) + } + + #[must_use] + pub const fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + + #[must_use] + pub fn with_provider(mut self, provider: ProviderKind) -> Self { + self.provider = Some(provider); + self + } + + #[must_use] + pub fn account_id(&self) -> Option<&ProviderAccountId> { + self.account_id.as_ref() + } + + #[must_use] + pub fn upstream_model(&self) -> Option<&crate::routing::UpstreamModelId> { + self.upstream_model.as_ref() + } + + #[must_use] + pub fn response_model(&self) -> Option<&str> { + self.response_model.as_deref() + } + + #[must_use] + pub fn service_tier(&self) -> Option<&str> { + self.service_tier.as_deref() + } + + #[must_use] + pub const fn with_attempt_count(mut self, attempt_count: u32) -> Self { + self.attempt_count = attempt_count; + self + } + + #[must_use] + pub const fn with_client_status_code(mut self, client_status_code: u16) -> Self { + self.client_status_code = Some(client_status_code); + self + } + + #[must_use] + pub const fn with_upstream_status_code(mut self, upstream_status_code: u16) -> Self { + self.upstream_status_code = Some(upstream_status_code); + self + } + + #[must_use] + pub const fn with_error_kind(mut self, error_kind: GatewayErrorKind) -> Self { + self.error_kind = Some(error_kind); + self + } + + #[must_use] + pub const fn with_retry_after_ms(mut self, retry_after_ms: u64) -> Self { + self.retry_after_ms = Some(retry_after_ms); + self + } + + #[must_use] + pub fn with_usage(mut self, usage: Usage) -> Self { + self.usage = usage; + self + } + + #[must_use] + pub fn with_cost(mut self, cost: CostEstimate) -> Self { + self.cost = cost; + self + } + + #[must_use] + pub fn with_timings(mut self, timings: ModelRequestTimings) -> Self { + self.timings = timings; + self + } + + #[must_use] + pub fn event_id(&self) -> &str { + &self.event_id + } + + #[must_use] + pub const fn request_id(&self) -> &ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn config_revision(&self) -> ConfigRevision { + self.config_revision + } + + #[must_use] + pub const fn client_key_id(&self) -> Option<&ClientApiKeyId> { + self.client_key_id.as_ref() + } + + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub const fn operation(&self) -> OperationKind { + self.operation + } + + #[must_use] + pub const fn requested_model(&self) -> Option<&PublicModelId> { + self.requested_model.as_ref() + } + + #[must_use] + pub const fn provider(&self) -> Option<&ProviderKind> { + self.provider.as_ref() + } + + #[must_use] + pub const fn outcome(&self) -> RequestObservationOutcome { + self.outcome + } + + #[must_use] + pub const fn send_state(&self) -> UpstreamSendState { + self.send_state + } + + #[must_use] + pub const fn attempt_count(&self) -> u32 { + self.attempt_count + } + + #[must_use] + pub const fn client_status_code(&self) -> Option { + self.client_status_code + } + + #[must_use] + pub const fn upstream_status_code(&self) -> Option { + self.upstream_status_code + } + + #[must_use] + pub const fn error_kind(&self) -> Option { + self.error_kind + } + + #[must_use] + pub const fn retry_after_ms(&self) -> Option { + self.retry_after_ms + } + + #[must_use] + pub const fn usage(&self) -> &Usage { + &self.usage + } + + #[must_use] + pub const fn cost(&self) -> &CostEstimate { + &self.cost + } + + #[must_use] + pub const fn timings(&self) -> &ModelRequestTimings { + &self.timings + } + + #[must_use] + pub const fn completed_at(&self) -> SystemTime { + self.completed_at + } +} + +/// 一个发布代次的不可变请求观察计划;实现必须自行保证派发有界且不阻塞业务结果。 +pub trait RequestObserverPlan: Send + Sync { + /// 旁路观察一条实际上游 WebSocket 事件;未启用观察时,默认实现不额外处理事件。 + fn dispatch_websocket_response( + &self, + _generation: ExtensionSetReference, + _observation: WebSocketResponseObservation, + ) { + } + + /// `generation` 必须由后台工作持有到本次派发结束,避免旧代次提前排空。 + fn dispatch(&self, generation: ExtensionSetReference, observation: RequestObservation); +} + +/// 观察计划注册冲突;同一个集合 ID 不能被静默替换。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("request observer generation is already registered")] +pub struct RequestObserverRegistrationError; + +/// 按发布集合解析计划的非拥有索引;旧代次由发布视图和在途派发保活。 +#[derive(Clone, Default)] +pub struct RequestObserverExtensionIndex { + sets: Arc>>>, +} + +impl RequestObserverExtensionIndex { + /// 注册候选代次;返回值必须由候选集合强持有。 + pub fn register( + &self, + id: ExtensionSetId, + plan: Arc, + ) -> Result, RequestObserverRegistrationError> { + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, plan| plan.strong_count() > 0); + if sets.contains_key(&id) { + return Err(RequestObserverRegistrationError); + } + sets.insert(id, Arc::downgrade(&plan)); + Ok(plan) + } + + /// 只解析请求已经冻结的集合;缺少计划表示该代次没有观察绑定。 + #[must_use] + pub fn resolve( + &self, + generation: &ExtensionSetReference, + ) -> Option> { + self.sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(generation.id()) + .and_then(Weak::upgrade) + } +} + +/// 同一请求的所有终结分支共享这个一次性派发状态。 +#[derive(Clone)] +pub(super) struct RequestObservationDispatch { + state: Arc, +} + +struct RequestObservationDispatchState { + plan: Arc, + generation: ExtensionSetReference, + dispatched: AtomicBool, + context: FrozenRequestObservationContext, +} + +pub(super) struct FrozenRequestObservationContext { + request_id: ModelRequestId, + config_revision: ConfigRevision, + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, + operation: OperationKind, + requested_model: Option, + extension_scope: ExtensionCallScope, +} + +impl FrozenRequestObservationContext { + #[must_use] + pub(super) fn new( + request_id: ModelRequestId, + config_revision: ConfigRevision, + client_key_id: ClientApiKeyId, + mut account_group_ids: Vec, + operation: OperationKind, + requested_model: Option, + extension_scope: ExtensionCallScope, + ) -> Self { + account_group_ids.sort(); + account_group_ids.dedup(); + Self { + request_id, + config_revision, + client_key_id, + account_group_ids: account_group_ids.into(), + operation, + requested_model, + extension_scope, + } + } +} + +impl RequestObservationDispatch { + #[must_use] + pub(super) fn new( + plan: Arc, + generation: ExtensionSetReference, + context: FrozenRequestObservationContext, + ) -> Self { + Self { + state: Arc::new(RequestObservationDispatchState { + plan, + generation, + dispatched: AtomicBool::new(false), + context, + }), + } + } + + pub(super) fn reject(&self, error: &GatewayError) { + self.dispatch(RequestObservation { + event_id: self.event_id(), + request_id: self.state.context.request_id.clone(), + config_revision: self.state.context.config_revision, + client_key_id: Some(self.state.context.client_key_id.clone()), + account_group_ids: Arc::clone(&self.state.context.account_group_ids), + extension_scope: self.state.context.extension_scope.clone(), + operation: self.state.context.operation, + requested_model: self.state.context.requested_model.clone(), + provider: None, + outcome: RequestObservationOutcome::Rejected, + account_id: None, + upstream_model: None, + response_model: None, + service_tier: None, + send_state: UpstreamSendState::NotSent, + attempt_count: 0, + client_status_code: None, + upstream_status_code: None, + error_kind: Some(error.kind()), + retry_after_ms: error.retry_after().map(duration_millis), + usage: Usage::new(), + cost: CostEstimate::unavailable(), + timings: ModelRequestTimings::default(), + completed_at: SystemTime::now(), + }); + } + + pub(super) fn websocket_response( + &self, + attempt: WebSocketResponseAttempt, + sequence: u64, + wire: ProtocolWireEvent, + ) { + let mut observation = WebSocketResponseObservation::new( + self.state.context.request_id.clone(), + self.state.context.config_revision, + self.state.context.operation, + attempt, + sequence, + wire, + ) + .with_client_scope( + self.state.context.client_key_id.clone(), + self.state.context.account_group_ids.to_vec(), + ) + .with_extension_scope(self.state.context.extension_scope.clone()); + if let Some(model) = self.state.context.requested_model.clone() { + observation = observation.with_requested_model(model); + } + self.state + .plan + .dispatch_websocket_response(self.state.generation.clone(), observation); + } + + #[must_use] + pub(super) fn finalization( + &self, + finalization: &ModelRequestFinalization, + provider: Option, + attempt: Option<&super::provider::ProviderCallMetadata>, + ) -> RequestObservation { + let policy_rejected = finalization.send_state == UpstreamSendState::NotSent + && finalization + .error + .as_ref() + .is_some_and(|error| error.kind() == GatewayErrorKind::PolicyDenied); + let outcome = match finalization.outcome { + ExecutionOutcome::Succeeded => RequestObservationOutcome::Succeeded, + ExecutionOutcome::Failed | ExecutionOutcome::Running if policy_rejected => { + RequestObservationOutcome::Rejected + } + ExecutionOutcome::Failed | ExecutionOutcome::Running => { + RequestObservationOutcome::Failed + } + ExecutionOutcome::Cancelled => RequestObservationOutcome::Cancelled, + ExecutionOutcome::Incomplete => RequestObservationOutcome::Incomplete, + }; + RequestObservation { + event_id: self.event_id(), + request_id: self.state.context.request_id.clone(), + config_revision: self.state.context.config_revision, + client_key_id: Some(self.state.context.client_key_id.clone()), + account_group_ids: Arc::clone(&self.state.context.account_group_ids), + extension_scope: self.state.context.extension_scope.clone(), + operation: self.state.context.operation, + requested_model: self.state.context.requested_model.clone(), + provider, + account_id: attempt.map(|attempt| attempt.provider_account_id().clone()), + upstream_model: attempt.and_then(|attempt| attempt.upstream_model().cloned()), + response_model: finalization.upstream_response_model.clone(), + service_tier: finalization.service_tier.clone(), + outcome, + send_state: finalization.send_state, + attempt_count: finalization.attempt_count, + client_status_code: finalization.client_status_code, + upstream_status_code: finalization.upstream_status_code, + error_kind: finalization.error.as_ref().map(GatewayError::kind), + retry_after_ms: finalization.retry_after_ms, + usage: finalization.usage.clone(), + cost: finalization.cost.clone(), + timings: finalization.timings.clone(), + completed_at: finalization.completed_at, + } + } + + pub(super) fn dispatch(&self, observation: RequestObservation) { + if self + .state + .dispatched + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + self.state + .plan + .dispatch(self.state.generation.clone(), observation); + } + + fn event_id(&self) -> String { + format!("{}:terminal", self.state.context.request_id.as_str()) + } +} + +fn duration_millis(duration: std::time::Duration) -> u64 { + duration.as_millis().try_into().unwrap_or(u64::MAX) +} pub(super) struct ResponseObservation { pub(super) timing_started_at: Instant, diff --git a/backend/crates/gateway-core/src/engine/policy.rs b/backend/crates/gateway-core/src/engine/policy.rs new file mode 100644 index 000000000..1c487af6d --- /dev/null +++ b/backend/crates/gateway-core/src/engine/policy.rs @@ -0,0 +1,462 @@ +//! 请求级插件路由与账号调度计划;Core 保留目标、资格和租约复核。 + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, + num::NonZeroU32, + sync::{Arc, RwLock, Weak}, + time::SystemTime, +}; + +use futures::future::BoxFuture; + +use crate::{ + account::{ + AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, + ProviderAccountId, + }, + engine::{extensions::ExtensionCallScope, nested::ExecutionEffects}, + identity::ProviderKind, + operation::Operation, + policy::ClientApiKeyId, + routing::{AccountGroupId, PublicModelId}, + runtime::extensions::{ExtensionSetId, ExtensionSetReference}, +}; + +/// 模型路由插件的一次输入;正文仍保存在 `operation` 中,由 Runtime 按权限投影。 +#[derive(Clone)] +pub struct ModelRouteInput { + request_id: super::ModelRequestId, + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, + extension_scope: ExtensionCallScope, + operation: Operation, + requested_model: PublicModelId, + available_providers: BTreeSet, + execution_effects: Arc, +} + +impl ModelRouteInput { + #[must_use] + pub const fn request_id(&self) -> &super::ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn client_key_id(&self) -> &ClientApiKeyId { + &self.client_key_id + } + + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub fn suppresses_plugin(&self, instance_id: &str) -> bool { + self.extension_scope.contains(instance_id) + } + + #[must_use] + pub const fn operation(&self) -> &Operation { + &self.operation + } + + #[must_use] + pub const fn requested_model(&self) -> &PublicModelId { + &self.requested_model + } + + #[must_use] + pub const fn available_providers(&self) -> &BTreeSet { + &self.available_providers + } + + /// 只供受信 Runtime 记录本次策略调用已经产生的外部副作用,不进入插件 wire。 + #[must_use] + pub fn execution_effects(&self) -> Arc { + Arc::clone(&self.execution_effects) + } +} + +impl fmt::Debug for ModelRouteInput { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ModelRouteInput") + .field("request_id", &self.request_id) + .field("client_key_id", &self.client_key_id) + .field("account_group_ids", &self.account_group_ids) + .field("operation", &self.operation.kind()) + .field("requested_model", &self.requested_model) + .field("available_providers", &self.available_providers) + .finish() + } +} + +/// 模型路由结果;Runtime 区分未处理、明确拒绝和调用故障。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModelRouteDecision { + Unhandled, + Route { + provider: Option, + model: Option, + }, + Reject, +} + +/// 账号调度插件可见的单个候选事实,不包含凭据或账号资料。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountScheduleCandidate { + account_id: ProviderAccountId, + weight: u16, + in_flight: u32, + maximum_concurrency: u32, + last_started_at: Option, + quota_reset_at: Option, + quota_remaining_rank: Option, + failure_rate_basis_points: Option, + first_output_latency_ms: Option, +} + +impl AccountScheduleCandidate { + #[must_use] + pub const fn account_id(&self) -> &ProviderAccountId { + &self.account_id + } + + #[must_use] + pub const fn weight(&self) -> u16 { + self.weight + } + + #[must_use] + pub const fn in_flight(&self) -> u32 { + self.in_flight + } + + #[must_use] + pub const fn maximum_concurrency(&self) -> u32 { + self.maximum_concurrency + } + + #[must_use] + pub const fn last_started_at(&self) -> Option { + self.last_started_at + } + + #[must_use] + pub const fn quota_reset_at(&self) -> Option { + self.quota_reset_at + } + + #[must_use] + pub const fn quota_remaining_rank(&self) -> Option { + self.quota_remaining_rank + } + + #[must_use] + pub const fn failure_rate_basis_points(&self) -> Option { + self.failure_rate_basis_points + } + + #[must_use] + pub const fn first_output_latency_ms(&self) -> Option { + self.first_output_latency_ms + } +} + +/// 账号调度输入;Key 与账号组仅用于 Runtime 匹配绑定,不发送给插件。 +#[derive(Debug, Clone)] +pub struct AccountScheduleInput { + request_id: super::ModelRequestId, + attempt_index: NonZeroU32, + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, + extension_scope: ExtensionCallScope, + provider: ProviderKind, + model: Option, + candidates: Vec, + execution_effects: Arc, +} + +impl AccountScheduleInput { + #[must_use] + pub const fn request_id(&self) -> &super::ModelRequestId { + &self.request_id + } + + #[must_use] + pub const fn attempt_index(&self) -> NonZeroU32 { + self.attempt_index + } + + #[must_use] + pub const fn client_key_id(&self) -> &ClientApiKeyId { + &self.client_key_id + } + + #[must_use] + pub fn account_group_ids(&self) -> &[AccountGroupId] { + &self.account_group_ids + } + + #[must_use] + pub fn suppresses_plugin(&self, instance_id: &str) -> bool { + self.extension_scope.contains(instance_id) + } + + #[must_use] + pub const fn provider(&self) -> &ProviderKind { + &self.provider + } + + #[must_use] + pub fn model(&self) -> Option<&str> { + self.model.as_deref() + } + + #[must_use] + pub fn candidates(&self) -> &[AccountScheduleCandidate] { + &self.candidates + } + + /// 只供受信 Runtime 记录本次策略调用已经产生的外部副作用,不进入插件 wire。 + #[must_use] + pub fn execution_effects(&self) -> Arc { + Arc::clone(&self.execution_effects) + } +} + +/// 账号调度结果;插件选中的账号仍须通过 Core 资格复核并取得 Provider 租约。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountScheduleDecision { + Pick(ProviderAccountId), + Delegate, + Reject, +} + +/// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("request policy call failed")] +pub struct RequestPolicyFault; + +/// Provider 选择器需要区分明确拒绝、策略故障和等待期间过期的选择。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum AccountPolicyError { + #[error("account scheduling policy rejected the request")] + Rejected, + #[error("account scheduling policy failed")] + Fault, + #[error("account scheduling candidate became stale")] + StaleCandidate, +} + +/// 一个发布代次的不可变请求策略计划。 +pub trait RequestPolicyPlan: Send + Sync + fmt::Debug { + fn route_model( + &self, + input: ModelRouteInput, + ) -> BoxFuture<'static, Result>; + + fn schedule_account( + &self, + input: AccountScheduleInput, + ) -> BoxFuture<'static, Result>; +} + +/// 同一次请求冻结的策略计划和授权身份;完整集合引用保活所有 RPC 会话。 +#[derive(Clone)] +pub struct RequestPolicyContext { + plan: Arc, + _generation: ExtensionSetReference, + request_id: super::ModelRequestId, + client_key_id: ClientApiKeyId, + account_group_ids: Arc<[AccountGroupId]>, + extension_scope: ExtensionCallScope, + execution_effects: Arc, +} + +impl fmt::Debug for RequestPolicyContext { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RequestPolicyContext") + .field("request_id", &self.request_id) + .field("client_key_id", &self.client_key_id) + .field("account_group_ids", &self.account_group_ids) + .finish_non_exhaustive() + } +} + +impl RequestPolicyContext { + #[must_use] + pub fn new( + plan: Arc, + generation: ExtensionSetReference, + request_id: super::ModelRequestId, + client_key_id: ClientApiKeyId, + mut account_group_ids: Vec, + ) -> Self { + account_group_ids.sort(); + account_group_ids.dedup(); + Self { + plan, + _generation: generation, + request_id, + client_key_id, + account_group_ids: account_group_ids.into(), + extension_scope: ExtensionCallScope::default(), + execution_effects: Arc::new(ExecutionEffects::default()), + } + } + + /// 附着父子调用图;计划必须跳过集合中的实例,防止直接或间接重入。 + #[must_use] + pub fn with_extension_scope(mut self, extension_scope: ExtensionCallScope) -> Self { + self.extension_scope = extension_scope; + self + } + + #[must_use] + pub(crate) fn with_execution_effects(mut self, effects: Arc) -> Self { + self.execution_effects = effects; + self + } + + #[must_use] + pub fn extension_scope(&self) -> &ExtensionCallScope { + &self.extension_scope + } + + pub async fn route_model( + &self, + operation: Operation, + requested_model: PublicModelId, + available_providers: BTreeSet, + ) -> Result { + self.plan + .route_model(ModelRouteInput { + request_id: self.request_id.clone(), + client_key_id: self.client_key_id.clone(), + account_group_ids: Arc::clone(&self.account_group_ids), + extension_scope: self.extension_scope.clone(), + operation, + requested_model, + available_providers, + execution_effects: Arc::clone(&self.execution_effects), + }) + .await + } + + /// 硬绑定已在候选资格中收窄;插件先决定,委托时再采用内置亲和与排序。 + pub async fn select_account<'a>( + &self, + attempt_index: NonZeroU32, + provider: &ProviderKind, + model: Option<&str>, + candidates: &'a [AccountCandidate], + context: &AccountSelectionContext, + ) -> Result>, AccountPolicyError> { + let eligible = AccountSelector.policy_candidates(candidates, context); + if eligible.is_empty() { + return Ok(None); + } + let policy = context.policy; + let projected = eligible + .iter() + .map(|candidate| AccountScheduleCandidate { + account_id: candidate.account.id().clone(), + weight: candidate.account.weight().get(), + in_flight: candidate.signals.in_flight, + maximum_concurrency: candidate + .account + .effective_concurrency(policy.max_concurrent_per_account()) + .get(), + last_started_at: candidate.signals.last_started_at, + quota_reset_at: candidate.signals.quota_reset_at, + quota_remaining_rank: candidate.signals.quota_remaining_rank, + failure_rate_basis_points: candidate.signals.failure_rate_basis_points, + first_output_latency_ms: candidate.signals.first_output_latency_ms, + }) + .collect(); + let decision = self + .plan + .schedule_account(AccountScheduleInput { + request_id: self.request_id.clone(), + attempt_index, + client_key_id: self.client_key_id.clone(), + account_group_ids: Arc::clone(&self.account_group_ids), + extension_scope: self.extension_scope.clone(), + provider: provider.clone(), + model: model.map(str::to_owned), + candidates: projected, + execution_effects: Arc::clone(&self.execution_effects), + }) + .await + .map_err(|_| AccountPolicyError::Fault)?; + // 插件调用可能消耗显著时间;无论插件选择还是委托,均用返回时刻重新执行 + // 同一资格判断,不能让调用前尚未过期的候选在调用后被内置策略选中。 + let mut current = context.clone(); + current.now = SystemTime::now(); + match decision { + AccountScheduleDecision::Delegate => AccountSelector + .select(candidates, ¤t) + .map(Some) + .ok_or(AccountPolicyError::StaleCandidate), + AccountScheduleDecision::Reject => Err(AccountPolicyError::Rejected), + AccountScheduleDecision::Pick(account_id) => { + if !eligible + .iter() + .any(|candidate| candidate.account.id() == &account_id) + { + return Err(AccountPolicyError::Fault); + } + AccountSelector + .select_policy_candidate(candidates, ¤t, &account_id) + .map(Some) + .ok_or(AccountPolicyError::StaleCandidate) + } + } + } +} + +/// 策略计划注册冲突;同一代次不能被静默替换。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("request policy generation is already registered")] +pub struct RequestPolicyRegistrationError; + +/// 按发布集合解析策略计划的非拥有索引。 +#[derive(Clone, Default)] +pub struct RequestPolicyExtensionIndex { + sets: Arc>>>, +} + +impl RequestPolicyExtensionIndex { + pub fn register( + &self, + id: ExtensionSetId, + plan: Arc, + ) -> Result, RequestPolicyRegistrationError> { + let mut sets = self + .sets + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + sets.retain(|_, plan| plan.strong_count() > 0); + if sets.contains_key(&id) { + return Err(RequestPolicyRegistrationError); + } + sets.insert(id, Arc::downgrade(&plan)); + Ok(plan) + } + + #[must_use] + pub fn resolve( + &self, + generation: &ExtensionSetReference, + ) -> Option> { + self.sets + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(generation.id()) + .and_then(Weak::upgrade) + } +} diff --git a/backend/crates/gateway-core/src/engine/probe.rs b/backend/crates/gateway-core/src/engine/probe.rs index 6980a2960..6e434978c 100644 --- a/backend/crates/gateway-core/src/engine/probe.rs +++ b/backend/crates/gateway-core/src/engine/probe.rs @@ -162,8 +162,10 @@ impl From for AccountProbeError { } pub trait AccountProbe: Send + Sync { + /// 插件管理操作传入其冻结快照;未绑定动态 Provider 的调用可使用当前发布视图。 fn probe( &self, request: AccountProbeRequest, + snapshot: Option>, ) -> BoxFuture<'_, Result>; } diff --git a/backend/crates/gateway-core/src/engine/provider.rs b/backend/crates/gateway-core/src/engine/provider.rs index 5bb50ea02..b0e0221cd 100644 --- a/backend/crates/gateway-core/src/engine/provider.rs +++ b/backend/crates/gateway-core/src/engine/provider.rs @@ -1,6 +1,6 @@ //! Provider 的唯一动态执行边界。 -use std::collections::BTreeMap; +use std::collections::{BTreeMap, VecDeque}; use std::fmt; use std::pin::Pin; use std::sync::Arc; @@ -8,15 +8,21 @@ use std::task::{Context, Poll}; use std::time::Instant; use async_trait::async_trait; -use futures::{Stream, future::BoxFuture}; +use bytes::Bytes; +use futures::{Stream, StreamExt, future::BoxFuture}; use thiserror::Error; use crate::account::{ AccountAttemptFeedback, AccountCapacitySnapshot, AccountFeedbackStats, ProviderAccountId, }; use crate::engine::AttemptContext; +use crate::engine::execution::ClientTransport; +use crate::engine::middleware::{ + MiddlewareBody, MiddlewareContext, MiddlewareError, MiddlewareFrame, MiddlewareFraming, + MiddlewareHeader, MiddlewareNext, MiddlewareRequest, MiddlewareResponse, +}; use crate::error::{PreDeliveryRetry, ProviderError, ProviderErrorKind}; -use crate::event::{EventSequenceValidator, ProviderEvent}; +use crate::event::{EventSequenceValidator, GatewayEvent, ProtocolWireEvent, ProviderEvent}; use crate::identity::ProviderKind; use crate::operation::Operation; use crate::policy::ClientApiKeyId; @@ -176,6 +182,30 @@ pub trait ResourceLease: Send + Sync + 'static {} impl ResourceLease for T where T: Send + Sync + 'static {} +/// Provider 原生响应格式到统一响应格式的有状态转换边界。 +/// +/// Core 只负责调用顺序、格式与终态约束,不解释协议正文。实现必须由一次 +/// [`ProviderStream`] 独占,不能在并发请求间共享可变转换状态。 +pub trait NativeResponseTranslator: Send + 'static { + /// 转换前协议格式。 + fn source_protocol(&self) -> &str; + + /// 转换后协议格式。 + fn target_protocol(&self) -> &str; + + /// 把一个原生响应事件转换为零至多个统一响应事件。 + /// + /// # Errors + /// + /// 原生协议事件无效或转换状态不一致时返回已发送的 Provider 错误。 + fn translate( + &mut self, + event: &crate::event::ProtocolWireEvent, + ) -> Result, ProviderError>; +} + +const MAX_NATIVE_RESPONSE_EVENTS_PER_INPUT: usize = 64; + /// Metadata、canonical event stream 与 owned lease 的统一返回值。 /// /// 底层 stream 必须是 cold stream:在第一次 poll 前不得发送请求级 handshake @@ -184,6 +214,7 @@ pub struct ProviderStream { metadata: ProviderCallMetadata, events: EventStream, _lease: Box, + native_response_translator: Option>, account_feedback: Option, validator: EventSequenceValidator, strict_canonical_seen: bool, @@ -290,6 +321,7 @@ impl ProviderStream { metadata, events: Box::pin(events), _lease: Box::new(lease), + native_response_translator: None, account_feedback: None, validator: EventSequenceValidator::new(), strict_canonical_seen: false, @@ -315,6 +347,80 @@ impl ProviderStream { self } + /// 在原生响应加工的两个策略阶段之间安装 Provider 自有转换器。 + #[must_use] + pub fn with_native_response_translator( + mut self, + translator: impl NativeResponseTranslator, + ) -> Self { + self.native_response_translator = Some(Box::new(translator)); + self + } + + /// 返回本次 stream 是否需要原生响应转换。 + #[must_use] + pub const fn has_native_response_translator(&self) -> bool { + self.native_response_translator.is_some() + } + + /// 在 Core 已记录原始事实且完成 `BeforeTranslation` 后执行原生转换。 + /// + /// # Errors + /// + /// source/target 格式不一致、单事件展开越界、终态被丢弃或 Provider 转换失败时 + /// 返回错误。 + pub fn translate_native_response( + &mut self, + mut event: ProviderEvent, + terminal: bool, + ) -> Result, ProviderError> { + let Some(translator) = self.native_response_translator.as_mut() else { + return Ok(vec![event]); + }; + let middleware_transformed = event.middleware_transformed(); + let Some(wire) = event.wire_event() else { + return Ok(vec![event]); + }; + if wire.protocol() != translator.source_protocol() { + return Err(native_response_protocol_error()); + } + let mut translated = translator.translate(wire)?; + if translated.len() > MAX_NATIVE_RESPONSE_EVENTS_PER_INPUT + || (terminal && translated.is_empty()) + || translated + .iter() + .any(|wire| wire.protocol() != translator.target_protocol()) + { + return Err(native_response_protocol_error()); + } + let Some(last) = translated.pop() else { + // wire 转换器可以吞掉纯协议结构事件,但不能连带丢失 Core 已经 + // 识别出的 usage 等 canonical facts。facts-only 封套仍需交给 + // 客户端 adapter;没有 facts 时才是真正的零输出。 + event.replace_wire(None); + return Ok(event + .has_canonical_facts() + .then_some(event) + .into_iter() + .collect()); + }; + // 一对多时把 canonical/observation/session 信封放到最后一个 wire;这样 + // `Completed` 不会在同一源事件的前置输出交付前提前终结 Coordinator。 + let mut events = translated + .into_iter() + .map(|wire| { + let mut translated_event = ProviderEvent::wire(wire); + if middleware_transformed { + translated_event.inherit_middleware_provenance(&event); + } + translated_event + }) + .collect::>(); + event.replace_wire(Some(last)); + events.push(event); + Ok(events) + } + fn set_account_feedback( &mut self, stats: Arc, @@ -336,6 +442,401 @@ impl ProviderStream { pub const fn metadata(&self) -> &ProviderCallMetadata { &self.metadata } + + fn middleware_protocol(&self, fallback: &str) -> String { + self.native_response_translator.as_ref().map_or_else( + || fallback.to_owned(), + |translator| translator.target_protocol().to_owned(), + ) + } +} + +/// Attempt 中间件消费一次后建立 cold Provider stream 的 owned terminal。 +pub type ProviderMiddlewareTerminal = Box< + dyn FnOnce( + Operation, + Vec, + ) -> BoxFuture<'static, Result> + + Send + + 'static, +>; + +const MAX_ATTEMPT_HEADERS: usize = 128; +const MAX_ATTEMPT_HEADER_NAME_BYTES: usize = 128; +const MAX_ATTEMPT_HEADER_VALUE_BYTES: usize = 16 * 1024; +const MAX_ATTEMPT_HEADER_TOTAL_BYTES: usize = 64 * 1024; + +struct ProviderMiddlewareNext { + operation: Operation, + transport: ClientTransport, + terminal: Option, +} + +impl MiddlewareNext for ProviderMiddlewareNext { + fn run( + mut self: Box, + request: MiddlewareRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + let fallback_protocol = self.operation.protocol().to_owned(); + let (protocol, headers, body) = request.into_parts(); + validate_attempt_headers(&headers)?; + let operation = self + .operation + .replace_middleware_wire(protocol, body) + .map_err(|_| MiddlewareError::InvalidState)?; + let terminal = self.terminal.take().ok_or(MiddlewareError::InvalidState)?; + let stream = terminal(operation, headers) + .await + .map_err(MiddlewareError::Provider)?; + let metadata = stream.metadata().clone(); + let protocol = stream.middleware_protocol(&fallback_protocol); + let body = ProviderMiddlewareBody { + stream, + pending: VecDeque::new(), + transport: self.transport, + }; + Ok( + MiddlewareResponse::new(protocol, 200, Vec::new(), Box::new(body)) + .with_provider_metadata(metadata), + ) + }) + } +} + +fn validate_attempt_headers(headers: &[MiddlewareHeader]) -> Result<(), MiddlewareError> { + if headers.len() > MAX_ATTEMPT_HEADERS { + return Err(MiddlewareError::InvalidState); + } + let mut total = 0_usize; + for header in headers { + let name = header.name(); + let normalized = name.to_ascii_lowercase(); + if name.is_empty() + || name.len() > MAX_ATTEMPT_HEADER_NAME_BYTES + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) + || header.value().len() > MAX_ATTEMPT_HEADER_VALUE_BYTES + || header + .value() + .iter() + .any(|byte| *byte != b'\t' && (*byte < b' ' || *byte == 0x7f)) + || attempt_header_is_protected(&normalized) + { + return Err(MiddlewareError::InvalidState); + } + total = total + .checked_add(name.len()) + .and_then(|value| value.checked_add(header.value().len())) + .ok_or(MiddlewareError::InvalidState)?; + if total > MAX_ATTEMPT_HEADER_TOTAL_BYTES { + return Err(MiddlewareError::InvalidState); + } + } + Ok(()) +} + +fn attempt_header_is_protected(name: &str) -> bool { + name.contains("auth") + || name.contains("credential") + || name.contains("secret") + || name.contains("token") + || name.contains("cookie") + || name.contains("session") + || name.contains("conversation") + || name.contains("thread") + || name.contains("account") + || name.contains("organization") + || name.contains("project") + || name.contains("tenant") + || name.contains("principal") + || name.contains("identity") + || name.contains("user-id") + || name.ends_with("-key") + || name.ends_with("_key") + || name.starts_with("sec-websocket-") + || matches!( + name, + "connection" + | "keep-alive" + | "proxy-connection" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "host" + | "content-length" + | "content-type" + | "content-encoding" + | "accept" + | "accept-encoding" + | "user-agent" + | "x-request-id" + | "x-gateway-request-id" + ) +} + +struct ProviderMiddlewareBody { + stream: ProviderStream, + pending: VecDeque<(ProviderEvent, bool)>, + transport: ClientTransport, +} + +impl MiddlewareBody for ProviderMiddlewareBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + loop { + if let Some((event, terminal)) = self.pending.pop_front() { + return provider_event_to_middleware_frame(event, self.transport, terminal) + .map(Some) + .map_err(MiddlewareError::Provider); + } + let Some(event) = self.stream.next().await else { + return Ok(None); + }; + let event = event.map_err(MiddlewareError::Provider)?; + let terminal = event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))); + let translated = self + .stream + .translate_native_response(event, terminal) + .map_err(MiddlewareError::Provider)?; + let last = translated.len().saturating_sub(1); + self.pending.extend( + translated + .into_iter() + .enumerate() + .map(|(index, event)| (event, terminal && index == last)), + ); + } + }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { drop(self) }) + } +} + +fn provider_event_to_middleware_frame( + event: ProviderEvent, + transport: ClientTransport, + terminal: bool, +) -> Result { + let Some(wire) = event.wire_event() else { + return Ok(MiddlewareFrame::from_provider_event( + Bytes::new(), + MiddlewareFraming::RawBytes, + terminal, + event, + )); + }; + let (bytes, framing) = match transport { + ClientTransport::HttpSse => ( + wire.raw_sse_frame() + .cloned() + .map_or_else(|| encode_sse_wire_event(wire), Ok)?, + MiddlewareFraming::SseEvent, + ), + ClientTransport::HttpJson => ( + wire.raw_json_body() + .or_else(|| wire.raw_http_body_bytes()) + .cloned() + .map_or_else(|| serde_json::to_vec(wire.data()).map(Bytes::from), Ok) + .map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?, + MiddlewareFraming::JsonDocument, + ), + ClientTransport::WebSocket + | ClientTransport::InternalProbe + | ClientTransport::InternalPlugin => ( + serde_json::to_vec(wire.data()) + .map(Bytes::from) + .map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?, + MiddlewareFraming::JsonDocument, + ), + }; + Ok(MiddlewareFrame::from_provider_event( + bytes, framing, terminal, event, + )) +} + +fn encode_sse_wire_event(wire: &ProtocolWireEvent) -> Result { + let mut encoded = Vec::new(); + if let Some(id) = wire.sse_id() { + encoded.extend_from_slice(b"id: "); + encoded.extend_from_slice(id.as_bytes()); + encoded.push(b'\n'); + } + if let Some(retry) = wire.sse_retry() { + encoded.extend_from_slice(format!("retry: {retry}\n").as_bytes()); + } + if let Some(event_type) = wire.event_type() { + encoded.extend_from_slice(b"event: "); + encoded.extend_from_slice(event_type.as_bytes()); + encoded.push(b'\n'); + } + encoded.extend_from_slice(b"data: "); + serde_json::to_writer(&mut encoded, wire.data()) + .map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?; + encoded.extend_from_slice(b"\n\n"); + Ok(Bytes::from(encoded)) +} + +/// 执行每次 retry 都重新建立的 attempt 中间件链。 +pub async fn execute_attempt_middleware( + plan: Option<&crate::engine::middleware::FrozenMiddlewarePlan>, + context: MiddlewareContext, + operation: Operation, + transport: ClientTransport, + terminal: ProviderMiddlewareTerminal, +) -> Result { + let Some(plan) = plan else { + return terminal(operation, Vec::new()).await; + }; + let request = MiddlewareRequest::new( + operation.protocol(), + Vec::::new(), + operation + .middleware_body() + .map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?, + ); + let response = plan + .handle( + context, + request, + Box::new(ProviderMiddlewareNext { + operation, + transport, + terminal: Some(terminal), + }), + ) + .await + .map_err(middleware_prepare_error)?; + let (protocol, status_code, headers, body, envelope) = response.into_parts(); + if status_code != 200 || !headers.is_empty() { + return Err(middleware_protocol_error(UpstreamSendState::NotSent)); + } + crate::validation::validate_text(&protocol, 64, true, None) + .map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?; + let metadata = envelope + .ok_or_else(|| middleware_protocol_error(UpstreamSendState::NotSent))? + .into_provider_metadata(); + let events = futures::stream::try_unfold((body, protocol), |(mut body, protocol)| async move { + let Some(frame) = body.next_frame().await.map_err(middleware_body_error)? else { + return Ok(None); + }; + let event = middleware_frame_to_provider_event(frame, &protocol)?; + Ok(Some((event, (body, protocol)))) + }); + Ok(ProviderStream::new(metadata, events, ())) +} + +fn middleware_frame_to_provider_event( + frame: MiddlewareFrame, + protocol: &str, +) -> Result { + let transformed = frame.transformed(); + let (bytes, framing, _, mut envelope) = frame.into_provider_parts(); + // 透传不能把已解析的事件降格成 raw bytes,否则 Responses 会丢失终态和 WS 帧。 + // 同时核对正文,避免进程内中间件漏标 transformed 时忽略了实际改写。 + if let Some(event) = envelope.as_ref() + && let Some(wire) = event + .wire_event() + .filter(|wire| wire.protocol() == protocol) + { + let original = match framing { + MiddlewareFraming::JsonDocument => wire + .raw_json_body() + .or_else(|| wire.raw_http_body_bytes()) + .cloned() + .or_else(|| serde_json::to_vec(wire.data()).ok().map(Bytes::from)), + MiddlewareFraming::SseEvent => wire + .raw_sse_frame() + .cloned() + .or_else(|| encode_sse_wire_event(wire).ok()), + MiddlewareFraming::RawBytes => wire.raw_http_body_bytes().cloned(), + }; + if original.as_ref() == Some(&bytes) + && let Some(mut event) = envelope.take() + { + if transformed { + event.mark_middleware_transformed(); + } + return Ok(event); + } + } + let wire = if bytes.is_empty() { + None + } else { + Some( + match framing { + MiddlewareFraming::JsonDocument => ProtocolWireEvent::raw_json(protocol, bytes), + MiddlewareFraming::SseEvent => ProtocolWireEvent::raw_sse(protocol, bytes), + MiddlewareFraming::RawBytes => ProtocolWireEvent::raw_http_body(protocol, bytes), + } + .map_err(|_| middleware_protocol_error(UpstreamSendState::Ambiguous))?, + ) + }; + if let Some(mut event) = envelope { + if transformed { + event.replace_middleware_wire(wire); + } else { + event.replace_wire(wire); + } + return Ok(event); + } + let mut event = wire + .map(ProviderEvent::wire) + .ok_or_else(|| middleware_protocol_error(UpstreamSendState::Ambiguous))?; + if transformed { + event.mark_middleware_transformed(); + } + Ok(event) +} + +fn middleware_prepare_error(error: MiddlewareError) -> ProviderError { + match error { + MiddlewareError::Provider(error) => error, + MiddlewareError::Rejected => ProviderError::new( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::NotSent, + ), + MiddlewareError::Fault + | MiddlewareError::InvalidState + | MiddlewareError::Gateway(_) + | MiddlewareError::Engine(_) => middleware_protocol_error(UpstreamSendState::NotSent), + } +} + +fn middleware_body_error(error: MiddlewareError) -> ProviderError { + match error { + MiddlewareError::Provider(error) => error, + MiddlewareError::Rejected => ProviderError::new( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::Ambiguous, + ), + MiddlewareError::Fault + | MiddlewareError::InvalidState + | MiddlewareError::Gateway(_) + | MiddlewareError::Engine(_) => middleware_protocol_error(UpstreamSendState::Ambiguous), + } +} + +fn middleware_protocol_error(send_state: UpstreamSendState) -> ProviderError { + ProviderError::new(ProviderErrorKind::Protocol, send_state) + .redact_sensitive_context("invalid middleware boundary") +} + +fn native_response_protocol_error() -> ProviderError { + ProviderError::new( + ProviderErrorKind::Protocol, + crate::upstream::UpstreamSendState::Sent, + ) + .redact_sensitive_context("invalid native response translation") } impl Stream for ProviderStream { @@ -491,8 +992,15 @@ pub trait Provider: Send + Sync { Ok(configuration.clone()) } - /// 返回编译期注册名称。 - fn name(&self) -> &'static str; + /// 没有持久选择时返回 Provider 的已准备默认画像;结果仍由 Core 按请求冻结。 + fn default_request_profile( + &self, + ) -> Result, ProviderError> { + Ok(None) + } + + /// 返回实例生命周期内稳定的注册名称。 + fn name(&self) -> &str; /// 返回当前进程已经成功发布的目录代次。 fn catalog_generation(&self) -> ProviderCatalogGeneration; @@ -545,7 +1053,7 @@ pub trait Provider: Send + Sync { /// 没有可用资源、请求无效或准备失败时返回 `NotSent` 错误; /// 可能已发送的失败必须通过 stream 返回,不得降级发送事实。 async fn execute( - &self, + self: Arc, request: ProviderRequest, context: AttemptContext, ) -> Result; @@ -554,6 +1062,10 @@ pub trait Provider: Send + Sync { /// Provider Registry 构建错误。 #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RegistryError { + #[error("provider extension generation is unavailable")] + GenerationUnavailable, + #[error("provider extension generation is already registered")] + DuplicateGeneration, /// Provider 名称无效。 #[error("invalid provider registry name")] InvalidName, @@ -601,17 +1113,41 @@ impl ProviderRegistryBuilder { pub fn build(self) -> ProviderRegistry { ProviderRegistry { providers: Arc::new(self.providers), + extensions: None, + lease: None, } } } -/// Bootstrap 后不可变的 Provider Registry。 +/// 不可变 Provider 视图;动态入口按请求冻结的集合解析。 #[derive(Clone, Default)] pub struct ProviderRegistry { - providers: Arc>>, + pub(super) providers: Arc>>, + pub(super) extensions: Option, + pub(super) lease: Option, } impl ProviderRegistry { + pub fn for_extensions( + &self, + reference: Option<&crate::runtime::extensions::ExtensionSetReference>, + ) -> Result { + let Some(index) = &self.extensions else { + return Ok(self.clone()); + }; + let reference = reference.ok_or(RegistryError::GenerationUnavailable)?; + if !reference.can_serve() { + return Err(RegistryError::GenerationUnavailable); + } + let mut registry = index + .resolve(reference.id()) + .ok_or(RegistryError::GenerationUnavailable)? + .as_ref() + .clone(); + registry.lease = Some(reference.clone()); + Ok(registry) + } + /// 从一组异构 Provider 构造冻结注册表。 pub fn new( providers: impl IntoIterator>, @@ -669,6 +1205,15 @@ impl ProviderRegistry { } impl ProviderCatalogPort for ProviderRegistry { + fn for_extensions( + &self, + reference: Option<&crate::runtime::extensions::ExtensionSetReference>, + ) -> Result>, ProviderCatalogUnavailable> { + self.for_extensions(reference) + .map(|registry| Some(Arc::new(registry) as Arc)) + .map_err(|_| ProviderCatalogUnavailable) + } + fn model_catalog_is_exhaustive(&self, provider: &ProviderKind) -> bool { self.providers .get(provider) diff --git a/backend/crates/gateway-core/src/error.rs b/backend/crates/gateway-core/src/error.rs index 7c8eb8876..5caedebfd 100644 --- a/backend/crates/gateway-core/src/error.rs +++ b/backend/crates/gateway-core/src/error.rs @@ -28,6 +28,8 @@ pub enum ProviderErrorKind { Unauthorized, /// Credential 没有权限。 PermissionDenied, + /// 冻结的请求策略在 Provider 发送前明确拒绝本次请求。 + RequestPolicyDenied, /// Provider 限流。 RateLimited, /// Credential 配额耗尽。 @@ -66,6 +68,7 @@ impl ProviderErrorKind { Self::Unsupported => "unsupported", Self::Unauthorized => "unauthorized", Self::PermissionDenied => "permission_denied", + Self::RequestPolicyDenied => "request_policy_denied", Self::RateLimited => "rate_limited", Self::QuotaExhausted => "quota_exhausted", Self::AccountCapacityUnavailable => "account_capacity_unavailable", @@ -1079,6 +1082,10 @@ impl GatewayError { GatewayErrorKind::UpstreamUnavailable, "upstream authentication resource is unavailable", ), + ProviderErrorKind::RequestPolicyDenied => Self::new( + GatewayErrorKind::PolicyDenied, + "request policy rejected the request", + ), ProviderErrorKind::RateLimited | ProviderErrorKind::QuotaExhausted => Self::new( GatewayErrorKind::RateLimited, "upstream capacity is temporarily unavailable", diff --git a/backend/crates/gateway-core/src/event.rs b/backend/crates/gateway-core/src/event.rs index 2b98272e9..d82d42f5f 100644 --- a/backend/crates/gateway-core/src/event.rs +++ b/backend/crates/gateway-core/src/event.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use std::fmt; +use std::sync::Arc; use bytes::Bytes; use serde_json::Value; @@ -202,6 +203,7 @@ pub struct ProtocolWireEvent { has_json_data: bool, raw_sse_frame: Option, raw_json_body: Option, + raw_http_body: Option, sse_id: Option, sse_retry: Option, } @@ -241,6 +243,7 @@ impl ProtocolWireEvent { has_json_data: true, raw_sse_frame: None, raw_json_body: None, + raw_http_body: None, sse_id, sse_retry, }) @@ -288,6 +291,7 @@ impl ProtocolWireEvent { has_json_data: false, raw_sse_frame: Some(raw_sse_frame), raw_json_body: None, + raw_http_body: None, sse_id: None, sse_retry: None, }) @@ -313,6 +317,31 @@ impl ProtocolWireEvent { has_json_data: false, raw_sse_frame: None, raw_json_body: Some(raw_json_body), + raw_http_body: None, + sse_id: None, + sse_retry: None, + }) + } + + /// 创建未经改写的 HTTP 响应正文片段;正文不要求为 JSON。 + /// + /// # Errors + /// + /// 协议名不满足内部路由标识约束时返回错误。 + pub fn raw_http_body( + protocol: impl Into, + raw_http_body: Bytes, + ) -> Result { + let protocol = protocol.into(); + validate_text(&protocol, 64, true, None)?; + Ok(Self { + protocol, + event_type: None, + data: Value::Null, + has_json_data: false, + raw_sse_frame: None, + raw_json_body: None, + raw_http_body: Some(raw_http_body), sse_id: None, sse_retry: None, }) @@ -372,6 +401,18 @@ impl ProtocolWireEvent { self.raw_json_body } + /// 返回可直接交付给 HTTP 客户端的任意响应正文片段。 + #[must_use] + pub const fn raw_http_body_bytes(&self) -> Option<&Bytes> { + self.raw_http_body.as_ref() + } + + /// 拆出未经改写的 HTTP 响应正文片段。 + #[must_use] + pub fn into_raw_http_body(self) -> Option { + self.raw_http_body + } + /// 拆出协议原生 JSON 数据。 #[must_use] pub fn into_data(self) -> Value { @@ -388,6 +429,7 @@ impl fmt::Debug for ProtocolWireEvent { .field("has_json_data", &self.has_json_data) .field("has_raw_sse_frame", &self.raw_sse_frame.is_some()) .field("has_raw_json_body", &self.raw_json_body.is_some()) + .field("has_raw_http_body", &self.raw_http_body.is_some()) .field("has_sse_id", &self.sse_id.is_some()) .field("sse_retry", &self.sse_retry) .field("data", &"") @@ -734,6 +776,8 @@ pub struct ProviderEvent { wire: Option>, observation: Option>, session_update: Option>, + middleware_transformed: bool, + middleware_origin_wire: Option>, } impl ProviderEvent { @@ -745,9 +789,18 @@ impl ProviderEvent { wire: None, observation: None, session_update: None, + middleware_transformed: false, + middleware_origin_wire: None, } } + /// 附加同一上游事件产生的业务事实,不重复生成协议输出。 + #[must_use] + pub fn with_fact(mut self, fact: GatewayEvent) -> Self { + self.canonical.push(fact); + self + } + /// 创建只有协议原生表达的事件。 #[must_use] pub fn wire(wire: ProtocolWireEvent) -> Self { @@ -756,6 +809,8 @@ impl ProviderEvent { wire: Some(Box::new(wire)), observation: None, session_update: None, + middleware_transformed: false, + middleware_origin_wire: None, } } @@ -767,6 +822,8 @@ impl ProviderEvent { wire: Some(Box::new(wire)), observation: None, session_update: None, + middleware_transformed: false, + middleware_origin_wire: None, } } @@ -778,9 +835,51 @@ impl ProviderEvent { wire: None, observation: Some(Box::new(observation)), session_update: None, + middleware_transformed: false, + middleware_origin_wire: None, } } + /// 返回协议表达是否由中间件替换、展开或丢弃映射产生。 + /// + /// 该事实只供宿主最终协议边界决定是否启用严格复核,不进入插件 wire。 + #[must_use] + pub const fn middleware_transformed(&self) -> bool { + self.middleware_transformed + } + + /// 返回中间件第一次改写前的协议表达;仅宿主协议复核可见。 + #[must_use] + pub fn middleware_origin_wire(&self) -> Option<&ProtocolWireEvent> { + self.middleware_origin_wire.as_deref() + } + + /// 记录宿主已经确认的中间件改写;后续原生编码和投递必须继续传播。 + pub(crate) fn mark_middleware_transformed(&mut self) { + self.middleware_transformed = true; + } + + /// 用中间件输出替换正文,同时冻结第一次改写前的原始 wire。 + pub(crate) fn replace_middleware_wire(&mut self, wire: Option) { + let previous = self.wire.take(); + if self.middleware_origin_wire.is_none() { + self.middleware_origin_wire = previous.map(Arc::from); + } + self.wire = wire.map(Box::new); + self.middleware_transformed = true; + } + + /// 一对多或内建协议转换生成的新封套继承同一份宿主 provenance。 + pub(crate) fn inherit_middleware_provenance(&mut self, source: &Self) { + self.middleware_transformed = source.middleware_transformed; + self.middleware_origin_wire = source.middleware_origin_wire.clone(); + } + + /// 把响应观察附在同一上游封套,不拆分或重复交付原生表达。 + pub fn attach_observation(&mut self, observation: ProviderResponseObservation) { + self.observation = Some(Box::new(observation)); + } + /// 附加 Provider 私有状态检查点;Core 可将其用于同请求恢复,协议连接可在 /// terminal 事件上持有它供下一轮使用。 pub fn attach_session_update(&mut self, state: ProviderSessionState) { @@ -811,6 +910,11 @@ impl ProviderEvent { self.wire.as_deref() } + /// 仅替换客户端协议表达;canonical facts、响应观察和会话状态保持原始事实。 + pub fn replace_wire(&mut self, wire: Option) { + self.wire = wire.map(Box::new); + } + /// 取出仅供 Core 持久化的响应观察。 #[must_use] pub fn take_observation(&mut self) -> Option { diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 1e65bcc7e..06e14e1eb 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -34,9 +34,11 @@ use engine::execution::{ ClientApiKeyUsageSink, ClientKeyVerifier, DefaultExecutionService, ExecutionService, ProviderCircuitPort, }; +use engine::nested::{AffinityLookupPort, NestedModelExecutionPort}; use engine::probe::AccountProbe; use engine::provider::ProviderRegistry; use health::HealthProbe; +use provider_ports::ProviderSessionAffinityPort; use routing::snapshot::{RuntimeSnapshotCompiler, SnapshotStorePort}; use runtime::{ RuntimeSnapshotHandle, RuntimeSnapshotPublisher, SnapshotControl, SnapshotSubscriptionPort, @@ -55,6 +57,7 @@ pub struct CoreStorePorts { snapshot_subscriptions: Arc, client_api_key_usage: Arc, budget: Option>, + session_affinity: Option>, } impl CoreStorePorts { @@ -83,6 +86,7 @@ impl CoreStorePorts { snapshot_subscriptions, client_api_key_usage, budget: None, + session_affinity: None, } } @@ -91,18 +95,139 @@ impl CoreStorePorts { self.budget = Some(budget); self } + + #[must_use] + pub fn with_session_affinity( + mut self, + session_affinity: Arc, + ) -> Self { + self.session_affinity = Some(session_affinity); + self + } } pub struct CoreBundle { + snapshots: RuntimeSnapshotHandle, execution: Arc, client_key_verifier: Arc, snapshot_control: Arc, account_probe: Arc, + nested_model_execution: Arc, + affinity_lookup: Arc, health_probes: Vec>, worker_contributions: Vec, } +/// 已构造但尚未恢复准入或发现 Provider 目录的数据面启动对象。 +pub struct CoreStartup { + ports: CoreStorePorts, + providers: ProviderRegistry, + request_observers: Option, + request_policies: Option, + middlewares: Option, + frontend_authentication: Option, + control: CoreControlPlaneStartup, +} + +impl CoreStartup { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.control.snapshots() + } + + #[must_use] + pub fn snapshot_control(&self) -> Arc { + self.control.snapshot_control() + } + + /// 绑定组合根依赖后执行 fail-closed 准入恢复与首次目录发现。 + pub async fn activate(self) -> Result { + restore_client_admission_startup( + self.ports.execution.as_ref(), + self.ports.admission_recovery.as_ref(), + self.ports.admissions.as_ref(), + SystemTime::now(), + ) + .await + .map_err(|_| CoreError::AdmissionRecoveryUnavailable)?; + let control = self.control.activate().await?; + let snapshots = control.snapshots; + let publisher = control.publisher; + let worker_contributions = publisher.worker_contributions()?; + let service = Arc::new(build_execution_service( + snapshots.clone(), + &self.ports, + self.providers, + self.request_observers, + self.request_policies, + self.middlewares, + self.frontend_authentication, + )); + let execution: Arc = service.clone(); + let client_key_verifier: Arc = service.clone(); + let account_probe: Arc = service.clone(); + let nested_model_execution: Arc = service.clone(); + let affinity_lookup: Arc = service; + let snapshot_control: Arc = publisher; + let health_probes: Vec> = vec![Arc::new(snapshots.clone())]; + Ok(CoreBundle { + snapshots, + execution, + client_key_verifier, + snapshot_control, + account_probe, + nested_model_execution, + affinity_lookup, + health_probes, + worker_contributions, + }) + } +} + +fn build_execution_service( + snapshots: RuntimeSnapshotHandle, + ports: &CoreStorePorts, + providers: ProviderRegistry, + request_observers: Option, + request_policies: Option, + middlewares: Option, + frontend_authentication: Option, +) -> DefaultExecutionService { + let mut service = DefaultExecutionService::new( + snapshots, + Arc::clone(&ports.execution), + providers, + Arc::clone(&ports.admissions), + Arc::clone(&ports.circuits), + Arc::clone(&ports.continuation), + Arc::clone(&ports.client_api_key_usage), + ); + if let Some(budget) = &ports.budget { + service = service.with_budget(Arc::clone(budget)); + } + if let Some(request_observers) = request_observers { + service = service.with_request_observers(request_observers); + } + if let Some(request_policies) = request_policies { + service = service.with_request_policies(request_policies); + } + if let Some(middlewares) = middlewares { + service = service.with_middlewares(middlewares); + } + if let Some(frontend_authentication) = frontend_authentication { + service = service.with_frontend_authentication(frontend_authentication); + } + if let Some(session_affinity) = &ports.session_affinity { + service = service.with_session_affinity(Arc::clone(session_affinity)); + } + service +} + impl CoreBundle { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.snapshots.clone() + } #[must_use] pub fn execution_service(&self) -> Arc { Arc::clone(&self.execution) @@ -123,6 +248,16 @@ impl CoreBundle { Arc::clone(&self.account_probe) } + #[must_use] + pub fn nested_model_execution_port(&self) -> Arc { + Arc::clone(&self.nested_model_execution) + } + + #[must_use] + pub fn affinity_lookup_port(&self) -> Arc { + Arc::clone(&self.affinity_lookup) + } + #[must_use] pub fn health_probes(&self) -> Vec> { self.health_probes.clone() @@ -137,56 +272,224 @@ impl CoreBundle { pub async fn initialize( ports: CoreStorePorts, providers: ProviderRegistry, + extensions: Option>, + request_observers: Option, + request_policies: Option, + middlewares: Option, + frontend_authentication: Option, ) -> Result { - restore_client_admission_startup( - ports.execution.as_ref(), - ports.admission_recovery.as_ref(), - ports.admissions.as_ref(), - SystemTime::now(), + prepare( + ports, + providers, + extensions, + request_observers, + request_policies, + middlewares, + frontend_authentication, ) + .activate() .await - .map_err(|_| CoreError::AdmissionRecoveryUnavailable)?; - let compiler = Arc::new(RuntimeSnapshotCompiler::new( - Arc::clone(&ports.snapshots), - Arc::new(providers.clone()), - )); - let initial = compiler - .compile() +} + +/// 构造 Core 与快照发布能力;首次 Provider 目录发现延迟到 [`CoreStartup::activate`]。 +#[must_use] +pub fn prepare( + ports: CoreStorePorts, + providers: ProviderRegistry, + extensions: Option>, + request_observers: Option, + request_policies: Option, + middlewares: Option, + frontend_authentication: Option, +) -> CoreStartup { + let control = prepare_control_plane(ports.clone(), providers.clone(), extensions); + CoreStartup { + ports, + providers, + request_observers, + request_policies, + middlewares, + frontend_authentication, + control, + } +} + +/// 管理命令只持有快照读写能力,不恢复数据面准入,也不启动执行/结算 Worker。 +pub struct CoreControlPlaneBundle { + snapshots: RuntimeSnapshotHandle, + publisher: Arc, +} + +/// CLI/组合根可先绑定回调端口,再激活首次目录发现。 +pub struct CoreControlPlaneStartup { + snapshots: RuntimeSnapshotHandle, + publisher: Arc, +} + +impl CoreControlPlaneStartup { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.snapshots.clone() + } + + #[must_use] + pub fn snapshot_control(&self) -> Arc { + self.publisher.clone() + } + + pub async fn activate(self) -> Result { + match self.publisher.refresh().await { + Ok(_) => {} + // 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求。 + Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {} + Err(_) => return Err(CoreError::SnapshotUnavailable), + } + Ok(CoreControlPlaneBundle { + snapshots: self.snapshots, + publisher: self.publisher, + }) + } +} + +impl CoreControlPlaneBundle { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.snapshots.clone() + } + + #[must_use] + pub fn snapshot_control(&self) -> Arc { + self.publisher.clone() + } +} + +/// CLI 命令按需拥有嵌套模型执行与会话亲和查询端口,但不恢复网关启动时的准入租约, +/// 也不产生任何后台 Worker 定义。 +pub struct CoreCommandPlaneStartup { + ports: CoreStorePorts, + providers: ProviderRegistry, + request_observers: Option, + request_policies: Option, + middlewares: Option, + control: CoreControlPlaneStartup, +} + +pub struct CoreCommandPlaneBundle { + control: CoreControlPlaneBundle, + nested_model_execution: Arc, + affinity_lookup: Arc, +} + +impl CoreCommandPlaneStartup { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.control.snapshots() + } + + #[must_use] + pub fn snapshot_control(&self) -> Arc { + self.control.snapshot_control() + } + + pub async fn activate(self) -> Result { + let control = self.control.activate().await?; + let service = Arc::new( + build_execution_service( + control.snapshots.clone(), + &self.ports, + self.providers, + self.request_observers, + self.request_policies, + self.middlewares, + None, + ) + .with_snapshot_refresh(Arc::clone(&control.publisher)), + ); + let nested_model_execution: Arc = service.clone(); + let affinity_lookup: Arc = service; + Ok(CoreCommandPlaneBundle { + control, + nested_model_execution, + affinity_lookup, + }) + } +} + +impl CoreCommandPlaneBundle { + #[must_use] + pub fn snapshots(&self) -> RuntimeSnapshotHandle { + self.control.snapshots() + } + + #[must_use] + pub fn snapshot_control(&self) -> Arc { + self.control.snapshot_control() + } + + #[must_use] + pub fn nested_model_execution_port(&self) -> Arc { + Arc::clone(&self.nested_model_execution) + } + + #[must_use] + pub fn affinity_lookup_port(&self) -> Arc { + Arc::clone(&self.affinity_lookup) + } +} + +/// 为单次 CLI 命令构造当前快照与窄执行端口;激活时不恢复全局准入状态。 +#[must_use] +pub fn prepare_command_plane( + ports: CoreStorePorts, + providers: ProviderRegistry, + extensions: Option>, + request_observers: Option, + request_policies: Option, + middlewares: Option, +) -> CoreCommandPlaneStartup { + let control = prepare_control_plane(ports.clone(), providers.clone(), extensions); + CoreCommandPlaneStartup { + ports, + providers, + request_observers, + request_policies, + middlewares, + control, + } +} + +pub async fn initialize_control_plane( + ports: CoreStorePorts, + providers: ProviderRegistry, + extensions: Option>, +) -> Result { + prepare_control_plane(ports, providers, extensions) + .activate() .await - .map_err(|_| CoreError::SnapshotUnavailable)?; - let snapshots = RuntimeSnapshotHandle::new(initial); +} + +/// 仅构造管理命令所需快照能力,不恢复准入、不发现目录且不启动 Worker。 +#[must_use] +pub fn prepare_control_plane( + ports: CoreStorePorts, + providers: ProviderRegistry, + extensions: Option>, +) -> CoreControlPlaneStartup { + let mut compiler = RuntimeSnapshotCompiler::new(ports.snapshots, Arc::new(providers)); + if let Some(extensions) = extensions { + compiler = compiler.with_extensions(extensions); + } + let compiler = Arc::new(compiler); + let snapshots = RuntimeSnapshotHandle::default(); let publisher = Arc::new(RuntimeSnapshotPublisher::new( compiler, snapshots.clone(), - Arc::clone(&ports.snapshot_subscriptions), + ports.snapshot_subscriptions, )); - let worker_contributions = publisher.worker_contributions()?; - let mut service = DefaultExecutionService::new( - snapshots.clone(), - ports.execution, - providers, - ports.admissions, - ports.circuits, - ports.continuation, - ports.client_api_key_usage, - ); - if let Some(budget) = ports.budget { - service = service.with_budget(budget); + CoreControlPlaneStartup { + snapshots, + publisher, } - let service = Arc::new(service); - let execution: Arc = service.clone(); - let client_key_verifier: Arc = service.clone(); - let account_probe: Arc = service; - let snapshot_control: Arc = publisher; - let health_probes: Vec> = vec![Arc::new(snapshots)]; - Ok(CoreBundle { - execution, - client_key_verifier, - snapshot_control, - account_probe, - health_probes, - worker_contributions, - }) } #[derive(Debug, thiserror::Error)] diff --git a/backend/crates/gateway-core/src/lifecycle.rs b/backend/crates/gateway-core/src/lifecycle.rs index 58a918888..3b20cd022 100644 --- a/backend/crates/gateway-core/src/lifecycle.rs +++ b/backend/crates/gateway-core/src/lifecycle.rs @@ -4,7 +4,7 @@ use std::fmt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::BoxFuture}; struct CancellationState { cancelled: AtomicBool, @@ -13,7 +13,10 @@ struct CancellationState { /// 可克隆的请求、任务与连接取消信号。 #[derive(Clone)] -pub struct CancellationToken(Arc); +pub struct CancellationToken { + state: Arc, + ancestors: Arc<[Arc]>, +} impl fmt::Debug for CancellationToken { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -33,18 +36,36 @@ impl Default for CancellationToken { impl CancellationToken { #[must_use] pub fn new() -> Self { - Self(Arc::new(CancellationState { - cancelled: AtomicBool::new(false), - waiters: Mutex::new(Vec::new()), - })) + Self { + state: Arc::new(CancellationState { + cancelled: AtomicBool::new(false), + waiters: Mutex::new(Vec::new()), + }), + ancestors: Arc::from([]), + } + } + + /// 创建只从父级继承取消的子 token;取消子级不会反向取消父请求。 + #[must_use] + pub fn child_token(&self) -> Self { + let ancestors = std::iter::once(Arc::clone(&self.state)) + .chain(self.ancestors.iter().cloned()) + .collect::>(); + Self { + state: Arc::new(CancellationState { + cancelled: AtomicBool::new(false), + waiters: Mutex::new(Vec::new()), + }), + ancestors: ancestors.into(), + } } pub fn cancel(&self) { - if self.0.cancelled.swap(true, Ordering::AcqRel) { + if self.state.cancelled.swap(true, Ordering::AcqRel) { return; } let waiters = { - let mut guard = lock_unpoisoned(&self.0.waiters); + let mut guard = lock_unpoisoned(&self.state.waiters); std::mem::take(&mut *guard) }; for waiter in waiters { @@ -54,23 +75,39 @@ impl CancellationToken { #[must_use] pub fn is_cancelled(&self) -> bool { - self.0.cancelled.load(Ordering::Acquire) + self.state.cancelled.load(Ordering::Acquire) + || self + .ancestors + .iter() + .any(|state| state.cancelled.load(Ordering::Acquire)) } pub async fn cancelled(&self) { if self.is_cancelled() { return; } + let mut waiters: Vec> = Vec::with_capacity(1 + self.ancestors.len()); + waiters.push(wait_for_state(Arc::clone(&self.state))); + waiters.extend(self.ancestors.iter().cloned().map(wait_for_state)); + let _ = futures::future::select_all(waiters).await; + } +} + +fn wait_for_state(state: Arc) -> BoxFuture<'static, ()> { + Box::pin(async move { + if state.cancelled.load(Ordering::Acquire) { + return; + } let (sender, receiver) = oneshot::channel(); { - let mut waiters = lock_unpoisoned(&self.0.waiters); - if self.is_cancelled() { + let mut waiters = lock_unpoisoned(&state.waiters); + if state.cancelled.load(Ordering::Acquire) { return; } waiters.push(sender); } let _ = receiver.await; - } + }) } fn lock_unpoisoned(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { diff --git a/backend/crates/gateway-core/src/operation/mod.rs b/backend/crates/gateway-core/src/operation/mod.rs index 31d438071..e5baedb2f 100644 --- a/backend/crates/gateway-core/src/operation/mod.rs +++ b/backend/crates/gateway-core/src/operation/mod.rs @@ -24,6 +24,10 @@ pub enum OperationKind { GenerateImage, /// Provider 原生 standalone search。 Search, + /// 使用目标模型的真实 tokenizer 计数,不允许 Core 估算。 + CountTokens, + /// Provider 声明并映射到固定上游目标的账号认证 HTTP 操作。 + ProviderHttp, } impl OperationKind { @@ -34,6 +38,8 @@ impl OperationKind { Self::Generate => "generate", Self::GenerateImage => "generate_image", Self::Search => "search", + Self::CountTokens => "count_tokens", + Self::ProviderHttp => "provider_http", } } } @@ -234,6 +240,7 @@ pub struct RawJsonPayload { protocol: String, body: Bytes, context: Map, + translated: bool, } impl RawJsonPayload { @@ -251,6 +258,7 @@ impl RawJsonPayload { protocol, body, context: Map::new(), + translated: false, }) } @@ -291,6 +299,70 @@ impl fmt::Debug for RawJsonPayload { } } +/// Provider HTTP 端点使用的不透明字节正文。 +/// +/// 与 [`RawJsonPayload`] 不同,这里不要求正文是 JSON。Core 只保留字节和 +/// 非 wire 上下文,最终 origin、路径与账号认证仍由 Provider 决定。 +#[derive(Clone, PartialEq, Eq)] +pub struct RawHttpPayload { + protocol: String, + body: Bytes, + context: Map, + translated: bool, +} + +impl RawHttpPayload { + /// 创建不透明 HTTP 正文。 + /// + /// # Errors + /// + /// 协议名称为空、过长或含控制字符时返回错误。 + pub fn new(protocol: impl Into, body: Bytes) -> Result { + let protocol = protocol.into(); + validate_text(&protocol, 64, true, None).map_err(|_| OperationError::EmptyField { + field: "raw_http_payload protocol", + })?; + Ok(Self { + protocol, + body, + context: Map::new(), + translated: false, + }) + } + + #[must_use] + pub fn protocol(&self) -> &str { + &self.protocol + } + + #[must_use] + pub const fn body(&self) -> &Bytes { + &self.body + } + + #[must_use] + pub fn with_context(mut self, context: Map) -> Self { + self.context = context; + self + } + + #[must_use] + pub const fn context(&self) -> &Map { + &self.context + } +} + +impl fmt::Debug for RawHttpPayload { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RawHttpPayload") + .field("protocol", &self.protocol) + .field("body", &"") + .field("context", &"") + .finish() + } +} + /// 通用生成请求。 #[derive(Clone, PartialEq)] pub struct GenerateRequest { @@ -301,6 +373,7 @@ pub struct GenerateRequest { struct GeneratePayload { protocol_payload: ProtocolPayload, provider_session_state: Option, + source_requirements: Option, } impl GenerateRequest { @@ -314,10 +387,33 @@ impl GenerateRequest { payload: Arc::new(GeneratePayload { protocol_payload, provider_session_state: None, + source_requirements: None, }), } } + /// 写回原生 Provider 编码结果,保留已准入的能力、连接上下文和会话状态。 + /// + /// 原生编码不消耗中间件的一次跨协议改写额度:可以保留协议名,也可以在一次 + /// 中间件改写后编码到 Provider wire。调用方仍须验证保护字段没有被改动。 + /// + /// # Errors + /// + /// 目标协议名无效时返回错误。 + pub fn with_native_encoded_body( + mut self, + protocol: impl Into, + body: Map, + ) -> Result { + let encoded = ProtocolPayload::json_object(protocol, body)? + .with_context(self.protocol_payload().context().clone()); + let requirements = self.requirements(); + let payload = Arc::make_mut(&mut self.payload); + payload.protocol_payload = encoded; + payload.source_requirements = Some(requirements); + Ok(self) + } + /// 附着同一客户端连接上一轮由 Provider 返回的不透明状态。 #[must_use] pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self { @@ -386,6 +482,9 @@ impl GenerateRequest { } fn requirements(&self) -> CapabilityRequirements { + if let Some(requirements) = &self.payload.source_requirements { + return requirements.clone(); + } let mut requirements = CapabilityRequirements::new(OperationKind::Generate) .with_requested_output_tokens(self.max_output_tokens()); if self @@ -533,6 +632,194 @@ impl fmt::Debug for StandaloneSearchRequest { } } +/// 使用目标模型的 Provider 原生 Token 计数请求。 +#[derive(Clone, PartialEq, Eq)] +pub struct TokenCountRequest { + payload: RawJsonPayload, +} + +impl TokenCountRequest { + #[must_use] + pub const fn from_raw_json(payload: RawJsonPayload) -> Self { + Self { payload } + } + + #[must_use] + pub const fn payload(&self) -> &RawJsonPayload { + &self.payload + } +} + +impl fmt::Debug for TokenCountRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TokenCountRequest") + .field("payload", &"") + .finish() + } +} + +/// API 可公开的 Provider HTTP method 集合;不自动包含 `HEAD` 或任意 method。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProviderHttpMethod { + Get, + Post, +} + +impl ProviderHttpMethod { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + } + } +} + +/// 已由 API 解析的单个 HTTP header;值不会出现在 `Debug`。 +#[derive(Clone, PartialEq, Eq)] +pub struct ProviderHttpHeader { + name: String, + value: Bytes, +} + +impl ProviderHttpHeader { + #[must_use] + pub fn new(name: impl Into, value: Bytes) -> Self { + Self { + name: name.into(), + value, + } + } + + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + #[must_use] + pub const fn value(&self) -> &Bytes { + &self.value + } +} + +impl fmt::Debug for ProviderHttpHeader { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderHttpHeader") + .field("name", &self.name) + .field("value", &"") + .finish() + } +} + +/// Provider 显式声明的账号认证 HTTP 操作。 +#[derive(Clone, PartialEq, Eq)] +pub struct ProviderHttpRequest { + endpoint: String, + method: ProviderHttpMethod, + query: Option, + headers: Vec, + payload: RawHttpPayload, +} + +impl ProviderHttpRequest { + /// # Errors + /// + /// endpoint 不是安全符号名,query 或 headers 超过边界时返回错误。 + pub fn new( + endpoint: impl Into, + method: ProviderHttpMethod, + query: Option, + headers: Vec, + payload: RawHttpPayload, + ) -> Result { + let endpoint = endpoint.into(); + let valid_endpoint = !endpoint.is_empty() + && endpoint.len() <= 64 + && endpoint != "." + && endpoint != ".." + && endpoint + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')); + let valid_query = query.as_ref().is_none_or(|query| { + query.len() <= 8 * 1024 && !query.bytes().any(|byte| matches!(byte, b'\r' | b'\n' | 0)) + }); + let valid_headers = headers.len() <= 64 + && headers + .iter() + .try_fold(0_usize, |total, header| { + let valid_name = !header.name.is_empty() + && header.name.len() <= 128 + && header + .name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + let valid_value = header.value.len() <= 8 * 1024 + && !header + .value + .iter() + .any(|byte| matches!(byte, b'\r' | b'\n' | 0)); + valid_name + .then_some(()) + .filter(|_| valid_value) + .and_then(|()| total.checked_add(header.name.len() + header.value.len())) + .filter(|total| *total <= 32 * 1024) + }) + .is_some(); + if !valid_endpoint || !valid_query || !valid_headers { + return Err(OperationError::EmptyField { + field: "provider_http_request", + }); + } + Ok(Self { + endpoint, + method, + query, + headers, + payload, + }) + } + + #[must_use] + pub fn endpoint(&self) -> &str { + &self.endpoint + } + + #[must_use] + pub const fn method(&self) -> ProviderHttpMethod { + self.method + } + + #[must_use] + pub fn query(&self) -> Option<&str> { + self.query.as_deref() + } + + #[must_use] + pub fn headers(&self) -> &[ProviderHttpHeader] { + &self.headers + } + + #[must_use] + pub const fn payload(&self) -> &RawHttpPayload { + &self.payload + } +} + +impl fmt::Debug for ProviderHttpRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProviderHttpRequest") + .field("endpoint", &self.endpoint) + .field("method", &self.method) + .field("query", &self.query.as_ref().map(|_| "")) + .field("header_count", &self.headers.len()) + .field("payload", &self.payload) + .finish() + } +} + /// 网关内部业务请求;不包含任何客户端 wire 或 Provider SDK 类型。 #[derive(Clone, PartialEq)] #[non_exhaustive] @@ -543,9 +830,249 @@ pub enum Operation { GenerateImage(ImageRequest), /// Provider 原生 standalone search。 Search(StandaloneSearchRequest), + /// 使用目标模型进行真实 Token 计数。 + CountTokens(TokenCountRequest), + /// Provider 显式登记的账号认证 HTTP 操作。 + ProviderHttp(ProviderHttpRequest), } impl Operation { + /// 把协议正文编码为中间件可见的原始字节;不包含非 wire context 或会话状态。 + /// + /// # Errors + /// + /// 生成请求的 JSON object 无法编码时返回错误。 + pub fn middleware_body(&self) -> Result { + match self { + Self::Generate(request) => serde_json::to_vec(request.protocol_payload().body()) + .map(Bytes::from) + .map_err(|_| OperationError::EmptyField { + field: "protocol_payload body", + }), + Self::GenerateImage(request) => Ok(request.payload().body().clone()), + Self::Search(request) => Ok(request.payload().body().clone()), + Self::CountTokens(request) => Ok(request.payload().body().clone()), + Self::ProviderHttp(request) => Ok(request.payload().body().clone()), + } + } + + /// 写回中间件返回的协议与正文,同时保留宿主持有的非 wire context。 + /// + /// 同协议是正文替换;跨协议只能使用既有 direct-once 转换边界,不能借中间件 + /// 绕过重复转换保护。 + /// + /// # Errors + /// + /// 协议名或正文无效,或请求已经发生过一次跨协议转换时返回错误。 + pub fn replace_middleware_wire( + self, + protocol: impl Into, + body: Bytes, + ) -> Result { + let protocol = protocol.into(); + if self.protocol() != protocol { + return self.translate_protocol_wire(protocol, body); + } + let context = match &self { + Self::Generate(request) => request.protocol_payload().context().clone(), + Self::GenerateImage(request) => request.payload().context().clone(), + Self::Search(request) => request.payload().context().clone(), + Self::CountTokens(request) => request.payload().context().clone(), + Self::ProviderHttp(request) => request.payload().context().clone(), + }; + self.replace_protocol_wire(Some(body), context) + } + + /// 替换协议正文和非 wire 上下文,同时保留 operation 类别、Provider 会话状态与 + /// 原协议名称。`body=None` 表示只改上下文,避免无加工路径重新编码正文。 + /// + /// # Errors + /// + /// Generate 的替换正文不是 JSON object 时返回错误;原始 JSON 端点继续按字节保存。 + pub fn replace_protocol_wire( + self, + body: Option, + context: Map, + ) -> Result { + self.replace_protocol_wire_inner(None, body, context) + } + + /// 替换 Provider HTTP 正文、非 wire 上下文和已解析 headers,同时保持 endpoint、 + /// method 与 query 不变。headers 继续由 [`ProviderHttpRequest::new`] 统一验证; + /// Core 不解析插件传输使用的 base64 表示。 + /// + /// # Errors + /// + /// 当前 operation 不是 Provider HTTP,或替换后的 headers 超出请求边界时返回错误。 + pub fn replace_provider_http_wire( + self, + body: Option, + context: Map, + headers: Vec, + ) -> Result { + let Self::ProviderHttp(request) = self else { + return Err(OperationError::EmptyField { + field: "provider_http_request", + }); + }; + let payload = request.payload; + ProviderHttpRequest::new( + request.endpoint, + request.method, + request.query, + headers, + RawHttpPayload { + protocol: payload.protocol, + body: body.unwrap_or(payload.body), + context, + translated: payload.translated, + }, + ) + .map(Self::ProviderHttp) + } + + /// 把一次已选择的协议转换结果写回 operation,同时保留非 wire 上下文和 + /// Provider 会话状态。该方法只验证公共载荷形状,不解释目标协议字段。 + /// + /// # Errors + /// + /// 目标协议名无效,或 Generate 的目标正文不是 JSON object 时返回错误。 + pub fn translate_protocol_wire( + self, + target_protocol: impl Into, + body: Bytes, + ) -> Result { + let target_protocol = target_protocol.into(); + validate_text(&target_protocol, 64, true, None).map_err(|_| { + OperationError::EmptyField { + field: "translated protocol", + } + })?; + if self.protocol() == target_protocol || self.protocol_was_translated() { + return Err(OperationError::EmptyField { + field: "direct protocol translation", + }); + } + let context = match &self { + Self::Generate(request) => request.protocol_payload().context().clone(), + Self::GenerateImage(request) => request.payload().context().clone(), + Self::Search(request) => request.payload().context().clone(), + Self::CountTokens(request) => request.payload().context().clone(), + Self::ProviderHttp(request) => request.payload().context().clone(), + }; + self.replace_protocol_wire_inner(Some(target_protocol), Some(body), context) + } + + fn protocol_was_translated(&self) -> bool { + match self { + Self::Generate(request) => request.payload.source_requirements.is_some(), + Self::GenerateImage(request) => request.payload().translated, + Self::Search(request) => request.payload().translated, + Self::CountTokens(request) => request.payload().translated, + Self::ProviderHttp(request) => request.payload().translated, + } + } + + fn replace_protocol_wire_inner( + self, + target_protocol: Option, + body: Option, + context: Map, + ) -> Result { + let translating = target_protocol.is_some(); + match self { + Self::Generate(request) => { + let protocol_payload = request.protocol_payload(); + let source_requirements = translating.then(|| request.requirements()); + let body = match body { + Some(body) => { + serde_json::from_slice::>(&body).map_err(|_| { + OperationError::EmptyField { + field: "protocol_payload body", + } + })? + } + None => protocol_payload.body().clone(), + }; + let replacement = ProtocolPayload { + protocol: target_protocol + .unwrap_or_else(|| protocol_payload.protocol().to_owned()), + body, + context, + }; + let mut payload = (*request.payload).clone(); + payload.protocol_payload = replacement; + if let Some(requirements) = source_requirements { + payload.source_requirements = Some(requirements); + } + Ok(Self::Generate(GenerateRequest { + payload: Arc::new(payload), + })) + } + Self::GenerateImage(request) => { + let payload = request.payload; + Ok(Self::GenerateImage(ImageRequest { + kind: request.kind, + payload: RawJsonPayload { + protocol: target_protocol.unwrap_or(payload.protocol), + body: body.unwrap_or(payload.body), + context, + translated: payload.translated || translating, + }, + })) + } + Self::Search(request) => { + let payload = request.payload; + Ok(Self::Search(StandaloneSearchRequest { + payload: RawJsonPayload { + protocol: target_protocol.unwrap_or(payload.protocol), + body: body.unwrap_or(payload.body), + context, + translated: payload.translated || translating, + }, + })) + } + Self::CountTokens(request) => { + let payload = request.payload; + Ok(Self::CountTokens(TokenCountRequest { + payload: RawJsonPayload { + protocol: target_protocol.unwrap_or(payload.protocol), + body: body.unwrap_or(payload.body), + context, + translated: payload.translated || translating, + }, + })) + } + Self::ProviderHttp(request) => { + let payload = request.payload; + Ok(Self::ProviderHttp(ProviderHttpRequest { + endpoint: request.endpoint, + method: request.method, + query: request.query, + headers: request.headers, + payload: RawHttpPayload { + protocol: target_protocol.unwrap_or(payload.protocol), + body: body.unwrap_or(payload.body), + context, + translated: payload.translated || translating, + }, + })) + } + } + } + + /// 返回协议 adapter 持有的不透明格式标识。 + #[must_use] + pub fn protocol(&self) -> &str { + match self { + Self::Generate(request) => request.protocol_payload().protocol(), + Self::GenerateImage(request) => request.payload().protocol(), + Self::Search(request) => request.payload().protocol(), + Self::CountTokens(request) => request.payload().protocol(), + Self::ProviderHttp(request) => request.payload().protocol(), + } + } + /// 附着协议连接持有的 Provider 私有状态;非生成 operation 保持不变。 #[must_use] pub fn with_provider_session_state(self, state: ProviderSessionState) -> Self { @@ -569,6 +1096,8 @@ impl Operation { Self::Generate(_) => OperationKind::Generate, Self::GenerateImage(_) => OperationKind::GenerateImage, Self::Search(_) => OperationKind::Search, + Self::CountTokens(_) => OperationKind::CountTokens, + Self::ProviderHttp(_) => OperationKind::ProviderHttp, } } @@ -579,6 +1108,8 @@ impl Operation { Self::Generate(request) => request.requirements(), Self::GenerateImage(_) => CapabilityRequirements::new(OperationKind::GenerateImage), Self::Search(_) => CapabilityRequirements::new(OperationKind::Search), + Self::CountTokens(_) => CapabilityRequirements::new(OperationKind::CountTokens), + Self::ProviderHttp(_) => CapabilityRequirements::new(OperationKind::ProviderHttp), } } @@ -588,7 +1119,7 @@ impl Operation { match self { Self::Generate(request) => request.image_generation_requested(), Self::GenerateImage(_) => true, - Self::Search(_) => false, + Self::Search(_) | Self::CountTokens(_) | Self::ProviderHttp(_) => false, } } @@ -597,7 +1128,10 @@ impl Operation { pub fn provider_session_state(&self, provider: &str) -> Option<&ProviderSessionState> { match self { Self::Generate(request) => request.provider_session_state(provider), - Self::GenerateImage(_) | Self::Search(_) => None, + Self::GenerateImage(_) + | Self::Search(_) + | Self::CountTokens(_) + | Self::ProviderHttp(_) => None, } } } diff --git a/backend/crates/gateway-core/src/provider_ports/mod.rs b/backend/crates/gateway-core/src/provider_ports/mod.rs index 1a35c3301..b85d5e93b 100644 --- a/backend/crates/gateway-core/src/provider_ports/mod.rs +++ b/backend/crates/gateway-core/src/provider_ports/mod.rs @@ -14,7 +14,7 @@ use crate::account::{ }; use crate::identity::ProviderKind; use crate::policy::ClientApiKeyId; -use crate::routing::UpstreamModelId; +use crate::routing::{ConfigRevision, UpstreamModelId}; use crate::validation::{IdentifierError, validate_text}; const MAX_PENDING_FLOW_TTL: Duration = Duration::from_secs(30 * 60); @@ -945,6 +945,18 @@ pub trait ProviderRuntimePolicyPort: Send + Sync { Box::pin(async move { Ok(initial) }) } + /// 读取候选配置版本实际引用的全局与 Client Key 画像配置。 + /// + /// 实现必须在同一数据库快照内核对 revision,且只返回画像投影,不能读取 Key + /// 明文。Provider 代次据此在发布前拒绝已失效的选择。 + fn load_request_profile_configurations<'a>( + &'a self, + _revision: ConfigRevision, + _provider: &'a ProviderKind, + ) -> BoxFuture<'a, Result, ProviderStoreError>> { + Box::pin(async move { Ok(Vec::new()) }) + } + fn load_refresh_policy( &self, ) -> BoxFuture<'_, Result>; diff --git a/backend/crates/gateway-core/src/routing/catalog.rs b/backend/crates/gateway-core/src/routing/catalog.rs index 0d58638ab..35c314f19 100644 --- a/backend/crates/gateway-core/src/routing/catalog.rs +++ b/backend/crates/gateway-core/src/routing/catalog.rs @@ -113,12 +113,20 @@ pub struct ProviderCatalogUnavailable; /// 快照编译与对账使用的对象安全目录端口。 pub trait ProviderCatalogPort: Send + Sync { + /// 动态目录返回对应集合的冻结视图;静态目录继续使用自身。 + fn for_extensions( + &self, + _reference: Option<&crate::runtime::extensions::ExtensionSetReference>, + ) -> Result>, ProviderCatalogUnavailable> { + Ok(None) + } + /// 目录是否完整到足以根据缺项拒绝请求;发现型目录交由上游验证模型名。 fn model_catalog_is_exhaustive(&self, _provider: &ProviderKind) -> bool { true } - /// 返回全部已注册 Provider 的目录代次;注册集合在初始化后保持不变。 + /// 返回当前冻结视图内全部 Provider 的目录代次。 /// /// 即使某个目录暂时不可读,也必须保留它的 Provider 与最近成功发布的代次。 fn catalog_generations(&self) -> BTreeMap; diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 6b132b65e..5531120ae 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -11,7 +11,7 @@ use crate::account::{ AccountConcurrency, AccountSelectionPolicy, ProviderAccountId, RotationStrategy, }; use crate::concurrency::ConcurrencyQueuePolicy; -use crate::operation::Operation; +use crate::operation::{Operation, OperationKind}; use crate::policy::{ ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion, PlaintextClientApiKey, RateLimits, @@ -295,6 +295,8 @@ pub trait SnapshotStorePort: Send + Sync { /// 快照未发布时可安全记录的稳定错误。 #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum RuntimeSnapshotCompileError { + #[error("runtime extensions could not be prepared")] + ExtensionsUnavailable, #[error("runtime snapshot store is unavailable")] StoreUnavailable, #[error("runtime configuration changed while the snapshot was loading")] @@ -310,6 +312,7 @@ pub enum RuntimeSnapshotCompileError { pub struct RuntimeSnapshotCompiler { store: Arc, catalogs: Arc, + extensions: Option>, } impl RuntimeSnapshotCompiler { @@ -318,7 +321,20 @@ impl RuntimeSnapshotCompiler { store: Arc, catalogs: Arc, ) -> Self { - Self { store, catalogs } + Self { + store, + catalogs, + extensions: None, + } + } + + #[must_use] + pub fn with_extensions( + mut self, + extensions: Arc, + ) -> Self { + self.extensions = Some(extensions); + self } pub(crate) fn store(&self) -> Arc { @@ -327,14 +343,34 @@ impl RuntimeSnapshotCompiler { pub(crate) fn provider_catalog_generations( &self, - ) -> BTreeMap { - self.catalogs.catalog_generations() + extensions: Option<&crate::runtime::extensions::ExtensionSetReference>, + ) -> Result, RuntimeSnapshotCompileError> + { + Ok(self + .catalogs_for_extensions(extensions)? + .catalog_generations()) + } + + fn catalogs_for_extensions( + &self, + extensions: Option<&crate::runtime::extensions::ExtensionSetReference>, + ) -> Result, RuntimeSnapshotCompileError> { + self.catalogs + .for_extensions(extensions) + .map(|catalogs| catalogs.unwrap_or_else(|| self.catalogs.clone())) + .map_err(|_| RuntimeSnapshotCompileError::ExtensionsUnavailable) } /// 读取一个 revision,并为已注册 Provider 查询实时模型目录。 pub async fn compile(&self) -> Result { + // 同一配置 revision 的目录稳定性重试必须复用且强持有同一个扩展集合。 + // 否则一次动态目录发布会让临时候选在下一轮前析构,随后重复 prepare + // 得到新的初始目录代次,稳定性检查永远无法收敛。 + let mut prepared_extensions: Option<( + ConfigRevision, + crate::runtime::extensions::ExtensionSetReference, + )> = None; for _ in 0..MAXIMUM_CATALOG_STABILITY_ATTEMPTS { - let catalog_generations = self.catalogs.catalog_generations(); let facts = self .store .load_snapshot_facts() @@ -343,12 +379,48 @@ impl RuntimeSnapshotCompiler { if facts.config_revision != facts.observed_current_revision { return Err(RuntimeSnapshotCompileError::RevisionChanged); } + let revision = facts.config_revision; + let extensions = match &self.extensions { + Some(preparer) => { + let must_prepare = + prepared_extensions + .as_ref() + .is_none_or(|(prepared_revision, prepared)| { + *prepared_revision != revision || !prepared.is_ready() + }); + if must_prepare { + let prepared = preparer + .prepare(revision) + .await + .map_err(|_| RuntimeSnapshotCompileError::ExtensionsUnavailable)?; + prepared_extensions = Some((revision, prepared)); + } + prepared_extensions + .as_ref() + .map(|(_, prepared)| prepared.clone()) + } + None => None, + }; + let catalogs = self.catalogs_for_extensions(extensions.as_ref())?; + let catalog_generations = catalogs.catalog_generations(); let provider_kinds = catalog_generations.keys().cloned().collect(); let snapshot = - compile_runtime_snapshot(facts, self.catalogs.as_ref(), provider_kinds).await?; - let observed_generations = self.catalogs.catalog_generations(); + compile_runtime_snapshot(facts, catalogs.as_ref(), provider_kinds).await?; + let observed_generations = catalogs.catalog_generations(); if catalog_generations == observed_generations { - return Ok(snapshot.with_provider_catalog_generations(observed_generations)); + if extensions.is_some() + && self + .store + .current_config_revision() + .await + .map_err(|_| RuntimeSnapshotCompileError::StoreUnavailable)? + != revision + { + return Err(RuntimeSnapshotCompileError::RevisionChanged); + } + return Ok(snapshot + .with_provider_catalog_generations(observed_generations) + .with_extensions(extensions)); } } Err(RuntimeSnapshotCompileError::CatalogChanged) @@ -360,8 +432,6 @@ async fn compile_runtime_snapshot( catalogs: &dyn ProviderCatalogPort, provider_kinds: Vec, ) -> Result { - let registered_providers = provider_kinds.iter().cloned().collect::>(); - // 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证。 let mut provider_models = Vec::new(); let mut exhaustive_provider_catalogs = BTreeSet::new(); @@ -402,14 +472,14 @@ async fn compile_runtime_snapshot( for account in facts.provider_accounts { let provider_kind = ProviderKind::new(account.provider_kind) .map_err(|_| RuntimeSnapshotCompileError::InvalidData)?; - if !registered_providers.contains(&provider_kind) - || accounts - .insert( - account.account_id.clone(), - RuntimeAccount::new(provider_kind, BTreeSet::new()) - .with_model_access(account.model_access), - ) - .is_some() + // 账号归属独立于当前执行器集合;Provider 撤下后仍保留账号和分组,路由只选已注册执行器。 + if accounts + .insert( + account.account_id.clone(), + RuntimeAccount::new(provider_kind, BTreeSet::new()) + .with_model_access(account.model_access), + ) + .is_some() { return Err(RuntimeSnapshotCompileError::InvalidData); } @@ -571,6 +641,7 @@ async fn compile_runtime_snapshot( /// 数据面使用的不可变配置快照。 #[derive(Debug, Clone)] pub struct RuntimeSnapshot { + extensions: Option, pricing: Arc, responses_max_decompressed_body_bytes: std::num::NonZeroUsize, request_location: Option, @@ -590,6 +661,19 @@ pub struct RuntimeSnapshot { } impl RuntimeSnapshot { + #[must_use] + pub fn with_extensions( + mut self, + extensions: Option, + ) -> Self { + self.extensions = extensions; + self + } + + #[must_use] + pub const fn extensions(&self) -> Option<&crate::runtime::extensions::ExtensionSetReference> { + self.extensions.as_ref() + } #[must_use] pub fn with_pricing(mut self, pricing: Arc) -> Self { self.pricing = pricing; @@ -698,6 +782,7 @@ impl RuntimeSnapshot { client_policy_map.retain(|_, policy| policy.enabled()); Ok(Self { + extensions: None, responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024) .expect("positive default limit"), pricing: Arc::default(), @@ -773,6 +858,9 @@ impl RuntimeSnapshot { /// 返回目录发现模型与设置映射的并集,仅用于公开模型展示。 #[must_use] pub fn public_models_for_provider(&self, provider: &ProviderKind) -> Vec { + if !self.providers.contains(provider) { + return Vec::new(); + } let mut models = BTreeSet::new(); if let Some(discovered) = self.provider_models.get(provider) { models.extend( @@ -926,6 +1014,33 @@ impl RuntimeSnapshot { self.client_policies.values() } + #[must_use] + pub fn client_policy(&self, id: &ClientApiKeyId) -> Option<&ClientPolicy> { + self.client_policies.get(id) + } + + /// 返回当前 Key 范围、本代次注册表与 circuit 条件共同允许的 Provider。 + #[must_use] + pub fn available_providers( + &self, + account_scope: &FrozenAccountScope, + context: &RoutingContext, + ) -> BTreeSet { + account_scope + .provider_kinds() + .iter() + .filter(|provider| { + self.providers.contains(*provider) + && context + .required_provider + .as_ref() + .is_none_or(|required| required == *provider) + && !context.blocked_providers.contains(*provider) + }) + .cloned() + .collect() + } + #[must_use] pub const fn min_codex_client_versions(&self) -> &CodexClientMinVersions { &self.min_codex_client_versions @@ -937,6 +1052,33 @@ impl RuntimeSnapshot { operation: &Operation, account_scope: Arc, context: &RoutingContext, + ) -> Result { + self.plan_inner(public_model, operation, account_scope, context, true) + } + + /// 管理探测已固定目标账号,不使用数据面 Key 的账号模型政策裁决探测资格。 + pub(crate) fn plan_diagnostic( + &self, + public_model: &PublicModelId, + operation: &Operation, + context: &RoutingContext, + ) -> Result { + self.plan_inner( + public_model, + operation, + self.all_account_scope(), + context, + false, + ) + } + + fn plan_inner( + &self, + public_model: &PublicModelId, + operation: &Operation, + account_scope: Arc, + context: &RoutingContext, + enforce_account_model_policy: bool, ) -> Result { let requirements = operation.capability_requirements(); let mut candidates = Vec::new(); @@ -969,6 +1111,12 @@ impl RuntimeSnapshot { UpstreamModelId::from_client_wire(mapped_model) } .map_err(|_| RoutingError::InvalidIdentifier)?; + // 强制 Provider 路由也不能扩大 Key 授权;管理诊断的目标账号由探测入口固定。 + if enforce_account_model_policy + && !account_scope.allows_provider_model(provider, upstream_model.as_str()) + { + continue; + } let emulated_features = match self .provider_models .get(provider) @@ -1023,18 +1171,19 @@ impl RuntimeSnapshot { }) } - /// 为 Provider 自有、且不属于文本模型目录的端点冻结请求计划。 + /// 为 Provider 自有端点冻结请求计划。 /// - /// 端点 adapter 已经确定 Provider,因此这里只执行账号范围、circuit 和注册 - /// 状态检查;不会读取业务正文、构造模型或查询 Provider 文本模型目录。 + /// 端点 adapter 已经确定 Provider。非模型 HTTP 端点不读取文本模型目录;Token + /// 计数端点仍必须匹配冻结账号范围及该模型声明的计数能力。 pub fn plan_provider_endpoint( &self, provider: &ProviderKind, + upstream_model: Option<&UpstreamModelId>, operation: &Operation, account_scope: Arc, context: &RoutingContext, ) -> Result { - let allowed = !account_scope.provider_kinds().is_empty() + let available = !account_scope.provider_kinds().is_empty() && account_scope.provider_kinds().contains(provider) && self.providers.contains(provider) && context @@ -1042,14 +1191,37 @@ impl RuntimeSnapshot { .as_ref() .is_none_or(|required| required == provider) && !context.blocked_providers.contains(provider); - if !allowed { + if !available + || upstream_model + .is_some_and(|model| !account_scope.allows_provider_model(provider, model.as_str())) + { return Err(RoutingError::NoCapableProviderEndpoint { provider: provider.as_str().to_owned(), }); } + let model_binding_valid = + matches!(operation.kind(), OperationKind::CountTokens) == upstream_model.is_some(); + let model_capable = upstream_model.is_none_or(|model| { + match self + .provider_models + .get(provider) + .and_then(|models| models.get(model)) + { + Some(capabilities) => capabilities + .match_requirements(&operation.capability_requirements()) + .is_some(), + None => !self.exhaustive_provider_catalogs.contains(provider), + } + }); + if !model_binding_valid || !model_capable { + return Err(RoutingError::UnsupportedProviderEndpoint { + provider: provider.as_str().to_owned(), + operation: operation.kind().as_str(), + }); + } let candidate = ProviderCandidate { provider: provider.clone(), - upstream_model: None, + upstream_model: upstream_model.cloned(), emulated_features: BTreeSet::new(), account_scope: Arc::clone(&account_scope), }; diff --git a/backend/crates/gateway-core/src/runtime/extensions.rs b/backend/crates/gateway-core/src/runtime/extensions.rs new file mode 100644 index 000000000..c44357dd9 --- /dev/null +++ b/backend/crates/gateway-core/src/runtime/extensions.rs @@ -0,0 +1,85 @@ +//! 可发布能力集合的中立身份与保活合同;不解释包格式、进程或管理配置。 + +use std::{fmt, sync::Arc}; + +use futures::future::BoxFuture; + +use crate::routing::ConfigRevision; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionSetId(String); + +impl ExtensionSetId { + pub fn new(value: String) -> Result { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte)) + { + return Err(ExtensionPreparationError); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// 发布视图及在途请求持有引用;准备器只能用非拥有索引查找代次。 +pub trait ExtensionSetLease: Send + Sync { + fn is_ready(&self) -> bool; + + /// 局部故障已有请求级处理计划时,集合仍可提供服务。 + fn can_serve(&self) -> bool { + self.is_ready() + } +} + +#[derive(Clone)] +pub struct ExtensionSetReference { + id: ExtensionSetId, + lease: Arc, +} + +impl ExtensionSetReference { + #[must_use] + pub fn new(id: ExtensionSetId, lease: Arc) -> Self { + Self { id, lease } + } + #[must_use] + pub const fn id(&self) -> &ExtensionSetId { + &self.id + } + #[must_use] + pub fn is_ready(&self) -> bool { + self.lease.is_ready() + } + #[must_use] + pub fn can_serve(&self) -> bool { + self.lease.can_serve() + } +} + +impl fmt::Debug for ExtensionSetReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExtensionSetReference") + .field("id", &self.id) + .finish_non_exhaustive() + } +} + +#[derive(Debug, Clone, Copy, thiserror::Error)] +#[error("extension preparation is unavailable")] +pub struct ExtensionPreparationError; + +/// 准备阶段读取与请求快照相同的持久 revision;不得自行发布当前代次。 +pub trait ExtensionPreparationPort: Send + Sync { + fn prepare( + &self, + revision: ConfigRevision, + ) -> BoxFuture<'_, Result>; +} diff --git a/backend/crates/gateway-core/src/runtime/mod.rs b/backend/crates/gateway-core/src/runtime/mod.rs index 5595709f4..d352803ca 100644 --- a/backend/crates/gateway-core/src/runtime/mod.rs +++ b/backend/crates/gateway-core/src/runtime/mod.rs @@ -1,8 +1,10 @@ //! 运行时快照的原子发布、健康状态与跨进程版本收敛。 +pub mod extensions; + use std::collections::BTreeMap; use std::pin::Pin; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex as SyncMutex, RwLock}; use std::time::Duration; use futures::future::BoxFuture; @@ -28,6 +30,7 @@ const INITIAL_BACKOFF: Duration = Duration::from_secs(1); const MAXIMUM_BACKOFF: Duration = Duration::from_secs(30); const UNUSED_LEASE_TTL: Duration = Duration::from_secs(30); const UNUSED_LEASE_RENEWAL: Duration = Duration::from_secs(10); +const MAXIMUM_DEFERRED_REFRESH_ATTEMPTS: usize = 2; /// 不泄漏订阅基础设施细节的通知错误。 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -100,10 +103,17 @@ impl RuntimeSnapshotHandle { .map(|snapshot| snapshot.provider_catalog_generations().clone()) } + /// 控制面只读诊断可观察尚未就绪的发布候选;数据面仍必须通过 [`Self::acquire`]。 + #[must_use] + pub fn snapshot_for_diagnostics(&self) -> Option> { + read_unpoisoned(&self.current).clone() + } + /// 冻结当前 Arc;后续发布不改变已经开始的请求。 pub fn acquire(&self) -> Result, RuntimeSnapshotUnavailable> { read_unpoisoned(&self.current) .clone() + .filter(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve())) .ok_or(RuntimeSnapshotUnavailable) } } @@ -115,7 +125,7 @@ impl HealthProbe for RuntimeSnapshotHandle { fn check(&self) -> BoxFuture<'_, HealthState> { Box::pin(async move { - if self.revision().is_some() { + if self.acquire().is_ok() { HealthState::Healthy } else { HealthState::Unhealthy("Runtime snapshot is unavailable".to_owned()) @@ -127,6 +137,93 @@ impl HealthProbe for RuntimeSnapshotHandle { /// Admin 提交配置后触发本进程刷新与跨进程通知的对象安全端口。 pub trait SnapshotControl: Send + Sync { fn publish_committed(&self, committed_revision: ConfigRevision) -> BoxFuture<'_, ()>; + + /// 目录发现回调可能发生在快照编译内部;该入口允许实现合并提交并在外层编译后刷新。 + fn publish_committed_deferred(&self, committed_revision: ConfigRevision) -> BoxFuture<'_, ()> { + self.publish_committed(committed_revision) + } +} + +#[derive(Default)] +struct DeferredPublicationState { + active: bool, + scheduled: bool, + pending_revision: Option, +} + +struct ActiveRefreshGuard { + deferred: Arc>, + committed_revision: Option, + armed: bool, +} + +impl ActiveRefreshGuard { + fn begin(deferred: Arc>) -> Self { + let committed_revision = { + let mut state = lock_unpoisoned(&deferred); + state.active = true; + state.pending_revision.take() + }; + Self { + deferred, + committed_revision, + armed: true, + } + } + + fn merge(&mut self, revision: ConfigRevision) { + self.committed_revision = latest_revision(self.committed_revision, revision); + } + + fn finish(&mut self) { + lock_unpoisoned(&self.deferred).active = false; + self.armed = false; + } +} + +impl Drop for ActiveRefreshGuard { + fn drop(&mut self) { + if self.armed { + let mut state = lock_unpoisoned(&self.deferred); + if let Some(revision) = self.committed_revision { + state.pending_revision = latest_revision(state.pending_revision, revision); + } + state.active = false; + } + } +} + +struct DeferredScheduleGuard { + deferred: Arc>, + armed: bool, +} + +impl DeferredScheduleGuard { + fn new(deferred: Arc>) -> Self { + Self { + deferred, + armed: true, + } + } + + fn finish(&mut self) { + lock_unpoisoned(&self.deferred).scheduled = false; + self.armed = false; + } +} + +impl Drop for DeferredScheduleGuard { + fn drop(&mut self) { + if self.armed { + let mut state = lock_unpoisoned(&self.deferred); + state.scheduled = false; + if state.pending_revision.is_some() { + tracing::warn!( + "deferred runtime snapshot publication was cancelled; reconciliation will retry" + ); + } + } + } } /// 配置提交后的本进程快照发布与跨进程失效通知。 @@ -136,6 +233,7 @@ pub struct RuntimeSnapshotPublisher { snapshots: RuntimeSnapshotHandle, subscriptions: Arc, refresh_lock: Arc>, + deferred: Arc>, } enum RefreshMode { @@ -155,6 +253,7 @@ impl RuntimeSnapshotPublisher { snapshots, subscriptions, refresh_lock: Arc::new(Mutex::new(())), + deferred: Arc::new(SyncMutex::new(DeferredPublicationState::default())), } } @@ -170,6 +269,14 @@ impl RuntimeSnapshotPublisher { // 所有入口共享从读取事实到发布或暂停的完整临界区;只锁最后的替换会让 // 旧编译覆盖新授权,或让晚到的失败暂停新快照。请求读取不等待此锁。 let _refresh = self.refresh_lock.lock().await; + self.refresh_locked(mode).await + } + + async fn refresh_locked( + &self, + mode: RefreshMode, + ) -> Result { + let mut active = ActiveRefreshGuard::begin(Arc::clone(&self.deferred)); let configuration_changed = if matches!(mode, RefreshMode::Reconcile) { let persisted_revision = self .compiler @@ -184,23 +291,107 @@ impl RuntimeSnapshotPublisher { self.published_revision().map(ConfigRevision::get), persisted_revision.get(), ); - if !changed && !self.provider_catalogs_need_refresh() { + if !changed + && !self.provider_catalogs_need_refresh() + && active.committed_revision.is_none() + && self + .snapshots + .acquire() + .is_ok_and(|snapshot| snapshot.extensions().is_none_or(|set| set.is_ready())) + { + active.finish(); return Ok(persisted_revision); } changed } else { true }; - let snapshot = self.compiler.compile().await.inspect_err(|_| { - // 仅目录代次变化时保留旧快照,下一周期继续对账;配置缺失、变化 - // 或持久 revision 回退均须 fail closed,不按 revision 大小丢弃刷新。 - if configuration_changed { - self.snapshots.suspend(); + for attempt in 0..MAXIMUM_DEFERRED_REFRESH_ATTEMPTS { + let snapshot = match self.compiler.compile().await { + Ok(snapshot) => snapshot, + Err(RuntimeSnapshotCompileError::RevisionChanged) + if attempt + 1 < MAXIMUM_DEFERRED_REFRESH_ATTEMPTS => + { + let pending = lock_unpoisoned(&self.deferred).pending_revision.take(); + if let Some(pending) = pending { + active.merge(pending); + continue; + } + if configuration_changed { + self.snapshots.suspend(); + } + return Err(RuntimeSnapshotCompileError::RevisionChanged); + } + Err(error) => { + // 仅目录代次变化时保留旧快照,下一周期继续对账;配置缺失、变化 + // 或持久 revision 回退均须 fail closed,不按 revision 大小丢弃刷新。 + if configuration_changed { + self.snapshots.suspend(); + } + return Err(error); + } + }; + let revision = snapshot.revision(); + self.snapshots.publish(snapshot); + if let Some(committed_revision) = active.committed_revision.take() { + self.notify_committed_revision(committed_revision).await; } - })?; - let revision = snapshot.revision(); - self.snapshots.publish(snapshot); - Ok(revision) + loop { + let pending = { + let mut deferred = lock_unpoisoned(&self.deferred); + match deferred.pending_revision.take() { + Some(pending) => pending, + None => { + deferred.active = false; + active.armed = false; + return Ok(revision); + } + } + }; + if pending <= revision { + // 编译器自身可能因目录代次变化重读事实,已经包含该提交时 + // 只发送通知,不再做一次重复编译。通知期间的新提交仍回到同一单槽。 + self.notify_committed_revision(pending).await; + continue; + } + active.merge(pending); + break; + } + } + self.snapshots.suspend(); + tracing::warn!("deferred runtime snapshot refresh attempt limit reached"); + Err(RuntimeSnapshotCompileError::RevisionChanged) + } + + async fn publish_committed_deferred_inner(&self, committed_revision: ConfigRevision) { + let should_refresh = { + let mut deferred = lock_unpoisoned(&self.deferred); + deferred.pending_revision = + latest_revision(deferred.pending_revision, committed_revision); + if deferred.active || deferred.scheduled { + false + } else { + deferred.scheduled = true; + true + } + }; + if !should_refresh { + return; + } + + let mut schedule = DeferredScheduleGuard::new(Arc::clone(&self.deferred)); + let _refresh = self.refresh_lock.lock().await; + schedule.finish(); + let has_pending = { + let deferred = lock_unpoisoned(&self.deferred); + deferred.pending_revision.is_some() + }; + if !has_pending { + return; + } + if let Err(error) = self.refresh_locked(RefreshMode::Required).await { + tracing::warn!(?error, "deferred runtime snapshot publication failed"); + } } #[must_use] @@ -210,17 +401,35 @@ impl RuntimeSnapshotPublisher { #[must_use] fn provider_catalogs_need_refresh(&self) -> bool { - self.snapshots.provider_catalog_generations().as_ref() - != Some(&self.compiler.provider_catalog_generations()) + let Ok(snapshot) = self.snapshots.acquire() else { + return true; + }; + self.compiler + .provider_catalog_generations(snapshot.extensions()) + .as_ref() + != Ok(snapshot.provider_catalog_generations()) } /// 数据库提交不能被目录或通知基础设施的暂时故障伪装成回滚。 async fn publish_committed_inner(&self, committed_revision: ConfigRevision) { - let _ = self.refresh().await; - let _ = self + if let Err(error) = self.refresh().await { + tracing::warn!(?error, "committed runtime snapshot refresh failed"); + } + self.notify_committed_revision(committed_revision).await; + } + + async fn notify_committed_revision(&self, committed_revision: ConfigRevision) { + if let Err(error) = self .subscriptions .publish_snapshot_revision(committed_revision) - .await; + .await + { + tracing::warn!( + ?error, + revision = committed_revision.get(), + "runtime snapshot revision notification failed" + ); + } } /// 交给 Host 的周期对账与长驻订阅任务。 @@ -272,6 +481,13 @@ impl SnapshotControl for RuntimeSnapshotPublisher { self.publish_committed_inner(committed_revision).await; }) } + + fn publish_committed_deferred(&self, committed_revision: ConfigRevision) -> BoxFuture<'_, ()> { + Box::pin(async move { + self.publish_committed_deferred_inner(committed_revision) + .await; + }) + } } struct RuntimeSnapshotReconciliationTask { @@ -366,3 +582,15 @@ fn write_unpoisoned(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { lock.write() .unwrap_or_else(std::sync::PoisonError::into_inner) } + +fn lock_unpoisoned(lock: &SyncMutex) -> std::sync::MutexGuard<'_, T> { + lock.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn latest_revision( + current: Option, + candidate: ConfigRevision, +) -> Option { + Some(current.map_or(candidate, |current| current.max(candidate))) +} diff --git a/backend/crates/gateway-core/src/validation.rs b/backend/crates/gateway-core/src/validation.rs index 04b19a5be..622140256 100644 --- a/backend/crates/gateway-core/src/validation.rs +++ b/backend/crates/gateway-core/src/validation.rs @@ -107,6 +107,14 @@ pub enum RoutingError { /// adapter 已绑定的 Provider。 provider: String, }, + /// 固定 Provider 可用,但未声明该原生端点操作所需能力。 + #[error("provider endpoint `{provider}` does not support `{operation}`")] + UnsupportedProviderEndpoint { + /// adapter 已绑定的 Provider。 + provider: String, + /// 请求的稳定操作名。 + operation: &'static str, + }, } /// 调用方策略不满足约束或拒绝请求。 diff --git a/backend/crates/gateway-core/tests/engine/coordinator.rs b/backend/crates/gateway-core/tests/engine/coordinator.rs index 4459462ba..899330ac8 100644 --- a/backend/crates/gateway-core/tests/engine/coordinator.rs +++ b/backend/crates/gateway-core/tests/engine/coordinator.rs @@ -294,6 +294,8 @@ enum Script { struct ScriptedProvider { profile_generation: AtomicUsize, + default_profile_calls: AtomicUsize, + default_profile: Mutex>, scripts: Mutex>, contexts: Mutex>, operations: Mutex>, @@ -312,6 +314,8 @@ impl ScriptedProvider { fn new(scripts: Vec".to_vec(), + ), + ( + "ui/app.js".to_owned(), + b"parent.postMessage({kind:'loaded'}, '*')".to_vec(), + ), + ( + "ui/public.svg".to_owned(), + b"".to_vec(), + ), + ]); + let digests: BTreeMap<_, _> = files + .iter() + .map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes)))) + .collect(); + let manifest = json!({ + "manifestVersion":3,"name":"example","displayName":"管理示例","publisher":"management","version":"1.0.0", + "engines":{"codex-proxy-rs":">=1.0.0, <2.0.0"},"main":"bin/worker","author":"test","description":"管理测试","license":"MIT", + "runtime":"trustedProcess","resources":{"ui/index.html":"text/html","ui/app.js":"text/javascript","ui/public.svg":"image/svg+xml"}, + "contributes":{"management":{"id":"management.example.management","version":1,"stages":["management"],"inputFormats":[],"outputFormats":[]}}, + "permissions":["public_endpoints"], + "package":{"protocolVersion":4,"target":{"os":std::env::consts::OS,"architecture":std::env::consts::ARCH},"files":digests}, + }); + files.insert("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect(support::archive(files), None) + .await + .unwrap(); + let store = environment.store.admin_ports().plugins(); + let installed = store + .install_artifact(artifact, PluginSource::Upload, &mutation()) + .await + .unwrap(); + let installed = store + .accept_artifact(&installed.artifact.metadata.sha256, &mutation()) + .await + .unwrap(); + let instance = PluginInstance { + id: uuid::Uuid::new_v4().to_string(), + name: "管理页面".into(), + artifact_sha256: installed.artifact.metadata.sha256, + enabled: true, + trusted_process: true, + configuration: config, + secrets: BTreeMap::new(), + bindings: vec![], + grants: vec![serde_json::from_value(json!({"permission":"public_endpoints"})).unwrap()], + revision: Revision::new(1).unwrap(), + }; + store + .save_instance(instance, installed.config_revision, &mutation()) + .await + .unwrap() + .instance +} + +fn request() -> PluginManagementRequest { + PluginManagementRequest { + method: "POST".into(), + path: "echo".into(), + query: "unicode=%E4%B8%AD".into(), + content_type: Some("application/octet-stream".into()), + body: vec![0, 255, 128, 10], + request_id: "management-test".into(), + } +} + +#[tokio::test] +async fn management_model_stream_retains_selected_identity_until_completion_and_rechecks_revocation() + { + let Some(mut environment) = Environment::create_command().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account = environment.account(None).await; + let key_id = format!("key_{}", uuid::Uuid::new_v4().simple()); + environment + .client_key(&key_id, "sk-management-bound-fixture") + .await; + let bound_nested = environment + .directory + .path() + .join("management-bound-model.jsonl"); + let bound_calls = environment.directory.path().join("management-bound.jsonl"); + let unbound_nested = environment + .directory + .path() + .join("management-unbound-model.jsonl"); + let model_grant = account_grant("models"); + let bound_credentials = account_grant("accounts"); + let unbound_credentials = account_grant("accounts"); + let nested_request = json!({ + "stream":true, + "request":{ + "client_key_id":key_id, + "model":"plugin-model", + "protocol":"openai", + "operation":"generate", + "provider":"example", + "account_id":account.as_str() + }, + "body":{"model":"plugin-model","input":"bound management"} + }); + let model_route = json!({ + "routes":[{ + "method":"POST", + "path":"echo", + "request_content_types":["application/octet-stream"], + "response_content_types":["application/octet-stream"] + }] + }); + environment + .install_provider( + json!({ + "provider_id":"management-source", + "management_registration":model_route, + "management_nested_model_fixture":nested_request, + "management_nested_model_marker":bound_nested, + "nested_stream_trace_marker":environment.directory.path().join("management-stream.jsonl"), + "management_marker":bound_calls, + }), + vec![model_grant.clone(), bound_credentials], + ) + .await; + let mut denied_request = nested_request; + denied_request["stream"] = json!(false); + denied_request["request"] + .as_object_mut() + .unwrap() + .remove("client_key_id"); + denied_request["expect_error"] = json!(true); + environment + .install_provider( + json!({ + "provider_id":"management-unbound", + "management_registration":model_route, + "management_nested_model_fixture":denied_request, + "management_nested_model_marker":unbound_nested, + }), + vec![model_grant, unbound_credentials], + ) + .await; + environment + .install_provider( + json!({"provider_id":"example","generation_marker":"management-target"}), + vec![account_grant("accounts")], + ) + .await; + let (runtime, core) = environment.runtime().await; + let service = PluginManagementService::new( + runtime.clone(), + environment.store.admin_ports().plugins(), + core.snapshots(), + ); + let instances = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let bound_id = &instances + .instances + .iter() + .find(|instance| instance.configuration["provider_id"] == "management-source") + .unwrap() + .id; + let unbound_id = &instances + .instances + .iter() + .find(|instance| instance.configuration["provider_id"] == "management-unbound") + .unwrap() + .id; + let views = service.views().await.unwrap(); + let bound = &views + .iter() + .find(|view| &view.target.instance_id == bound_id) + .unwrap() + .target; + let unbound = &views + .iter() + .find(|view| &view.target.instance_id == unbound_id) + .unwrap() + .target; + environment.store.start_command_line_writes().unwrap(); + assert_eq!( + tokio::time::timeout( + std::time::Duration::from_secs(20), + service.handle(bound, request()), + ) + .await + .unwrap_or_else(|_| { + let trace = std::fs::read_to_string( + environment.directory.path().join("management-stream.jsonl"), + ); + panic!("管理模型流未完成:{trace:?}"); + }) + .unwrap() + .status, + 200, + ); + assert_eq!( + service.handle(unbound, request()).await.unwrap().status, + 200 + ); + assert_eq!( + std::fs::read_to_string(&bound_nested) + .unwrap() + .lines() + .count(), + 1 + ); + let unbound_marker: Value = + serde_json::from_str(std::fs::read_to_string(&unbound_nested).unwrap().trim()).unwrap(); + assert_eq!(unbound_marker["error"], "invalid_input"); + assert_eq!( + environment.wait_for_bound_model_requests(&key_id, 1).await, + vec![("plugin_bound_model".into(), bound_id.clone())] + ); + + let (revision, _) = environment + .store + .admin_ports() + .client_keys() + .set_client_key_enabled( + SetClientKeyEnabled { + id: ClientApiKeyId::new(key_id).unwrap(), + enabled: false, + }, + &mutation(), + ) + .await + .unwrap(); + core.snapshot_control() + .publish_committed(ConfigRevision::new(revision.get()).unwrap()) + .await; + assert!(service.handle(bound, request()).await.is_err()); + assert_eq!( + std::fs::read_to_string(bound_calls) + .unwrap() + .lines() + .count(), + 2, + "Key revocation is checked by the model callback, not before entering the plugin" + ); + + drop(service); + drop(core); + drop(runtime); + environment + .store + .shutdown_command_line_writes() + .await + .unwrap(); + environment.close().await; +} + +#[tokio::test] +async fn management_resources_and_raw_calls_are_version_bound_and_revocation_removes_pages() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let marker = environment.directory.path().join("management-calls.jsonl"); + install( + &environment, + json!({"management_registration":registration(),"management_marker":marker}), + ) + .await; + let (runtime, core) = environment.runtime().await; + let service = PluginManagementService::new( + runtime.clone(), + environment.store.admin_ports().plugins(), + core.snapshots(), + ); + let views = service.views().await.unwrap(); + assert_eq!(views.len(), 1); + assert_eq!(views[0].pages[0].id, "overview"); + assert_eq!( + views[0].pages[0].description.as_deref(), + Some("查看插件状态与调用结果") + ); + assert_eq!(views[0].resources.len(), 3); + let target = &views[0].target; + assert_eq!( + service + .resource(target, "ui/index.html", false) + .await + .unwrap() + .content_type, + "text/html" + ); + assert!( + service + .resource(target, "ui/index.html", true) + .await + .is_err() + ); + assert_eq!( + service + .resource(target, "ui/public.svg", true) + .await + .unwrap() + .body + .as_ref(), + b"" + ); + for path in [ + "../bin/worker", + "ui/../app.js", + "%2e%2e/bin/worker", + "ui//index.html", + "bin/worker", + ] { + assert!(service.resource(target, path, false).await.is_err()); + } + assert!(!marker.exists(), "静态资源读取不能执行插件 handler"); + let response = service.handle(target, request()).await.unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body.as_ref(), &[0, 255, 128, 10]); + let mut invalid = request(); + invalid.content_type = Some("text/html".into()); + assert!(service.handle(target, invalid).await.is_err()); + let mut invalid = request(); + invalid.path = "unregistered".into(); + assert!(service.handle(target, invalid).await.is_err()); + assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1); + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.enabled = false; + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + // 模拟持久撤销已提交但旧发布视图仍存在;页面与资源都不能继续使用旧权限。 + assert!(service.views().await.unwrap().is_empty()); + assert!(service.handle(target, request()).await.is_err()); + assert!( + service + .resource(target, "ui/public.svg", true) + .await + .is_err() + ); + assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1); + drop(service); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs new file mode 100644 index 000000000..11baa9ce0 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/management/registration.rs @@ -0,0 +1,77 @@ +use gateway_admin::ports::plugins::PluginPreparation; +use serde_json::json; + +use super::{Environment, install, registration}; + +#[tokio::test] +async fn candidate_rejects_undeclared_resources_invalid_routes_and_missing_public_grants() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + install( + &environment, + json!({"management_registration":registration()}), + ) + .await; + let (runtime, core) = environment.runtime().await; + let source = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + for (field, value) in [ + ( + "routes", + json!([{"method":"CONNECT","path":"tunnel","response_content_types":["application/json"]}]), + ), + ( + "routes", + json!([{"method":"GET","path":"../admin","response_content_types":["application/json"]}]), + ), + ( + "routes", + json!([{"method":"GET","path":"ok","response_content_types":["text/html\r\nSet-Cookie: attack"]}]), + ), + ("resources", json!([{"path":"bin/worker"}])), + ( + "pages", + json!([{"id":"bad","title":"Bad","entry":"ui/app.js"}]), + ), + ( + "pages", + json!([{"id":"bad","title":"Bad","description":" ","entry":"ui/index.html"}]), + ), + ( + "pages", + json!([{"id":"bad","title":"Bad","description":"line\ncontrol","entry":"ui/index.html"}]), + ), + ( + "pages", + json!([{"id":"bad","title":"Bad","description":"x".repeat(513),"entry":"ui/index.html"}]), + ), + ] { + let mut candidate = source.clone(); + candidate.instances[0].configuration["management_registration"][field] = value; + assert!( + runtime + .prepare(source.config_revision, candidate) + .await + .is_err(), + "{field}" + ); + } + let mut candidate = source.clone(); + candidate.instances[0].grants.clear(); + assert!( + runtime + .prepare(source.config_revision, candidate) + .await + .is_err() + ); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/mod.rs new file mode 100644 index 000000000..22dd2c98e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/mod.rs @@ -0,0 +1,3 @@ +mod command_line; +mod management; +mod provider; diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/authorization.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/authorization.rs new file mode 100644 index 000000000..170702f1c --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/authorization.rs @@ -0,0 +1,599 @@ +use std::{sync::Arc, time::Duration}; + +use futures::future::BoxFuture; +use gateway_admin::{ + CredentialsService, + model::{ + MutationActor, MutationContext, + provider_credentials::{ + AuthorizationPollResult, PollAuthorization, PrepareCredentialRefresh, + PreparedAuthorizationPoll, StartAuthorization, + }, + }, + ports::provider::ProviderAdminErrorKind, +}; +use gateway_core::{ + account::{ProviderAccountId, ProviderAccountUpdate}, + provider_ports::{ + NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, + OAuthPendingConsumeOutcome, OAuthPendingFlowPort, OAuthPendingPutOutcome, + OAuthPendingReleaseOutcome, ProviderStoreError, ProviderStorePorts, + }, + routing::ProviderKind, +}; +use serde_json::json; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn owner(name: &str) -> MutationContext { + MutationContext { + actor: MutationActor::AdminSession { + admin_user_id: name.into(), + }, + request_id: uuid::Uuid::new_v4().to_string(), + } +} + +async fn environment() -> Option { + let environment = Environment::create().await?; + environment.administrator("first-admin").await; + environment.administrator("second-admin").await; + Some(environment) +} + +fn start(reauthorization: Option) -> StartAuthorization { + StartAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), + context: owner("first-admin"), + name: "login account".into(), + reauthorization, + outbound_proxy: None, + } +} + +fn poll(flow: &str, ready: bool) -> PollAuthorization { + PollAuthorization { + context: owner("first-admin"), + flow_id: flow.into(), + callback_url: ready.then(|| "ready".into()), + settings: None, + } +} + +fn service( + environment: &Environment, + runtime: &gateway_plugin_runtime::PluginRuntime, + core: &gateway_core::CoreBundle, +) -> CredentialsService { + let ports = environment.store.admin_ports(); + CredentialsService::new( + runtime.admin_registry(core.snapshots()), + ports.accounts(), + ports.proxies(), + core.snapshot_control(), + ) +} + +#[tokio::test] +async fn login_polls_with_owner_isolation_and_commits_all_accounts_once() { + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment.provider(json!({"login_accounts":[ + {"name":"first", "authentication_kind":"api_key", "material":{"key":"first-test-key"}}, + {"name":"second", "authentication_kind":"api_key", "material":{"key":"second-test-key"}} + ]}), vec![account_grant("accounts")]).await; + let credentials = service(&environment, &runtime, &core); + let provider = credentials + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap(); + let before = core.snapshots().acquire().unwrap().revision(); + let started = provider.start_authorization(start(None)).await.unwrap(); + let mut intruder = poll(&started.flow_id, true); + intruder.context = owner("second-admin"); + assert!( + credentials + .poll_authorization(&ProviderKind::new("example").unwrap(), intruder) + .await + .is_err() + ); + for _ in 0..2 { + assert_eq!( + credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&started.flow_id, false) + ) + .await + .unwrap(), + AuthorizationPollResult::Pending { + retry_after: Duration::from_secs(1) + } + ); + } + assert_eq!(core.snapshots().acquire().unwrap().revision(), before); + let AuthorizationPollResult::Complete(result) = credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&started.flow_id, true), + ) + .await + .unwrap() + else { + panic!("expected completed login"); + }; + assert_eq!(result.accounts.len(), 2); + for (index, account) in result.accounts.iter().enumerate() { + assert_eq!(account.credential_revision.unwrap().get(), 1); + let loaded = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&account.account_id) + .await + .unwrap(); + assert_eq!( + loaded.credential.expose_to_provider()["key"], + if index == 0 { + "first-test-key" + } else { + "second-test-key" + } + ); + } + assert!(core.snapshots().acquire().unwrap().revision().get() > before.get()); + assert_eq!( + credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&started.flow_id, true) + ) + .await + .unwrap(), + AuthorizationPollResult::Complete(result) + ); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn encrypted_login_survives_restart_but_rejects_changed_plugin_configuration() { + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let credentials = service(&environment, &runtime, &core); + let kind = ProviderKind::new("example").unwrap(); + let provider = credentials.for_provider(&kind).unwrap(); + let survives = provider.start_authorization(start(None)).await.unwrap(); + let invalidated = provider.start_authorization(start(None)).await.unwrap(); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + let (runtime, core) = environment.runtime().await; + let credentials = service(&environment, &runtime, &core); + let provider = credentials.for_provider(&kind).unwrap(); + assert!(matches!( + credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&survives.flow_id, true) + ) + .await + .unwrap(), + AuthorizationPollResult::Complete(_) + )); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.configuration = json!({"new_configuration":true}); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + let (runtime, core) = environment.runtime().await; + let credentials = service(&environment, &runtime, &core); + let provider = credentials.for_provider(&kind).unwrap(); + assert!( + credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&invalidated.flow_id, true) + ) + .await + .is_err() + ); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} + +#[tokio::test] +async fn claimed_login_blocks_other_pollers_and_reauthorization_preserves_latest_profile() { + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let id = environment.account(None).await; + let credentials = service(&environment, &runtime, &core); + let kind = ProviderKind::new("example").unwrap(); + let provider = credentials.for_provider(&kind).unwrap(); + let started = provider + .start_authorization(start(Some(id.clone()))) + .await + .unwrap(); + let registry = runtime.admin_registry(core.snapshots()); + let adapter = registry.require(&kind).unwrap(); + let prepared = adapter + .poll_authorization(poll(&started.flow_id, true)) + .await + .unwrap(); + assert!(matches!(prepared, PreparedAuthorizationPoll::Complete(_))); + let error = adapter + .poll_authorization(poll(&started.flow_id, true)) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), ProviderAdminErrorKind::Conflict); + let account = environment + .store + .admin_ports() + .accounts() + .credential_details(&kind, &id) + .await + .unwrap() + .unwrap() + .credential; + assert_eq!( + adapter + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Conflict + ); + environment + .store + .provider_ports() + .accounts() + .update_account(ProviderAccountUpdate { + account_id: id.clone(), + name: "administrator edited during login".into(), + email: None, + plan_type: None, + }) + .await + .unwrap(); + // 丢弃尚未提交的结果同时释放登录 claim 与账号 lease,下一次轮询可接续。 + drop(prepared); + let result = tokio::time::timeout(Duration::from_secs(3), async { + loop { + match adapter + .poll_authorization(poll(&started.flow_id, false)) + .await + { + Ok(result) => break result, + Err(error) if error.kind() == ProviderAdminErrorKind::Conflict => { + tokio::time::sleep(Duration::from_millis(20)).await + } + Err(error) => panic!("poll after release failed: {error:?}"), + } + } + }) + .await + .unwrap(); + assert!(matches!(result, PreparedAuthorizationPoll::Pending { .. })); + let AuthorizationPollResult::Complete(result) = credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll(&started.flow_id, true), + ) + .await + .unwrap() + else { + panic!("expected complete"); + }; + assert_eq!(result.accounts[0].account_id, id); + assert_eq!(result.accounts[0].credential_revision.unwrap().get(), 2); + let loaded = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&id) + .await + .unwrap(); + assert_eq!(loaded.account.name(), "administrator edited during login"); + assert_eq!( + loaded.credential.expose_to_provider()["key"], + "login-test-only" + ); + drop(adapter); + drop(registry); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn invalid_login_results_cannot_commit_accounts() { + for configuration in [ + json!({"login_accounts":[]}), + json!({"login_retry_ms":0}), + json!({"login_ttl_ms":-1}), + ] { + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(configuration.clone(), vec![account_grant("accounts")]) + .await; + let credentials = service(&environment, &runtime, &core); + let provider = credentials + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap(); + let revision = core.snapshots().acquire().unwrap().revision(); + match provider.start_authorization(start(None)).await { + Ok(started) => { + for _ in 0..2 { + assert!( + credentials + .poll_authorization( + &ProviderKind::new("example").unwrap(), + poll( + &started.flow_id, + configuration.get("login_accounts").is_some() + ) + ) + .await + .is_err() + ); + } + } + Err(_) => assert!(configuration.get("login_ttl_ms").is_some()), + } + assert_eq!(core.snapshots().acquire().unwrap().revision(), revision); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn expired_login_cannot_be_reclaimed_or_commit_credentials() { + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider( + json!({"login_ttl_ms":1000}), + vec![account_grant("accounts")], + ) + .await; + let credentials = service(&environment, &runtime, &core); + let kind = ProviderKind::new("example").unwrap(); + let provider = credentials.for_provider(&kind).unwrap(); + let before = core.snapshots().acquire().unwrap().revision(); + let started = provider.start_authorization(start(None)).await.unwrap(); + let remaining = (started.expires_at - chrono::Utc::now()) + .to_std() + .unwrap_or_default(); + tokio::time::sleep(remaining + Duration::from_millis(30)).await; + let registry = runtime.admin_registry(core.snapshots()); + let adapter = registry.require(&kind).unwrap(); + assert_eq!( + adapter + .poll_authorization(poll(&started.flow_id, true)) + .await + .err() + .unwrap() + .kind(), + ProviderAdminErrorKind::NotFound + ); + assert_eq!(core.snapshots().acquire().unwrap().revision(), before); + drop(adapter); + drop(registry); + drop(provider); + drop(credentials); + drop(core); + drop(runtime); + environment.close().await; +} + +struct PausedConsumption { + inner: Arc, + reached: tokio::sync::Notify, +} + +impl OAuthPendingFlowPort for PausedConsumption { + fn put_if_absent( + &self, + flow: NewOAuthPendingFlow, + ) -> BoxFuture<'_, Result> { + self.inner.put_if_absent(flow) + } + fn claim_if_owner<'a>( + &'a self, + provider: &'a ProviderKind, + flow: &'a OAuthPendingBinding, + owner: &'a OAuthPendingBinding, + claim: &'a OAuthPendingBinding, + ttl: Duration, + ) -> BoxFuture<'a, Result> { + self.inner.claim_if_owner(provider, flow, owner, claim, ttl) + } + fn release_claim<'a>( + &'a self, + provider: &'a ProviderKind, + flow: &'a OAuthPendingBinding, + owner: &'a OAuthPendingBinding, + claim: &'a OAuthPendingBinding, + ) -> BoxFuture<'a, Result> { + self.inner.release_claim(provider, flow, owner, claim) + } + fn consume_claim<'a>( + &'a self, + _: &'a ProviderKind, + _: &'a OAuthPendingBinding, + _: &'a OAuthPendingBinding, + _: &'a OAuthPendingBinding, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + self.reached.notify_one(); + // 精确停在 PostgreSQL 提交之后;中断此 future 模拟宿主丢失后续结算。 + std::future::pending().await + }) + } +} + +#[tokio::test] +async fn committed_login_recovers_after_interruption_before_redis_consumption_and_plugin_shutdown() +{ + let Some(environment) = environment().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let credentials = service(&environment, &runtime, &core); + let kind = ProviderKind::new("example").unwrap(); + let started = credentials + .for_provider(&kind) + .unwrap() + .start_authorization(start(None)) + .await + .unwrap(); + drop(credentials); + drop(core); + drop(runtime); + let ports = environment.store.provider_ports(); + let pending = Arc::new(PausedConsumption { + inner: ports.oauth_pending(), + reached: tokio::sync::Notify::new(), + }); + let injected = ProviderStorePorts::new( + ports.accounts(), + ports.leases(), + ports.session_affinity(), + ports.session_exclusions(), + ports.catalog_cache(), + ports.artifact_profiles(), + ports.credential_state(), + ports.cooldowns(), + ports.runtime_policy(), + pending.clone(), + ); + let (runtime, core) = environment.runtime_with_ports(injected).await; + let credentials = Arc::new(service(&environment, &runtime, &core)); + let request = poll(&started.flow_id, true); + let operation = { + let credentials = credentials.clone(); + let kind = kind.clone(); + tokio::spawn(async move { credentials.poll_authorization(&kind, request).await }) + }; + tokio::time::timeout(Duration::from_secs(10), pending.reached.notified()) + .await + .unwrap(); + let key = gateway_admin::model::provider_credentials::AuthorizationReceiptKey::new( + kind.clone(), + &started.flow_id, + &owner("first-admin"), + ) + .unwrap(); + let receipt = environment + .store + .admin_ports() + .accounts() + .authorization_receipt(&key) + .await + .unwrap() + .unwrap(); + assert!(core.snapshots().acquire().unwrap().revision().get() < receipt.config_revision.get()); + operation.abort(); + assert!(operation.await.unwrap_err().is_cancelled()); + let claim = pending + .inner + .claim_if_owner( + &kind, + &OAuthPendingBinding::try_new(&started.flow_id).unwrap(), + &OAuthPendingBinding::try_new("admin_session:first-admin").unwrap(), + &OAuthPendingBinding::try_new("another-claim").unwrap(), + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!(claim, OAuthPendingClaimOutcome::InProgress); + assert_eq!( + credentials + .poll_authorization(&kind, poll(&started.flow_id, true)) + .await + .unwrap(), + AuthorizationPollResult::Complete(receipt.clone()) + ); + assert_eq!( + core.snapshots().acquire().unwrap().revision().get(), + receipt.config_revision.get() + ); + drop(credentials); + drop(core); + drop(runtime); + drop(pending); + drop(ports); + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.enabled = false; + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + let (runtime, core) = environment.runtime().await; + let credentials = service(&environment, &runtime, &core); + assert!(credentials.for_provider(&kind).is_err()); + assert_eq!( + credentials + .poll_authorization(&kind, poll(&started.flow_id, true)) + .await + .unwrap(), + AuthorizationPollResult::Complete(receipt.clone()) + ); + let account = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&receipt.accounts[0].account_id) + .await + .unwrap(); + assert_eq!(account.account.revision().get(), 1); + drop(credentials); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/billing.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/billing.rs new file mode 100644 index 000000000..cc741bbcc --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/billing.rs @@ -0,0 +1,294 @@ +use std::{ + num::NonZeroU32, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use futures::StreamExt as _; +use gateway_admin::{ + model::observability::{CurrencyCost, ProviderBillingInput}, + ports::plugins::PluginPreparation, +}; +use gateway_core::{ + account::{AccountSelectionPolicy, RotationStrategy}, + engine::{ + AccountAttemptContext, AttemptContext, ModelRequestId, RequestAttemptContext, + provider::ProviderRequest, + }, + event::{GatewayEvent, ProviderEvent}, + lifecycle::CancellationToken, + metering::PricingOverrides, + policy::ClientApiKeyId, + routing::{ProviderKind, PublicModelId, RoutingContext, UpstreamModelId}, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn configuration() -> Value { + json!({ + "billing":{"rule":"token_v1", "prices":{"plugin-model":{ + "standard":{"input":"1", "output":"2", "cache_read":"0.5", "cache_write":"1.5"} + }}}, + "execution_events":[ + {"facts":[{"type":"started", "id":"response-billing", "model":"untrusted-response-model"}]}, + {"facts":[{"type":"billable_usage", "band":"standard", "usage":{ + "input_tokens":1000, "output_tokens":500, "cached_tokens":100, "cache_write_tokens":50 + }}]}, + {"facts":[{"type":"completed", "id":"response-billing", "model":"untrusted-response-model", "reason":"stop"}]} + ] + }) +} + +async fn execute( + runtime: &gateway_plugin_runtime::PluginRuntime, + core: &gateway_core::CoreBundle, + pricing: PricingOverrides, +) -> Vec { + let kind = ProviderKind::new("example").unwrap(); + let snapshot = core.snapshots().acquire().unwrap(); + let admin = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let operation = admin + .connection_test_operation(&UpstreamModelId::new("plugin-model").unwrap(), "billing") + .await + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let registry = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = registry.get(&kind).unwrap(); + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_plugin_billing").unwrap(), + ClientApiKeyId::new("key_plugin_billing").unwrap(), + ) + .with_pricing(Arc::new(pricing)), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(15), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::new(1).unwrap(), + Duration::ZERO, + ), + AccountAttemptContext::default().with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ); + Arc::clone(provider) + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + context, + ) + .await + .unwrap() + .map(|event| event.unwrap()) + .collect() + .await +} + +#[tokio::test] +async fn token_pricing_uses_sent_model_and_frozen_prices_without_changing_usage() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let (runtime, core) = environment + .provider(configuration(), vec![account_grant("accounts")]) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let frozen_admin = registry.freeze().unwrap(); + assert_eq!( + String::from( + frozen_admin.pricing_catalog().unwrap()["example"]["plugin-model"].bands["standard"] + .input + ), + "1" + ); + assert!( + frozen_admin + .calculated_billing( + &ProviderKind::new("example").unwrap(), + &ProviderBillingInput { + upstream_model_id: "plugin-model".to_owned(), + service_tier: None, + input_tokens: Some(1_000), + output_tokens: Some(500), + cached_tokens: Some(100), + cache_write_tokens: Some(50), + total: CurrencyCost { + currency: "USD".to_owned(), + amount: "0.001975".parse().unwrap(), + }, + }, + ) + .unwrap() + .is_none(), + "current plugin pricing must not reconstruct a request without its persisted snapshot" + ); + let prices = serde_json::from_value(json!({"example":{"plugin-model":{ + "multiplierBps":20000, + "bands":{"standard":{"input":"3","output":"4","cacheRead":"0.5","cacheWrite":"1.5"}} + }}})) + .unwrap(); + let events = execute(&runtime, &core, prices).await; + let facts: Vec<_> = events + .iter() + .flat_map(ProviderEvent::canonical_facts) + .collect(); + let usage = facts + .iter() + .find_map(|event| match event { + GatewayEvent::Usage(usage) => Some(usage), + _ => None, + }) + .unwrap(); + assert_eq!( + ( + usage.input_tokens, + usage.output_tokens, + usage.cached_tokens, + usage.cache_write_tokens + ), + (Some(1000), Some(500), Some(100), Some(50)) + ); + let cost = facts + .iter() + .find_map(|event| match event { + GatewayEvent::CalculatedCost(cost) => Some(cost), + _ => None, + }) + .unwrap(); + assert_eq!(cost.total().amount().canonical(), "0.00935"); + let estimate = (*cost).clone().into_estimate(); + let breakdown = estimate.breakdown().unwrap(); + // Core 明细保存应用自定义倍率后的有效单价,原始价目仍在冻结配置中。 + assert_eq!( + breakdown.input_price_per_million().amount().canonical(), + "6" + ); + assert_eq!(breakdown.custom_multiplier_bps(), 20000); + + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.configuration["billing"]["prices"]["plugin-model"]["standard"]["input"] = json!("100"); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + let revision = store.load_instances().await.unwrap().config_revision; + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + assert_eq!( + String::from( + registry.pricing_catalog().unwrap()["example"]["plugin-model"].bands["standard"].input + ), + "100" + ); + assert_eq!( + String::from( + frozen_admin.pricing_catalog().unwrap()["example"]["plugin-model"].bands["standard"] + .input + ), + "1" + ); + assert_eq!(cost.total().amount().canonical(), "0.00935"); + drop(frozen_admin); + drop(registry); + drop(store); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn missing_usage_unknown_bands_and_non_token_charges_keep_cost_unknown() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let mut config = configuration(); + config["execution_events"] = json!([ + {"facts":[{"type":"started","id":"response-unknown","model":"plugin-model"}]}, + {"facts":[{"type":"billable_usage","band":"standard","usage":{"input_tokens":1,"output_tokens":1}}]}, + {"facts":[{"type":"billable_usage","band":"flex","usage":{"input_tokens":1,"output_tokens":1,"cached_tokens":0,"cache_write_tokens":0}}]}, + {"facts":[{"type":"billable_usage","band":"standard","usage":{"input_tokens":1,"output_tokens":1,"cached_tokens":2,"cache_write_tokens":0}}]}, + {"facts":[{"type":"billable_usage","band":"standard","usage":{"input_tokens":1,"output_tokens":1,"cached_tokens":0,"cache_write_tokens":0,"image_input_tokens":1}}]}, + {"facts":[{"type":"completed","id":"response-unknown","model":"plugin-model","reason":"stop"}]} + ]); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let events = execute(&runtime, &core, PricingOverrides::new()).await; + assert_eq!( + events + .iter() + .flat_map(ProviderEvent::canonical_facts) + .filter(|event| matches!(event, GatewayEvent::Usage(_))) + .count(), + 4 + ); + assert!( + !events + .iter() + .flat_map(ProviderEvent::canonical_facts) + .any(|event| matches!(event, GatewayEvent::CalculatedCost(_))) + ); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn invalid_price_data_never_replaces_the_published_catalog() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(configuration(), vec![account_grant("accounts")]) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let before = registry.pricing_catalog().unwrap(); + let snapshot = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + for invalid in [ + json!("-1"), + json!("0.00001"), + json!("1000001"), + json!("NaN"), + ] { + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration["billing"]["prices"]["plugin-model"]["standard"]["input"] = + invalid; + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + assert_eq!(registry.pricing_catalog().unwrap(), before); + } + drop(registry); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/capabilities.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/capabilities.rs new file mode 100644 index 000000000..85e873dc7 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/capabilities.rs @@ -0,0 +1,426 @@ +use gateway_admin::{ + CredentialsService, + model::{ + AdminErrorKind, + provider_capabilities::AuthorizationCompletion, + provider_credentials::{ + AuthorizationPollResult, PollAuthorization, ProviderDocument, StartAuthorization, + }, + }, + ports::plugins::PluginPreparation, +}; +use gateway_core::{account::OpaqueProviderData, routing::ProviderKind}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn start(input: Value) -> StartAuthorization { + StartAuthorization { + context: mutation(), + name: "form login".into(), + reauthorization: None, + outbound_proxy: None, + input: ProviderDocument::new(OpaqueProviderData::new(input.as_object().unwrap().clone())), + } +} + +#[tokio::test] +async fn declared_login_inputs_are_validated_before_rpc_and_reach_the_rust_plugin() { + let diagnostics = crate::support::diagnostics::Diagnostics::default(); + let _diagnostics = diagnostics.install(); + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let schema = json!({ + "type":"object", "additionalProperties":false, + "properties":{ + "token":{"type":"string", "minLength":1, "writeOnly":true}, + "region":{"type":"string", "enum":["us", "eu"]} + }, + "required":["token", "region"] + }); + let valid = json!({"token":"fixture-sensitive-input", "region":"eu"}); + let (runtime, core) = environment + .provider( + json!({ + "credential_input_schemas":{"login":schema}, "expected_login_input":valid, + }), + vec![account_grant("accounts")], + ) + .await; + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + runtime.admin_registry(core.snapshots()), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let kind = ProviderKind::new("example").unwrap(); + let descriptors = service.providers().unwrap(); + assert_eq!(descriptors.len(), 1); + assert_eq!(descriptors[0].provider, kind); + let login = descriptors[0].capabilities.login.as_ref().unwrap(); + assert_eq!(login.input_schema, schema); + assert_eq!(login.completion, AuthorizationCompletion::Poll); + let provider = service.for_provider(&kind).unwrap(); + for input in [ + json!({}), + json!({"token":"fixture-sensitive-input", "region":"invalid"}), + json!({"token":"fixture-sensitive-input", "region":"eu", "unexpected":true}), + json!({"token":"x".repeat(64 * 1024), "region":"eu"}), + ] { + let error = provider + .start_authorization(start(input)) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert!(!format!("{error:?}").contains("fixture-sensitive-input")); + } + // worker 只接受完整的合法输入;错误输入若进入 RPC,就无法得到上面要求的本地 Invalid 结果。 + let started = provider.start_authorization(start(valid)).await.unwrap(); + let result = service + .poll_authorization( + &kind, + PollAuthorization { + context: mutation(), + flow_id: started.flow_id, + callback_url: Some("ready".into()), + settings: None, + }, + ) + .await + .unwrap(); + let AuthorizationPollResult::Complete(result) = result else { + panic!("login must complete") + }; + assert_eq!(result.accounts.len(), 1); + drop(provider); + drop(service); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn login_rpc_failure_reports_its_class_without_input_or_plugin_message() { + let diagnostics = crate::support::diagnostics::Diagnostics::default(); + let _diagnostics = diagnostics.install(); + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider( + json!({ + "credential_input_schemas":{"login":{ + "type":"object", "properties":{"token":{"type":"string", "writeOnly":true}}, + "required":["token"] + }}, + "login_fault":{ + "code":"fault", "message":"fixture-sensitive-rpc-detail", "send_state":"not_sent" + } + }), + vec![account_grant("accounts")], + ) + .await; + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + runtime.admin_registry(core.snapshots()), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let provider = service + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap(); + let error = provider + .start_authorization(start(json!({"token":"fixture-sensitive-login-input"}))) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Unavailable); + let records = diagnostics.records(); + let rpc = records + .iter() + .find(|fields| { + fields + .get("method") + .is_some_and(|method| method.contains("provider.login.start")) + }) + .expect("保留原始 RPC 分类"); + assert!(rpc["error"].contains("Remote")); + assert!(rpc["error"].contains("Fault")); + assert!(!format!("{records:?}").contains("fixture-sensitive-rpc-detail")); + assert!(!format!("{records:?}").contains("fixture-sensitive-login-input")); + drop(provider); + drop(service); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn invalid_form_schemas_cannot_replace_the_published_capabilities() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let descriptors = registry.credential_descriptors().unwrap(); + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + for schemas in [ + json!({"login":{"type":"array"}}), + json!({"login":{"type":"object", "properties":{"code":{"type":"unknown"}}}}), + json!({"login":{"type":"object", "$ref":"https://example.test/schema.json"}}), + json!({"login":{"type":"object", "$ref":"file:///etc/passwd"}}), + json!({"login":{"type":"object", "description":"x".repeat(64 * 1024)}}), + json!({"refresh":{"type":"object"}}), + ] { + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration = json!({"credential_input_schemas":schemas}); + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + assert_eq!(registry.credential_descriptors().unwrap(), descriptors); + assert!(core.snapshots().acquire().is_ok()); + } + let provider = registry + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider + .credential_capabilities() + .login + .unwrap() + .input_schema, + json!({"type":"object", "additionalProperties":false}) + ); + drop(provider); + drop(registry); + drop(store); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn capability_descriptions_follow_publication_and_frozen_readers_keep_their_generation() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let frozen = registry.freeze().unwrap(); + let before = frozen.credential_descriptors().unwrap(); + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + let schema = + json!({"type":"object", "properties":{"region":{"enum":["eu"]}}, "required":["region"]}); + instance.configuration = json!({"credential_input_schemas":{"login":schema}}); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + assert_eq!(registry.credential_descriptors().unwrap(), before); + let revision = store.load_instances().await.unwrap().config_revision; + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + let after = registry.credential_descriptors().unwrap(); + assert_eq!( + after[0].capabilities.login.as_ref().unwrap().input_schema, + schema + ); + assert_eq!(frozen.credential_descriptors().unwrap(), before); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.enabled = false; + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + let revision = store.load_instances().await.unwrap().config_revision; + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + assert!(registry.credential_descriptors().unwrap().is_empty()); + assert_eq!(frozen.credential_descriptors().unwrap(), before); + drop(frozen); + drop(registry); + drop(store); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn import_and_rotation_validate_forms_before_plugin_calls_and_account_writes() { + use gateway_admin::model::provider_credentials::{ + CredentialMutation, ImportCredentials, RotateCredential, + }; + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let document = |value: Value| { + ProviderDocument::new(OpaqueProviderData::new(value.as_object().unwrap().clone())) + }; + let valid_import = json!({"accounts":[{"name":"form import", "authentication_kind":"api_key", "material":{"key":"import-test-key"}}]}); + let valid_rotation = json!({"key":"rotated-test-key"}); + let (runtime, core) = environment.provider(json!({ + "credential_input_schemas": { + "import":{"type":"object", "required":["accounts"], "properties":{"accounts":{"type":"array", "minItems":1}}}, + "rotate":{"type":"object", "required":["key"], "properties":{"key":{"type":"string", "minLength":1}}, "additionalProperties":false} + }, + "expected_import_input":valid_import, "expected_rotation_input":valid_rotation, + }), vec![account_grant("accounts")]).await; + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + runtime.admin_registry(core.snapshots()), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let provider = service + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap(); + for input in [json!({}), json!({"accounts":[]})] { + let error = provider + .import_document(ImportCredentials { + context: mutation(), + document: document(input), + outbound_proxy_id: None, + settings: None, + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + } + let imported = provider + .import_document(ImportCredentials { + context: mutation(), + document: document(valid_import), + outbound_proxy_id: None, + settings: None, + }) + .await + .unwrap(); + let id = imported.credential_ids[0].clone(); + let before = core.snapshots().acquire().unwrap().revision(); + let error = provider + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id.clone(), + context: mutation(), + }, + provider_material: document(json!({"key":42})), + settings: None, + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + assert_eq!(core.snapshots().acquire().unwrap().revision(), before); + let rotated = provider + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id.clone(), + context: mutation(), + }, + provider_material: document(valid_rotation), + settings: None, + }) + .await + .unwrap(); + assert_eq!(rotated.credential_revision.unwrap().get(), 2); + let stored = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&id) + .await + .unwrap(); + assert_eq!( + stored.credential.expose_to_provider()["key"], + "rotated-test-key" + ); + drop(provider); + drop(service); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn account_capabilities_cover_provider_accounts_and_follow_published_generations() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account = environment.account(None).await; + let other = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({ + "account_operations":["profile", "subscription", "avatar"], + "quota_url":"http://127.0.0.1:9/quota", + }), + vec![account_grant("accounts")], + ) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let kind = ProviderKind::new("example").unwrap(); + let frozen = registry.require(&kind).unwrap(); + let before = frozen.account_capabilities(&account, "api_key"); + assert!(before.quota && before.profile && before.subscription && before.avatar); + assert!(before.quota_refresh); + assert!(!before.reset_credits && !before.consume_reset_credit); + assert_eq!(frozen.account_capabilities(&other, "api_key"), before); + + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.configuration["account_operations"] = json!(["subscription"]); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + assert_eq!( + registry + .require(&kind) + .unwrap() + .account_capabilities(&account, "api_key"), + before + ); + let revision = store.load_instances().await.unwrap().config_revision; + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + let after = registry + .require(&kind) + .unwrap() + .account_capabilities(&account, "api_key"); + assert!(after.subscription); + assert!(after.quota); + assert!(after.quota_refresh); + assert!(!after.profile && !after.avatar); + assert_eq!(frozen.account_capabilities(&account, "api_key"), before); + drop(frozen); + drop(registry); + drop(store); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/catalog/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/catalog/mod.rs new file mode 100644 index 000000000..8e4910f84 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/catalog/mod.rs @@ -0,0 +1,710 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, + sync::Arc, + time::Duration, +}; + +use gateway_admin::{ + model::plugins::instances::PluginPermissionGrant, ports::provider::ProviderAdminErrorKind, +}; +use gateway_core::{ + account::scope::{ + ClientRoutingScope, FrozenAccountScope, RuntimeAccount, RuntimeAccountDirectory, + }, + engine::probe::AccountProbeRequest, + error::GatewayErrorKind, + operation::{GenerateRequest, Operation, ProtocolPayload}, + routing::{ProviderKind, UpstreamModelId}, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant}; + +fn configuration() -> Value { + json!({ + "model_discovery":{"include_static":false,"cache_ttl_seconds":60}, + "discovered_catalog":{"models":[{"id":"discovered-model","operations":["generate"]}],"exhaustive":true}, + }) +} + +fn records(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn grants() -> Vec { + vec![account_grant("accounts"), account_grant("network")] +} + +#[tokio::test] +async fn native_continuation_routing_requires_the_static_or_discovered_model_declaration() { + for discovered in [false, true] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let models = json!([ + {"id":"continuation-model", "operations":["generate"], "features":["native_continuation"]}, + {"id":"plain-model", "operations":["generate"]} + ]); + let config = if discovered { + json!({ + "model_discovery":{"include_static":false,"cache_ttl_seconds":60}, + "discovered_catalog":{"models":models,"exhaustive":true} + }) + } else { + json!({"static_models":models}) + }; + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let snapshot = core.snapshots().acquire().unwrap(); + for (model, supported) in [("continuation-model", true), ("plain-model", false)] { + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", json!({"model":model,"input":"next turn","previous_response_id":"response-before"}).as_object().unwrap().clone()).unwrap(), + )); + let plan = snapshot.plan( + &gateway_core::routing::PublicModelId::new(model).unwrap(), + &operation, + snapshot.all_account_scope(), + &gateway_core::routing::RoutingContext::default(), + ); + assert_eq!( + plan.is_ok(), + supported, + "discovered={discovered}, model={model}" + ); + } + drop(snapshot); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn directory_publication_is_independent_of_bounded_account_cache_eviction() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + // 比账号缓存多一个账号;每轮重建被淘汰项不得持续推进全局目录代次。 + for _ in 0..257 { + environment.account(None).await; + } + let marker = environment.directory.path().join("large-models.jsonl"); + let mut config = configuration(); + config["models_marker"] = json!(marker); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + assert_eq!(records(&marker).len(), 257); + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers + .get(&ProviderKind::new("example").unwrap()) + .unwrap(); + let generation = provider.catalog_generation(); + for _ in 0..3 { + assert_eq!( + provider.query_model_capabilities().await.unwrap()[0] + .upstream_model() + .as_str(), + "discovered-model" + ); + assert_eq!(provider.catalog_generation(), generation); + } + assert_eq!(records(&marker).len(), 257); + drop(providers); + drop(snapshot); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn directory_ttl_starts_after_the_complete_account_scan() { + let diagnostics = crate::support::diagnostics::Diagnostics::default(); + let _diagnostics = diagnostics.install(); + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + for _ in 0..8 { + environment.account(None).await; + } + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(configuration()["discovered_catalog"].clone()) + .set_delay(Duration::from_millis(550)), + ) + .expect(8) + .mount(&server) + .await; + let mut config = configuration(); + config["model_discovery"]["cache_ttl_seconds"] = json!(1); + config["models_url"] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants()).await; + assert_eq!( + server.received_requests().await.unwrap().len(), + 8, + "首次目录扫描必须完成所有账号查询:{:?}", + diagnostics.records() + ); + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers + .get(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider.query_model_capabilities().await.unwrap()[0] + .upstream_model() + .as_str(), + "discovered-model" + ); + let generation = provider.catalog_generation(); + tokio::time::sleep(Duration::from_millis(1100)).await; + assert!(provider.catalog_generation() > generation); + // 到期只标记一次失效;同步代次读取不得发起后台 RPC 或重复推进版本。 + assert_eq!(provider.catalog_generation(), provider.catalog_generation()); + drop(providers); + drop(snapshot); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn partial_directory_failure_reports_account_and_stage_without_model_payload() { + let diagnostics = crate::support::diagnostics::Diagnostics::default(); + let _diagnostics = diagnostics.install(); + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let rejected = environment.account(None).await; + let mut config = configuration(); + config["account_catalogs"] = json!({ + rejected.as_str(): {"models":[{"id":"fixture-sensitive-model", "operations":[]}], "exhaustive":true} + }); + let (runtime, core) = environment.provider(config, grants()).await; + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers + .get(&ProviderKind::new("example").unwrap()) + .unwrap(); + let models = provider.query_model_capabilities().await.unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0].upstream_model().as_str(), "discovered-model"); + let records = diagnostics.records(); + let failure = records + .iter() + .find(|fields| { + fields + .get("account_id") + .is_some_and(|id| id.contains(rejected.as_str())) + }) + .expect("部分失败不能在目录合并时丢失诊断"); + assert_eq!(failure["stage"], "\"validate\""); + assert_eq!(failure["kind"], "BadGateway"); + assert_eq!(failure["timed_out"], "false"); + assert!(!format!("{records:?}").contains("fixture-sensitive-model")); + drop(providers); + drop(snapshot); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn account_model_cache_coalesces_reads_and_publishes_invalidation_generations() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let marker = environment.directory.path().join("models.jsonl"); + let mut config = configuration(); + config["models_marker"] = json!(marker); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let admin = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers + .get(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!(records(&marker).len(), 1); + for result in futures::future::join_all((0..8).map(|_| admin.models(&id, false))).await { + let result = result.unwrap(); + assert_eq!(result.models[0].id.as_str(), "discovered-model"); + assert!(result.observed_at.is_some()); + } + assert_eq!(records(&marker).len(), 1); + let generation = provider.catalog_generation(); + admin.account_facts_changed(std::slice::from_ref(&id)).await; + assert!(provider.catalog_generation() > generation); + for result in futures::future::join_all((0..8).map(|_| admin.models(&id, false))).await { + result.unwrap(); + } + assert_eq!(records(&marker).len(), 2); + admin.models(&id, true).await.unwrap(); + assert_eq!(records(&marker).len(), 3); + drop(providers); + drop(snapshot); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn initial_model_discovery_commits_prepared_facts_without_refresh_reentry() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let marker = environment + .directory + .path() + .join("prepared-account-facts.jsonl"); + let mut config = configuration(); + config["models_marker"] = json!(marker); + config["prepared_facts_after_models"] = json!(0); + config["prepared_account_facts"] = json!({ + "name":"plugin refreshed account", + "authentication_kind":"api_key", + "material":{"key":"rotated-test-only"}, + "email":"refreshed@example.invalid", + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":"team", + "has_refresh_token":false, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null + }); + // 死锁预算只约束初始化与目录发布,不能被前置制品打包、校验和安装消耗。 + environment + .install_provider(config, vec![account_grant("accounts")]) + .await; + let (runtime, core) = + tokio::time::timeout(Duration::from_secs(30), environment.runtime()) + .await + .unwrap_or_else(|error| { + panic!( + "initial discovery must not wait on its own snapshot publication: {error}; model queries={}", + records(&marker).len() + ) + }); + // 首轮提交推进配置 revision,Core 用合并的候选 revision 再编译一次。 + assert_eq!(records(&marker).len(), 2); + + let accounts = environment.store.admin_ports().accounts(); + let details = accounts + .credential_details(&ProviderKind::new("example").unwrap(), &id) + .await + .unwrap() + .unwrap(); + assert_eq!(details.credential.credential_revision.get(), 2); + assert_eq!(details.credential.name, "plugin refreshed account"); + let admin_bundle = environment.bind_admin_accounts(&runtime, &core).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + + let result = provider.models(&id, true).await.unwrap(); + assert_eq!(result.models[0].id.as_str(), "discovered-model"); + + assert_eq!( + details.credential.email.as_deref(), + Some("refreshed@example.invalid") + ); + let exported = accounts + .load_credentials_for_export( + &ProviderKind::new("example").unwrap(), + std::slice::from_ref(&id), + ) + .await + .unwrap(); + assert_eq!( + exported[0] + .provider_material + .expose_to_provider() + .expose_to_provider()["key"], + "rotated-test-only" + ); + let audit = environment.audit_requests("refresh_credential").await; + assert_eq!(audit.len(), 1); + assert!(audit[0].starts_with("plugin:")); + assert!(audit[0].contains(":call:")); + + drop(provider); + drop(admin_bundle); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn account_callbacks_use_host_scope_and_reject_the_stale_catalog_publication() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let other = environment.account(None).await; + let marker = environment.directory.path().join("account-callbacks.jsonl"); + let mut config = configuration(); + config["account_callback_fixture"] = json!({ + "after_models":2, + "marker":marker, + "other_account_id":other.as_str(), + "expected_key":"test-only", + "replacement_key":"callback-rotated-test-only" + }); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let admin_bundle = environment.bind_admin_accounts(&runtime, &core).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + + let error = provider.models(&id, true).await.unwrap_err(); + assert_eq!(error.kind(), ProviderAdminErrorKind::Conflict); + assert_eq!( + records(&marker), + [json!({ + "listed":2, + "account_id":id.as_str(), + "cross_account_readable":true, + "read_revision":1, + "saved_revision":2, + })] + ); + + let accounts = environment.store.admin_ports().accounts(); + let details = accounts + .credential_details(&ProviderKind::new("example").unwrap(), &id) + .await + .unwrap() + .unwrap(); + assert_eq!(details.credential.credential_revision.get(), 2); + assert_eq!(details.credential.name, "callback rotated account"); + let exported = accounts + .load_credentials_for_export( + &ProviderKind::new("example").unwrap(), + std::slice::from_ref(&id), + ) + .await + .unwrap(); + assert_eq!( + exported[0] + .provider_material + .expose_to_provider() + .expose_to_provider()["key"], + "callback-rotated-test-only" + ); + let audit = environment.audit_requests("rotate_credential").await; + assert_eq!(audit.len(), 1); + assert!(audit[0].starts_with("plugin:")); + assert!(audit[0].contains(":call:")); + + drop(provider); + drop(admin_bundle); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn merged_catalog_keeps_client_scope_and_account_feature_gates() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let first = environment.account(None).await; + let second = environment.account(None).await; + let mut config = configuration(); + config["model_discovery"]["include_static"] = json!(true); + config["account_catalogs"] = json!({ + (first.as_str()):{"models":[{"id":"first-model","operations":["generate"]},{"id":"shared","operations":["generate"]}],"exhaustive":true}, + (second.as_str()):{"models":[{"id":"second-model","operations":["generate"]},{"id":"shared","operations":["generate"],"features":["tools"],"maximum_output_tokens":32}],"exhaustive":true}, + }); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let kind = ProviderKind::new("example").unwrap(); + let admin = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + assert_eq!( + admin + .models(&first, false) + .await + .unwrap() + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(), + vec!["first-model", "plugin-model", "shared"] + ); + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers.get(&kind).unwrap(); + let scope = FrozenAccountScope::new( + Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( + first.clone(), + RuntimeAccount::new(kind.clone(), BTreeSet::new()), + )]))), + ClientRoutingScope::all_accounts(), + ); + let client = provider + .query_client_model_catalog(&scope, "openai", "test") + .await + .unwrap() + .unwrap(); + assert_eq!( + client + .iter() + .map(|model| model.model.as_str()) + .collect::>(), + vec!["first-model", "plugin-model", "shared"] + ); + let operation = |limit| { + Operation::Generate(GenerateRequest::from_protocol_payload(ProtocolPayload::json_object("openai", json!({"model":"shared", "input":"hello", "tools":[{"type":"function", "name":"echo", "parameters":{"type":"object"}}], "max_output_tokens":limit}).as_object().unwrap().clone()).unwrap())) + }; + let denied = core + .account_probe() + .probe( + AccountProbeRequest { + account_id: first, + provider_kind: kind.clone(), + upstream_model: UpstreamModelId::new("shared").unwrap(), + operation: operation(16), + }, + admin.snapshot(), + ) + .await + .unwrap_err(); + assert_eq!(denied.kind(), GatewayErrorKind::NoAvailableProvider); + let reply = core + .account_probe() + .probe( + AccountProbeRequest { + account_id: second.clone(), + provider_kind: kind.clone(), + upstream_model: UpstreamModelId::new("shared").unwrap(), + operation: operation(16), + }, + admin.snapshot(), + ) + .await + .unwrap(); + assert_eq!(reply.text, vec!["Rust plugin response"]); + let denied = core + .account_probe() + .probe( + AccountProbeRequest { + account_id: second, + provider_kind: kind, + upstream_model: UpstreamModelId::new("shared").unwrap(), + operation: operation(33), + }, + admin.snapshot(), + ) + .await + .unwrap_err(); + assert!(matches!( + denied.kind(), + GatewayErrorKind::NoAvailableProvider | GatewayErrorKind::Unsupported + )); + drop(providers); + drop(snapshot); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn model_http_uses_each_selected_account_without_permission_prebinding() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let proxy = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "proxy-authorization", + "Basic dXNlcjpwYXNz", + )) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(configuration()["discovered_catalog"].clone()), + ) + .expect(2) + .mount(&proxy) + .await; + let outbound_proxy = gateway_core::account::OutboundProxy::parse(&format!( + "http://user:pass@{}", + proxy.address() + )) + .unwrap(); + let id = environment.account(Some(outbound_proxy.clone())).await; + let other = environment.account(Some(outbound_proxy)).await; + let mut config = configuration(); + config["models_url"] = json!("http://127.0.0.1:9/models"); + let (runtime, core) = environment.provider(config, grants()).await; + let admin = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + admin.models(&id, false).await.unwrap().models[0] + .id + .as_str(), + "discovered-model" + ); + assert_eq!( + admin.models(&other, false).await.unwrap().models[0] + .id + .as_str(), + "discovered-model" + ); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn malformed_refresh_does_not_replace_a_valid_account_catalog() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + let good = configuration()["discovered_catalog"].clone(); + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&good)) + .mount(&server) + .await; + let id = environment.account(None).await; + let mut config = configuration(); + config["models_url"] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants()).await; + let admin = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + for models in [ + json!([{"id":"duplicate","operations":["generate"]},{"id":"duplicate","operations":["generate"]}]), + json!([{"id":"bad","operations":[]}]), + json!([{"id":"bad","operations":["generate"],"features":["tools","tools"]}]), + json!([{"id":"bad","operations":["generate"],"maximum_output_tokens":0}]), + ] { + server.reset().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(json!({"models":models,"exhaustive":true})), + ) + .mount(&server) + .await; + assert_eq!( + admin.models(&id, true).await.unwrap_err().kind(), + ProviderAdminErrorKind::BadGateway + ); + assert_eq!( + admin.models(&id, false).await.unwrap().models[0] + .id + .as_str(), + "discovered-model" + ); + } + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn invalidation_during_discovery_rejects_the_late_publication() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + let good = configuration()["discovered_catalog"].clone(); + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(&good) + .set_delay(Duration::from_millis(500)), + ) + .mount(&server) + .await; + let id = environment.account(None).await; + let marker = environment.directory.path().join("late-models.jsonl"); + let mut config = configuration(); + config["models_url"] = json!(server.uri()); + config["models_marker"] = json!(marker); + let (runtime, core) = environment.provider(config, grants()).await; + let admin = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let invalidate = async { + tokio::time::timeout(Duration::from_secs(3), async { + while records(&marker).len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + admin.account_facts_changed(std::slice::from_ref(&id)).await; + }; + let (result, ()) = tokio::join!(admin.models(&id, true), invalidate); + assert_eq!(result.unwrap_err().kind(), ProviderAdminErrorKind::Conflict); + admin.models(&id, false).await.unwrap(); + assert_eq!(records(&marker).len(), 3); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/configuration.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/configuration.rs new file mode 100644 index 000000000..cd1c6c817 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/configuration.rs @@ -0,0 +1,286 @@ +use gateway_admin::{ + CredentialsService, + model::provider_credentials::{CredentialMutation, ProviderDocument, RotateCredential}, + ports::{plugins::PluginPreparation, provider::ProviderAdminErrorKind}, +}; +use gateway_core::{account::OpaqueProviderData, routing::ProviderKind}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn configuration(result: Value) -> Value { + json!({ + "credential_input_schemas": { + "rotate": { + "type": "object", + "additionalProperties": false, + "properties": { + "base_url": {"type": "string", "format": "uri", "maxLength": 2048}, + "transport": {"type": "string", "enum": ["http", "prefer_websocket"]}, + "api_key": {"type": "string", "writeOnly": true} + } + } + }, + "account_configuration": {"public_fields": ["base_url", "transport"]}, + "account_configuration_result": result, + }) +} + +#[tokio::test] +async fn declared_connection_settings_are_projected_with_the_accounts_domain() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account_id = environment.account(None).await; + let expected = json!({ + "account_id": account_id.as_str(), + "credential_revision": 1, + "credential": {"key": "test-only"}, + }); + let mut config = configuration(json!({ + "base_url": "https://example.test/v1", + "transport": "http", + })); + config["expected_account_configuration"] = expected; + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + + let projected = provider + .account_configuration(&account_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + Value::Object(projected.into_provider_data().into_inner()), + json!({"base_url":"https://example.test/v1", "transport":"http"}) + ); + assert_eq!( + environment + .store + .provider_ports() + .accounts() + .get_account(&account_id) + .await + .unwrap() + .unwrap() + .revision() + .get(), + 1 + ); + + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn unsafe_or_undeclared_connection_settings_cannot_replace_the_published_provider() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider( + configuration(json!({"base_url":"https://example.test", "transport":"http"})), + vec![account_grant("accounts")], + ) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let published = registry.credential_descriptors().unwrap(); + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + + for (public_fields, property) in [ + (json!(["missing"]), json!({"type":"string"})), + (json!(["base_url", "base_url"]), json!({"type":"string"})), + ( + json!(["base_url"]), + json!({"type":"string", "writeOnly":true}), + ), + ( + json!(["base_url"]), + json!({"type":"string", "format":"password"}), + ), + (json!(["base_url"]), json!({"$ref":"#/$defs/value"})), + ( + json!(["base_url"]), + json!({"type":"string", "allOf":[{"writeOnly":true}]}), + ), + (json!(["base_url"]), json!({"type":"object"})), + ] { + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration = json!({ + "credential_input_schemas": { + "rotate": { + "type":"object", + "properties":{"base_url":property}, + } + }, + "account_configuration":{"public_fields":public_fields}, + }); + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + assert_eq!(registry.credential_descriptors().unwrap(), published); + } + + for root_constraint in [ + json!({"writeOnly":true}), + json!({"$ref":"#/$defs/connection"}), + json!({"allOf":[{"properties":{"base_url":{"writeOnly":true}}}]}), + json!({"if":{"properties":{"base_url":{"const":"https://private.test"}}}, "then":{"properties":{"base_url":{"format":"password"}}}}), + ] { + let mut rotate_schema = json!({ + "type":"object", + "properties":{"base_url":{"type":"string"}}, + }); + rotate_schema + .as_object_mut() + .unwrap() + .extend(root_constraint.as_object().unwrap().clone()); + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration = json!({ + "credential_input_schemas":{"rotate":rotate_schema}, + "account_configuration":{"public_fields":["base_url"]}, + }); + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + assert_eq!(registry.credential_descriptors().unwrap(), published); + } + + let mut candidate = snapshot; + candidate.instances[0].grants.clear(); + // Provider 是账号驱动扩展;缺少整个 Accounts 域时必须在候选代次准备阶段拒绝。 + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + assert_eq!(registry.credential_descriptors().unwrap(), published); + + drop(store); + drop(registry); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn plugin_results_cannot_add_credential_fields_or_leak_rejected_values() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account_id = environment.account(None).await; + let (runtime, core) = environment + .provider( + configuration(json!({ + "base_url":"https://example.test", + "transport":"http", + "api_key":"fixture-sensitive-value", + })), + vec![account_grant("accounts")], + ) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let error = provider + .account_configuration(&account_id) + .await + .unwrap_err(); + assert_eq!(error.kind(), ProviderAdminErrorKind::BadGateway); + assert!(!format!("{error:?}").contains("fixture-sensitive-value")); + + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn credential_revision_changes_discard_a_late_configuration_projection() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account_id = environment.account(None).await; + let marker = environment + .directory + .path() + .join("configuration-started.jsonl"); + let mut config = configuration(json!({ + "base_url":"https://stale.example.test", + "transport":"http", + })); + let release = environment.directory.path().join("configuration-release"); + config["account_configuration_release_marker"] = json!(release); + config["account_configuration_started_marker"] = json!(marker.to_string_lossy().into_owned()); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let kind = ProviderKind::new("example").unwrap(); + let registry = runtime.admin_registry(core.snapshots()); + let provider = registry.require(&kind).unwrap(); + let pending_id = account_id.clone(); + let pending = tokio::spawn(async move { provider.account_configuration(&pending_id).await }); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while !marker.exists() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + + let admin = environment.store.admin_ports(); + let credentials = CredentialsService::new( + registry, + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + credentials + .for_provider(&kind) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id, + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"api_key":"replacement-test-key"}) + .as_object() + .unwrap() + .clone(), + )), + settings: None, + }) + .await + .unwrap(); + // 先确认轮换提交,再释放旧 revision 的投影,避免依赖机器负载决定先后。 + assert!(!pending.is_finished()); + std::fs::write(release, b"committed").unwrap(); + assert_eq!( + pending.await.unwrap().unwrap_err().kind(), + ProviderAdminErrorKind::Conflict + ); + + drop(credentials); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/credentials.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/credentials.rs new file mode 100644 index 000000000..82ff2809e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/credentials.rs @@ -0,0 +1,291 @@ +use gateway_admin::{ + CredentialsService, + model::provider_credentials::{ + ImportCredentials, PrepareCredentialImport, ProviderDocument, ProviderExportCredentialInput, + }, +}; +use gateway_core::{account::OpaqueProviderData, routing::ProviderKind}; +use serde_json::json; + +use crate::support::environment::{Environment, account_grant, mutation}; + +#[tokio::test] +async fn rust_plugin_imports_and_exports_through_admin_transactions_and_core_publication() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let kind = ProviderKind::new("example").unwrap(); + let registry = runtime.admin_registry(core.snapshots()); + let admin = environment.store.admin_ports(); + let credentials = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let document = json!({"accounts":[ + {"name":"first", "authentication_kind":"api_key", "material":{"key":"test-first"}}, + {"name":"second", "authentication_kind":"api_key", "material":{"key":"test-second"}} + ]}); + let before = core.snapshots().acquire().unwrap(); + let result = credentials + .for_provider(&kind) + .unwrap() + .import_document(ImportCredentials { + outbound_proxy_id: None, + settings: None, + context: mutation(), + document: provider_document(document.clone()), + }) + .await + .unwrap(); + assert_eq!(result.credential_ids.len(), 2); + let after = core.snapshots().acquire().unwrap(); + assert!(after.revision().get() > before.revision().get()); + let ports = environment.store.provider_ports(); + let mut exported = Vec::new(); + for id in &result.credential_ids { + let loaded = ports + .accounts() + .load_credential( + id, + gateway_core::account::CredentialRevision::new(1).unwrap(), + ) + .await + .unwrap(); + assert_eq!(loaded.account.provider(), &kind); + assert_eq!(loaded.account.revision().get(), 1); + let details = admin + .accounts() + .credential_details(&kind, id) + .await + .unwrap() + .unwrap(); + exported.push(ProviderExportCredentialInput { + account: details.credential, + provider_material: provider_document(json!(loaded.credential.expose_to_provider())), + }); + } + let provider = registry.require(&kind).unwrap(); + let export = provider.export_credentials(exported).await.unwrap(); + assert_eq!(export.account_ids, result.credential_ids); + let exported = export.document.expose_to_provider().expose_to_provider(); + assert_eq!( + exported["accounts"][0]["material"], + document["accounts"][0]["material"] + ); + assert_eq!( + exported["accounts"][1]["material"], + document["accounts"][1]["material"] + ); + // 整个批次准备失败时,前面的合法账号也不能提前进入 Store。 + let invalid = json!({"accounts":[document["accounts"][0], {"name":"", "authentication_kind":"api_key", "material":{"key":"test-invalid"}}]}); + assert!( + credentials + .for_provider(&kind) + .unwrap() + .import_document(ImportCredentials { + outbound_proxy_id: None, + settings: None, + context: mutation(), + document: provider_document(invalid), + }) + .await + .is_err() + ); + assert_eq!( + core.snapshots().acquire().unwrap().revision(), + after.revision() + ); + drop(provider); + drop(credentials); + drop(registry); + drop(before); + drop(after); + drop(admin); + drop(ports); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn accounts_domain_allows_plugin_to_prepare_account_creation() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let prepared = provider + .prepare_import(PrepareCredentialImport { + default_outbound_proxy: None, + document: provider_document(json!({"accounts":[{ + "name":"prepared account", + "authentication_kind":"api_key", + "material":{"key":"prepared-test-only"} + }]})), + }) + .await + .unwrap(); + assert_eq!(prepared.credentials.len(), 1); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn host_account_create_uses_admin_transaction_and_host_generated_id() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let marker = environment.directory.path().join("account-create.jsonl"); + let (runtime, core) = environment + .provider( + json!({"account_create_marker":marker}), + vec![account_grant("accounts")], + ) + .await; + let admin_bundle = environment.bind_admin_accounts(&runtime, &core).await; + let kind = ProviderKind::new("example").unwrap(); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let prepared = provider + .prepare_import(PrepareCredentialImport { + default_outbound_proxy: None, + document: provider_document(json!({"accounts":[{ + "name":"callback created account", + "authentication_kind":"api_key", + "material":{"key":"created-test-only"} + }]})), + }) + .await + .unwrap(); + assert_eq!(prepared.credentials.len(), 1); + + let saved: serde_json::Value = serde_json::from_str( + std::fs::read_to_string(&marker) + .unwrap() + .lines() + .next() + .unwrap(), + ) + .unwrap(); + let account_id = gateway_core::account::ProviderAccountId::new( + saved["account_id"].as_str().unwrap().to_owned(), + ) + .unwrap(); + assert!(account_id.as_str().starts_with("acct_")); + assert_eq!(saved["credential_revision"], 1); + assert_ne!(account_id, prepared.credentials[0].account_id); + let details = environment + .store + .admin_ports() + .accounts() + .credential_details(&kind, &account_id) + .await + .unwrap() + .unwrap(); + assert_eq!(details.credential.name, "callback created account"); + assert_eq!(details.credential.credential_revision.get(), 1); + let audit = environment.audit_requests("import_document").await; + assert_eq!(audit.len(), 1); + assert!(audit[0].starts_with("plugin:")); + assert!(audit[0].contains(":call:")); + + drop(provider); + drop(admin_bundle); + drop(core); + drop(runtime); + environment.close().await; +} + +fn provider_document(value: serde_json::Value) -> ProviderDocument { + ProviderDocument::new(OpaqueProviderData::new(value.as_object().unwrap().clone())) +} + +#[tokio::test] +async fn accounts_domain_allows_export_while_missing_network_blocks_http_operations() { + use gateway_admin::{ + model::provider_credentials::{PrepareCredentialRefresh, ProviderQuotaRequest}, + ports::provider::ProviderAdminErrorKind, + }; + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"quota_url":server.uri(), "management_url":server.uri()}), + vec![account_grant("accounts")], + ) + .await; + let kind = ProviderKind::new("example").unwrap(); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let account = environment + .store + .admin_ports() + .accounts() + .credential_details(&kind, &id) + .await + .unwrap() + .unwrap() + .credential; + assert_eq!( + provider + .prepare_refresh(PrepareCredentialRefresh { + account: account.clone() + }) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Invalid + ); + assert_eq!( + provider + .quota(ProviderQuotaRequest { + account_id: id.clone(), + refresh: true, + rolling_usage: None + }) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Invalid + ); + let exported = provider + .export_credentials(vec![ProviderExportCredentialInput { + account, + provider_material: provider_document(json!({"key":"test-only"})), + }]) + .await + .unwrap(); + assert_eq!(exported.account_ids, vec![id]); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/events/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/events/mod.rs new file mode 100644 index 000000000..a508d974d --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/events/mod.rs @@ -0,0 +1,886 @@ +use std::{ + collections::BTreeMap, + num::{NonZeroU32, NonZeroUsize}, + path::Path, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use futures::TryStreamExt as _; +use gateway_admin::{ + CredentialsService, + model::{ + plugins::PluginSource, + provider_credentials::{CredentialMutation, ProviderDocument, RotateCredential}, + }, + ports::plugins::{PluginPackageInspector as _, PluginPreparation, PluginStore}, +}; +use gateway_core::{ + account::{AccountSelectionPolicy, OpaqueProviderData, RotationStrategy}, + engine::{ + AccountAttemptContext, AttemptContext, ModelRequestId, RequestAttemptContext, + provider::ProviderRequest, + }, + error::{ProviderError, ProviderErrorKind}, + event::{GatewayEvent, ProviderEvent, UpstreamHttpVersion}, + lifecycle::CancellationToken, + operation::ProviderSessionState, + policy::ClientApiKeyId, + routing::{ConfigRevision, ProviderKind, PublicModelId, RoutingContext, UpstreamModelId}, + upstream::UpstreamSendState, +}; +use gateway_plugin_runtime::{ + ContinuationDrainConfig, PackageInspector, PackageLimits, ValidatedPackage, +}; +use serde_json::json; + +use crate::support::{ + archive, + environment::{Environment, account_grant, mutation}, + worker, +}; + +pub(super) async fn execute( + runtime: &gateway_plugin_runtime::PluginRuntime, + core: &gateway_core::CoreBundle, + state: Option, +) -> Result, ProviderError> { + let kind = ProviderKind::new("example").unwrap(); + let snapshot = core.snapshots().acquire().unwrap(); + let admin = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let mut operation = admin + .connection_test_operation(&UpstreamModelId::new("plugin-model").unwrap(), "events") + .await + .unwrap(); + if let Some(state) = state { + operation.set_provider_session_state(state); + } + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let registry = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = registry.get(&kind).unwrap(); + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_plugin_events").unwrap(), + ClientApiKeyId::new("key_plugin_events").unwrap(), + ), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(15), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::MIN, + Duration::ZERO, + ), + AccountAttemptContext::default().with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ); + Arc::clone(provider) + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + context, + ) + .await? + .try_collect() + .await +} + +fn continuation_state(events: &[ProviderEvent]) -> ProviderSessionState { + events + .last() + .and_then(ProviderEvent::session_update) + .cloned() + .expect("terminal event continuation state") +} + +fn assert_replay_required(error: &ProviderError) { + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); +} + +async fn wait_until_entry_count(path: &Path, expected: usize) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + match std::fs::read_dir(path) { + Ok(entries) => { + if entries.count() == expected { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound && expected == 0 => { + break; + } + Err(error) => panic!("read plugin cache: {error}"), + } + } + }) + .await + .expect("plugin generation resources should reach the expected count"); +} + +async fn publish(core: &gateway_core::CoreBundle, revision: u64) { + core.snapshot_control() + .publish_committed(ConfigRevision::new(revision).unwrap()) + .await; +} + +async fn install_artifact_version( + store: &dyn PluginStore, + source_digest: &str, + version: &str, +) -> String { + let artifact = store.load_artifact(source_digest).await.unwrap(); + let package = ValidatedPackage::read(artifact.archive, None, PackageLimits::default()).unwrap(); + let mut manifest = package.manifest().clone(); + manifest.version = version.parse().unwrap(); + let archive = archive(BTreeMap::from([ + ("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()), + ("bin/worker".into(), worker().to_vec()), + ])); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect(archive, None) + .await + .unwrap(); + let installed = store + .install_artifact(artifact, PluginSource::Upload, &mutation()) + .await + .unwrap(); + store + .accept_artifact(&installed.artifact.metadata.sha256, &mutation()) + .await + .unwrap() + .artifact + .metadata + .sha256 +} + +#[tokio::test] +async fn event_envelope_preserves_raw_bytes_metadata_and_bound_continuation() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let raw = b": keep comment\r\nid: frame-1\r\nretry: 123\r\nevent: response.created\r\ndata: {\"type\":\"response.created\",\r\ndata: \"response\":{\"id\":\"response-plugin\"}}\r\n\r\n".as_slice(); + let raw_json = b"{ \"z\": 1, \"a\": 2 }\n".as_slice(); + let raw_body = b"opaque raw http body".as_slice(); + let (runtime, core) = environment.provider(json!({ + "output_formats":["canonical","openai"], + "execution_events":[ + {"observation":{"status":200,"request_id":"opaque request-id","http_version":"http2","timings":{"headers_ms":7},"client_headers":[{"name":"retry-after","value":b"12".as_slice()}]}}, + {"facts":[{"type":"started","id":"response-plugin","model":"plugin-model"}],"wire":{"protocol":"openai","payload":{"kind":"json","event":"response.created","id":"frame-1","retry":123,"data":{"type":"response.created","response":{"id":"response-plugin"}},"raw_sse":raw}}}, + {"wire":{"protocol":"openai","payload":{"kind":"raw_sse","frame":b": comment only\n\n".as_slice()}}}, + {"wire":{"protocol":"openai","payload":{"kind":"raw_json","body":raw_json}}}, + {"observation":{"status":200,"client_headers":[{"name":"content-type","value":b"application/octet-stream".as_slice()}]},"wire":{"protocol":"openai","payload":{"kind":"raw_body","body":raw_body}}}, + {"facts":[{"type":"usage","usage":{"input_tokens":7}},{"type":"completed","id":"response-plugin","model":"plugin-model","reason":"stop"}],"wire":{"protocol":"openai","payload":{"kind":"json","event":"response.completed","data":{"type":"response.completed","response":{"id":"response-plugin"}}}},"session_update":{"payload":{"turn":"private checkpoint"}}} + ] + }), vec![account_grant("accounts")]).await; + let events = execute(&runtime, &core, None).await.unwrap(); + assert_eq!(events.len(), 6); + let observation = events[0].response_observation().unwrap(); + assert_eq!(observation.status_code(), Some(200)); + assert_eq!( + observation.request_id().unwrap().as_str(), + "opaque request-id" + ); + assert_eq!(observation.http_version(), Some(UpstreamHttpVersion::Http2)); + assert_eq!(observation.timings().headers_ms, Some(7)); + assert_eq!( + observation.client_headers()[0].value().as_ref(), + b"12".as_slice() + ); + assert_eq!( + events[1] + .wire_event() + .unwrap() + .raw_sse_frame() + .unwrap() + .as_ref(), + raw + ); + assert!(matches!( + events[1].canonical_facts(), + [GatewayEvent::Started(_)] + )); + assert_eq!( + events[2] + .wire_event() + .unwrap() + .raw_sse_frame() + .unwrap() + .as_ref(), + b": comment only\n\n".as_slice() + ); + assert_eq!( + events[3] + .wire_event() + .unwrap() + .raw_json_body() + .unwrap() + .as_ref(), + raw_json + ); + assert_eq!( + events[4] + .wire_event() + .unwrap() + .raw_http_body_bytes() + .unwrap() + .as_ref(), + raw_body + ); + let raw_observation = events[4].response_observation().unwrap(); + assert_eq!(raw_observation.client_headers().len(), 1); + assert_eq!(raw_observation.client_headers()[0].name(), "content-type"); + let state = events[5].session_update().unwrap(); + assert_eq!(state.provider(), "example"); + assert_eq!( + execute(&runtime, &core, Some(state.clone())) + .await + .unwrap() + .len(), + 6 + ); + // 宿主私有绑定在 RPC 前拒绝,不能切账号、换凭据或跨实例授权重用。 + for (field, value) in [ + ("account_id", json!("another-account")), + ("credential_revision", json!(999)), + ("authorization", json!("other-instance")), + ("continuation_authorization", json!("other-authority")), + ("execution_owner", json!("not-a-host-owner")), + ("provider", json!("other-provider")), + ] { + let mut payload = state.payload().clone(); + payload["binding"][field] = value; + let invalid = ProviderSessionState::new("example", payload).unwrap(); + let error = execute(&runtime, &core, Some(invalid)).await.unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + } + drop(events); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn declared_continuation_format_survives_artifact_upgrade_but_not_authority_changes() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account_id = environment.account(None).await; + let grants = vec![account_grant("accounts")]; + environment + .install_provider( + json!({ + "continuation_state":{"format":"example.responses","write_version":1,"readable_versions":[1]}, + "execution_events":[ + {"facts":[{"type":"started","id":"response-plugin","model":"plugin-model"}]}, + {"facts":[{"type":"completed","id":"response-plugin","model":"plugin-model","reason":"stop"}],"session_update":{"payload":{"turn":"opaque-v1"}}} + ] + }), + grants, + ) + .await; + + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let initial_digest = snapshot.instances[0].artifact_sha256.clone(); + let artifact = store.load_artifact(&initial_digest).await.unwrap(); + let package = ValidatedPackage::read(artifact.archive, None, PackageLimits::default()).unwrap(); + let mut manifest = package.manifest().clone(); + manifest.version = "1.0.1".parse().unwrap(); + let next_archive = archive(BTreeMap::from([ + ("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()), + ("bin/worker".into(), worker().to_vec()), + ])); + let next_artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect(next_archive, None) + .await + .unwrap(); + let installed = store + .install_artifact(next_artifact, PluginSource::Upload, &mutation()) + .await + .unwrap(); + let installed = store + .accept_artifact(&installed.artifact.metadata.sha256, &mutation()) + .await + .unwrap(); + let next_digest = installed.artifact.metadata.sha256; + assert_ne!(next_digest, initial_digest); + + // 测试 worker 依据不可变制品摘要返回该版本自身的格式声明;实例配置在升级前后不变。 + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + let mut by_artifact = serde_json::Map::new(); + by_artifact.insert( + next_digest.clone(), + json!({"format":"example.responses","write_version":2,"readable_versions":[1,2]}), + ); + instance.configuration.as_object_mut().unwrap().insert( + "continuation_state_by_artifact".into(), + serde_json::Value::Object(by_artifact), + ); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + + let (runtime, core) = environment.runtime().await; + let events = execute(&runtime, &core, None).await.unwrap(); + let state = continuation_state(&events); + assert_eq!( + state.payload()["binding"]["continuation_state"], + json!({"format":"example.responses","version":1}) + ); + + // 发布新制品时旧快照模拟在途请求;发布后新请求使用新解释器,旧资源直到引用释放才回收。 + let old_snapshot = core.snapshots().acquire().unwrap(); + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.artifact_sha256.clone_from(&next_digest); + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + drop(events); + let events = execute(&runtime, &core, Some(state.clone())).await.unwrap(); + let upgraded_state = continuation_state(&events); + assert_eq!( + upgraded_state.payload()["binding"]["continuation_state"], + json!({"format":"example.responses","version":2}) + ); + assert!(execute(&runtime, &core, Some(upgraded_state)).await.is_ok()); + assert_eq!( + std::fs::read_dir(environment.directory.path().join("cache")) + .unwrap() + .count(), + 2 + ); + drop(old_snapshot); + wait_until_entry_count(&environment.directory.path().join("cache"), 1).await; + + for (field, value) in [("format", json!("another.format")), ("version", json!(3))] { + let mut payload = state.payload().clone(); + payload["binding"]["continuation_state"][field] = value; + let invalid = ProviderSessionState::new("example", payload).unwrap(); + assert_replay_required(&execute(&runtime, &core, Some(invalid)).await.unwrap_err()); + } + + drop(events); + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance + .configuration + .as_object_mut() + .unwrap() + .insert("authorization_change".into(), json!(true)); + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + assert_replay_required( + &execute(&runtime, &core, Some(state.clone())) + .await + .unwrap_err(), + ); + + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance + .configuration + .as_object_mut() + .unwrap() + .remove("authorization_change"); + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + assert!(execute(&runtime, &core, Some(state.clone())).await.is_ok()); + + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance + .grants + .retain(|grant| grant.permission != "accounts"); + // 授权来自制品接受事实;配置写入不能另造一份权限集合。 + assert!( + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .is_err() + ); + assert!(execute(&runtime, &core, Some(state.clone())).await.is_ok()); + + let registry = runtime.admin_registry(core.snapshots()); + let admin = environment.store.admin_ports(); + let credentials = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + credentials + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id, + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"rotated-continuation-test"}) + .as_object() + .unwrap() + .clone(), + )), + settings: None, + }) + .await + .unwrap(); + assert_replay_required(&execute(&runtime, &core, Some(state)).await.unwrap_err()); + + drop(credentials); + drop(registry); + drop(admin); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} + +#[tokio::test] +async fn incompatible_upgrade_uses_retired_executor_but_current_disable_wins() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + environment + .install_provider( + json!({ + "execution_events":[ + {"facts":[{"type":"started","id":"response-old","model":"plugin-model"}]}, + {"facts":[{"type":"completed","id":"response-old","model":"plugin-model","reason":"stop"}],"session_update":{"payload":{"executor":"old"}}} + ] + }), + vec![account_grant("accounts")], + ) + .await; + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let initial_digest = snapshot.instances[0].artifact_sha256.clone(); + let next_digest = install_artifact_version(store.as_ref(), &initial_digest, "1.0.1").await; + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.configuration["execution_events_by_artifact"] = json!({ + next_digest.clone():[ + {"facts":[{"type":"started","id":"response-new","model":"plugin-model"}]}, + {"facts":[{"type":"completed","id":"response-new","model":"plugin-model","reason":"stop"}],"session_update":{"payload":{"executor":"new"}}} + ] + }); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + + let (runtime, core) = environment.runtime().await; + let initial = execute(&runtime, &core, None).await.unwrap(); + let state = continuation_state(&initial); + assert_eq!(state.payload()["payload"]["executor"], "old"); + drop(initial); + + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.artifact_sha256.clone_from(&next_digest); + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + let resumed = execute(&runtime, &core, Some(state.clone())).await.unwrap(); + assert_eq!( + continuation_state(&resumed).payload()["payload"]["executor"], + "old" + ); + drop(resumed); + + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.enabled = false; + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + let snapshot = core.snapshots().acquire().unwrap(); + let registry = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + assert!(!registry.contains(&ProviderKind::new("example").unwrap())); + drop(registry); + drop(snapshot); + + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.enabled = true; + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + // 停用期间入口已移除;重新启用相同权限与配置后,未到期的旧执行器仍可排空续链。 + let resumed = execute(&runtime, &core, Some(state)).await.unwrap(); + assert_eq!( + continuation_state(&resumed).payload()["payload"]["executor"], + "old" + ); + drop(resumed); + + // 当前代次仍由 Core 保活,旧执行器仍由续写排空表保活;显式关闭必须同时回收两者。 + runtime.shutdown().await; + wait_until_entry_count(&environment.directory.path().join("cache"), 0).await; + + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} + +#[tokio::test] +async fn retired_executor_expires_without_sliding_on_continuation_use() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + environment + .install_provider( + json!({ + "execution_events":[ + {"facts":[{"type":"started","id":"response","model":"plugin-model"}]}, + {"facts":[{"type":"completed","id":"response","model":"plugin-model","reason":"stop"}],"session_update":{"payload":{"turn":1}}} + ] + }), + vec![account_grant("accounts")], + ) + .await; + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let initial_digest = snapshot.instances[0].artifact_sha256.clone(); + let next_digest = install_artifact_version(store.as_ref(), &initial_digest, "1.0.1").await; + let (runtime, core) = environment + .runtime_with_continuation_drain(ContinuationDrainConfig { + retention: Duration::from_millis(500), + maximum_generations: NonZeroUsize::new(64).unwrap(), + maximum_per_instance: NonZeroUsize::new(2).unwrap(), + }) + .await; + let state = continuation_state(&execute(&runtime, &core, None).await.unwrap()); + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.artifact_sha256 = next_digest; + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + let expected = store.load_instances().await.unwrap(); + let published = core + .snapshots() + .snapshot_for_diagnostics() + .expect("published plugin generation"); + let diagnostics = PluginPreparation::runtime_diagnostics( + runtime.as_ref(), + &expected, + Some(published.revision().get()), + published.extensions(), + ) + .await + .expect("runtime diagnostics"); + assert_eq!( + diagnostics[&expected.instances[0].id].retained_continuations, + 1 + ); + tokio::time::sleep(Duration::from_millis(350)).await; + assert!(execute(&runtime, &core, Some(state.clone())).await.is_ok()); + // 使用旧执行器不会延长从撤下时开始的固定期限。 + tokio::time::sleep(Duration::from_millis(250)).await; + assert_replay_required(&execute(&runtime, &core, Some(state)).await.unwrap_err()); + wait_until_entry_count(&environment.directory.path().join("cache"), 1).await; + + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} + +#[tokio::test] +async fn retired_executor_capacity_evicts_the_oldest_owner() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + environment + .install_provider( + json!({ + "execution_events":[ + {"facts":[{"type":"started","id":"response","model":"plugin-model"}]}, + {"facts":[{"type":"completed","id":"response","model":"plugin-model","reason":"stop"}],"session_update":{"payload":{"turn":1}}} + ] + }), + vec![account_grant("accounts")], + ) + .await; + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let digest1 = snapshot.instances[0].artifact_sha256.clone(); + let digest2 = install_artifact_version(store.as_ref(), &digest1, "1.0.1").await; + let digest3 = install_artifact_version(store.as_ref(), &digest1, "1.0.2").await; + let digest4 = install_artifact_version(store.as_ref(), &digest1, "1.0.3").await; + let (runtime, core) = environment + .runtime_with_continuation_drain(ContinuationDrainConfig { + retention: Duration::from_secs(30 * 60), + maximum_generations: NonZeroUsize::new(2).unwrap(), + maximum_per_instance: NonZeroUsize::new(2).unwrap(), + }) + .await; + let state1 = continuation_state(&execute(&runtime, &core, None).await.unwrap()); + + let mut states = Vec::new(); + for digest in [&digest2, &digest3] { + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.artifact_sha256.clone_from(digest); + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + states.push(continuation_state( + &execute(&runtime, &core, None).await.unwrap(), + )); + } + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.artifact_sha256 = digest4; + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + publish(&core, saved.config_revision.get()).await; + wait_until_entry_count(&environment.directory.path().join("cache"), 3).await; + + assert_replay_required(&execute(&runtime, &core, Some(state1)).await.unwrap_err()); + assert!( + execute(&runtime, &core, Some(states.remove(0))) + .await + .is_ok() + ); + assert!( + execute(&runtime, &core, Some(states.remove(0))) + .await + .is_ok() + ); + + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(store); + environment.close().await; +} + +#[tokio::test] +async fn continuation_format_contract_is_bounded_unique_and_self_readable() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"continuation_state":{"format":"example.responses","write_version":1,"readable_versions":[1]}}), + vec![account_grant("accounts")], + ) + .await; + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let mut cases = vec![ + json!({"format":"","write_version":1,"readable_versions":[1]}), + json!({"format":"unsafe/path","write_version":1,"readable_versions":[1]}), + json!({"format":"example.responses","write_version":0,"readable_versions":[1]}), + json!({"format":"example.responses","write_version":1,"readable_versions":[]}), + json!({"format":"example.responses","write_version":2,"readable_versions":[1]}), + json!({"format":"example.responses","write_version":1,"readable_versions":[1,1]}), + ]; + cases.push(json!({ + "format":"example.responses", + "write_version":1, + "readable_versions":(1_u32..=33).collect::>() + })); + for descriptor in cases { + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration["continuation_state"] = descriptor; + let error = + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .expect_err("invalid continuation descriptor"); + assert_eq!(error.message(), "插件续写状态兼容声明无效"); + } + drop(store); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn event_envelope_rejects_ambiguous_frames_unsafe_headers_and_unbound_state() { + let invalid = [ + json!({}), + json!({"wire":{"protocol":"undeclared","payload":{"kind":"raw_sse","frame":b"data: {}\n\n".as_slice()}}}), + json!({"wire":{"protocol":"openai","payload":{"kind":"raw_sse","frame":b"data: {}\n\ndata: {}\n\n".as_slice()}}}), + json!({"wire":{"protocol":"openai","payload":{"kind":"raw_sse","frame":b"data: {}\n".as_slice()}}}), + json!({"wire":{"protocol":"openai","payload":{"kind":"json","event":null,"data":{"actual":false},"raw_sse":b"data: {\"actual\":true}\n\n".as_slice()}}}), + json!({"wire":{"protocol":"openai","payload":{"kind":"raw_json","body":b"{} {}".as_slice()}}}), + json!({"observation":{"status":600}}), + json!({"observation":{"client_headers":[{"name":"set-cookie","value":b"private=value".as_slice()}]}}), + json!({"observation":{"client_headers":[{"name":"x-safe","value":b"injected\r\nheader: value".as_slice()}]}}), + json!({"facts":[{"type":"started","id":"id","model":null}],"session_update":{"payload":{}}}), + json!({"facts":[{"type":"completed","id":"id","model":null,"reason":"stop"}],"session_update":{"payload":{"too_large":""}}}), + json!({"observation":{"client_headers":[{"name":"content-type","value":b"application/json".as_slice()}]},"wire":{"protocol":"openai","payload":{"kind":"raw_json","body":b"{}".as_slice()}}}), + ]; + for (index, event) in invalid.into_iter().enumerate() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"output_formats":["canonical","openai"],"execution_events":[event],"oversize_session_payload":index == 10}), + vec![account_grant("accounts")], + ) + .await; + let error = execute(&runtime, &core, None).await.unwrap_err(); + assert_eq!( + error.kind(), + ProviderErrorKind::Protocol, + "invalid envelope case {index}" + ); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn structured_execution_failure_keeps_client_body_and_host_send_watermark() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let upstream = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(429).set_body_string("upstream reached")) + .expect(1) + .mount(&upstream) + .await; + let network = account_grant("network"); + let body = b"{ \"error\": {\"message\":\"private upstream response\"} }\n".as_slice(); + let (runtime, core) = environment.provider(json!({ + "http_url":upstream.uri(), + "execution_events":[{ + "observation":{"status":429,"request_id":"private-request-id","client_headers":[{"name":"retry-after","value":b"2".as_slice()}]}, + "failure":{"kind":"rate_limited","send_state":"not_sent","code":"private-code","retry_after_ms":2000,"response":{"status":429,"content_type":b"application/json".as_slice(),"body":body}} + }] + }), vec![account_grant("accounts"), network]).await; + let error = execute(&runtime, &core, None).await.unwrap_err(); + assert_eq!(error.kind(), ProviderErrorKind::RateLimited); + assert_eq!(error.send_state(), UpstreamSendState::Sent); + assert_eq!(error.upstream_status(), Some(429)); + assert_eq!( + error.upstream_request_id().unwrap().as_str(), + "private-request-id" + ); + assert_eq!(error.retry_after(), Some(Duration::from_secs(2))); + let response = error.client_visible_upstream_response().unwrap(); + assert_eq!(response.body().as_ref(), body); + assert_eq!(response.headers()[0].value().as_ref(), b"2".as_slice()); + assert!(error.clone().client_visible_upstream_response().is_none()); + let diagnostic = format!("{error:?}"); + assert!(!diagnostic.contains("private")); + drop(error); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn failure_wire_is_atomic_and_cannot_also_report_completion() { + for completed in [false, true] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let raw = b"event: response.failed\ndata: {\"type\":\"response.failed\"}\n\n".as_slice(); + let facts = if completed { + json!([{"type":"completed","id":"response","model":null,"reason":"stop"}]) + } else { + json!([]) + }; + let (runtime, core) = environment.provider(json!({ + "output_formats":["canonical","openai"], + "execution_events":[{"facts":facts,"wire":{"protocol":"openai","payload":{"kind":"raw_sse","frame":raw}},"failure":{"kind":"unavailable","send_state":"sent","code":null,"retry_after_ms":null,"response":null}}] + }), vec![account_grant("accounts")]).await; + let mut error = execute(&runtime, &core, None).await.unwrap_err(); + if completed { + assert_eq!(error.kind(), ProviderErrorKind::Protocol); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + } else { + assert_eq!(error.kind(), ProviderErrorKind::Unavailable); + assert_eq!(error.send_state(), UpstreamSendState::Sent); + assert!(error.clone().take_atomic_client_events().is_empty()); + let events = error.take_atomic_client_events(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0] + .wire_event() + .unwrap() + .raw_sse_frame() + .unwrap() + .as_ref(), + raw + ); + } + drop(error); + drop(core); + drop(runtime); + environment.close().await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/execution.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/execution.rs new file mode 100644 index 000000000..68258723b --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/execution.rs @@ -0,0 +1,1058 @@ +use std::{ + num::NonZeroU32, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use futures::StreamExt as _; +use gateway_admin::model::plugins::instances::PluginPermissionGrant; +use gateway_core::{ + account::{AccountSelectionPolicy, OutboundProxy, RotationStrategy}, + engine::{ + AccountAttemptContext, AttemptContext, EngineError, ModelRequestId, RequestAttemptContext, + execution::{ClientTransport, ExecutionRequestMetadata, StartExecution}, + policy::RequestPolicyContext, + probe::AccountProbeRequest, + provider::ProviderRequest, + }, + error::ProviderErrorKind, + event::GatewayEvent, + lifecycle::CancellationToken, + metering::Decimal, + operation::{GenerateRequest, Operation, ProtocolPayload}, + policy::{ClientApiKeyId, RateLimits}, + provider_ports::ProviderSessionAffinityKey, + routing::{ProviderKind, PublicModelId, RoutingContext, UpstreamModelId}, + upstream::UpstreamSendState, +}; +use serde_json::json; + +#[tokio::test] +async fn rust_provider_uses_real_database_leases_and_core_probe_without_sending_during_prepare() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + run_provider( + environment, + json!({}), + None, + network_grant(), + Ok(Some("Rust plugin response")), + ) + .await; +} + +#[tokio::test] +async fn rust_provider_streams_http_through_selected_account_proxy() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let proxy = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "proxy-authorization", + "Basic dXNlcjpwYXNz", + )) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_string("managed response ".repeat(200)), + ) + .expect(2) + .mount(&proxy) + .await; + let response = "managed response ".repeat(200); + run_provider( + environment, + json!({"http_url":"http://127.0.0.1:9/model"}), + Some(OutboundProxy::parse(&format!("http://user:pass@{}", proxy.address())).unwrap()), + network_grant(), + Ok(Some(&response)), + ) + .await; +} + +#[tokio::test] +async fn rust_provider_cannot_downgrade_host_http_send_state() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("accepted")) + .expect(1) + .mount(&server) + .await; + run_provider( + environment, + json!({"http_url":server.uri(),"error_after_http":true}), + None, + network_grant(), + Err(UpstreamSendState::Sent), + ) + .await; +} + +#[tokio::test] +async fn network_domain_does_not_require_origin_prebinding() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("domain response")) + .expect(2) + .mount(&server) + .await; + run_provider( + environment, + json!({"http_url":server.uri()}), + None, + network_grant(), + Ok(Some("domain response")), + ) + .await; +} + +#[tokio::test] +async fn rust_provider_runs_scheduler_rpc_before_lease_and_execution() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let kind = ProviderKind::new("example").unwrap(); + let account_id = environment.account(None).await; + let schedule_marker = environment.directory.path().join("scheduled.jsonl"); + let execution_marker = environment.directory.path().join("executed"); + let (runtime, core) = environment + .provider( + json!({ + "schedule_decision":{"decision":"reject"}, + "schedule_marker":schedule_marker.to_string_lossy(), + "execution_marker":execution_marker.to_string_lossy(), + "generation_marker":"scheduler", + }), + vec![crate::support::environment::account_grant("accounts")], + ) + .await; + let ports = environment.store.provider_ports(); + let snapshot = core.snapshots().acquire().unwrap(); + let generation = snapshot.extensions().cloned().unwrap(); + let policy = RequestPolicyContext::new( + runtime.policy_registry().resolve(&generation).unwrap(), + generation, + ModelRequestId::new("req_plugin_scheduler").unwrap(), + ClientApiKeyId::new("key_plugin_scheduler").unwrap(), + vec![], + ); + let admin_registry = runtime.admin_registry(core.snapshots()); + let admin = admin_registry.require(&kind).unwrap(); + let model = UpstreamModelId::new("plugin-model").unwrap(); + let operation = admin + .connection_test_operation(&model, "scheduler") + .await + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers.get(&kind).unwrap(); + let attempt = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_plugin_scheduler").unwrap(), + ClientApiKeyId::new("key_plugin_scheduler").unwrap(), + ) + .with_request_policy(Some(policy)), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(30), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::MIN, + Duration::ZERO, + ), + AccountAttemptContext::default().with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ); + let error = match Arc::clone(provider) + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + attempt, + ) + .await + { + Ok(_) => panic!("scheduler should reject before provider execution"), + Err(error) => error, + }; + assert_eq!(error.kind(), ProviderErrorKind::RequestPolicyDenied); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert_eq!( + ports + .leases() + .account_in_flight(std::slice::from_ref(&account_id)) + .await + .unwrap()[&account_id], + 0, + "明确拒绝发生在账号租约获取前" + ); + assert!(!execution_marker.exists(), "明确拒绝不能进入 Provider 执行"); + let schedules = std::fs::read_to_string(schedule_marker).unwrap(); + assert_eq!(schedules.lines().count(), 1, "必须真实派发一次调度 RPC"); + + drop(admin); + drop(admin_registry); + drop(providers); + drop(snapshot); + drop(core); + drop(runtime); + drop(ports); + environment.close().await; +} + +#[tokio::test] +async fn routing_callback_inherits_admission_and_reads_real_affinity_without_recursing() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let provider = ProviderKind::new("example").unwrap(); + let account_id = environment + .account_for_with_concurrency(provider.as_str(), None, Some(1)) + .await; + let _router_account = environment + .account_for_with_concurrency("router", None, Some(1)) + .await; + let affinity_key = ProviderSessionAffinityKey::try_new("nested-routing-affinity").unwrap(); + let ports = environment.store.provider_ports(); + ports + .session_affinity() + .bind( + &provider, + &affinity_key, + &account_id, + Duration::from_secs(60), + ) + .await + .unwrap(); + + let route_marker = environment.directory.path().join("nested-route.jsonl"); + let nested_marker = environment.directory.path().join("nested-model.jsonl"); + let affinity_marker = environment.directory.path().join("nested-affinity.jsonl"); + let child_execution_marker = environment.directory.path().join("child-executions.jsonl"); + let model_grant = crate::support::environment::account_grant("models"); + let affinity_grant = crate::support::environment::account_grant("requests"); + let router_credential = crate::support::environment::account_grant("accounts"); + environment + .install_provider( + json!({ + "provider_id":"router", + "generation_marker":"nested-router", + "route_marker":route_marker, + "route_decision":{ + "decision":"route", + "provider":"example", + "model":"plugin-model" + }, + "nested_model_fixture":{ + "request":{ + "model":"plugin-model", + "protocol":"openai", + "operation":"generate", + "provider":"example", + "account_id":account_id.as_str() + }, + "body":{"model":"plugin-model","input":"nested routing request"} + }, + "nested_model_marker":nested_marker, + "affinity_fixture":{ + "request":{ + "provider":"example", + "key":affinity_key.expose_to_store() + } + }, + "affinity_marker":affinity_marker, + }), + vec![model_grant, affinity_grant, router_credential], + ) + .await; + environment + .install_provider( + json!({ + "provider_id":"example", + "generation_marker":"nested-target", + "execution_calls_marker":child_execution_marker, + }), + vec![crate::support::environment::account_grant("accounts")], + ) + .await; + let key_secret = "sk-nested-routing-fixture"; + let key_id = format!("key_{}", uuid::Uuid::new_v4().simple()); + environment + .client_key_with_limits( + &key_id, + key_secret, + RateLimits { + max_concurrency: 1, + requests_per_minute: 0, + }, + ) + .await; + let (runtime, core) = environment.runtime().await; + let execution = core.execution_service(); + let client = execution.authenticate(key_secret).unwrap(); + let mut started = tokio::time::timeout( + Duration::from_secs(10), + execution.start(StartExecution { + client, + public_model: PublicModelId::new("plugin-model").unwrap(), + operation: generate_operation(), + metadata: ExecutionRequestMetadata { + protocol: "openai".into(), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip: None, + user_agent: Some("nested-routing-integration".into()), + previous_response_id: None, + }, + }), + ) + .await + .expect("nested execution must reuse the admitted Key slot") + .unwrap_or_else(|error| { + panic!( + "routing callback failed: {error:?}; route={:?}; affinity={:?}; nested={:?}", + marker_records(&route_marker), + marker_records(&affinity_marker), + marker_records(&nested_marker), + ) + }); + let events = tokio::time::timeout( + Duration::from_secs(10), + started.session.collect_uncommitted(), + ) + .await + .expect("parent execution must not recurse into the router") + .unwrap(); + assert!( + events + .iter() + .any(|event| matches!(event.canonical_facts(), [GatewayEvent::Completed(_)])) + ); + started.session.commit_downstream(Some(200)).await.unwrap(); + + let route_calls = marker_records(&route_marker); + assert_eq!(route_calls.len(), 1, "A→child must suppress router A"); + let nested_calls = marker_records(&nested_marker); + assert_eq!(nested_calls.len(), 1); + assert!(nested_calls[0]["events"].as_u64().unwrap() >= 1); + let affinity_calls = marker_records(&affinity_marker); + assert_eq!(affinity_calls.len(), 1); + assert_eq!( + affinity_calls[0]["result"]["account_id"], + account_id.as_str() + ); + assert_eq!( + marker_records(&child_execution_marker).len(), + 2, + "the child and then the parent each execute exactly once" + ); + let markers = format!( + "{}{}{}", + std::fs::read_to_string(&route_marker).unwrap(), + std::fs::read_to_string(&nested_marker).unwrap(), + std::fs::read_to_string(&affinity_marker).unwrap() + ); + assert!(!markers.contains(key_secret)); + + drop(started); + tokio::time::timeout(Duration::from_secs(3), async { + while ports + .leases() + .account_in_flight(std::slice::from_ref(&account_id)) + .await + .unwrap()[&account_id] + != 0 + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + drop(execution); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(ports); + environment.close().await; +} + +#[tokio::test] +async fn cancelling_a_single_slot_parent_reaps_the_child_and_charges_its_cost_once() { + let Some(mut environment) = crate::support::environment::Environment::create_command().await + else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + // 测试不启动 Host 监督器,直接启动生产同款四个写泵, + // 确保终态、计费和准入释放都真实落入 PostgreSQL/Redis。 + environment.store.start_command_line_writes().unwrap(); + let parent_account = environment + .account_for_with_concurrency("parent", None, Some(1)) + .await; + let child_account = environment + .account_for_with_concurrency("child", None, Some(1)) + .await; + let parent_execution_marker = environment.directory.path().join("cancel-parent.jsonl"); + let child_execution_marker = environment.directory.path().join("cancel-child.jsonl"); + let child_waiting_marker = environment.directory.path().join("child-waiting.jsonl"); + let child_cost_consumed_marker = environment.directory.path().join("child-cost.jsonl"); + let child_stream_trace_marker = environment.directory.path().join("child-stream.jsonl"); + let child_cancelled_marker = environment.directory.path().join("child-cancelled.jsonl"); + let parent_credential = crate::support::environment::account_grant("accounts"); + let model_grant = crate::support::environment::account_grant("models"); + environment + .install_provider( + json!({ + "provider_id":"parent", + "generation_marker":"cancel-parent", + "execution_calls_marker":parent_execution_marker, + "execution_nested_model_fixture":{ + "stream_until_after_provider_cost":true, + "request":{ + "model":"child-model", + "protocol":"openai", + "operation":"generate", + "provider":"child", + "account_id":child_account.as_str() + }, + "body":{"model":"child-model","input":"cancel the active child"} + }, + "execution_nested_model_marker":child_cost_consumed_marker, + "nested_stream_trace_marker":child_stream_trace_marker + }), + vec![parent_credential, model_grant], + ) + .await; + let child_credential = crate::support::environment::account_grant("accounts"); + environment + .install_provider( + json!({ + "provider_id":"child", + "generation_marker":"cancel-child", + "static_models":[{"id":"child-model","operations":["generate"]}], + "output_formats":["canonical","openai"], + "execution_calls_marker":child_execution_marker, + "execution_wait_for_cancel_marker":child_waiting_marker, + "execution_cancelled_marker":child_cancelled_marker, + "execution_events":[ + {"facts":[{"type":"started","id":"cancel-child","model":"child-model"}]}, + {"facts":[{"type":"provider_cost","amount":"0.0125","currency":"USD"}]}, + { + "facts":[ + {"type":"content_added","index":0,"kind":"text"}, + {"type":"text_delta","index":0,"text":"visible after cost"} + ], + "wire":{ + "protocol":"openai", + "payload":{ + "kind":"json", + "event":"response.output_text.delta", + "data":{"delta":"visible after cost"} + } + } + } + ] + }), + vec![child_credential], + ) + .await; + let key_secret = "sk-nested-cancel-fixture"; + let key_id = format!("key_{}", uuid::Uuid::new_v4().simple()); + environment + .client_key_with_limits( + &key_id, + key_secret, + RateLimits { + max_concurrency: 1, + requests_per_minute: 0, + }, + ) + .await; + let ports = environment.store.provider_ports(); + let (runtime, core) = environment.runtime().await; + let execution = core.execution_service(); + let client = execution.authenticate(key_secret).unwrap(); + let mut started = execution + .start(StartExecution { + client, + public_model: PublicModelId::new("plugin-model").unwrap(), + operation: generate_operation(), + metadata: ExecutionRequestMetadata { + protocol: "openai".into(), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip: None, + user_agent: Some("nested-cancel-single-slot".into()), + previous_response_id: None, + }, + }) + .await + .unwrap(); + let mut next = started.session.next_event(); + tokio::select! { + biased; + () = wait_for_marker(&child_cost_consumed_marker) => {} + result = &mut next => panic!( + "parent completed before cancellation: {result:?}; parent={:?}; child={:?}; waiting={:?}; barrier={:?}; stream={:?}", + marker_records(&parent_execution_marker), + marker_records(&child_execution_marker), + marker_records(&child_waiting_marker), + marker_records(&child_cost_consumed_marker), + marker_records(&child_stream_trace_marker), + ), + } + drop(next); + assert_eq!(marker_records(&parent_execution_marker).len(), 1); + assert_eq!(marker_records(&child_execution_marker).len(), 1); + assert_eq!(marker_records(&child_waiting_marker).len(), 1); + assert!( + marker_records(&child_cost_consumed_marker)[0]["after_provider_cost_consumed"] + .as_bool() + .unwrap_or(false) + ); + + started.session.cancel(); + tokio::time::timeout(Duration::from_secs(3), started.session.detach_finalize()) + .await + .expect("parent and child cleanup must be bounded"); + wait_for_marker(&child_cancelled_marker).await; + assert_eq!(marker_records(&child_cancelled_marker).len(), 1); + + let ledger = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let ledger = environment.model_request_ledger(&key_id).await; + if ledger.len() >= 2 + && ledger.iter().all(|request| request.completed) + && ledger + .iter() + .all(|request| request.charged_amount.is_some()) + { + break ledger; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + let ledger = match ledger { + Ok(ledger) => ledger, + Err(_) => panic!( + "parent and child ledger writes must drain; last facts: {:?}", + environment.model_request_ledger(&key_id).await, + ), + }; + assert_eq!( + ledger.len(), + 2, + "one parent and one child request are expected" + ); + let parent = ledger + .iter() + .find(|request| request.provider_kind.as_deref() == Some("parent")) + .expect("parent ledger row"); + let child = ledger + .iter() + .find(|request| request.provider_kind.as_deref() == Some("child")) + .expect("child ledger row"); + assert_ne!(parent.request_id, child.request_id); + assert!(parent.completed); + assert_eq!(parent.outcome, "cancelled"); + assert_eq!(parent.cost_source, "unavailable"); + assert!(parent.cost_amount.is_none()); + assert!(child.completed); + assert_eq!(child.outcome, "cancelled"); + assert_eq!(child.request_kind.as_deref(), Some("plugin_child_model")); + assert!(child.subagent_kind.is_some()); + assert_eq!(child.cost_source, "provider_reported"); + assert_eq!( + child + .cost_amount + .as_deref() + .unwrap() + .parse::() + .unwrap() + .canonical(), + "0.0125" + ); + let charges = ledger + .iter() + .map(|request| { + request + .charged_amount + .as_deref() + .unwrap() + .parse::() + .unwrap() + }) + .collect::>(); + assert_eq!( + parent + .charged_amount + .as_deref() + .unwrap() + .parse::() + .unwrap(), + Decimal::ZERO + ); + assert_eq!( + child + .charged_amount + .as_deref() + .unwrap() + .parse::() + .unwrap() + .canonical(), + "0.0125" + ); + let charged = charges + .iter() + .copied() + .fold(Decimal::ZERO, |total, amount| { + total.checked_add(amount).unwrap() + }); + assert_eq!(charged.canonical(), "0.0125"); + assert_eq!( + charges + .iter() + .filter(|amount| **amount != Decimal::ZERO) + .count(), + 1, + "the orchestrating parent must not duplicate the child's upstream cost" + ); + + let accounts = [parent_account, child_account]; + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let in_flight = ports.leases().account_in_flight(&accounts).await.unwrap(); + if in_flight.values().all(|count| *count == 0) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("parent and child account leases must be released"); + + drop(execution); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + drop(ports); + environment + .store + .shutdown_command_line_writes() + .await + .unwrap(); + environment.close().await; +} + +fn marker_records(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +async fn wait_for_marker(path: &std::path::Path) { + tokio::time::timeout(Duration::from_secs(5), async { + while marker_records(path).is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("worker marker must arrive"); +} + +#[tokio::test] +async fn provider_nested_model_side_effect_makes_a_claimed_not_sent_failure_non_replayable() { + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let parent_accounts = [ + environment + .account_for_with_concurrency("parent", None, Some(1)) + .await, + environment + .account_for_with_concurrency("parent", None, Some(1)) + .await, + ]; + let child_account = environment + .account_for_with_concurrency("child", None, Some(1)) + .await; + let parent_execution_marker = environment.directory.path().join("parent-executions.jsonl"); + let child_execution_marker = environment.directory.path().join("child-executions.jsonl"); + let nested_marker = environment.directory.path().join("provider-nested.jsonl"); + let parent_credential = crate::support::environment::account_grant("accounts"); + let model_grant = crate::support::environment::account_grant("models"); + environment + .install_provider( + json!({ + "provider_id":"parent", + "generation_marker":"nested-parent", + "execution_calls_marker":parent_execution_marker, + "execution_nested_model_fixture":{ + "request":{ + "model":"child-model", + "protocol":"openai", + "operation":"generate", + "provider":"child", + "account_id":child_account.as_str() + }, + "body":{"model":"child-model","input":"child side effect"} + }, + "execution_nested_model_marker":nested_marker, + "error_after_nested_model":true, + }), + vec![parent_credential, model_grant], + ) + .await; + let child_credential = crate::support::environment::account_grant("accounts"); + environment + .install_provider( + json!({ + "provider_id":"child", + "generation_marker":"nested-child", + "static_models":[{"id":"child-model","operations":["generate"]}], + "execution_calls_marker":child_execution_marker, + }), + vec![child_credential], + ) + .await; + let key_secret = "sk-nested-side-effect-fixture"; + let key_id = format!("key_{}", uuid::Uuid::new_v4().simple()); + environment + .client_key_with_limits( + &key_id, + key_secret, + RateLimits { + max_concurrency: 1, + requests_per_minute: 0, + }, + ) + .await; + let (runtime, core) = environment.runtime().await; + let execution = core.execution_service(); + let client = execution.authenticate(key_secret).unwrap(); + let mut started = execution + .start(StartExecution { + client, + public_model: PublicModelId::new("plugin-model").unwrap(), + operation: generate_operation(), + metadata: ExecutionRequestMetadata { + protocol: "openai".into(), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip: None, + user_agent: Some("nested-side-effect-watermark".into()), + previous_response_id: None, + }, + }) + .await + .unwrap(); + let error = tokio::time::timeout( + Duration::from_secs(10), + started.session.collect_uncommitted(), + ) + .await + .unwrap() + .expect_err("the parent fixture must fail after the child completed"); + assert!(matches!( + error, + EngineError::Provider(ref error) if error.send_state() == UpstreamSendState::Ambiguous + )); + assert_eq!(marker_records(&parent_execution_marker).len(), 1); + assert_eq!(marker_records(&child_execution_marker).len(), 1); + assert_eq!(marker_records(&nested_marker).len(), 1); + + started.session.detach_finalize().await; + let all_accounts = parent_accounts + .iter() + .cloned() + .chain(std::iter::once(child_account.clone())) + .collect::>(); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let in_flight = environment + .store + .provider_ports() + .leases() + .account_in_flight(&all_accounts) + .await + .unwrap(); + if all_accounts + .iter() + .all(|account| in_flight.get(account).copied().unwrap_or(0) == 0) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + drop(execution); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; +} + +fn generate_operation() -> Operation { + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"plugin-model","input":"generate"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + )) +} + +#[tokio::test] +async fn network_domain_does_not_override_the_host_live_network_policy() { + if std::env::var("CPR_PLUGIN_TEST_LIVE_HTTP").as_deref() != Ok("1") { + eprintln!("SKIP: CPR_PLUGIN_TEST_LIVE_HTTP 未启用"); + return; + } + let Some(environment) = crate::support::environment::Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + run_provider( + environment, + json!({"http_url":"https://api.github.com/zen"}), + None, + network_grant(), + Err(UpstreamSendState::NotSent), + ) + .await; +} + +fn network_grant() -> PluginPermissionGrant { + crate::support::environment::account_grant("network") +} + +async fn run_provider( + environment: crate::support::environment::Environment, + mut configuration: serde_json::Value, + proxy: Option, + network: PluginPermissionGrant, + expected: Result, UpstreamSendState>, +) { + let kind = ProviderKind::new("example").unwrap(); + let account_id = environment.account(proxy).await; + let ports = environment.store.provider_ports(); + let marker = environment.directory.path().join("executed"); + configuration["execution_marker"] = json!(marker); + configuration["generation_marker"] = json!("old"); + let (runtime, core) = environment + .provider( + configuration, + vec![ + crate::support::environment::account_grant("accounts"), + network, + ], + ) + .await; + let registry = runtime.provider_registry(); + let snapshot = core.snapshots().acquire().unwrap(); + let admin_registry = runtime.admin_registry(core.snapshots()); + let old_admin = admin_registry.require(&kind).unwrap(); + let model = UpstreamModelId::new("plugin-model").unwrap(); + assert_eq!( + old_admin.models(&account_id, false).await.unwrap().models[0].id, + model + ); + let operation = old_admin + .connection_test_operation(&model, "hello") + .await + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let providers = registry.for_extensions(snapshot.extensions()).unwrap(); + let provider = providers.get(&kind).unwrap(); + let context = AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_plugin_cold").unwrap(), + ClientApiKeyId::new("key_plugin_test").unwrap(), + ), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(30), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::new(1).unwrap(), + Duration::ZERO, + ), + AccountAttemptContext::default().with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ); + let mut response = Arc::clone(provider) + .execute( + ProviderRequest::new(operation.clone(), plan.candidates()[0].clone()), + context, + ) + .await + .unwrap(); + assert!(!marker.exists(), "准备完成不能发送业务请求"); + assert_eq!( + ports + .leases() + .account_in_flight(std::slice::from_ref(&account_id)) + .await + .unwrap()[&account_id], + 1 + ); + let mut events = Vec::new(); + let mut failure = None; + while let Some(event) = response.next().await { + match event { + Ok(event) => events.push(event), + Err(error) => { + failure = Some(error); + break; + } + } + } + assert_eq!( + std::fs::read_to_string(&marker).unwrap(), + account_id.as_str() + ); + match expected { + Ok(_) => { + assert!(failure.is_none(), "provider failed: {failure:?}"); + assert!(events.iter().any(|event| matches!(event.canonical_facts(), [GatewayEvent::ProviderCost(cost)] if cost.total().amount().canonical() == "0.0125"))); + } + Err(state) => assert_eq!(failure.unwrap().send_state(), state), + } + drop(response); + tokio::time::timeout(Duration::from_secs(3), async { + while ports + .leases() + .account_in_flight(std::slice::from_ref(&account_id)) + .await + .unwrap() + .get(&account_id) + .copied() + .unwrap_or(0) + != 0 + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + + let mut candidate = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + candidate.instances[0].configuration["generation_marker"] = json!("new"); + let next = gateway_admin::ports::plugins::PluginPreparation::prepare( + runtime.as_ref(), + candidate.config_revision, + candidate, + ) + .await + .unwrap(); + core.snapshots() + .publish(snapshot.as_ref().clone().with_extensions(Some(next))); + let current_admin = admin_registry.require(&kind).unwrap(); + if let Ok(expected_text) = expected { + let probe = core + .account_probe() + .probe( + AccountProbeRequest { + account_id, + provider_kind: kind.clone(), + upstream_model: UpstreamModelId::new("plugin-model").unwrap(), + operation, + }, + old_admin.snapshot(), + ) + .await + .unwrap(); + if let Some(text) = expected_text { + assert_eq!(probe.text, vec![text]); + } else { + assert!(!probe.text.concat().is_empty()); + } + } + drop(providers); + drop(snapshot); + // 此时只有管理句柄持有旧集合;发布切换不能在管理操作 await 时关闭其 RPC。 + let Operation::Generate(old_probe) = old_admin + .connection_test_operation(&model, "old") + .await + .unwrap() + else { + panic!("probe operation") + }; + let Operation::Generate(new_probe) = current_admin + .connection_test_operation(&model, "new") + .await + .unwrap() + else { + panic!("probe operation") + }; + assert_eq!( + old_probe.protocol_payload().body()["plugin_generation"], + "old" + ); + assert_eq!( + new_probe.protocol_payload().body()["plugin_generation"], + "new" + ); + drop(old_admin); + tokio::time::timeout(Duration::from_secs(3), async { + while std::fs::read_dir(environment.directory.path().join("cache")) + .unwrap() + .count() + != 1 + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); + drop(current_admin); + drop(admin_registry); + drop(core); + drop(runtime); + drop(ports); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/forecast.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/forecast.rs new file mode 100644 index 000000000..5f89ab029 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/forecast.rs @@ -0,0 +1,377 @@ +use chrono::{DateTime, Utc}; +use gateway_admin::model::provider_credentials::{ + ProviderDocument, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, + QuotaLocalUsageAttribution, +}; +use gateway_core::{ + account::OpaqueProviderData, + engine::{ + CommitRequirement, + execution::{ClientTransport, ExecutionRequestMetadata, StartExecution}, + }, + error::ProviderErrorKind, + operation::{GenerateRequest, Operation, ProtocolPayload}, + routing::{ConfigRevision, ProviderKind, PublicModelId}, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +const RESET_AT_MS: i64 = 1_800_000_000_000; + +fn forecast() -> Value { + json!({"plan_type":"plugin-pro","windows":[{ + "key":"weekly","group":"chat","limit_id":"chat-main","role":"secondary", + "account_wide":true,"window_seconds":604800,"used_percent":25.0,"reset_at_ms":RESET_AT_MS + }]}) +} + +fn configuration(value: Value) -> Value { + json!({ + "quota_url":"http://127.0.0.1:1/not-requested", + "execution_events":[ + {"observation":{"quota_forecast":value}}, + {"facts":[{"type":"started","id":"forecast-response","model":"plugin-model"}]}, + {"facts":[{"type":"usage","usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}, + {"type":"completed","id":"forecast-response","model":"plugin-model","reason":"stop"}]} + ] + }) +} + +fn window() -> ProviderQuotaWindow { + ProviderQuotaWindow { + key: "weekly".into(), + group: "chat".into(), + label: "Weekly quota".into(), + limit_id: Some("chat-main".into()), + limit_name: None, + role: Some(ProviderQuotaWindowRole::Secondary), + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(604800), + used_percent: Some(50.0), + reset_at: DateTime::::from_timestamp_millis(RESET_AT_MS), + limit_reached: false, + local_usage: None, + provider_data: None, + } +} + +async fn complete_request(core: &gateway_core::CoreBundle, key: &str) { + let execution = core.execution_service(); + let client = execution.authenticate(key).unwrap(); + let mut started = execution + .start(StartExecution { + client, + public_model: PublicModelId::new("plugin-model").unwrap(), + operation: Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"plugin-model","input":"forecast fixture","stream":true}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + )), + metadata: ExecutionRequestMetadata { + protocol: "openai".into(), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpSse, + stream: true, + client_ip: None, + user_agent: Some("plugin-forecast-test".into()), + previous_response_id: None, + }, + }) + .await + .unwrap(); + let mut committed = false; + while let Some(event) = started.session.next_event().await.unwrap() { + if !committed && event.commit_requirement() == CommitRequirement::CommitBeforeDelivery { + started.session.commit_downstream(Some(200)).await.unwrap(); + committed = true; + } + } + assert!(committed); + assert!(started.session.is_finalized()); +} + +#[tokio::test] +async fn core_persists_quota_facts_and_admin_pairs_them_after_generation_changes() { + use gateway_admin::model::{accounts::AccountUsageWindowQuery, observability::TimeRange}; + + let Some(mut environment) = Environment::create_command().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let account = environment.account(None).await; + let key_id = format!("key_{}", uuid::Uuid::new_v4().simple()); + let key = format!("sk-forecast-{}", uuid::Uuid::new_v4().simple()); + environment.client_key(&key_id, &key).await; + let reset = Utc::now() + chrono::Duration::days(3); + let mut observed = forecast(); + observed["windows"][0]["reset_at_ms"] = json!(reset.timestamp_millis()); + let mut current = observed.clone(); + current["access"] = json!({"kind":"allowed"}); + current["windows"][0]["label"] = json!("Weekly quota"); + current["windows"][0]["used_percent"] = json!(50.0); + current["windows"][0]["limit_reached"] = json!(false); + let current: gateway_plugin_sdk::call::provider::quota::Quota = + serde_json::from_value(current).unwrap(); + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(current)) + .expect(1) + .mount(&server) + .await; + let network = account_grant("network"); + let mut config = configuration(observed.clone()); + config["quota_url"] = json!(server.uri()); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts"), network]) + .await; + // 复用 Store 的有界写泵,断言真实 Core 结算持久化;不直接种植请求行或历史文档。 + environment.store.start_command_line_writes().unwrap(); + let begin = Utc::now(); + complete_request(&core, &key).await; + + // 新代次不再上报额度事实;历史解释不能依赖当时的子进程,也不能补造新请求的事实。 + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance.configuration["execution_events"] + .as_array_mut() + .unwrap() + .remove(0); + let changed = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + core.snapshot_control() + .publish_committed(ConfigRevision::new(changed.config_revision.get()).unwrap()) + .await; + complete_request(&core, &key).await; + environment + .store + .shutdown_command_line_writes() + .await + .unwrap(); + + let history = environment + .store + .admin_ports() + .accounts() + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: account.to_string(), + key: "weekly".into(), + range: TimeRange::new(begin, Utc::now()).unwrap(), + }) + .await + .unwrap(); + assert_eq!(history.pending_request_count, 0); + assert_eq!(history.usage.request_count, 2); + assert_eq!(history.usage.tokens, 14); + assert_eq!(history.points.len(), 1); + assert_eq!(history.points[0].usage.request_count, 1); + assert_eq!(history.points[0].usage.tokens, 7); + assert_eq!( + history.points[0] + .provider_observation + .expose_to_provider() + .expose_to_provider(), + json!({"plugin_quota_forecast_v1":observed}) + .as_object() + .unwrap() + ); + let other_account = environment.account(None).await; + let unrelated = environment + .store + .admin_ports() + .accounts() + .load_quota_forecast_history(&AccountUsageWindowQuery { + account_id: other_account.to_string(), + key: "weekly".into(), + range: TimeRange::new(begin, Utc::now()).unwrap(), + }) + .await + .unwrap(); + assert!( + unrelated.points.is_empty(), + "相同 Provider 和窗口不能串入其他账号历史" + ); + assert_eq!(unrelated.usage.request_count, 0); + assert_eq!(unrelated.usage.tokens, 0); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + provider + .quota(ProviderQuotaRequest { + account_id: account.clone(), + refresh: true, + rolling_usage: None, + }) + .await + .unwrap(); + let admin = environment.bind_admin_accounts(&runtime, &core).await; + let report = admin + .services() + .accounts() + .quota_forecast(&account) + .await + .unwrap(); + let weekly = &report.forecasts[0]; + assert_eq!(weekly.source.as_ref().unwrap().tokens, Some(14)); + assert_eq!(weekly.unavailable_reason, None); + assert_eq!(weekly.remaining_tokens, Some(14)); + assert_eq!(weekly.estimated_tokens, Some(28)); + assert!( + weekly.estimated_usd.is_none(), + "没有费用事实时不按请求数捏造金额" + ); + assert!(weekly.incomplete_cost); + // 只有显式刷新调用过上游;历史查询和预测都只读本地事实。 + server.verify().await; + drop(provider); + drop(admin); + drop(store); + drop(core); + runtime.shutdown().await; + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn real_worker_quota_facts_are_projected_locally_with_exact_window_identity() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let value = forecast(); + let (runtime, core) = environment + .provider( + configuration(value.clone()), + vec![account_grant("accounts")], + ) + .await; + let events = super::events::execute(&runtime, &core, None).await.unwrap(); + let metadata = events[0] + .response_observation() + .unwrap() + .provider_metadata() + .unwrap(); + let saved: Value = serde_json::from_str(metadata.as_json()).unwrap(); + assert_eq!(saved, json!({"plugin_quota_forecast_v1":value})); + assert!(events[0].wire_event().is_none()); + assert!(events[0].canonical_facts().is_empty()); + let document = + ProviderDocument::new(OpaqueProviderData::new(saved.as_object().unwrap().clone())); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let expected = provider + .quota_forecast_observation(&document, &window()) + .unwrap(); + assert_eq!(expected.used_percent, 25.0, "不从当前额度 50% 反推历史"); + assert_eq!(expected.reset_at.timestamp_millis(), RESET_AT_MS); + assert_eq!(expected.plan_type.as_deref(), Some("plugin-pro")); + // 发布对象仍持有句柄,但同步历史解释无需活着的子进程或额外权限。 + runtime.shutdown().await; + assert_eq!( + provider.quota_forecast_observation(&document, &window()), + Some(expected) + ); + for index in 0..6 { + let mut mismatch = window(); + match index { + 0 => mismatch.key = "monthly".into(), + 1 => mismatch.group = "images".into(), + 2 => mismatch.limit_id = None, + 3 => mismatch.role = Some(ProviderQuotaWindowRole::Primary), + 4 => mismatch.window_seconds = Some(3600), + _ => mismatch.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable, + } + assert!( + provider + .quota_forecast_observation(&document, &mismatch) + .is_none(), + "window mismatch {index}" + ); + } + let legacy = ProviderDocument::new(OpaqueProviderData::new(Default::default())); + assert!( + provider + .quota_forecast_observation(&legacy, &window()) + .is_none() + ); + drop(provider); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn real_worker_rejects_invalid_or_oversized_quota_observations() { + let mut invalid = Vec::new(); + for (field, replacement) in [ + ("window_seconds", json!(0)), + ("used_percent", json!(-1.0)), + ("reset_at_ms", json!(i64::MAX)), + ("key", json!("")), + ("group", json!("invalid\nvalue")), + ("limit_id", json!("x".repeat(1025))), + ] { + let mut value = forecast(); + value["windows"][0][field] = replacement; + invalid.push(value); + } + let mut duplicate = forecast(); + duplicate["windows"] + .as_array_mut() + .unwrap() + .push(forecast()["windows"][0].clone()); + invalid.push(duplicate); + for count in [18, 65] { + let mut value = forecast(); + value["windows"] = (0..count) + .map(|index| { + let mut window = forecast()["windows"][0].clone(); + window["key"] = json!(format!( + "{index}-{}", + "k".repeat(if count == 18 { 1000 } else { 1 }) + )); + window["group"] = json!("g".repeat(if count == 18 { 1000 } else { 1 })); + window + }) + .collect(); + invalid.push(value); + } + for (index, value) in invalid.into_iter().enumerate() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + environment.account(None).await; + let (runtime, core) = environment + .provider(configuration(value), vec![account_grant("accounts")]) + .await; + let error = super::events::execute(&runtime, &core, None) + .await + .unwrap_err(); + assert_eq!( + error.kind(), + ProviderErrorKind::Protocol, + "invalid quota observation {index}" + ); + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/invalidation.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/invalidation.rs new file mode 100644 index 000000000..664b6753c --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/invalidation.rs @@ -0,0 +1,223 @@ +use std::{path::Path, time::Duration}; + +use gateway_admin::{ + CredentialsService, + model::provider_credentials::{CredentialMutation, ProviderDocument, RotateCredential}, +}; +use gateway_core::{ + account::{OpaqueProviderData, ProviderAccountId}, + routing::{ProviderKind, UpstreamModelId}, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn observations(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +#[tokio::test] +async fn notifications_are_not_prebound_deduplicate_and_require_network_for_http() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let other = ProviderAccountId::new("acct_outside_grant").unwrap(); + let marker = environment.directory.path().join("invalidations.jsonl"); + let denied = environment.directory.path().join("denied.jsonl"); + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let (runtime, core) = environment + .provider( + json!({ + "account_operations":["unavailable", "facts_changed"], + "invalidation_marker":marker, "invalidation_http_url":server.uri(), + "invalidation_denied_marker":denied, + }), + vec![account_grant("accounts")], + ) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + provider.account_unavailable(&other).await; + provider.account_unavailable(&id).await; + provider + .account_facts_changed(&[id.clone(), other.clone(), id.clone()]) + .await; + let mut changed = vec![id.as_str(), other.as_str()]; + changed.sort_unstable(); + assert_eq!( + observations(&marker), + vec![ + json!({"kind":"unavailable", "account_id":other.as_str()}), + json!({"kind":"unavailable", "account_id":id.as_str()}), + json!({"kind":"facts_changed", "account_ids":changed}), + ] + ); + assert_eq!( + observations(&denied), + vec![json!(true), json!(true), json!(true)] + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn bulk_notifications_are_bounded_and_undeclared_operations_are_not_called() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let marker = environment.directory.path().join("batches.jsonl"); + let (runtime, core) = environment + .provider( + json!({"account_operations":["facts_changed"], "invalidation_marker":marker}), + vec![account_grant("accounts")], + ) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let ids: Vec<_> = (0..513) + .map(|index| ProviderAccountId::new(format!("acct_{index:04}")).unwrap()) + .collect(); + provider.account_unavailable(&ids[0]).await; + provider.account_facts_changed(&[]).await; + assert!(observations(&marker).is_empty()); + provider.account_facts_changed(&ids).await; + let events = observations(&marker); + assert_eq!( + events + .iter() + .map(|event| event["account_ids"].as_array().unwrap().len()) + .collect::>(), + vec![256, 256, 1] + ); + let notified: Vec<_> = events + .iter() + .flat_map(|event| event["account_ids"].as_array().unwrap()) + .map(|id| id.as_str().unwrap()) + .collect(); + assert_eq!( + notified, + ids.iter() + .map(ProviderAccountId::as_str) + .collect::>() + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn failed_notification_cannot_undo_a_committed_credential_rotation() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let marker = environment.directory.path().join("failed.jsonl"); + let (runtime, core) = environment + .provider( + json!({"account_operations":["facts_changed"], "invalidation_marker":marker, "invalidation_fault":true}), + vec![account_grant("accounts")], + ) + .await; + let admin = environment.store.admin_ports(); + let registry = runtime.admin_registry(core.snapshots()); + let service = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + service + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id.clone(), + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"rotated-test-only"}) + .as_object() + .unwrap() + .clone(), + )), + settings: None, + }) + .await + .unwrap(); + let current = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&id) + .await + .unwrap(); + assert_eq!( + current.credential.expose_to_provider()["key"], + "rotated-test-only" + ); + assert_eq!(current.account.revision().get(), 2); + assert_eq!( + observations(&marker), + vec![json!({"kind":"facts_changed", "account_ids":[id.as_str()]})] + ); + drop(service); + drop(registry); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn notification_timeout_ends_the_whole_batch_and_preserves_the_session() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let marker = environment.directory.path().join("timeout.jsonl"); + let (runtime, core) = environment + .provider( + json!({"account_operations":["facts_changed"], "invalidation_marker":marker, "invalidation_delay_ms":30_000}), + vec![account_grant("accounts")], + ) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let ids: Vec<_> = (0..513) + .map(|index| ProviderAccountId::new(format!("acct_{index:04}")).unwrap()) + .collect(); + tokio::time::timeout(Duration::from_secs(4), provider.account_facts_changed(&ids)) + .await + .unwrap(); + assert_eq!(observations(&marker).len(), 1); + provider + .connection_test_operation(&UpstreamModelId::new("plugin-model").unwrap(), "test") + .await + .unwrap(); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/mod.rs new file mode 100644 index 000000000..504d2d317 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/mod.rs @@ -0,0 +1,15 @@ +mod authorization; +mod billing; +mod capabilities; +mod catalog; +mod configuration; +mod credentials; +mod events; +mod execution; +mod forecast; +mod invalidation; +mod profile; +mod quota; +mod request_profile; +mod reset_credits; +mod rotation; diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/profile.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/profile.rs new file mode 100644 index 000000000..b7d49377f --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/profile.rs @@ -0,0 +1,536 @@ +use futures::TryStreamExt as _; +use gateway_admin::{ + CredentialsService, + model::{ + plugins::instances::PluginPermissionGrant, + provider_credentials::{CredentialMutation, ProviderDocument, RotateCredential}, + }, + ports::{ + plugins::{PluginPackageInspector as _, PluginPreparation}, + provider::ProviderAdminErrorKind, + }, +}; +use gateway_core::{account::OpaqueProviderData, routing::ProviderKind}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn profile() -> Value { + json!({ + "display_name":"插件用户", "username":"plugin-user", "image_url":"https://example.test/avatar.png", + "has_stats_error":false, "summary":{"total_text_tokens":91234, "current_streak_days":7}, + "daily_usage":[{"date":"2026-09-19", "tokens":1234}], + "activity_insights":{"fast_mode_percent":25.0, "reasoning_effort":"high", "reasoning_effort_percent":75.0, + "invocations":[{"invocation_type":"skill", "skill_id":"test-skill", "skill_name":"测试技能", "usage_count":8}]}, + }) +} + +fn subscription() -> Value { + json!({ + "starts_at_ms":1_780_000_000_000_i64, "expires_at_ms":1_790_000_000_000_i64, + "will_renew":true, "billing_period":"monthly", "billing_currency":"USD", "observed_at_ms":1_785_000_000_000_i64, + }) +} + +fn configuration() -> Value { + json!({"account_operations":["profile", "subscription", "avatar"], "profile":profile(), "subscription":subscription()}) +} + +fn grants() -> Vec { + vec![account_grant("accounts"), account_grant("network")] +} + +#[tokio::test] +async fn profile_http_uses_the_accounts_proxy_and_its_credentials() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let proxy = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "proxy-authorization", + "Basic dXNlcjpwYXNz", + )) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(profile())) + .expect(1) + .mount(&proxy) + .await; + let id = environment + .account(Some( + gateway_core::account::OutboundProxy::parse(&format!( + "http://user:pass@{}", + proxy.address() + )) + .unwrap(), + )) + .await; + let mut config = configuration(); + config["profile_url"] = json!("http://127.0.0.1:9/profile"); + let (runtime, core) = environment.provider(config, grants()).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider + .profile_statistics(&id) + .await + .unwrap() + .display_name + .as_deref(), + Some("插件用户") + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn avatar_rejects_malformed_headers_before_exposing_a_body() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let mut config = configuration(); + config["avatar_metadata"] = + json!({"content_type":"image/png\r\nx-private: secret", "content_length":524288}); + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider.profile_avatar(&id).await.unwrap_err().kind(), + ProviderAdminErrorKind::BadGateway + ); + assert!(provider.profile_statistics(&id).await.is_ok()); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn profile_subscription_and_avatar_use_managed_http_without_mutating_account_or_quota() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + for (path, body) in [("/profile", profile()), ("/subscription", subscription())] { + wiremock::Mock::given(wiremock::matchers::path(path)) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body)) + .expect(1) + .mount(&server) + .await; + } + let avatar_bytes = vec![42; 524288]; + wiremock::Mock::given(wiremock::matchers::path("/avatar")) + .and(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(avatar_bytes.clone())) + .expect(1) + .mount(&server) + .await; + let id = environment.account(None).await; + let ports = environment.store.provider_ports(); + let before = ports.accounts().get_account(&id).await.unwrap().unwrap(); + let mut config = configuration(); + for operation in ["profile", "subscription", "avatar"] { + config[format!("{operation}_url")] = json!(format!("{}/{operation}", server.uri())); + } + // Accounts 域授权后,三个只读操作都应完成且不得改写账号事实。 + let (runtime, core) = environment.provider(config, grants()).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let (profile, subscription) = + tokio::join!(provider.profile_statistics(&id), provider.subscription(&id)); + let profile = profile.unwrap(); + assert_eq!(profile.display_name.as_deref(), Some("插件用户")); + assert_eq!(profile.summary.total_text_tokens, Some(91234)); + assert_eq!(profile.daily_usage.unwrap()[0].tokens, 1234); + assert_eq!(profile.activity_insights.fast_mode_percent, Some(25.0)); + assert_eq!( + profile.activity_insights.invocations.unwrap()[0].usage_count, + Some(8) + ); + assert_eq!( + subscription.unwrap().unwrap().expires_at.timestamp_millis(), + 1_790_000_000_000 + ); + let avatar = provider.profile_avatar(&id).await.unwrap(); + assert_eq!(avatar.content_length, Some(avatar_bytes.len() as u64)); + let chunks: Vec<_> = avatar.body.try_collect().await.unwrap(); + assert_eq!(chunks.concat(), avatar_bytes); + let after = ports.accounts().get_account(&id).await.unwrap().unwrap(); + assert_eq!(after.revision(), before.revision()); + assert_eq!(after.quota(), before.quota()); + assert_eq!(after.name(), before.name()); + assert_eq!(after.upstream_account_id(), before.upstream_account_id()); + drop(provider); + drop(core); + drop(runtime); + drop(ports); + environment.close().await; +} + +#[tokio::test] +async fn profile_requires_declared_operation_and_network_domain_before_rpc() { + for restriction in ["undeclared", "network"] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let id = environment.account(None).await; + let mut config = configuration(); + config["profile_url"] = json!(server.uri()); + let mut grants = grants(); + match restriction { + "undeclared" => config["account_operations"] = json!(["subscription"]), + "network" => grants.retain(|grant| grant.permission != "network"), + _ => unreachable!(), + } + let (runtime, core) = environment.provider(config, grants).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let error = provider.profile_statistics(&id).await.unwrap_err(); + assert_eq!( + error.kind(), + if restriction == "undeclared" { + ProviderAdminErrorKind::Unsupported + } else { + ProviderAdminErrorKind::Invalid + } + ); + assert!(!format!("{error:?}").contains("test-only")); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn malformed_profile_and_subscription_results_do_not_publish_untrusted_facts() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + let id = environment.account(None).await; + let mut config = configuration(); + config["profile_url"] = json!(server.uri()); + config["subscription_url"] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants()).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let mut bad_profiles = Vec::new(); + for (field, value) in [ + ("display_name", json!("x".repeat(1025))), + ("username", json!("injected\nline")), + ("image_url", json!("https://token@example.test/a")), + ] { + let mut value_profile = profile(); + value_profile[field] = value; + bad_profiles.push(value_profile); + } + let mut bad = profile(); + bad["activity_insights"]["fast_mode_percent"] = json!(100.1); + bad_profiles.push(bad); + for usage in [ + json!([{"date":"2026-02-30", "tokens":1}]), + json!([{"date":"2026-09-19", "tokens":1}, {"date":"2026-09-19", "tokens":2}]), + json!(vec![json!({"date":"2026-09-19", "tokens":1}); 4097]), + ] { + let mut bad = profile(); + bad["daily_usage"] = usage; + bad_profiles.push(bad); + } + for value in bad_profiles { + server.reset().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(value)) + .expect(1) + .mount(&server) + .await; + assert_eq!( + provider.profile_statistics(&id).await.unwrap_err().kind(), + ProviderAdminErrorKind::BadGateway + ); + } + for (field, value) in [ + ("expires_at_ms", json!(i64::MAX)), + ("starts_at_ms", json!(1_800_000_000_000_i64)), + ("billing_currency", json!("USD\r\nprivate")), + ] { + let mut bad = subscription(); + bad[field] = value; + server.reset().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(bad)) + .expect(1) + .mount(&server) + .await; + assert_eq!( + provider.subscription(&id).await.unwrap_err().kind(), + ProviderAdminErrorKind::BadGateway + ); + } + server.reset().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(Value::Null)) + .expect(1) + .mount(&server) + .await; + assert!(provider.subscription(&id).await.unwrap().is_none()); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn credential_rotation_discards_late_profile_subscription_and_avatar_results() { + for operation in ["profile", "subscription", "avatar"] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let body = if operation == "subscription" { + subscription() + } else { + profile() + }; + let mut server = crate::support::http::GatedResponse::start(body).await; + let id = environment.account(None).await; + let mut config = configuration(); + config[format!("{operation}_url")] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants()).await; + let kind = ProviderKind::new("example").unwrap(); + let registry = runtime.admin_registry(core.snapshots()); + let provider = registry.require(&kind).unwrap(); + let pending_id = id.clone(); + let pending = tokio::spawn(async move { + match operation { + "profile" => provider.profile_statistics(&pending_id).await.map(|_| ()), + "subscription" => provider.subscription(&pending_id).await.map(|_| ()), + "avatar" => provider.profile_avatar(&pending_id).await.map(|_| ()), + _ => unreachable!(), + } + }); + server.received().await; + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + registry, + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + service + .for_provider(&kind) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id, + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"replacement-test-key"}) + .as_object() + .unwrap() + .clone(), + )), + settings: None, + }) + .await + .unwrap(); + server.respond().await; + assert_eq!( + pending.await.unwrap().unwrap_err().kind(), + ProviderAdminErrorKind::Conflict + ); + drop(service); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn avatar_preserves_stream_errors_and_checks_declared_length() { + for metadata in [ + json!({"content_type":"image/png", "content_length":524289}), + json!({"content_type":"image/png", "content_length":1}), + Value::Null, + ] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let mut config = configuration(); + if metadata.is_null() { + config["avatar_end_error"] = json!(true); + } else { + config["avatar_metadata"] = metadata; + } + let (runtime, core) = environment + .provider(config, vec![account_grant("accounts")]) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let avatar = provider.profile_avatar(&id).await.unwrap(); + assert!(avatar.body.try_collect::>().await.is_err()); + // 头像失败是该次调用的终态,不能破坏同进程后续资料查询。 + assert!(provider.profile_statistics(&id).await.is_ok()); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn dropping_a_backpressured_avatar_cancels_only_that_call() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider(configuration(), vec![account_grant("accounts")]) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + // 总量超过 RPC 信用窗口;未消费的流必须能释放槽位且不终止整个进程。 + for _ in 0..140 { + drop(provider.profile_avatar(&id).await.unwrap()); + } + assert_eq!( + provider + .profile_statistics(&id) + .await + .unwrap() + .username + .as_deref(), + Some("plugin-user") + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn malformed_account_operation_registration_keeps_the_published_provider() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider(configuration(), vec![account_grant("accounts")]) + .await; + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + for operations in [json!([]), json!(["profile", "profile"]), json!(["unknown"])] { + let mut candidate = snapshot.clone(); + candidate.instances[0].configuration["account_operations"] = operations; + assert!( + PluginPreparation::prepare(runtime.as_ref(), candidate.config_revision, candidate) + .await + .is_err() + ); + } + // 非中间件阶段由能力固定;无效安装合同在进入候选集合前被拒绝。 + let contributes = [ + Capability::Executor, + Capability::Authentication, + Capability::AccountManagement, + ] + .into_iter() + .map(|capability| { + crate::support::contribution( + capability, + if capability == Capability::AccountManagement { + vec![Stage::Execution] + } else { + capability.fixed_stages().to_vec() + }, + if capability == Capability::Executor { + vec!["openai".into()] + } else { + vec![] + }, + if capability == Capability::Executor { + vec!["canonical".into()] + } else { + vec![] + }, + ) + }) + .collect::(); + let archive = crate::support::package_with_contributions( + crate::support::worker(), + vec![Permission::Accounts], + contributes, + ); + let inspector = gateway_plugin_runtime::PackageInspector::new( + gateway_plugin_runtime::PackageLimits::default(), + "1.0.0".parse().unwrap(), + ); + assert_eq!( + gateway_plugin_runtime::ValidatedPackage::read( + archive.clone(), + None, + gateway_plugin_runtime::PackageLimits::default(), + ) + .err(), + Some(gateway_plugin_runtime::PackageError::Manifest( + gateway_plugin_sdk::ManifestError::Invalid, + )), + ); + assert!(inspector.inspect(archive, None).await.is_err()); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert!(provider.profile_statistics(&id).await.is_ok()); + drop(provider); + drop(store); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/quota.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/quota.rs new file mode 100644 index 000000000..9d1cc188a --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/quota.rs @@ -0,0 +1,302 @@ +use std::{ + num::NonZeroU32, + time::{Duration, SystemTime}, +}; + +use gateway_admin::{ + CredentialsService, + model::provider_credentials::{ + CredentialMutation, ProviderDocument, ProviderQuotaRequest, RotateCredential, + }, + ports::provider::ProviderAdminErrorKind, +}; +use gateway_core::{ + account::{AccountSelectionPolicy, OpaqueProviderData, QuotaAccessState, RotationStrategy}, + engine::{ + AccountAttemptContext, AttemptContext, ModelRequestId, RequestAttemptContext, + provider::ProviderRequest, + }, + error::ProviderErrorKind, + lifecycle::CancellationToken, + policy::ClientApiKeyId, + routing::{ProviderKind, PublicModelId, RoutingContext, UpstreamModelId}, +}; +use serde_json::json; + +use crate::support::environment::{Environment, account_grant, mutation}; + +#[tokio::test] +async fn quota_persists_and_survives_restart_without_inferring_access_from_percentage() { + for exhausted in [false, true] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(quota(exhausted))) + .expect(1) + .mount(&server) + .await; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider(json!({"quota_url":server.uri()}), grants()) + .await; + let kind = ProviderKind::new("example").unwrap(); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let result = provider + .quota(ProviderQuotaRequest { + account_id: id.clone(), + refresh: true, + rolling_usage: None, + }) + .await + .unwrap(); + assert_eq!(result.plan_type.as_deref(), Some("plugin-pro")); + assert_eq!(result.windows[0].used_percent, Some(100.0)); + assert_eq!(result.windows[0].window_seconds, Some(604800)); + let loaded = environment + .store + .provider_ports() + .accounts() + .get_account(&id) + .await + .unwrap() + .unwrap(); + assert_eq!( + loaded.quota().access(), + if exhausted { + QuotaAccessState::Exhausted + } else { + QuotaAccessState::Allowed + } + ); + let cached = provider + .quota(ProviderQuotaRequest { + account_id: id.clone(), + refresh: false, + rolling_usage: None, + }) + .await + .unwrap(); + assert_eq!(result, cached); + // 销毁进程和 Core 后仅从持久化实例与额度文档恢复,不再次查询上游。 + drop(provider); + drop(core); + drop(runtime); + let (runtime, core) = environment.runtime().await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + assert_eq!( + provider + .quota(ProviderQuotaRequest { + account_id: id, + refresh: false, + rolling_usage: None + }) + .await + .unwrap(), + result + ); + if exhausted { + let snapshot = core.snapshots().acquire().unwrap(); + let operation = provider + .connection_test_operation(&UpstreamModelId::new("plugin-model").unwrap(), "test") + .await + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let registry = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let execution = registry + .get(&kind) + .unwrap() + .clone() + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_quota_test").unwrap(), + ClientApiKeyId::new("key_quota_test").unwrap(), + ), + NonZeroU32::new(1).unwrap(), + SystemTime::now() + Duration::from_secs(5), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::new(1).unwrap(), + Duration::ZERO, + ), + AccountAttemptContext::default() + .with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ), + ) + .await; + match execution { + Err(error) => assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount), + Ok(_) => panic!("exhausted account was selected"), + } + } + drop(provider); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn late_quota_response_cannot_overwrite_a_rotated_credentials_account() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let mut server = crate::support::http::GatedResponse::start(quota(true)).await; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider(json!({"quota_url":server.uri()}), grants()) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let kind = ProviderKind::new("example").unwrap(); + let provider = registry.require(&kind).unwrap(); + let pending_id = id.clone(); + let pending = tokio::spawn(async move { + provider + .quota(ProviderQuotaRequest { + account_id: pending_id, + refresh: true, + rolling_usage: None, + }) + .await + }); + server.received().await; + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + service + .for_provider(&kind) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id.clone(), + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"new-test-key"}).as_object().unwrap().clone(), + )), + settings: None, + }) + .await + .unwrap(); + server.respond().await; + assert_eq!( + pending.await.unwrap().unwrap_err().kind(), + ProviderAdminErrorKind::Conflict + ); + let account = environment + .store + .provider_ports() + .accounts() + .get_account(&id) + .await + .unwrap() + .unwrap(); + assert_eq!(account.revision().get(), 2); + assert_eq!(account.quota().access(), QuotaAccessState::Unknown); + assert!( + environment + .store + .provider_ports() + .accounts() + .get_quotas(&[id]) + .await + .unwrap() + .is_empty() + ); + drop(service); + drop(registry); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +fn grants() -> Vec { + vec![account_grant("accounts"), account_grant("network")] +} + +fn quota(exhausted: bool) -> serde_json::Value { + json!({ + "plan_type":"plugin-pro", "access":if exhausted { json!({"kind":"exhausted","evidence":"usage_limit_reached"}) } else { json!({"kind":"allowed"}) }, + "windows":[{"key":"weekly", "group":"shortTerm", "label":"weekly", "role":"primary", "account_wide":true, "window_seconds":604800, "used_percent":100.0, "limit_reached":true}], + }) +} + +#[tokio::test] +async fn quota_requires_network_domain_before_sending() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(quota(false))) + .expect(0) + .mount(&server) + .await; + let id = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"quota_url":server.uri()}), + vec![account_grant("accounts")], + ) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert!( + provider + .quota(ProviderQuotaRequest { + account_id: id.clone(), + refresh: true, + rolling_usage: None + }) + .await + .is_err() + ); + assert!( + environment + .store + .provider_ports() + .accounts() + .get_quotas(&[id]) + .await + .unwrap() + .is_empty() + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/request_profile.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/request_profile.rs new file mode 100644 index 000000000..2a0bcab5c --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/request_profile.rs @@ -0,0 +1,280 @@ +use std::{ + num::NonZeroU32, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use gateway_admin::{ + model::{AdminErrorKind, Revision}, + ports::plugins::PluginPreparation, +}; +use gateway_core::{ + account::{AccountSelectionPolicy, OpaqueProviderData, RotationStrategy}, + engine::{ + AccountAttemptContext, AttemptContext, ModelRequestId, RequestAttemptContext, + provider::ProviderRequest, + }, + lifecycle::CancellationToken, + policy::ClientApiKeyId, + routing::{ProviderKind, PublicModelId, RoutingContext, UpstreamModelId}, +}; +use serde_json::{Map, Value, json}; + +use crate::support::environment::{Environment, account_grant}; + +fn object(value: Value) -> Map { + value.as_object().expect("object fixture").clone() +} + +fn request_profiles() -> Value { + let presentation = |channel: &str, version: &str| { + json!({ + "product":"Example CLI", + "version":version, + "build":format!("build-{version}"), + "target":{"os_type":"linux","os_version":"6.8","arch":"x86_64","terminal":"headless"}, + "user_agent":format!("Example CLI/{version}"), + "attributes":[{"label":"通道","value":channel}], + "verified_at_ms":1_780_000_000_000_i64, + "release":{"status":"current","checked_at_ms":1_780_000_000_100_i64,"latest_version":version}, + }) + }; + json!({ + "default_configuration":{"channel":"stable"}, + "options":[ + { + "id":"stable","label":"稳定版","description":"默认核验版本", + "configuration":{"channel":"stable"}, + "resolved":{"upstreamIdentity":"stable-wire"}, + "presentation":presentation("stable", "1.2.3"), + }, + { + "id":"beta","label":"测试版", + "configuration":{"channel":"beta"}, + "resolved":{"upstreamIdentity":"beta-wire"}, + "presentation":presentation("beta", "1.3.0-beta.1"), + } + ] + }) +} + +#[tokio::test] +async fn future_candidate_reads_current_profile_facts_and_cached_candidates_reject_stale_sources() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let (runtime, core) = environment + .provider( + json!({"request_profiles": request_profiles()}), + vec![account_grant("accounts")], + ) + .await; + let mut candidate = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let source_revision = candidate.config_revision; + candidate.config_revision = Revision::new(source_revision.get() + 1).unwrap(); + candidate.instances[0].revision = candidate.config_revision; + let prepared = runtime + .prepare(source_revision, candidate.clone()) + .await + .unwrap(); + let error = runtime + .prepare(candidate.config_revision, candidate.clone()) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Conflict); + assert_eq!( + environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap() + .config_revision, + source_revision, + "候选准备不能提前提交配置" + ); + environment + .set_request_profile("example", object(json!({"channel":"beta"}))) + .await; + let error = runtime + .prepare(source_revision, candidate) + .await + .unwrap_err(); + assert_eq!( + error.kind(), + AdminErrorKind::Conflict, + "缓存命中不能绕过源事实版本校验" + ); + drop(prepared); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn request_profiles_validate_cached_candidates_and_reach_the_real_worker() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: CPR_PLUGIN_TEST_DATABASE_URL / CPR_PLUGIN_TEST_REDIS_URL 未设置"); + return; + }; + let marker = environment.directory.path().join("request-profile.json"); + let _account_id = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({ + "request_profiles":request_profiles(), + "request_profile_marker":marker, + }), + vec![account_grant("accounts")], + ) + .await; + let provider_kind = ProviderKind::new("example").unwrap(); + let admin_registry = runtime.admin_registry(core.snapshots()); + assert_eq!( + admin_registry.client_profile_providers().unwrap(), + vec![provider_kind.clone()] + ); + let admin = admin_registry.require(&provider_kind).unwrap(); + let options = admin.client_profile_options().unwrap(); + assert_eq!( + options.expose_to_provider()["presets"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert!( + !serde_json::to_string(options.expose_to_provider()) + .unwrap() + .contains("stable-wire"), + "管理选项不能暴露 Provider resolved 文档" + ); + let stable = admin.default_client_profile().unwrap(); + assert_eq!(stable.expose_to_provider()["channel"], "stable"); + let beta = OpaqueProviderData::new(object(json!({"channel":"beta"}))); + let preview = admin.preview_client_profile(&beta).unwrap(); + assert_eq!( + preview.expose_to_provider()["userAgent"], + "Example CLI/1.3.0-beta.1" + ); + assert_eq!( + admin.configured_wire_profile(&beta).unwrap().user_agent, + "Example CLI/1.3.0-beta.1" + ); + + environment + .set_request_profile("example", object(json!({"channel":"removed"}))) + .await; + let invalid_candidate = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let error = runtime + .prepare(invalid_candidate.config_revision, invalid_candidate) + .await + .unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + + environment + .set_request_profile("example", beta.expose_to_provider().clone()) + .await; + let valid_candidate = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let prepared = runtime + .prepare(valid_candidate.config_revision, valid_candidate) + .await + .unwrap(); + assert_eq!( + prepared.id(), + core.snapshots() + .acquire() + .unwrap() + .extensions() + .unwrap() + .id(), + "仅全局选择变化应复用同一插件代次" + ); + + let snapshot = core.snapshots().acquire().unwrap(); + let providers = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let provider = providers.get(&provider_kind).unwrap(); + let default = provider.default_request_profile().unwrap().unwrap(); + assert_eq!( + default.expose_to_provider()["upstreamIdentity"], + "stable-wire" + ); + let resolved = provider.resolve_request_profile(&beta).unwrap(); + assert_eq!( + resolved.expose_to_provider()["upstreamIdentity"], + "beta-wire" + ); + let model = UpstreamModelId::new("plugin-model").unwrap(); + let operation = admin + .connection_test_operation(&model, "profile") + .await + .unwrap(); + let plan = snapshot + .plan( + &PublicModelId::new("plugin-model").unwrap(), + &operation, + snapshot.all_account_scope(), + &RoutingContext::default(), + ) + .unwrap(); + let request = RequestAttemptContext::new( + ModelRequestId::new("req_plugin_request_profile").unwrap(), + ClientApiKeyId::new("key_plugin_request_profile").unwrap(), + ) + .with_request_profile(Some(resolved.clone())); + let attempt = AttemptContext::new( + request, + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(30), + AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::MIN, + Duration::ZERO, + ), + AccountAttemptContext::default().with_account_scope(snapshot.all_account_scope()), + None, + CancellationToken::new(), + ); + let response = Arc::clone(provider) + .execute( + ProviderRequest::new(operation, plan.candidates()[0].clone()), + attempt, + ) + .await + .unwrap(); + let sent: Value = serde_json::from_slice(&std::fs::read(&marker).unwrap()).unwrap(); + assert_eq!(sent, Value::Object(resolved.into_inner())); + + drop(response); + drop(prepared); + drop(providers); + drop(snapshot); + drop(admin); + drop(admin_registry); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/reset_credits.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/reset_credits.rs new file mode 100644 index 000000000..407469135 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/reset_credits.rs @@ -0,0 +1,460 @@ +use std::{ + collections::BTreeSet, + sync::{Arc, Mutex}, + time::Duration, +}; + +use gateway_admin::{ + CredentialsService, + model::{ + plugins::instances::PluginPermissionGrant, + provider_credentials::{ + ConsumeProviderResetCredit, CredentialMutation, ProviderDocument, RotateCredential, + }, + }, + ports::provider::ProviderAdminErrorKind, +}; +use gateway_core::{ + account::{OpaqueProviderData, ProviderAccountId}, + routing::ProviderKind, +}; +use serde_json::{Value, json}; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{any, header, method, path}, +}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn credit() -> Value { + json!({"id":"credit-test", "status":"available", "title":"测试重置卡", "expires_at_ms":1_900_000_000_000_i64, "reset_type":"primary"}) +} + +fn configuration() -> Value { + json!({"account_operations":["reset_credits", "consume_reset_credit"], + "reset_credits":{"outcome":"completed", "result":{"available_count":1, "credits":[credit()]}}, + "consume_reset_credit":{"outcome":"completed", "result":{"code":"reset", "credit":credit()}}}) +} + +fn grants(with_network: bool) -> Vec { + let mut grants = vec![account_grant("accounts")]; + if with_network { + grants.push(account_grant("network")); + } + grants +} + +fn command(id: &ProviderAccountId) -> ConsumeProviderResetCredit { + ConsumeProviderResetCredit { + account_id: id.clone(), + credit_id: Some("credit-test".into()), + redeem_request_id: uuid::Uuid::new_v4(), + } +} + +#[tokio::test] +async fn reset_queries_and_consumption_preserve_target_and_idempotency_key_without_local_inventory() +{ + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = MockServer::start().await; + let consumed = Arc::new(Mutex::new(BTreeSet::new())); + let consumed_upstream = Arc::clone(&consumed); + Mock::given(method("GET")) + .and(path("/credits")) + .and(header("authorization", "Bearer test-only")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"available_count":1, "credits":[credit()]})), + ) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/consume")) + .and(header("authorization", "Bearer test-only")) + .respond_with(move |request: &wiremock::Request| { + let body: Value = serde_json::from_slice(&request.body).unwrap(); + assert_eq!(body["credit_id"], "credit-test"); + let key = body["redeem_request_id"].as_str().unwrap(); + let first = consumed_upstream.lock().unwrap().insert(key.to_owned()); + ResponseTemplate::new(200).set_body_json( + json!({"code":if first {"reset"} else {"already_redeemed"}, "credit":credit()}), + ) + }) + .expect(2) + .mount(&server) + .await; + let id = environment.account(None).await; + let mut config = configuration(); + config["reset_credits_url"] = json!(format!("{}/credits", server.uri())); + config["consume_reset_credit_url"] = json!(format!("{}/consume", server.uri())); + let (runtime, core) = environment.provider(config, grants(true)).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let capabilities = provider.account_capabilities(&id, "api_key"); + assert!(capabilities.reset_credits && capabilities.consume_reset_credit); + assert!(!capabilities.quota_refresh); + for _ in 0..2 { + let list = provider.reset_credits(&id).await.unwrap(); + assert_eq!(list.available_count, 1); + assert_eq!(list.credits[0].id, "credit-test"); + } + let command = command(&id); + assert_eq!( + provider + .consume_reset_credit(command.clone()) + .await + .unwrap() + .code, + "reset" + ); + assert_eq!( + provider + .consume_reset_credit(command.clone()) + .await + .unwrap() + .code, + "already_redeemed" + ); + assert_eq!( + *consumed.lock().unwrap(), + BTreeSet::from([command.redeem_request_id.to_string()]) + ); + let stored = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&id) + .await + .unwrap(); + assert_eq!(stored.account.revision().get(), 1); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| !request.headers.contains_key("cookie")) + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn accounts_domain_allows_reset_consumption_and_declared_operations_stay_authoritative() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let (runtime, core) = environment.provider(configuration(), grants(false)).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let capabilities = provider.account_capabilities(&id, "api_key"); + assert!(capabilities.reset_credits); + assert!(capabilities.consume_reset_credit); + assert!(provider.reset_credits(&id).await.is_ok()); + assert!(provider.consume_reset_credit(command(&id)).await.is_ok()); + drop(provider); + let store = environment.store.admin_ports().plugins(); + let mut snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.remove(0); + instance.configuration["account_operations"] = json!(["subscription"]); + store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + let revision = store.load_instances().await.unwrap().config_revision; + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider.reset_credits(&id).await.unwrap_err().kind(), + ProviderAdminErrorKind::Unsupported + ); + assert_eq!( + provider + .consume_reset_credit(command(&id)) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Unsupported + ); + drop(store); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn confirmed_reset_rejection_and_expired_credential_are_not_uncertain_consumption() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let server = MockServer::start().await; + let mut config = configuration(); + config["reset_credits_url"] = json!(server.uri()); + config["consume_reset_credit_url"] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants(true)).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + for (status, expected) in [ + (401, ProviderAdminErrorKind::CredentialRefreshRequired), + (403, ProviderAdminErrorKind::BadGateway), + (503, ProviderAdminErrorKind::BadGateway), + ] { + server.reset().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(status)) + .expect(2) + .mount(&server) + .await; + assert_eq!( + provider.reset_credits(&id).await.unwrap_err().kind(), + expected + ); + assert_eq!( + provider + .consume_reset_credit(command(&id)) + .await + .unwrap_err() + .kind(), + expected + ); + } + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn malformed_reset_results_never_become_a_confirmed_consumption() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let server = MockServer::start().await; + let mut config = configuration(); + config["reset_credits_url"] = json!(server.uri()); + config["consume_reset_credit_url"] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants(true)).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + let mut bad_date = credit(); + bad_date["expires_at_ms"] = json!(i64::MAX); + for credits in [ + vec![credit(), credit()], + vec![bad_date], + vec![credit(); 4097], + ] { + server.reset().await; + Mock::given(any()) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"available_count":1,"credits":credits})), + ) + .expect(1) + .mount(&server) + .await; + assert_eq!( + provider.reset_credits(&id).await.unwrap_err().kind(), + ProviderAdminErrorKind::BadGateway + ); + } + for result in [ + json!({"code":"","credit":null}), + json!({"code":"reset","credit":{"id":"other"}}), + json!({"code":"reset","credit":null,"unexpected":true}), + ] { + server.reset().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_json(result)) + .expect(1) + .mount(&server) + .await; + assert_eq!( + provider + .consume_reset_credit(command(&id)) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Ambiguous + ); + } + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn process_loss_and_forged_not_sent_after_reset_are_uncertain_and_never_retried() { + for mode in [ + "crash", + "fault", + "crash_without_http", + "malformed_without_http", + ] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let server = MockServer::start().await; + Mock::given(any()) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({"code":"reset","credit":null})), + ) + .expect(if mode.ends_with("without_http") { 0 } else { 1 }) + .mount(&server) + .await; + let mut config = configuration(); + if !mode.ends_with("without_http") { + config["consume_reset_credit_url"] = json!(server.uri()); + } + match mode { + "crash" | "crash_without_http" => config["consume_reset_credit_crash"] = json!(true), + "fault" => { + config["consume_reset_credit_fault"] = + json!({"code":"fault","message":"test-only","send_state":"not_sent"}) + } + "malformed_without_http" => config["consume_reset_credit"] = json!({"unexpected":true}), + _ => unreachable!(), + } + let (runtime, core) = environment.provider(config, grants(true)).await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&ProviderKind::new("example").unwrap()) + .unwrap(); + assert_eq!( + provider + .consume_reset_credit(command(&id)) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Ambiguous + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + usize::from(!mode.ends_with("without_http")) + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; + } +} + +#[tokio::test] +async fn reset_results_cannot_cross_credential_rotation_and_consumption_remains_uncertain() { + for consume in [false, true] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let id = environment.account(None).await; + let server = MockServer::start().await; + let result = if consume { + json!({"code":"reset","credit":null}) + } else { + json!({"available_count":1,"credits":[credit()]}) + }; + Mock::given(any()) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(result) + .set_delay(Duration::from_secs(1)), + ) + .expect(1) + .mount(&server) + .await; + let mut config = configuration(); + config[if consume { + "consume_reset_credit_url" + } else { + "reset_credits_url" + }] = json!(server.uri()); + let (runtime, core) = environment.provider(config, grants(true)).await; + let kind = ProviderKind::new("example").unwrap(); + let registry = runtime.admin_registry(core.snapshots()); + let provider = registry.require(&kind).unwrap(); + let pending_id = id.clone(); + let pending = tokio::spawn(async move { + if consume { + provider + .consume_reset_credit(command(&pending_id)) + .await + .map(|_| ()) + } else { + provider.reset_credits(&pending_id).await.map(|_| ()) + } + }); + tokio::time::timeout(Duration::from_secs(3), async { + while server.received_requests().await.unwrap().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + registry, + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + service + .for_provider(&kind) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id, + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"replacement-test-key"}) + .as_object() + .unwrap() + .clone(), + )), + settings: None, + }) + .await + .unwrap(); + assert_eq!( + pending.await.unwrap().unwrap_err().kind(), + if consume { + ProviderAdminErrorKind::Ambiguous + } else { + ProviderAdminErrorKind::Conflict + } + ); + drop(service); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/provider/rotation.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/rotation.rs new file mode 100644 index 000000000..2c0d4a4bc --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/provider/rotation.rs @@ -0,0 +1,284 @@ +use gateway_admin::{ + CredentialsService, + model::provider_credentials::{ + CredentialMutation, CredentialRotationCommit, ImportCredentials, PrepareCredentialRefresh, + ProviderDocument, RotateCredential, + }, + ports::provider::ProviderAdminErrorKind, +}; +use gateway_core::{ + account::{OpaqueProviderData, ProviderAccountUpdate}, + routing::ProviderKind, +}; +use serde_json::json; + +use crate::support::environment::{Environment, account_grant, mutation}; + +#[tokio::test] +async fn refresh_holds_real_redis_leases_through_commit_and_preserves_concurrent_profile_edits() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider(json!({}), vec![account_grant("accounts")]) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let admin = environment.store.admin_ports(); + let ports = environment.store.provider_ports(); + let service = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let kind = ProviderKind::new("example").unwrap(); + let id = service + .for_provider(&kind) + .unwrap() + .import_document(import()) + .await + .unwrap() + .credential_ids + .remove(0); + let provider = registry.require(&kind).unwrap(); + let account = admin + .accounts() + .credential_details(&kind, &id) + .await + .unwrap() + .unwrap() + .credential; + let prepared = provider + .prepare_refresh(PrepareCredentialRefresh { + account: account.clone(), + }) + .await + .unwrap(); + let busy = provider + .prepare_refresh(PrepareCredentialRefresh { + account: account.clone(), + }) + .await + .unwrap_err(); + assert_eq!(busy.kind(), ProviderAdminErrorKind::Conflict); + ports + .accounts() + .update_account(ProviderAccountUpdate { + account_id: id.clone(), + name: "concurrent admin edit".into(), + email: None, + plan_type: None, + }) + .await + .unwrap(); + let (facts, guard) = prepared.into_parts(); + let result = admin + .accounts() + .commit_credential_refresh( + CredentialRotationCommit { + prepared: facts, + settings: None, + }, + &mutation(), + ) + .await + .unwrap(); + guard.finish(); + core.snapshot_control() + .publish_committed( + gateway_core::routing::ConfigRevision::new(result.config_revision.get()).unwrap(), + ) + .await; + let loaded = ports.accounts().load_current_credential(&id).await.unwrap(); + assert_eq!(loaded.account.revision().get(), 2); + assert_eq!(loaded.account.name(), "concurrent admin edit"); + assert_eq!( + loaded.credential.expose_to_provider()["key"], + "refreshed-test-key" + ); + assert_eq!( + provider + .prepare_refresh(PrepareCredentialRefresh { account }) + .await + .unwrap_err() + .kind(), + ProviderAdminErrorKind::Conflict + ); + let rotated = service + .for_provider(&kind) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: id.clone(), + context: mutation(), + }, + provider_material: document(json!({"key":"manually-rotated"})), + settings: None, + }) + .await + .unwrap(); + assert_eq!(rotated.credential_revision.unwrap().get(), 3); + let loaded = ports.accounts().load_current_credential(&id).await.unwrap(); + assert_eq!( + loaded.credential.expose_to_provider()["key"], + "manually-rotated" + ); + let account = admin + .accounts() + .credential_details(&kind, &id) + .await + .unwrap() + .unwrap() + .credential; + // 失败事务或取消会 drop 准备结果,不能留下账号互斥锁。 + drop( + provider + .prepare_refresh(PrepareCredentialRefresh { + account: account.clone(), + }) + .await + .unwrap(), + ); + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + match provider + .prepare_refresh(PrepareCredentialRefresh { + account: account.clone(), + }) + .await + { + Ok(prepared) => { + drop(prepared); + break; + } + Err(error) if error.kind() == ProviderAdminErrorKind::Conflict => { + tokio::time::sleep(std::time::Duration::from_millis(20)).await + } + Err(error) => panic!("refresh after guard release failed: {error:?}"), + } + } + }) + .await + .unwrap(); + drop(provider); + drop(service); + drop(registry); + drop(ports); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn refresh_uses_managed_http_and_never_reports_sent_failures_as_safe_to_retry() { + for ambiguous in [false, true] { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::header("authorization", "Bearer original-test-key")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({"name":"upstream profile", "authentication_kind":"oauth", "material":{"key":"upstream-rotated-key"}, "has_refresh_token":true}))) + .expect(1).mount(&server).await; + let network = account_grant("network"); + let (runtime, core) = environment + .provider( + json!({"management_url":server.uri(), "management_error_after_http":ambiguous}), + vec![account_grant("accounts"), network], + ) + .await; + let registry = runtime.admin_registry(core.snapshots()); + let admin = environment.store.admin_ports(); + let service = CredentialsService::new( + registry.clone(), + admin.accounts(), + admin.proxies(), + core.snapshot_control(), + ); + let kind = ProviderKind::new("example").unwrap(); + let id = service + .for_provider(&kind) + .unwrap() + .import_document(import()) + .await + .unwrap() + .credential_ids + .remove(0); + let provider = registry.require(&kind).unwrap(); + let account = admin + .accounts() + .credential_details(&kind, &id) + .await + .unwrap() + .unwrap() + .credential; + let prepared = provider + .prepare_refresh(PrepareCredentialRefresh { account }) + .await; + if ambiguous { + assert_eq!( + prepared.unwrap_err().kind(), + ProviderAdminErrorKind::Ambiguous + ); + } else { + let prepared = prepared.unwrap(); + assert_eq!( + prepared + .facts() + .provider_material + .expose_to_provider() + .expose_to_provider()["key"], + "upstream-rotated-key" + ); + let (facts, guard) = prepared.into_parts(); + admin + .accounts() + .commit_credential_refresh( + CredentialRotationCommit { + prepared: facts, + settings: None, + }, + &mutation(), + ) + .await + .unwrap(); + guard.finish(); + } + let account = environment + .store + .provider_ports() + .accounts() + .load_current_credential(&id) + .await + .unwrap(); + assert_eq!( + account.account.revision().get(), + if ambiguous { 1 } else { 2 } + ); + drop(provider); + drop(service); + drop(registry); + drop(admin); + drop(core); + drop(runtime); + environment.close().await; + } +} + +fn import() -> ImportCredentials { + ImportCredentials { + outbound_proxy_id: None, + settings: None, + context: mutation(), + document: document( + json!({"accounts":[{"name":"initial profile", "authentication_kind":"oauth", "material":{"key":"original-test-key"}, "has_refresh_token":true}]}), + ), + } +} + +fn document(value: serde_json::Value) -> ProviderDocument { + ProviderDocument::new(OpaqueProviderData::new(value.as_object().unwrap().clone())) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/log.rs b/backend/crates/gateway-plugin/runtime/tests/callback/log.rs new file mode 100644 index 000000000..18e9f9f3a --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/callback/log.rs @@ -0,0 +1,340 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, Condvar, Mutex}, + time::Duration, +}; + +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{PluginInstance, PluginInstanceSnapshot}, + }, + ports::plugins::{PluginPackageInspector, PluginPreparation}, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, +}; +use serde_json::{Value, json}; + +#[derive(Clone, Default)] +struct Capture(Arc>>>, Arc); + +#[derive(Default)] +struct Gate { + released: Mutex, + changed: Condvar, +} + +impl Gate { + fn wait(&self) { + // 测试回归为同步阻塞时也能自行退出,不能把测试运行器永久挂住。 + let (mut released, _) = self + .changed + .wait_timeout_while( + self.released.lock().unwrap(), + Duration::from_secs(30), + |released| !*released, + ) + .unwrap(); + *released = true; + self.changed.notify_all(); + } + + fn release(&self) { + *self.released.lock().unwrap() = true; + self.changed.notify_all(); + } +} + +struct ReleaseOnDrop(Arc); + +impl Drop for ReleaseOnDrop { + fn drop(&mut self) { + self.0.release(); + } +} + +struct Fields(BTreeMap); + +impl tracing::field::Visit for Fields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().into(), format!("{value:?}")); + } +} + +impl tracing::Subscriber for Capture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.target() == "gateway_plugin" + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + if event.metadata().target() == "gateway_plugin" { + let mut fields = Fields(BTreeMap::from([( + "level".into(), + event.metadata().level().to_string(), + )])); + event.record(&mut fields); + if fields + .0 + .get("instance_id") + .is_some_and(|id| id == "log-blocked") + { + self.1.wait(); + } + self.0.lock().unwrap().push(fields.0); + } + } +} + +#[tokio::test] +async fn real_worker_logs_are_correlated_sanitized_validated_and_bounded() { + let capture = Capture::default(); + tracing::subscriber::set_global_default(capture.clone()).unwrap(); + let directory = tempfile::tempdir().unwrap(); + let marker = directory.path().join("callbacks.jsonl"); + let mut entries = ["debug", "info", "warn", "error"].map(|level| json!({ + "params":{"event":"fixture-secret-event-token","level":level,"fields":{ + "status":429,"authorization":"fixture-secret-token", + "metadata":{"message":"fixture-private-body","credential":"fixture-nested-secret"}, + "fixture-private-key-name":"fixture-private-value" + }} + })).to_vec(); + for params in [ + json!({"event":"bad\nline"}), + json!({"event":"x".repeat(65)}), + json!({"event":"fixture.log","fields":{"message":"x".repeat(9000)}}), + json!({"event":"fixture.log","fields": (0..33).map(|i| (format!("f{i}"),json!(1))).collect::>()}), + json!({"event":"fixture.log","instance_id":"forged"}), + json!({"event":"fixture.log","level":"trace"}), + ] { + entries.push(json!({"params":params})); + } + entries.push(json!({"params":{"event":"fixture.log"},"with_payload":true})); + entries.push(json!({"params":{"event":"fixture.flood"},"repeat":128})); + entries.push(json!({"params":{"event":"fixture.resumed"},"delay_ms":1100})); + + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect( + crate::support::package_with_permissions(crate::support::worker(), vec![]), + None, + ) + .await + .unwrap(); + let instance = PluginInstance { + id: "log-fixture".into(), + name: "日志验收".into(), + artifact_sha256: artifact.metadata.sha256.clone(), + enabled: true, + trusted_process: true, + configuration: json!({"log_method":"plugin.register","log_entries":entries,"log_marker":marker}), + secrets: BTreeMap::new(), + grants: vec![], + bindings: vec![], + revision: Revision::new(1).unwrap(), + }; + let snapshot = PluginInstanceSnapshot { + config_revision: Revision::new(7).unwrap(), + instances: vec![instance], + }; + let store = Arc::new(crate::support::store::Store { + artifacts: BTreeMap::from([(artifact.metadata.sha256.clone(), artifact)]), + snapshot: Mutex::new(snapshot.clone()), + }); + let runtime = PluginRuntime::new( + store.clone(), + store, + PluginRuntimeConfig { + cache_directory: directory.path().join("cache"), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + gateway_core::engine::provider::ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(4).unwrap(), + )), + ); + let generation = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .unwrap(); + let results: Vec = + serde_json::from_str(std::fs::read_to_string(&marker).unwrap().trim()).unwrap(); + assert!( + results[..4].iter().all(|result| result["recorded"] == true), + "{:?}", + &results[..4] + ); + assert!( + results[4..11] + .iter() + .all(|result| result["error"] == "invalid_input") + ); + let suppressed = results + .iter() + .filter(|result| result["recorded"] == false) + .count(); + assert!(suppressed > 0, "日志过量应丢弃,不把背压传播给业务调用"); + assert_eq!(results.last().unwrap()["recorded"], true); + let accepted = results + .iter() + .filter(|result| result["recorded"] == true) + .count(); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let count = capture + .0 + .lock() + .unwrap() + .iter() + .filter(|fields| { + fields + .get("instance_id") + .is_some_and(|id| id == "log-fixture") + }) + .count(); + if count == accepted { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + { + let captured = capture.0.lock().unwrap(); + let records = captured + .iter() + .filter(|fields| { + fields + .get("instance_id") + .is_some_and(|id| id == "log-fixture") + }) + .collect::>(); + assert_eq!( + records.len(), + results + .iter() + .filter(|result| result["recorded"] == true) + .count() + ); + for record in &records { + assert_eq!(record["plugin_id"], "test.example"); + assert_eq!(record["plugin_version"], "1.0.0"); + assert_eq!(record["generation"], "7"); + assert_ne!(record["call_id"], "0"); + assert!(!record["incarnation"].is_empty()); + } + for level in ["DEBUG", "INFO", "WARN", "ERROR"] { + assert!(records.iter().any(|record| record["level"] == level)); + } + let first_fields: Value = serde_json::from_str( + &records + .iter() + .find(|record| record["level"] == "ERROR") + .unwrap()["fields"], + ) + .unwrap(); + assert_eq!(first_fields["status"], 429); + let encoded = format!("{records:?}"); + for secret in [ + "fixture-secret-event-token", + "fixture-secret-token", + "fixture-private-body", + "fixture-nested-secret", + "fixture-private-key-name", + "fixture-private-value", + ] { + assert!( + !encoded.contains(secret), + "普通日志不得保留 fixture 敏感内容" + ); + } + assert!( + records + .iter() + .any(|record| record["suppressed_logs"].parse::().unwrap() > 0) + ); + } + let release = ReleaseOnDrop(capture.1.clone()); + let blocked_marker = directory.path().join("blocked.jsonl"); + let mut blocked = snapshot.clone(); + blocked.config_revision = Revision::new(9).unwrap(); + blocked.instances[0].id = "log-blocked".into(); + blocked.instances[0].configuration = json!({ + "log_method":"plugin.register", "log_entries":[{"params":{"event":"fixture.blocked"},"repeat":32}], + "log_marker":blocked_marker, + }); + // 日志后端停住时,注册的业务结果和父调用结束仍须及时完成。 + let blocked_generation = tokio::time::timeout( + Duration::from_secs(10), + PluginPreparation::prepare(&runtime, blocked.config_revision, blocked), + ) + .await + .unwrap() + .unwrap(); + let blocked_results: Vec = + serde_json::from_str(std::fs::read_to_string(blocked_marker).unwrap().trim()).unwrap(); + assert_eq!( + blocked_results + .iter() + .filter(|result| result["recorded"] == true) + .count(), + RpcLimits::default().maximum_callbacks + ); + assert!( + blocked_results + .iter() + .any(|result| result["recorded"] == false) + ); + drop(blocked_generation); + let next_marker = directory.path().join("next-generation.jsonl"); + let mut next = snapshot; + next.config_revision = Revision::new(10).unwrap(); + next.instances[0].id = "log-next-generation".into(); + next.instances[0].configuration = json!({ + "log_method":"plugin.register", "log_entries":[{"params":{"event":"fixture.next"}}], + "log_marker":next_marker, + }); + let next_generation = tokio::time::timeout( + Duration::from_secs(10), + PluginPreparation::prepare(&runtime, next.config_revision, next), + ) + .await + .unwrap() + .unwrap(); + let next_results: Vec = + serde_json::from_str(std::fs::read_to_string(next_marker).unwrap().trim()).unwrap(); + assert_eq!( + next_results, + [json!({"recorded":false})], + "父调用结束与代次撤下不能提前释放仍阻塞的写入容量" + ); + drop(next_generation); + drop(release); + drop(generation); + tokio::time::timeout(Duration::from_secs(3), async { + while std::fs::read_dir(directory.path().join("cache")) + .unwrap() + .next() + .is_some() + { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs b/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs new file mode 100644 index 000000000..aad31d7a6 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/callback/mod.rs @@ -0,0 +1 @@ +mod log; diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs new file mode 100644 index 000000000..68957f91c --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs @@ -0,0 +1,252 @@ +use gateway_admin::model::client_keys::SetClientKeyEnabled; +use gateway_core::{ + engine::{authentication::ClientAuthenticationRequest, execution::ClientAuthenticationError}, + policy::ClientApiKeyId, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +async fn authenticate( + configuration: Value, + authorization: &str, +) -> Option<( + Environment, + std::sync::Arc, + gateway_core::CoreBundle, + Result, +)> { + let environment = Environment::create().await?; + environment + .client_key("key_frontend", "sk-native-fixture") + .await; + let (runtime, core) = environment + .provider(configuration, vec![account_grant("accounts")]) + .await; + let result = core + .execution_service() + .authenticate_request(ClientAuthenticationRequest::new(authorization).unwrap()) + .await; + Some((environment, runtime, core, result)) +} + +async fn close( + environment: Environment, + runtime: std::sync::Arc, + core: gateway_core::CoreBundle, +) { + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; +} + +fn configuration(result: Value, exclusive: bool, expected_authorization: &str) -> Value { + json!({ + "frontend_authentication_result": result, + "frontend_authentication_principal": "external-user", + "frontend_authentication_client_key_id": "key_frontend", + "frontend_authentication_exclusive": exclusive, + "expected_frontend_authorization": expected_authorization, + }) +} + +#[tokio::test] +async fn frontend_authentication_maps_only_the_configured_principal_to_an_existing_policy() { + let Some((environment, runtime, core, result)) = authenticate( + configuration( + json!({"outcome":"authenticated","principal":"external-user"}), + true, + "External controlled-fixture", + ), + "External controlled-fixture", + ) + .await + else { + return; + }; + + let client = result.expect("configured principal authenticates"); + assert_eq!(client.policy().key_id().as_str(), "key_frontend"); + close(environment, runtime, core).await; +} + +#[tokio::test] +async fn delegate_falls_back_only_for_not_matched_while_exclusive_never_falls_back() { + let Some((delegating_environment, delegating_runtime, delegating_core, delegated)) = + authenticate( + configuration( + json!({"outcome":"not_matched"}), + false, + "Bearer sk-native-fixture", + ), + "Bearer sk-native-fixture", + ) + .await + else { + return; + }; + assert_eq!( + delegated.unwrap().policy().key_id().as_str(), + "key_frontend" + ); + close(delegating_environment, delegating_runtime, delegating_core).await; + + let Some((exclusive_environment, exclusive_runtime, exclusive_core, exclusive)) = authenticate( + configuration( + json!({"outcome":"not_matched"}), + true, + "Bearer sk-native-fixture", + ), + "Bearer sk-native-fixture", + ) + .await + else { + return; + }; + assert!(matches!( + exclusive, + Err(ClientAuthenticationError::InvalidKey) + )); + close(exclusive_environment, exclusive_runtime, exclusive_core).await; +} + +#[tokio::test] +async fn rejection_unmapped_principal_and_plugin_supplied_key_id_fail_closed() { + for result in [ + json!({"outcome":"rejected"}), + json!({"outcome":"authenticated","principal":"unmapped"}), + ] { + let Some((environment, runtime, core, authentication)) = authenticate( + configuration(result, false, "Bearer sk-native-fixture"), + "Bearer sk-native-fixture", + ) + .await + else { + return; + }; + assert!(matches!( + authentication, + Err(ClientAuthenticationError::InvalidKey) + )); + close(environment, runtime, core).await; + } + + let Some((environment, runtime, core, authentication)) = authenticate( + configuration( + json!({ + "outcome":"authenticated", + "principal":"external-user", + "client_key_id":"attacker-selected-key" + }), + false, + "Bearer sk-native-fixture", + ), + "Bearer sk-native-fixture", + ) + .await + else { + return; + }; + assert!(matches!( + authentication, + Err(ClientAuthenticationError::ProviderUnavailable) + )); + close(environment, runtime, core).await; +} + +#[tokio::test] +async fn disabling_the_mapped_client_key_revokes_frontend_authentication_after_publish() { + let Some((environment, runtime, core, first)) = authenticate( + configuration( + json!({"outcome":"authenticated","principal":"external-user"}), + true, + "External controlled-fixture", + ), + "External controlled-fixture", + ) + .await + else { + return; + }; + assert!(first.is_ok()); + + let (revision, _) = environment + .store + .admin_ports() + .client_keys() + .set_client_key_enabled( + SetClientKeyEnabled { + id: ClientApiKeyId::new("key_frontend").unwrap(), + enabled: false, + }, + &mutation(), + ) + .await + .unwrap(); + core.snapshot_control() + .publish_committed(gateway_core::routing::ConfigRevision::new(revision.get()).unwrap()) + .await; + let revoked = core + .execution_service() + .authenticate_request( + ClientAuthenticationRequest::new("External controlled-fixture").unwrap(), + ) + .await; + assert!(matches!( + revoked, + Err(ClientAuthenticationError::InvalidKey) + )); + close(environment, runtime, core).await; +} + +#[tokio::test] +async fn startup_failure_of_an_authentication_plugin_never_falls_back_to_a_native_key() { + let mut config = configuration( + json!({"outcome":"not_matched"}), + false, + "Bearer sk-native-fixture", + ); + config["startup"] = json!("fail"); + let Some((environment, runtime, core, authentication)) = + authenticate(config, "Bearer sk-native-fixture").await + else { + return; + }; + assert!( + core.snapshots().acquire().is_ok(), + "the host stays available" + ); + assert!(matches!( + authentication, + Err(ClientAuthenticationError::ProviderUnavailable) + )); + for probe in core.health_probes() { + assert!(matches!( + probe.check().await, + gateway_core::health::HealthState::Healthy + )); + } + close(environment, runtime, core).await; +} + +#[tokio::test] +async fn startup_failure_of_an_unrelated_plugin_preserves_native_authentication_and_health() { + let Some((environment, runtime, core, authentication)) = + authenticate(json!({"startup":"fail"}), "Bearer sk-native-fixture").await + else { + return; + }; + assert_eq!( + authentication.unwrap().policy().key_id().as_str(), + "key_frontend" + ); + assert!(core.snapshots().acquire().is_ok()); + for probe in core.health_probes() { + assert!(matches!( + probe.check().await, + gateway_core::health::HealthState::Healthy + )); + } + close(environment, runtime, core).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs b/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs new file mode 100644 index 000000000..701bdd23e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/configuration.rs @@ -0,0 +1,171 @@ +use gateway_admin::{ + model::plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginPermissionGrant, + }, + ports::plugins::PluginPreparation, +}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; + +#[tokio::test] +async fn readiness_uses_validated_metadata_without_loading_the_archive() { + let (_cache, store, runtime) = super::setup().await; + let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); + let mut metadata = store.artifacts[&instance.artifact_sha256].metadata.clone(); + // Store 中没有这个摘要;只读校验只能消费传入的声明,不能退回整包加载。 + metadata.sha256 = "0".repeat(64); + instance.artifact_sha256 = metadata.sha256.clone(); + metadata.configuration_schema = serde_json::json!({ + "type": "object", "required": ["name", "token"], + "properties": {"name": {"type": "string"}, "token": {"type": "string"}}, + "additionalProperties": false + }); + metadata.secret_fields = vec!["token".into()]; + instance.configuration = serde_json::json!({}); + assert!( + !runtime + .configuration_ready(instance.clone(), &metadata) + .await + .unwrap() + ); + instance.configuration = serde_json::json!({"name": "fixture"}); + instance + .secrets + .insert("token".into(), "fixture-value".into()); + assert!( + runtime + .configuration_ready(instance.clone(), &metadata) + .await + .unwrap() + ); + instance.configuration = serde_json::json!({"name": 42}); + assert!( + runtime + .configuration_ready(instance.clone(), &metadata) + .await + .is_err() + ); + instance.configuration = serde_json::json!({"name": "fixture", "token": "not-allowed"}); + assert!( + runtime + .configuration_ready(instance.clone(), &metadata) + .await + .is_err() + ); + instance.configuration = serde_json::json!({"name": "fixture"}); + instance + .secrets + .insert("undeclared".into(), "fixture-value".into()); + assert!( + runtime + .configuration_ready(instance, &metadata) + .await + .is_err() + ); +} + +#[tokio::test] +async fn schema_rejection_identifies_the_field_without_exposing_configuration_values() { + let (_cache, store, runtime) = super::setup().await; + let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); + let mut metadata = store.artifacts[&instance.artifact_sha256].metadata.clone(); + metadata.configuration_schema = serde_json::json!({ + "type": "object", + "properties": { + "timeout": {"type": "integer"}, + "token": {"type": "string", "pattern": "^accepted$"} + }, + "additionalProperties": false + }); + metadata.secret_fields = vec!["token".into()]; + for (configuration, secret, expected) in [ + ( + serde_json::json!({"timeout": "private-value"}), + None, + "插件配置 /timeout:类型不匹配", + ), + ( + serde_json::json!({"retired": "private-value"}), + None, + "插件配置 /retired:目标版本不支持此字段", + ), + ( + serde_json::json!({}), + Some("private-value"), + "插件配置 /token:不符合字段要求", + ), + ] { + instance.configuration = configuration; + instance.secrets.clear(); + if let Some(value) = secret { + instance.secrets.insert("token".into(), value.into()); + } + let error = runtime + .configuration_ready(instance.clone(), &metadata) + .await + .unwrap_err(); + assert_eq!(error.message(), expected); + } +} + +#[tokio::test] +async fn process_execution_requires_trust_and_undeclared_permissions_are_rejected() { + let (_cache, store, runtime) = super::setup().await; + let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); + instance.trusted_process = false; + assert!(runtime.validate(instance.clone()).await.is_err()); + instance.trusted_process = true; + instance.grants.push(PluginPermissionGrant { + permission: "accounts".into(), + }); + assert!(runtime.validate(instance).await.is_err()); +} + +#[tokio::test] +async fn secrets_must_be_declared_by_the_package() { + let (_cache, store, runtime) = super::setup().await; + let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); + instance + .secrets + .insert("token".into(), "sensitive-fixture".into()); + assert!(runtime.validate(instance).await.is_err()); +} + +#[tokio::test] +async fn enabled_bindings_must_reference_the_exact_declared_contribution_and_stage() { + let (_cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::Usage, + vec![Stage::Observation], + vec![], + vec![], + )])) + .await; + let instance = store.snapshot.lock().unwrap().instances[0].clone(); + let mut valid = instance.clone(); + valid.bindings = vec![PluginCapabilityBinding { + contribution: "test.example.usage".into(), + stage: "observation".into(), + order: 0, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }]; + assert!(runtime.validate(valid.clone()).await.is_ok()); + + for (contribution, stage) in [ + ("test.example.unknown", "observation"), + ("other.example.usage", "observation"), + ("test.example.usage", "routing"), + ] { + let mut candidate = valid.clone(); + candidate.bindings[0].contribution = contribution.into(); + candidate.bindings[0].stage = stage.into(); + assert!( + runtime.validate(candidate).await.is_err(), + "binding {contribution}/{stage} must be rejected" + ); + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs new file mode 100644 index 000000000..190ebb67f --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/mod.rs @@ -0,0 +1,112 @@ +mod authentication; +mod configuration; +mod observer; +mod observer_example; +mod policy; +mod prepare; +mod private_state; +mod worker; + +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{PluginInstance, PluginInstanceSnapshot}, + }, + ports::plugins::PluginPackageInspector, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRestartCircuitConfig, PluginRuntime, + PluginRuntimeConfig, RpcLimits, +}; +use gateway_plugin_sdk::Contributions; +use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, +}; + +use crate::support::store::Store; + +async fn setup() -> (tempfile::TempDir, Arc, PluginRuntime) { + setup_with_contributions(Contributions::new()).await +} + +async fn setup_with_contributions( + contributes: Contributions, +) -> (tempfile::TempDir, Arc, PluginRuntime) { + setup_with_contributions_and_restart_circuit(contributes, Default::default()).await +} + +async fn setup_with_restart_circuit( + restart_circuit: PluginRestartCircuitConfig, +) -> (tempfile::TempDir, Arc, PluginRuntime) { + setup_with_contributions_and_restart_circuit(Contributions::new(), restart_circuit).await +} + +async fn setup_with_contributions_and_restart_circuit( + contributes: Contributions, + restart_circuit: PluginRestartCircuitConfig, +) -> (tempfile::TempDir, Arc, PluginRuntime) { + let cache = tempfile::tempdir().unwrap(); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect( + crate::support::package_with_contributions( + crate::support::worker(), + Vec::new(), + contributes, + ), + None, + ) + .await + .unwrap(); + let instance = PluginInstance { + id: "instance-one".into(), + name: "Example".into(), + artifact_sha256: artifact.metadata.sha256.clone(), + enabled: true, + trusted_process: true, + configuration: serde_json::json!({}), + secrets: BTreeMap::new(), + grants: vec![], + bindings: vec![], + revision: Revision::new(1).unwrap(), + }; + let store = Arc::new(Store { + artifacts: BTreeMap::from([(artifact.metadata.sha256.clone(), artifact)]), + snapshot: Mutex::new(PluginInstanceSnapshot { + config_revision: Revision::new(1).unwrap(), + instances: vec![instance], + }), + }); + let runtime = PluginRuntime::new( + store.clone(), + store.clone(), + PluginRuntimeConfig { + cache_directory: cache.path().to_owned(), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit, + }, + gateway_core::engine::provider::ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(128).unwrap(), + )), + ); + (cache, store, runtime) +} + +async fn wait_until_empty(cache: &std::path::Path) { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + if std::fs::read_dir(cache).unwrap().next().is_none() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap(); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs new file mode 100644 index 000000000..8600130be --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs @@ -0,0 +1,868 @@ +use std::{collections::BTreeMap, num::NonZeroU32, sync::Arc, time::Duration}; + +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, + PluginPermissionGrant, + }, + }, + ports::plugins::PluginPackageInspector, +}; +use gateway_core::{ + account::ProviderAccountId, + engine::{ + ModelRequestId, ModelRequestTimings, + observation::{ + RequestObservation, RequestObservationOutcome, WebSocketResponseAttempt, + WebSocketResponseObservation, + }, + provider::ProviderRegistry, + }, + error::GatewayErrorKind, + event::ProtocolWireEvent, + identity::ProviderKind, + metering::{ProviderReportedCost, Usage}, + operation::OperationKind, + policy::ClientApiKeyId, + routing::{AccountGroupId, ConfigRevision, PublicModelId}, + upstream::UpstreamSendState, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, +}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; + +use crate::support::store::Store; + +const REQUEST_LIFECYCLE_CONTRIBUTION: &str = "test.example.requestLifecycle"; +const USAGE_CONTRIBUTION: &str = "test.example.usage"; +const WEB_SOCKET_OBSERVER_CONTRIBUTION: &str = "test.example.webSocketObserver"; + +fn binding(contribution: &str, order: i32) -> PluginCapabilityBinding { + PluginCapabilityBinding { + contribution: contribution.into(), + stage: "observation".into(), + order, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec!["openai".into()], + models: vec!["gpt-observed".into()], + identity_bindings: vec![], + } +} + +struct InstanceFixture { + id: String, + configuration: serde_json::Value, + grants: Vec, + bindings: Vec, +} + +fn grant(permission: Permission) -> PluginPermissionGrant { + PluginPermissionGrant { + permission: permission.as_str().to_owned(), + } +} + +async fn setup( + instances: Vec<(&str, serde_json::Value, Vec)>, + rpc_limits: RpcLimits, +) -> (tempfile::TempDir, PluginRuntime) { + setup_with_permissions( + instances + .into_iter() + .map(|(id, configuration, bindings)| InstanceFixture { + id: id.to_owned(), + configuration, + grants: vec![], + bindings, + }) + .collect(), + vec![], + rpc_limits, + ) + .await +} + +async fn setup_with_permissions( + instances: Vec, + manifest_permissions: Vec, + rpc_limits: RpcLimits, +) -> (tempfile::TempDir, PluginRuntime) { + let cache = tempfile::tempdir().unwrap(); + let contributes = Contributions::from([ + crate::support::contribution( + Capability::RequestLifecycle, + vec![Stage::Observation], + vec![], + vec![], + ), + crate::support::contribution(Capability::Usage, vec![Stage::Observation], vec![], vec![]), + crate::support::contribution( + Capability::WebSocketObserver, + vec![Stage::Observation], + vec![], + vec![], + ), + ]); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect( + crate::support::package_with_contributions( + crate::support::worker(), + manifest_permissions, + contributes, + ), + None, + ) + .await + .unwrap(); + let digest = artifact.metadata.sha256.clone(); + let instances = instances + .into_iter() + .map(|fixture| PluginInstance { + id: fixture.id.clone(), + name: fixture.id, + artifact_sha256: digest.clone(), + enabled: true, + trusted_process: true, + configuration: fixture.configuration, + secrets: BTreeMap::new(), + grants: fixture.grants, + bindings: fixture.bindings, + revision: Revision::new(1).unwrap(), + }) + .collect(); + let store = Arc::new(Store { + artifacts: BTreeMap::from([(digest, artifact)]), + snapshot: std::sync::Mutex::new(PluginInstanceSnapshot { + config_revision: Revision::new(1).unwrap(), + instances, + }), + }); + let runtime = PluginRuntime::new( + store.clone(), + store, + PluginRuntimeConfig { + cache_directory: cache.path().to_owned(), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits, + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(16).unwrap(), + )), + ); + (cache, runtime) +} + +fn observation(id: &str, outcome: RequestObservationOutcome) -> RequestObservation { + let send_state = if outcome == RequestObservationOutcome::Rejected { + UpstreamSendState::NotSent + } else { + UpstreamSendState::Sent + }; + RequestObservation::new( + ModelRequestId::new(id).unwrap(), + ConfigRevision::new(1).unwrap(), + OperationKind::Generate, + outcome, + send_state, + std::time::SystemTime::now(), + ) + .with_requested_model(PublicModelId::new("gpt-observed").unwrap()) + .with_usage(Usage { + input_tokens: Some(11), + output_tokens: Some(7), + total_tokens: Some(18), + ..Usage::default() + }) +} + +fn websocket_observation( + id: &str, + sequence: u64, + body: serde_json::Value, +) -> WebSocketResponseObservation { + WebSocketResponseObservation::new( + ModelRequestId::new(id).unwrap(), + ConfigRevision::new(1).unwrap(), + OperationKind::Generate, + WebSocketResponseAttempt::new( + ProviderKind::new("openai").unwrap(), + ProviderAccountId::new("acct_observed").unwrap(), + NonZeroU32::new(2).unwrap(), + ), + sequence, + ProtocolWireEvent::json("openai", Some("response.output_text.delta".into()), body).unwrap(), + ) + .with_client_scope( + ClientApiKeyId::new("client-key-observed").unwrap(), + vec![AccountGroupId::new("grp_11111111111111111111111111111111").unwrap()], + ) + .with_requested_model(PublicModelId::new("gpt-observed").unwrap()) +} + +async fn wait_for_lines(path: &std::path::Path, count: usize) -> Vec { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let lines = std::fs::read_to_string(path) + .unwrap_or_default() + // 子进程先追加 JSON 再写换行;只解析已经提交完整行的记录。 + .split_inclusive('\n') + .filter(|line| line.ends_with('\n')) + .map(|line| serde_json::from_str(line).unwrap()) + .collect::>(); + if lines.len() >= count { + return lines; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("observer marker") +} + +#[tokio::test] +async fn frozen_plan_applies_scope_order_and_instance_deduplication() { + let marker_directory = tempfile::tempdir().unwrap(); + let marker = marker_directory.path().join("observations.jsonl"); + let (cache, runtime) = setup( + vec![ + ( + "late", + serde_json::json!({"observation_label":"late","observation_marker":marker}), + vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 20)], + ), + ( + "early", + serde_json::json!({"observation_label":"early","observation_marker":marker}), + vec![ + binding(REQUEST_LIFECYCLE_CONTRIBUTION, 10), + binding(USAGE_CONTRIBUTION, 10), + ], + ), + ], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + observation("req_observed", RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + let lines = wait_for_lines(&marker, 2).await; + assert_eq!( + lines.len(), + 2, + "lifecycle and usage observations on one instance share one call" + ); + assert_eq!(lines[0]["label"], "early"); + assert!(lines[0]["observation"]["terminal"].is_object()); + assert_eq!(lines[0]["observation"]["usage"]["total_tokens"], 18); + assert_eq!(lines[1]["label"], "late"); + assert!(lines[1]["observation"]["terminal"].is_object()); + assert!(lines[1]["observation"].get("usage").is_none()); + + plan.dispatch( + generation.clone(), + observation("req_model_miss", RequestObservationOutcome::Succeeded) + .with_requested_model(PublicModelId::new("gpt-other").unwrap()) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + plan.dispatch( + generation.clone(), + observation("req_rejected", RequestObservationOutcome::Rejected), + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(wait_for_lines(&marker, 2).await.len(), 2); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn websocket_observer_preserves_order_generation_and_permission_boundaries() { + let marker_directory = tempfile::tempdir().unwrap(); + let hidden = marker_directory.path().join("hidden.jsonl"); + let readable = marker_directory.path().join("readable.jsonl"); + let (cache, runtime) = setup_with_permissions( + vec![ + InstanceFixture { + id: "hidden".into(), + configuration: serde_json::json!({ + "observation_label":"hidden", + "websocket_observation_marker":hidden, + }), + grants: vec![], + bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + }, + InstanceFixture { + id: "readable".into(), + configuration: serde_json::json!({ + "observation_label":"readable", + "websocket_observation_marker":readable, + }), + grants: vec![grant(Permission::Requests)], + bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2)], + }, + ], + vec![Permission::Requests], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + for (sequence, secret) in [(1, "first"), (2, "second")] { + plan.dispatch_websocket_response( + generation.clone(), + websocket_observation( + "req_websocket_ordered", + sequence, + serde_json::json!({"type":"response.output_text.delta","secret":secret}), + ), + ); + } + plan.dispatch( + generation.clone(), + observation( + "req_websocket_ordered", + RequestObservationOutcome::Succeeded, + ), + ); + drop(plan); + drop(generation); + + let hidden = wait_for_lines(&hidden, 2).await; + let readable = wait_for_lines(&readable, 2).await; + assert_eq!( + hidden + .iter() + .map(|line| line["observation"]["sequence"].as_u64().unwrap()) + .collect::>(), + vec![1, 2] + ); + assert!(hidden.iter().all(|line| { + line["payload_bytes"] == 0 + && line["observation"]["payload_included"] == false + && line["observation"].get("event_type").is_none() + && line["observation"]["account_id"] == "acct_observed" + && !line.to_string().contains("client-key-observed") + && !line + .to_string() + .contains("grp_11111111111111111111111111111111") + && !line.to_string().contains("secret") + })); + assert_eq!( + readable + .iter() + .map(|line| line["observation"]["sequence"].as_u64().unwrap()) + .collect::>(), + vec![1, 2] + ); + assert!(readable.iter().all(|line| { + line["payload_bytes"].as_u64().unwrap() > 0 + && line["observation"]["payload_included"] == true + && line["observation"]["event_type"] == "response.output_text.delta" + && line["observation"]["account_id"] == "acct_observed" + })); + assert_eq!( + readable + .iter() + .map(|line| { + serde_json::from_str::(line["payload_text"].as_str().unwrap()) + .unwrap()["secret"] + .as_str() + .unwrap() + .to_owned() + }) + .collect::>(), + vec!["first", "second"] + ); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blocking() { + let marker_directory = tempfile::tempdir().unwrap(); + let count_started = marker_directory.path().join("count-started.jsonl"); + let count_completed = marker_directory.path().join("count-completed.jsonl"); + let limits = RpcLimits { + maximum_calls: 1, + ..RpcLimits::default() + }; + let (count_cache, count_runtime) = setup_with_permissions( + vec![InstanceFixture { + id: "count".into(), + configuration: serde_json::json!({ + "websocket_observation_started_marker":count_started, + "websocket_observation_marker":count_completed, + "websocket_observation_delay_ms":150, + }), + grants: vec![grant(Permission::Requests)], + bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + }], + vec![Permission::Requests], + limits, + ) + .await; + let count_generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &count_runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let count_plan = count_runtime + .observer_registry() + .resolve(&count_generation) + .unwrap(); + count_plan.dispatch_websocket_response( + count_generation.clone(), + websocket_observation( + "req_websocket_count", + 1, + serde_json::json!({"type":"response.delta","value":1}), + ), + ); + wait_for_lines(&count_started, 1).await; + for sequence in [2, 3] { + count_plan.dispatch_websocket_response( + count_generation.clone(), + websocket_observation( + "req_websocket_count", + sequence, + serde_json::json!({"type":"response.delta","value":sequence}), + ), + ); + } + count_plan.dispatch( + count_generation.clone(), + observation("req_websocket_count", RequestObservationOutcome::Succeeded), + ); + drop(count_plan); + drop(count_generation); + let completed = wait_for_lines(&count_completed, 2).await; + assert_eq!(completed.len(), 2); + assert_eq!(completed[0]["observation"]["sequence"], 1); + assert_eq!(completed[1]["observation"]["sequence"], 2); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(wait_for_lines(&count_completed, 2).await.len(), 2); + super::wait_until_empty(count_cache.path()).await; + + let byte_started = marker_directory.path().join("byte-started.jsonl"); + let fault_completed = marker_directory.path().join("fault-completed.jsonl"); + let healthy_completed = marker_directory.path().join("healthy-completed.jsonl"); + let (byte_cache, byte_runtime) = setup_with_permissions( + vec![ + InstanceFixture { + id: "fault".into(), + configuration: serde_json::json!({ + "websocket_observation_started_marker":byte_started, + "websocket_observation_marker":fault_completed, + "websocket_observation_delay_ms":150, + "websocket_observation_fault":true, + }), + grants: vec![grant(Permission::Requests)], + bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1)], + }, + InstanceFixture { + id: "healthy".into(), + configuration: serde_json::json!({ + "websocket_observation_marker":healthy_completed, + }), + grants: vec![grant(Permission::Requests)], + bindings: vec![binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2)], + }, + ], + vec![Permission::Requests], + RpcLimits::default(), + ) + .await; + let byte_generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &byte_runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let byte_plan = byte_runtime + .observer_registry() + .resolve(&byte_generation) + .unwrap(); + let large_body = || { + serde_json::json!({ + "type":"response.delta", + "content":"x".repeat(600_000), + }) + }; + byte_plan.dispatch_websocket_response( + byte_generation.clone(), + websocket_observation("req_websocket_bytes", 1, large_body()), + ); + wait_for_lines(&byte_started, 1).await; + byte_plan.dispatch_websocket_response( + byte_generation.clone(), + websocket_observation("req_websocket_bytes", 2, large_body()), + ); + byte_plan.dispatch( + byte_generation.clone(), + observation("req_websocket_bytes", RequestObservationOutcome::Succeeded), + ); + drop(byte_plan); + drop(byte_generation); + assert_eq!(wait_for_lines(&fault_completed, 1).await.len(), 1); + let healthy = wait_for_lines(&healthy_completed, 1).await; + assert_eq!(healthy.len(), 1, "观察失败不能阻断同批后续订阅者"); + assert_eq!(healthy[0]["observation"]["sequence"], 1); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(wait_for_lines(&healthy_completed, 1).await.len(), 1); + super::wait_until_empty(byte_cache.path()).await; +} + +#[tokio::test] +async fn observer_failure_continues_the_plan_and_timeout_is_bounded() { + let marker_directory = tempfile::tempdir().unwrap(); + let started = marker_directory.path().join("started.jsonl"); + let completed = marker_directory.path().join("completed.jsonl"); + let (cache, runtime) = setup( + vec![ + ( + "fault", + serde_json::json!({ + "observation_label":"fault", + "observation_started_marker":started, + "observation_marker":completed, + "observation_fault":true, + }), + vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1)], + ), + ( + "timeout", + serde_json::json!({ + "observation_label":"timeout", + "observation_started_marker":started, + "observation_marker":completed, + "observation_delay_ms":2500, + }), + vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 2)], + ), + ], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + observation("req_failure", RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + let started_lines = wait_for_lines(&started, 2).await; + assert_eq!(started_lines[0]["label"], "fault"); + assert_eq!(started_lines[1]["label"], "timeout"); + tokio::time::sleep(Duration::from_millis(2100)).await; + let completed_lines = wait_for_lines(&completed, 1).await; + assert_eq!(completed_lines.len(), 1); + assert_eq!(completed_lines[0]["label"], "fault"); + assert!(generation.is_ready(), "观察错误与超时不能击穿插件会话"); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn invalid_observer_stage_policy_and_mixed_order_are_rejected_before_publish() { + let mut invalid_stage = binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1); + invalid_stage.stage = "routing".into(); + let mut invalid_policy = binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1); + invalid_policy.failure_policy = PluginFailurePolicy::Reject; + let mut invalid_websocket_stage = binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1); + invalid_websocket_stage.stage = "stream".into(); + let mut invalid_websocket_policy = binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 1); + invalid_websocket_policy.failure_policy = PluginFailurePolicy::Delegate; + for bindings in [ + vec![invalid_stage], + vec![invalid_policy], + vec![invalid_websocket_stage], + vec![invalid_websocket_policy], + vec![ + binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1), + binding(USAGE_CONTRIBUTION, 2), + ], + vec![ + binding(USAGE_CONTRIBUTION, 1), + binding(WEB_SOCKET_OBSERVER_CONTRIBUTION, 2), + ], + ] { + let (_cache, runtime) = setup( + vec![("invalid", serde_json::json!({}), bindings)], + RpcLimits::default(), + ) + .await; + assert!( + gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .is_err() + ); + } +} + +#[tokio::test] +async fn client_scope_filters_observations_and_exposes_only_the_key_identifier() { + let marker_directory = tempfile::tempdir().unwrap(); + let marker = marker_directory.path().join("scoped-observations.jsonl"); + let matching_group = "grp_11111111111111111111111111111111"; + let other_group = "grp_22222222222222222222222222222222"; + let mut scoped = binding(USAGE_CONTRIBUTION, 1); + scoped.client_key_ids = vec!["client-key-observed".into()]; + scoped.account_group_ids = vec![matching_group.into()]; + scoped.provider_ids.clear(); + let (cache, runtime) = setup( + vec![( + "scoped", + serde_json::json!({"observation_label":"scoped","observation_marker":marker}), + vec![scoped], + )], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + + plan.dispatch( + generation.clone(), + observation("req_scope_match", RequestObservationOutcome::Rejected).with_client_scope( + ClientApiKeyId::new("client-key-observed").unwrap(), + vec![AccountGroupId::new(matching_group).unwrap()], + ), + ); + let lines = wait_for_lines(&marker, 1).await; + assert_eq!(lines[0]["observation"]["request_id"], "req_scope_match"); + assert!(lines[0]["observation"].get("terminal").is_none()); + assert_eq!( + lines[0]["observation"]["usage"]["failure"]["outcome"], + "rejected" + ); + assert_eq!( + lines[0]["observation"]["usage"]["failure"]["send_state"], + "not_sent" + ); + assert_eq!( + lines[0]["observation"]["usage"]["cost"]["status"], + "unknown" + ); + let wire = lines[0]["observation"].to_string(); + assert_eq!( + lines[0]["observation"]["client_key_id"], + "client-key-observed" + ); + assert!(!wire.contains(matching_group)); + + for (request_id, key, groups) in [ + ( + "req_key_miss", + "client-key-other", + vec![AccountGroupId::new(matching_group).unwrap()], + ), + ( + "req_group_miss", + "client-key-observed", + vec![AccountGroupId::new(other_group).unwrap()], + ), + ("req_all_accounts_miss", "client-key-observed", vec![]), + ] { + plan.dispatch( + generation.clone(), + observation(request_id, RequestObservationOutcome::Rejected) + .with_client_scope(ClientApiKeyId::new(key).unwrap(), groups), + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(wait_for_lines(&marker, 1).await.len(), 1); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn usage_observation_contains_final_cost_timings_and_safe_failure_only() { + let marker_directory = tempfile::tempdir().unwrap(); + let marker = marker_directory.path().join("usage-observations.jsonl"); + let (cache, runtime) = setup( + vec![( + "usage", + serde_json::json!({"observation_label":"usage","observation_marker":marker}), + vec![binding(USAGE_CONTRIBUTION, 1)], + )], + RpcLimits::default(), + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + observation("req_usage_failure", RequestObservationOutcome::Failed) + .with_provider(ProviderKind::new("openai").unwrap()) + .with_attempt_count(2) + .with_client_status_code(502) + .with_upstream_status_code(503) + .with_error_kind(GatewayErrorKind::UpstreamUnavailable) + .with_retry_after_ms(1_500) + .with_cost( + ProviderReportedCost::from_usd_ticks(123_000_000) + .unwrap() + .into_estimate(), + ) + .with_timings(ModelRequestTimings { + first_text_ms: Some(12), + latency_ms: Some(34), + ..ModelRequestTimings::default() + }), + ); + let lines = wait_for_lines(&marker, 1).await; + let observed = &lines[0]["observation"]; + assert!(observed.get("terminal").is_none()); + assert_eq!(observed["usage"]["cost"]["status"], "known"); + assert_eq!(observed["usage"]["cost"]["source"], "provider_reported"); + assert_eq!(observed["usage"]["cost"]["total"]["amount"], "0.0123"); + assert_eq!(observed["usage"]["cost"]["total"]["currency"], "USD"); + assert_eq!(observed["usage"]["timings"]["first_text_ms"], 12); + assert_eq!(observed["usage"]["timings"]["latency_ms"], 34); + assert_eq!(observed["usage"]["failure"]["outcome"], "failed"); + assert_eq!(observed["usage"]["failure"]["attempt_count"], 2); + assert_eq!(observed["usage"]["failure"]["client_status_code"], 502); + assert_eq!(observed["usage"]["failure"]["upstream_status_code"], 503); + assert_eq!( + observed["usage"]["failure"]["error_code"], + "upstream_unavailable" + ); + assert_eq!(observed["usage"]["failure"]["retry_after_ms"], 1_500); + let wire = observed["usage"]["failure"].to_string(); + assert!(!wire.contains("message")); + assert!(!wire.contains("raw_upstream_error")); + assert!(!wire.contains("provider_error_code")); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn invalid_client_key_and_group_scopes_are_rejected_before_publish() { + let mut duplicate_keys = binding(USAGE_CONTRIBUTION, 1); + duplicate_keys.client_key_ids = vec!["client-key".into(), "client-key".into()]; + let mut invalid_group = binding(USAGE_CONTRIBUTION, 1); + invalid_group.account_group_ids = vec!["not-a-group".into()]; + let mut duplicate_groups = binding(USAGE_CONTRIBUTION, 1); + duplicate_groups.account_group_ids = vec![ + "grp_33333333333333333333333333333333".into(), + "grp_33333333333333333333333333333333".into(), + ]; + for bindings in [ + vec![duplicate_keys], + vec![invalid_group], + vec![duplicate_groups], + ] { + let (_cache, runtime) = setup( + vec![("invalid-scope", serde_json::json!({}), bindings)], + RpcLimits::default(), + ) + .await; + assert!( + gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .is_err() + ); + } +} + +#[tokio::test] +async fn observer_backpressure_drops_excess_and_inflight_dispatch_keeps_generation_alive() { + let marker_directory = tempfile::tempdir().unwrap(); + let started = marker_directory.path().join("started.jsonl"); + let completed = marker_directory.path().join("completed.jsonl"); + let limits = RpcLimits { + maximum_calls: 1, + ..RpcLimits::default() + }; + let (cache, runtime) = setup( + vec![( + "slow", + serde_json::json!({ + "observation_label":"slow", + "observation_started_marker":started, + "observation_marker":completed, + "observation_delay_ms":200, + }), + vec![binding(REQUEST_LIFECYCLE_CONTRIBUTION, 1)], + )], + limits, + ) + .await; + let generation = gateway_core::runtime::extensions::ExtensionPreparationPort::prepare( + &runtime, + ConfigRevision::new(1).unwrap(), + ) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + observation("req_first", RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + wait_for_lines(&started, 1).await; + plan.dispatch( + generation.clone(), + observation("req_excess", RequestObservationOutcome::Succeeded) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + drop(plan); + drop(generation); + + let completed = wait_for_lines(&completed, 1).await; + assert_eq!(completed.len(), 1); + assert_eq!( + completed[0]["observation"]["request_id"], "req_first", + "try-acquire backpressure must not queue a second observation" + ); + super::wait_until_empty(cache.path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs new file mode 100644 index 000000000..ed5af41db --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer_example.rs @@ -0,0 +1,290 @@ +use std::{ + collections::BTreeMap, + num::NonZeroU32, + sync::{Arc, Mutex}, + time::Duration, +}; + +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, + PluginPermissionGrant, + }, + }, + ports::plugins::PluginPackageInspector, +}; +use gateway_core::{ + account::ProviderAccountId, + diagnostics::diagnostic_json, + engine::{ + ModelRequestId, + observation::{WebSocketResponseAttempt, WebSocketResponseObservation}, + provider::ProviderRegistry, + }, + event::ProtocolWireEvent, + identity::ProviderKind, + operation::OperationKind, + policy::ClientApiKeyId, + routing::{ConfigRevision, PublicModelId}, + runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference}, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, +}; +use serde_json::{Value, json}; + +use crate::support::store::Store; + +#[derive(Clone, Default)] +struct Capture(Arc>>>); + +struct Fields(BTreeMap); + +impl tracing::field::Visit for Fields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_owned(), format!("{value:?}")); + } +} + +impl tracing::Subscriber for Capture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.target() == "gateway_plugin" + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + if event.metadata().target() == "gateway_plugin" { + let mut fields = Fields(BTreeMap::new()); + event.record(&mut fields); + self.0.lock().unwrap().push(fields.0); + } + } +} + +impl Capture { + async fn wait_for(&self, count: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + while self.0.lock().unwrap().len() < count { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("independent observer log callback must complete"); + } + + fn assert_summary(&self, instance: &str, generation: u64, expected: Value) { + let logs = self.0.lock().unwrap(); + let row = logs + .iter() + .rev() + .find(|row| { + row["instance_id"] == instance && row["generation"] == generation.to_string() + }) + .expect("observer generation summary"); + assert_eq!( + serde_json::from_str::(&row["fields"]).unwrap(), + diagnostic_json(&expected) + ); + assert!(!row["fields"].contains("fixture-private-payload")); + } +} + +fn grant(permission: &str) -> PluginPermissionGrant { + PluginPermissionGrant { + permission: permission.to_owned(), + } +} + +fn binding(contribution: &str) -> PluginCapabilityBinding { + PluginCapabilityBinding { + contribution: contribution.to_owned(), + stage: "observation".to_owned(), + order: 1, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: Vec::new(), + account_group_ids: Vec::new(), + provider_ids: vec!["openai".to_owned()], + models: vec!["gpt-observed".to_owned()], + identity_bindings: Vec::new(), + } +} + +async fn runtime(cache: &std::path::Path) -> (Arc, PluginRuntime) { + let inspector = PackageInspector::new(PackageLimits::default(), "3.12.1".parse().unwrap()); + let mut artifacts = BTreeMap::new(); + let mut instances = Vec::new(); + for (variable, plugin, permissions) in [ + ( + "CPR_PLUGIN_WS_COUNTER_ARCHIVE", + "example-ws-event-counter", + vec![], + ), + ( + "CPR_PLUGIN_WS_METRICS_ARCHIVE", + "example-ws-payload-metrics", + vec![grant("requests")], + ), + ] { + let path = std::env::var_os(variable).unwrap_or_else(|| { + panic!("{variable} must point to a packaged independent SDK example") + }); + let archive = std::fs::read(path).expect("independent observer archive"); + let artifact = inspector.inspect(archive.into(), None).await.unwrap(); + assert_eq!(artifact.metadata.plugin_id, plugin); + let contribution = artifact.metadata.contributes["web_socket_observer"] + .id + .clone(); + instances.push(PluginInstance { + id: plugin.to_owned(), + name: plugin.to_owned(), + artifact_sha256: artifact.metadata.sha256.clone(), + enabled: true, + trusted_process: true, + configuration: json!({}), + secrets: BTreeMap::new(), + grants: permissions, + bindings: vec![binding(&contribution)], + revision: Revision::new(1).unwrap(), + }); + artifacts.insert(artifact.metadata.sha256.clone(), artifact); + } + let store = Arc::new(Store { + artifacts, + snapshot: Mutex::new(PluginInstanceSnapshot { + config_revision: Revision::new(1).unwrap(), + instances, + }), + }); + let runtime = PluginRuntime::new( + store.clone(), + store.clone(), + PluginRuntimeConfig { + cache_directory: cache.to_owned(), + host_version: "3.12.1".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(8).unwrap(), + )), + ); + (store, runtime) +} + +fn observation( + generation: u64, + sequence: u64, + model: &str, + body: &Value, +) -> WebSocketResponseObservation { + WebSocketResponseObservation::new( + ModelRequestId::new("req_observer_example").unwrap(), + ConfigRevision::new(generation).unwrap(), + OperationKind::Generate, + WebSocketResponseAttempt::new( + ProviderKind::new("openai").unwrap(), + ProviderAccountId::new("acct_observed").unwrap(), + NonZeroU32::new(1).unwrap(), + ), + sequence, + ProtocolWireEvent::json( + "openai", + body["type"].as_str().map(str::to_owned), + body.clone(), + ) + .unwrap(), + ) + .with_client_scope( + ClientApiKeyId::new("key_observer_example").unwrap(), + Vec::new(), + ) + .with_requested_model(PublicModelId::new(model).unwrap()) +} + +fn dispatch( + runtime: &PluginRuntime, + generation: &ExtensionSetReference, + revision: u64, + sequence: u64, + model: &str, + body: &Value, +) { + runtime + .observer_registry() + .resolve(generation) + .unwrap() + .dispatch_websocket_response( + generation.clone(), + observation(revision, sequence, model, body), + ); +} + +/// 必须单独按名称显式执行;真实包缺失时报错,不将未运行的依赖场景算作通过。 +#[tokio::test] +#[ignore = "requires two packaged standalone observer examples and an isolated log subscriber"] +async fn actual_observer_examples_share_runtime_contract_with_separate_permissions_and_generations() +{ + let capture = Capture::default(); + tracing::subscriber::set_global_default(capture.clone()).unwrap(); + let cache = tempfile::tempdir().unwrap(); + let (store, runtime) = runtime(cache.path()).await; + let first = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let body = json!({"type":"response.output_text.delta","delta":"fixture-private-payload 中文","unknown":true}); + let bytes = serde_json::to_vec(&body).unwrap().len(); + let text_bytes = body["delta"].as_str().unwrap().len(); + dispatch(&runtime, &first, 1, 1, "gpt-observed", &body); + capture.wait_for(2).await; + dispatch(&runtime, &first, 1, 2, "other-model", &body); + dispatch(&runtime, &first, 1, 9, "gpt-observed", &body); + capture.wait_for(4).await; + capture.assert_summary( + "example-ws-event-counter", + 1, + json!({"events":2,"first_events":1,"maximum_sequence":9}), + ); + capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":2,"payload_bytes":bytes*2,"text_delta_bytes":text_bytes*2,"terminal_events":0})); + { + let mut snapshot = store.snapshot.lock().unwrap(); + snapshot.config_revision = Revision::new(2).unwrap(); + for instance in &mut snapshot.instances { + instance.revision = Revision::new(2).unwrap(); + } + } + let second = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + dispatch(&runtime, &second, 2, 1, "gpt-observed", &body); + dispatch(&runtime, &first, 1, 10, "gpt-observed", &body); + capture.wait_for(8).await; + capture.assert_summary( + "example-ws-event-counter", + 2, + json!({"events":1,"first_events":1,"maximum_sequence":1}), + ); + capture.assert_summary( + "example-ws-event-counter", + 1, + json!({"events":3,"first_events":1,"maximum_sequence":10}), + ); + capture.assert_summary("example-ws-payload-metrics", 2, json!({"delivered_events":1,"payload_bytes":bytes,"text_delta_bytes":text_bytes,"terminal_events":0})); + capture.assert_summary("example-ws-payload-metrics", 1, json!({"delivered_events":3,"payload_bytes":bytes*3,"text_delta_bytes":text_bytes*3,"terminal_events":0})); + assert_eq!(capture.0.lock().unwrap().len(), 8); + runtime.shutdown().await; + drop(first); + drop(second); + super::wait_until_empty(cache.path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs new file mode 100644 index 000000000..7f9321829 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy.rs @@ -0,0 +1,1149 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + num::{NonZeroU32, NonZeroUsize}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, SystemTime}, +}; + +use bytes::Bytes; +use futures::future::BoxFuture; +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, PluginInstanceSnapshot, + PluginPermissionGrant, + }, + }, + ports::plugins::PluginPackageInspector, +}; +use gateway_core::{ + account::{ + AccountCandidate, AccountEligibilityPolicy, AccountRuntimeSignals, AccountSelectionContext, + AccountSelectionPolicy, AccountWeight, CredentialRevision, CredentialState, + ProviderAccount, ProviderAccountId, QuotaState, RotationStrategy, + }, + engine::{ + ModelRequestId, + execution::ClientTransport, + extensions::ExtensionCallScope, + middleware::{ + MiddlewareAuthority, MiddlewareBody, MiddlewareContext, MiddlewareError, + MiddlewareFrame, MiddlewareFraming, MiddlewareHeader, MiddlewareMount, MiddlewareNext, + MiddlewareRequest, MiddlewareResponse, MiddlewareTarget, + }, + nested::ExecutionEffects, + policy::{ModelRouteDecision, RequestPolicyContext}, + provider::ProviderRegistry, + }, + identity::ProviderKind, + lifecycle::CancellationToken, + operation::{GenerateRequest, Operation, OperationKind, ProtocolPayload}, + policy::ClientApiKeyId, + routing::{AccountGroupId, ConfigRevision, PublicModelId}, + runtime::extensions::{ExtensionPreparationPort, ExtensionSetReference}, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, +}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any}; + +use crate::support::{ + environment::{Environment, account_grant}, + store::Store, +}; + +struct InstanceFixture { + id: &'static str, + configuration: serde_json::Value, + grants: Vec, + bindings: Vec, +} + +const MODEL_ROUTER_CONTRIBUTION: &str = "test.example.modelRouter"; +const SCHEDULER_CONTRIBUTION: &str = "test.example.scheduler"; +const MIDDLEWARE_CONTRIBUTION: &str = "test.example.middleware"; + +fn binding( + contribution: &str, + stage: &str, + order: i32, + failure_policy: PluginFailurePolicy, +) -> PluginCapabilityBinding { + PluginCapabilityBinding { + contribution: contribution.into(), + stage: stage.into(), + order, + failure_policy, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + } +} + +fn grant(permission: Permission) -> PluginPermissionGrant { + PluginPermissionGrant { + permission: permission.as_str().to_owned(), + } +} + +async fn setup( + instances: Vec, + manifest_permissions: Vec, +) -> (tempfile::TempDir, PluginRuntime) { + let contributes = Contributions::from([ + crate::support::contribution( + Capability::ModelRouter, + vec![Stage::Routing], + vec![], + vec![], + ), + crate::support::contribution( + Capability::Scheduler, + vec![Stage::Scheduling], + vec![], + vec![], + ), + crate::support::contribution( + Capability::Middleware, + vec![Stage::Request, Stage::Attempt], + vec!["openai".into()], + vec!["openai".into()], + ), + ]); + setup_package( + instances, + crate::support::package_with_contributions( + crate::support::worker(), + manifest_permissions, + contributes, + ), + ) + .await +} + +async fn setup_package( + instances: Vec, + package: Arc<[u8]>, +) -> (tempfile::TempDir, PluginRuntime) { + let cache = tempfile::tempdir().unwrap(); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect(package, None) + .await + .unwrap(); + let digest = artifact.metadata.sha256.clone(); + let instances = instances + .into_iter() + .map(|fixture| PluginInstance { + id: fixture.id.into(), + name: fixture.id.into(), + artifact_sha256: digest.clone(), + enabled: true, + trusted_process: true, + configuration: fixture.configuration, + secrets: BTreeMap::new(), + grants: fixture.grants, + bindings: fixture.bindings, + revision: Revision::new(1).unwrap(), + }) + .collect(); + let store = Arc::new(Store { + artifacts: BTreeMap::from([(digest, artifact)]), + snapshot: std::sync::Mutex::new(PluginInstanceSnapshot { + config_revision: Revision::new(1).unwrap(), + instances, + }), + }); + let runtime = PluginRuntime::new( + store.clone(), + store, + PluginRuntimeConfig { + cache_directory: cache.path().to_owned(), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + NonZeroUsize::new(32).unwrap(), + )), + ) + .with_network_policy( + gateway_host::outbound::NetworkPolicy::new(&["127.0.0.0/8".into(), "::1/128".into()]) + .unwrap(), + ); + (cache, runtime) +} + +async fn prepare(runtime: &PluginRuntime) -> ExtensionSetReference { + ExtensionPreparationPort::prepare(runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap() +} + +fn policy_context( + runtime: &PluginRuntime, + generation: ExtensionSetReference, + request_id: &str, +) -> RequestPolicyContext { + RequestPolicyContext::new( + runtime.policy_registry().resolve(&generation).unwrap(), + generation, + ModelRequestId::new(request_id).unwrap(), + ClientApiKeyId::new("key-policy").unwrap(), + Vec::new(), + ) +} + +fn operation() -> Operation { + let context = serde_json::json!({ + "opaque_request_headers": [ + ["x-feature", "b24="], + ["authorization", "c2VjcmV0"], + ] + }) + .as_object() + .unwrap() + .clone(); + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + serde_json::json!({"model":"public-a","input":"synthetic"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(context), + )) +} + +async fn marker_lines(path: &std::path::Path, count: usize) -> Vec { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let lines = std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect::>(); + if lines.len() >= count { + return lines; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("plugin marker") +} + +#[tokio::test] +async fn model_router_preserves_order_and_only_projects_authorized_request_data() { + let markers = tempfile::tempdir().unwrap(); + let marker = markers.path().join("routes.jsonl"); + let (cache, runtime) = setup( + vec![ + InstanceFixture { + id: "unhandled", + configuration: serde_json::json!({ + "route_marker":marker, + "route_decision":{"decision":"unhandled"}, + }), + grants: vec![], + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 1, + PluginFailurePolicy::Reject, + )], + }, + InstanceFixture { + id: "handled", + configuration: serde_json::json!({ + "route_marker":marker, + "route_decision":{"decision":"route","provider":"openai","model":"public-b"}, + }), + grants: vec![grant(Permission::Requests)], + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 2, + PluginFailurePolicy::Reject, + )], + }, + ], + vec![Permission::Requests], + ) + .await; + let generation = prepare(&runtime).await; + let context = policy_context(&runtime, generation.clone(), "req_route"); + let decision = context + .route_model( + operation(), + PublicModelId::new("public-a").unwrap(), + BTreeSet::from([ + ProviderKind::new("openai").unwrap(), + ProviderKind::new("example").unwrap(), + ]), + ) + .await + .unwrap(); + assert_eq!( + decision, + ModelRouteDecision::Route { + provider: Some(ProviderKind::new("openai").unwrap()), + model: Some(PublicModelId::new("public-b").unwrap()), + } + ); + let lines = marker_lines(&marker, 2).await; + assert_eq!(lines[0]["body"], ""); + assert_eq!(lines[0]["request"]["headers"], serde_json::json!([])); + assert_eq!( + serde_json::from_str::(lines[1]["body"].as_str().unwrap()).unwrap(), + serde_json::json!({"model":"public-a","input":"synthetic"}) + ); + assert_eq!( + lines[1]["request"]["headers"], + serde_json::json!([{"name":"x-feature","value_base64":"b24="}]) + ); + assert!(!lines[1].to_string().contains("secret")); + drop(context); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn model_router_can_delegate_after_a_managed_http_side_effect() { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok":true}))) + .expect(1) + .mount(&server) + .await; + let (cache, runtime) = setup( + vec![ + InstanceFixture { + id: "http-then-fault", + configuration: serde_json::json!({ + "route_http_url":server.uri(), + "route_fault":true, + }), + grants: vec![grant(Permission::Network)], + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 1, + PluginFailurePolicy::Delegate, + )], + }, + InstanceFixture { + id: "fallback", + configuration: serde_json::json!({ + "route_decision":{"decision":"unhandled"}, + }), + grants: vec![], + bindings: vec![binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 2, + PluginFailurePolicy::Reject, + )], + }, + ], + vec![Permission::Network], + ) + .await; + let generation = prepare(&runtime).await; + let context = policy_context(&runtime, generation.clone(), "req_route_http"); + assert_eq!( + context + .route_model( + operation(), + PublicModelId::new("public-a").unwrap(), + BTreeSet::from([ProviderKind::new("openai").unwrap()]), + ) + .await + .unwrap(), + ModelRouteDecision::Unhandled + ); + drop(context); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +fn candidate(id: &str, weight: u16) -> AccountCandidate { + AccountCandidate { + account: ProviderAccount::new( + ProviderAccountId::new(id).unwrap(), + ProviderKind::new("example").unwrap(), + id.into(), + None, + "test".into(), + CredentialRevision::new(1).unwrap(), + Some(SystemTime::now() + Duration::from_secs(60)), + ) + .with_account_facts( + true, + CredentialState::Ready, + QuotaState::unknown(), + None, + None, + ) + .with_scheduling(None, AccountWeight::new(weight).unwrap()), + signals: AccountRuntimeSignals { + in_flight: 0, + last_started_at: None, + quota_reset_at: None, + quota_remaining_rank: None, + cooldown: None, + failure_rate_basis_points: None, + first_output_latency_ms: None, + }, + } +} + +fn selection_context() -> AccountSelectionContext { + AccountSelectionContext { + policy: AccountSelectionPolicy::new( + RotationStrategy::RoundRobin, + NonZeroU32::new(4).unwrap(), + Duration::ZERO, + ), + now: SystemTime::now(), + excluded_accounts: BTreeSet::new(), + preferred_account: None, + preferred_account_overrides_weight: true, + round_robin_cursor: 0, + eligibility: AccountEligibilityPolicy::Enforce, + account_scope: None, + } +} + +#[tokio::test] +async fn scheduler_cannot_pick_outside_the_host_projected_priority_tier() { + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "scheduler", + configuration: serde_json::json!({"schedule_pick_index":0}), + grants: vec![], + bindings: vec![binding( + SCHEDULER_CONTRIBUTION, + "scheduling", + 0, + PluginFailurePolicy::Reject, + )], + }], + vec![], + ) + .await; + let generation = prepare(&runtime).await; + let context = policy_context(&runtime, generation.clone(), "req_schedule"); + let candidates = [candidate("acct_high", 100), candidate("acct_low", 10)]; + let selection = selection_context(); + let selected = context + .select_account( + NonZeroU32::MIN, + &ProviderKind::new("example").unwrap(), + Some("upstream-a"), + &candidates, + &selection, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(selected.candidate().account.id().as_str(), "acct_high"); + drop(context); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +struct OneFrameBody { + frame: Option, + reads: Arc, + closes: Arc, +} + +impl MiddlewareBody for OneFrameBody { + fn next_frame(&mut self) -> BoxFuture<'_, Result, MiddlewareError>> { + Box::pin(async move { + self.reads.fetch_add(1, Ordering::Relaxed); + Ok(self.frame.take()) + }) + } + + fn close(self: Box) -> BoxFuture<'static, ()> { + Box::pin(async move { + self.closes.fetch_add(1, Ordering::Relaxed); + }) + } +} + +struct Downstream { + calls: Arc, + reads: Arc, + closes: Arc, + error: bool, +} + +impl MiddlewareNext for Downstream { + fn run( + self: Box, + request: MiddlewareRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + self.calls.fetch_add(1, Ordering::Relaxed); + assert_eq!(request.protocol(), "openai"); + assert_eq!(request.body(), &Bytes::from_static(br#"{"input":"hello"}"#)); + assert!(request.headers().iter().any(|header| { + header.name() == "authorization" && header.value().as_ref() == b"Bearer private" + })); + assert!( + request + .headers() + .iter() + .any(|header| header.name() == "x_team" && header.value().as_ref() == b"team") + ); + let (status, framing, body) = if self.error { + (502, MiddlewareFraming::RawBytes, b"\0upstream".as_slice()) + } else { + ( + 200, + MiddlewareFraming::JsonDocument, + br#"{"downstream":true}"#.as_slice(), + ) + }; + Ok(MiddlewareResponse::new( + "openai".into(), + status, + vec![ + MiddlewareHeader::new("content-type", Bytes::from_static(b"application/json")), + MiddlewareHeader::new("x-upstream-marker", Bytes::from_static(b"kept")), + ], + Box::new(OneFrameBody { + reads: Arc::clone(&self.reads), + frame: Some(MiddlewareFrame::new( + Bytes::copy_from_slice(body), + framing, + true, + )), + closes: Arc::clone(&self.closes), + }), + )) + }) + } +} + +fn middleware_context(transport: ClientTransport) -> MiddlewareContext { + MiddlewareContext::new( + MiddlewareTarget { + request_id: ModelRequestId::new("req_middleware").unwrap(), + mount: MiddlewareMount::Request, + attempt_index: None, + operation: Some(OperationKind::Generate), + endpoint: "/v1/responses".into(), + transport, + provider: None, + model: Some("public-a".into()), + account_id: None, + }, + MiddlewareAuthority { + client_key_id: ClientApiKeyId::new("key-middleware").unwrap(), + account_group_ids: Arc::<[AccountGroupId]>::from([]), + cancellation: CancellationToken::new(), + deadline: SystemTime::now() + Duration::from_secs(5), + extension_scope: ExtensionCallScope::default(), + execution_effects: None, + }, + ) +} + +fn attempt_middleware_context(effects: Arc) -> MiddlewareContext { + MiddlewareContext::new( + MiddlewareTarget { + request_id: ModelRequestId::new("req_attempt_http").unwrap(), + mount: MiddlewareMount::Attempt, + attempt_index: Some(NonZeroU32::MIN), + operation: Some(OperationKind::Generate), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + provider: Some(ProviderKind::new("openai").unwrap()), + model: Some("public-a".into()), + account_id: Some(ProviderAccountId::new("acct_openai").unwrap()), + }, + MiddlewareAuthority { + client_key_id: ClientApiKeyId::new("key-middleware").unwrap(), + account_group_ids: Arc::<[AccountGroupId]>::from([]), + cancellation: CancellationToken::new(), + deadline: SystemTime::now() + Duration::from_secs(5), + extension_scope: ExtensionCallScope::default(), + execution_effects: Some(effects), + }, + ) +} + +fn middleware_headers() -> Vec { + vec![ + MiddlewareHeader::new("authorization", Bytes::from_static(b"Bearer private")), + MiddlewareHeader::new("connection", Bytes::from_static(b"x-hop")), + MiddlewareHeader::new("x-hop", Bytes::from_static(b"hidden")), + MiddlewareHeader::new("x_team", Bytes::from_static(b"team")), + ] +} + +#[tokio::test] +async fn attempt_middleware_can_delegate_after_a_managed_http_side_effect() { + let server = MockServer::start().await; + Mock::given(any()) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "attempt-http", + configuration: serde_json::json!({ + "middleware_http_url":server.uri(), + "middleware_fault_before_next":true, + }), + grants: vec![grant(Permission::Network)], + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "attempt", + 0, + PluginFailurePolicy::Delegate, + )], + }], + vec![Permission::Network], + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime + .middleware_registry() + .resolve(&generation) + .expect("middleware plan"); + let calls = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let effects = Arc::new(ExecutionEffects::default()); + let response = plan + .handle( + attempt_middleware_context(effects), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::default(), + error: false, + }), + ) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 1); + let (_, status, _, body, _) = response.into_parts(); + assert_eq!(status, 200); + body.close().await; + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn middleware_next_and_lazy_body_mapping_use_one_real_rpc_call() { + let markers = tempfile::tempdir().unwrap(); + let marker = markers.path().join("middleware.jsonl"); + let permissions = vec![Permission::Requests]; + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "middleware", + configuration: serde_json::json!({ + "middleware_marker":marker, + "middleware_map_body":true, + }), + grants: permissions.iter().copied().map(grant).collect(), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }], + permissions, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime + .middleware_registry() + .resolve(&generation) + .expect("middleware plan"); + let calls = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let context = middleware_context(ClientTransport::HttpJson); + let response = plan + .handle( + context.clone(), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::default(), + error: false, + }), + ) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 1); + let (protocol, status, headers, mut body, envelope) = response.into_parts(); + assert_eq!(protocol, "openai"); + assert_eq!(status, 200); + assert!(headers.iter().any(|header| { + header.name() == "content-type" && header.value().as_ref() == b"application/json" + })); + assert!(envelope.is_none()); + let frame = body.next_frame().await.unwrap().unwrap(); + assert_eq!(frame.framing(), MiddlewareFraming::JsonDocument); + assert!(frame.terminal()); + assert!(frame.transformed()); + assert_eq!( + frame.into_bytes(), + Bytes::from_static(b"{\"downstream\":true} ") + ); + assert!(body.next_frame().await.unwrap().is_none()); + let first_marker = &marker_lines(&marker, 1).await[0]; + assert_eq!(first_marker["body"], "{\"input\":\"hello\"}"); + assert_eq!( + first_marker["headers"], + serde_json::json!([{ + "name":"x_team", + "value":[116,101,97,109], + }]) + ); + body.close().await; + tokio::time::timeout(Duration::from_secs(1), async { + while closes.load(Ordering::Relaxed) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + let response = plan + .handle( + context, + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::default(), + error: false, + }), + ) + .await + .unwrap(); + let (_, _, _, body, _) = response.into_parts(); + body.close().await; + tokio::time::timeout(Duration::from_secs(1), async { + while closes.load(Ordering::Relaxed) != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 2); + + let response = plan + .handle( + middleware_context(ClientTransport::HttpSse), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::default(), + error: true, + }), + ) + .await + .unwrap(); + let (_, status, _, mut body, _) = response.into_parts(); + assert_eq!(status, 502); + let frame = body.next_frame().await.unwrap().unwrap(); + assert_eq!(frame.framing(), MiddlewareFraming::RawBytes); + assert!(frame.transformed()); + assert_eq!(frame.into_bytes(), Bytes::from_static(b"\0upstream ")); + body.close().await; + tokio::time::timeout(Duration::from_secs(1), async { + while closes.load(Ordering::Relaxed) != 3 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 3); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn middleware_classifies_real_rpc_output_after_a_terminal_frame() { + for (id, extra_chunk, terminal_error) in [ + ("middleware-terminal-chunk", true, false), + ("middleware-terminal-error", false, true), + ] { + let permissions = vec![Permission::Requests]; + let (cache, runtime) = setup( + vec![InstanceFixture { + id, + configuration: serde_json::json!({ + "middleware_map_body": true, + "middleware_chunk_after_terminal": extra_chunk, + "middleware_error_after_terminal": terminal_error, + }), + grants: permissions.iter().copied().map(grant).collect(), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }], + permissions, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let response = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::default(), + error: false, + }), + ) + .await + .unwrap(); + let (_, _, _, mut body, _) = response.into_parts(); + let error = body + .next_frame() + .await + .expect_err("output after the terminal frame must fail"); + let expected = if terminal_error { + matches!(&error, MiddlewareError::Fault) + } else { + matches!(&error, MiddlewareError::InvalidState) + }; + assert!( + expected, + "unexpected terminal stream error for {id}: {error:?}" + ); + body.close().await; + assert_eq!(calls.load(Ordering::Relaxed), 1); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; + } +} + +#[tokio::test] +async fn sdk_middleware_entry_registers_and_maps_a_real_process_response() { + let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap(); + let permissions = vec![Permission::Requests]; + let package = crate::support::package_with_contributions( + &worker, + permissions.clone(), + Contributions::from([crate::support::contribution( + Capability::Middleware, + vec![Stage::Request], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let (cache, runtime) = setup_package( + vec![InstanceFixture { + id: "sdk-middleware", + configuration: serde_json::json!({}), + grants: permissions.into_iter().map(grant).collect(), + bindings: vec![binding( + MIDDLEWARE_CONTRIBUTION, + "request", + 0, + PluginFailurePolicy::Reject, + )], + }], + package, + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let reads = Arc::new(AtomicUsize::new(0)); + let response = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + closes: Arc::clone(&closes), + reads: Arc::clone(&reads), + error: false, + }), + ) + .await + .unwrap(); + let (_, status, headers, mut body, _) = response.into_parts(); + assert_eq!(status, 200); + assert!( + headers.iter().any(|header| { + header.name() == "x-sdk-plugin" && header.value().as_ref() == b"active" + }) + ); + tokio::time::timeout(Duration::from_secs(5), async { + while reads.load(Ordering::Relaxed) == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + // 模拟慢消费者:SDK 可以继续发起读回调,但宿主不得预读第二个源。 + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(reads.load(Ordering::Relaxed), 1); + let frame = body.next_frame().await.unwrap().unwrap(); + assert!(frame.terminal()); + assert!(frame.transformed()); + assert_eq!( + frame.into_bytes(), + Bytes::from_static(b"{\"downstream\":true} ") + ); + assert!(body.next_frame().await.unwrap().is_none()); + assert_eq!(reads.load(Ordering::Relaxed), 2); + body.close().await; + assert_eq!(calls.load(Ordering::Relaxed), 1); + tokio::time::timeout(Duration::from_secs(1), async { + while closes.load(Ordering::Relaxed) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // 尚未消费源帧就关闭,等待源归还的回调必须一起取消并释放下游正文。 + reads.store(0, Ordering::Relaxed); + let response = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: Arc::clone(&calls), + reads: Arc::clone(&reads), + closes: Arc::clone(&closes), + error: false, + }), + ) + .await + .unwrap(); + let (_, _, _, body, _) = response.into_parts(); + tokio::time::timeout(Duration::from_secs(5), async { + while reads.load(Ordering::Relaxed) == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(reads.load(Ordering::Relaxed), 1); + body.close().await; + tokio::time::timeout(Duration::from_secs(1), async { + while closes.load(Ordering::Relaxed) != 2 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 2); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn core_execution_resolves_the_plugin_runtime_middleware_plan() { + let Some(environment) = Environment::create().await else { + return; + }; + let marker = environment.directory.path().join("core-middleware.jsonl"); + environment + .install_provider( + serde_json::json!({"middleware_marker": marker}), + vec![account_grant("accounts")], + ) + .await; + let key_secret = "sk-core-middleware-fixture"; + environment + .client_key("key_core_middleware", key_secret) + .await; + + let (runtime, core) = environment.runtime().await; + let execution = core.execution_service(); + let client = execution.authenticate(key_secret).unwrap(); + let prepared = execution.prepare_execution(client).await.unwrap(); + assert!(execution.middleware_plan(&prepared).is_some()); + + environment.release_plugin_accounts(&runtime); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn instances_without_data_plane_bindings_publish_neither_plan() { + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "unbound", + configuration: serde_json::json!({}), + grants: vec![], + bindings: vec![], + }], + vec![], + ) + .await; + let generation = prepare(&runtime).await; + assert!(runtime.policy_registry().resolve(&generation).is_none()); + assert!(runtime.middleware_registry().resolve(&generation).is_none()); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn a_failed_plugin_rejects_only_its_bound_models_and_preserves_delegate_policy() { + for failure_policy in [PluginFailurePolicy::Reject, PluginFailurePolicy::Delegate] { + let mut mount = binding( + MODEL_ROUTER_CONTRIBUTION, + "routing", + 0, + failure_policy.clone(), + ); + mount.models = vec!["plugin-required".into()]; + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "failed-router", + configuration: serde_json::json!({"startup":"fail"}), + grants: vec![], + bindings: vec![mount], + }], + vec![], + ) + .await; + let generation = prepare(&runtime).await; + let context = policy_context(&runtime, generation.clone(), "req_isolated_failure"); + let unrelated = context + .route_model( + operation(), + PublicModelId::new("unrelated-model").unwrap(), + BTreeSet::new(), + ) + .await; + assert_eq!(unrelated.unwrap(), ModelRouteDecision::Unhandled); + let required = context + .route_model( + operation(), + PublicModelId::new("plugin-required").unwrap(), + BTreeSet::new(), + ) + .await; + if failure_policy == PluginFailurePolicy::Reject { + assert!(required.is_err()); + } else { + assert_eq!(required.unwrap(), ModelRouteDecision::Unhandled); + } + drop(context); + drop(generation); + super::wait_until_empty(cache.path()).await; + } +} + +#[tokio::test] +async fn a_failed_middleware_preserves_scope_and_the_configured_failure_policy() { + for (model, failure_policy, expected_calls) in [ + ("unrelated", PluginFailurePolicy::Reject, 1), + ("public-a", PluginFailurePolicy::Reject, 0), + ("public-a", PluginFailurePolicy::Delegate, 1), + ] { + let mut mount = binding(MIDDLEWARE_CONTRIBUTION, "request", 0, failure_policy); + mount.models = vec![model.into()]; + let (cache, runtime) = setup( + vec![InstanceFixture { + id: "failed-middleware", + configuration: serde_json::json!({"startup":"fail"}), + grants: vec![], + bindings: vec![mount], + }], + vec![], + ) + .await; + let generation = prepare(&runtime).await; + let plan = runtime.middleware_registry().resolve(&generation).unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let response = plan + .handle( + middleware_context(ClientTransport::HttpJson), + MiddlewareRequest::new( + "openai", + middleware_headers(), + Bytes::from_static(br#"{"input":"hello"}"#), + ), + Box::new(Downstream { + calls: calls.clone(), + reads: Arc::default(), + closes: Arc::default(), + error: false, + }), + ) + .await; + assert_eq!(response.is_ok(), expected_calls == 1); + assert_eq!(calls.load(Ordering::Relaxed), expected_calls); + drop(response); + drop(plan); + drop(generation); + super::wait_until_empty(cache.path()).await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs new file mode 100644 index 000000000..d1b890cf5 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/mod.rs @@ -0,0 +1,866 @@ +use gateway_admin::{ + model::{ + Revision, + plugins::instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginInstanceRuntimeStatus, + }, + }, + ports::plugins::PluginPreparation, +}; +use gateway_core::{routing::ConfigRevision, runtime::extensions::ExtensionPreparationPort}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; +use std::{num::NonZeroUsize, path::Path, time::Duration}; + +async fn wait_until_unready(reference: &gateway_core::runtime::extensions::ExtensionSetReference) { + tokio::time::timeout(Duration::from_secs(2), async { + while reference.is_ready() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("worker exit observed"); +} + +fn startup_count(marker: &Path) -> usize { + std::fs::read_to_string(marker) + .map(|content| content.lines().count()) + .unwrap_or_default() +} + +#[tokio::test] +async fn runtime_shutdown_reaps_live_generation_and_rejects_late_preparation() { + let (cache, store, runtime) = super::setup().await; + let published = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!(published.is_ready()); + assert_eq!(std::fs::read_dir(cache.path()).unwrap().count(), 1); + + runtime.shutdown().await; + assert!(!published.is_ready()); + super::wait_until_empty(cache.path()).await; + assert!( + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .is_err() + ); + runtime.shutdown().await; + + drop(published); + drop(store); +} + +#[tokio::test] +async fn management_needs_no_binding_and_rejects_stale_execution_identity_configuration() { + let (_cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::Management, + vec![Stage::Management], + Vec::new(), + Vec::new(), + )])) + .await; + let mut instance = store.snapshot.lock().unwrap().instances[0].clone(); + assert!( + PluginPreparation::validate(&runtime, instance.clone()) + .await + .is_ok() + ); + instance.bindings = vec![PluginCapabilityBinding { + contribution: "test.example.management".into(), + stage: "management".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec!["key-one".into(), "key-two".into()], + account_group_ids: Vec::new(), + provider_ids: Vec::new(), + models: Vec::new(), + identity_bindings: Vec::new(), + }]; + assert!( + PluginPreparation::validate(&runtime, instance.clone()) + .await + .is_err() + ); + instance.enabled = false; + assert!( + PluginPreparation::validate(&runtime, instance) + .await + .is_err() + ); +} + +#[tokio::test] +async fn registration_with_a_changed_contribution_id_is_rejected() { + let (cache, store, runtime) = + super::setup_with_contributions(Contributions::from([crate::support::contribution( + Capability::Usage, + vec![Stage::Observation], + Vec::new(), + Vec::new(), + )])) + .await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + let candidate = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("the declared contribution prepares before its registration id changes"); + drop(candidate); + super::wait_until_empty(cache.path()).await; + + snapshot.instances[0].configuration = serde_json::json!({"registration_mismatch":true}); + let error = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot) + .await + .expect_err("a changed registration contribution id must be rejected"); + assert!( + error.to_string().contains("插件注册结果与清单不符"), + "unexpected preparation failure: {error}" + ); +} + +#[tokio::test] +async fn candidate_is_reused_after_commit_and_released_when_no_generation_owner_remains() { + let (cache, store, runtime) = super::setup().await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = + serde_json::from_str(r#"{"z":1,"a":{"z":2,"a":3}}"#).unwrap(); + let candidate = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .unwrap(); + assert!( + runtime.observer_registry().resolve(&candidate).is_none(), + "没有观察绑定时不应编译派发计划" + ); + snapshot.instances[0].configuration = + serde_json::from_str(r#"{"a":{"a":3,"z":2},"z":1}"#).unwrap(); + snapshot.config_revision = Revision::new(2).unwrap(); + *store.snapshot.lock().unwrap() = snapshot; + let published = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .unwrap(); + assert_eq!(candidate.id(), published.id()); + assert!(published.is_ready()); + drop(candidate); + assert_eq!(std::fs::read_dir(cache.path()).unwrap().count(), 1); + drop(published); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn diagnostics_distinguish_running_failed_preparation_and_draining_generations() { + let (cache, store, runtime) = super::setup().await; + let snapshot = store.snapshot.lock().unwrap().clone(); + let active = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .unwrap(); + + let running = PluginPreparation::runtime_diagnostics( + &runtime, + &snapshot, + Some(snapshot.config_revision.get()), + Some(&active), + ) + .await + .expect("runtime diagnostics"); + let running = &running["instance-one"]; + assert_eq!(running.status, PluginInstanceRuntimeStatus::Running); + assert_eq!(running.actual_revision, Some(1)); + assert_eq!( + running.actual_artifact_sha256.as_deref(), + Some(snapshot.instances[0].artifact_sha256.as_str()) + ); + assert!(running.failure.is_none()); + + let mut failed = snapshot.clone(); + failed.config_revision = Revision::new(2).unwrap(); + failed.instances[0].revision = Revision::new(2).unwrap(); + failed.instances[0].configuration = serde_json::json!({"startup":"fail"}); + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, failed.clone()) + .await + .is_err() + ); + let diagnostics = PluginPreparation::runtime_diagnostics( + &runtime, + &failed, + Some(snapshot.config_revision.get()), + Some(&active), + ) + .await + .expect("failed preparation diagnostics"); + let failed_runtime = &diagnostics["instance-one"]; + assert_eq!( + failed_runtime.status, + PluginInstanceRuntimeStatus::PreparationFailed + ); + assert!(failed_runtime.failure.is_some()); + assert_eq!(failed_runtime.actual_revision, Some(1)); + + let mut disabled = failed; + disabled.config_revision = Revision::new(3).unwrap(); + disabled.instances[0].revision = Revision::new(3).unwrap(); + disabled.instances[0].enabled = false; + let diagnostics = PluginPreparation::runtime_diagnostics( + &runtime, + &disabled, + Some(snapshot.config_revision.get()), + Some(&active), + ) + .await + .expect("draining diagnostics"); + let draining = &diagnostics["instance-one"]; + assert_eq!(draining.status, PluginInstanceRuntimeStatus::Draining); + assert_eq!(draining.draining_revisions, [1]); + + drop(active); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn preparing_diagnostics_never_wait_for_the_serialized_prepare_io() { + let (cache, store, runtime) = super::setup().await; + let source = store.snapshot.lock().unwrap().clone(); + let mut candidate = source.clone(); + candidate.config_revision = Revision::new(2).unwrap(); + candidate.instances[0].revision = Revision::new(2).unwrap(); + candidate.instances[0].configuration = serde_json::json!({"startup_delay_ms":400}); + let diagnostic_snapshot = candidate.clone(); + let mut preparation = Box::pin(PluginPreparation::prepare( + &runtime, + source.config_revision, + candidate, + )); + assert!(futures::poll!(preparation.as_mut()).is_pending()); + + let diagnostics = tokio::time::timeout( + std::time::Duration::from_millis(100), + PluginPreparation::runtime_diagnostics(&runtime, &diagnostic_snapshot, None, None), + ) + .await + .expect("diagnostics must not wait for plugin startup") + .expect("runtime diagnostics"); + assert_eq!( + diagnostics["instance-one"].status, + PluginInstanceRuntimeStatus::Preparing + ); + + let candidate = preparation.await.expect("delayed candidate"); + drop(candidate); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn diagnostics_report_a_published_process_fault_without_plugin_details() { + let (cache, store, runtime) = super::setup().await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({"exit_after_ready_ms":20}); + let active = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("candidate starts before the controlled exit"); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while active.is_ready() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("worker exit observed"); + + let diagnostics = PluginPreparation::runtime_diagnostics( + &runtime, + &snapshot, + Some(snapshot.config_revision.get()), + Some(&active), + ) + .await + .expect("runtime diagnostics"); + let fault = &diagnostics["instance-one"]; + assert_eq!(fault.status, PluginInstanceRuntimeStatus::Faulted); + assert_eq!( + fault.failure.as_ref().map(|failure| failure.code.as_str()), + Some("closed") + ); + assert_eq!( + fault + .failure + .as_ref() + .map(|failure| failure.message.as_str()), + Some("插件进程或传输已停止") + ); + + drop(active); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_caps_short_lived_crashes_and_a_new_revision_recovers() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::new(3).unwrap(), + stability_window: Duration::from_secs(1), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("starts.jsonl"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker": marker, + "exit_after_ready_ms": 20, + }); + let mut active = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("first incarnation"); + for expected_start in 2..=3 { + wait_until_unready(&active).await; + let replacement = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("failure budget still permits a restart"); + drop(active); + active = replacement; + assert_eq!(startup_count(&marker), expected_start); + } + wait_until_unready(&active).await; + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err(), + "the fourth incarnation must not start" + ); + assert_eq!(startup_count(&marker), 3); + + let diagnostics = PluginPreparation::runtime_diagnostics( + &runtime, + &snapshot, + Some(snapshot.config_revision.get()), + Some(&active), + ) + .await + .expect("runtime diagnostics"); + assert_eq!( + diagnostics["instance-one"] + .failure + .as_ref() + .map(|failure| failure.code.as_str()), + Some("restart_circuit_open") + ); + + let mut disabled = snapshot; + disabled.config_revision = Revision::new(2).unwrap(); + disabled.instances[0].revision = Revision::new(2).unwrap(); + disabled.instances[0].enabled = false; + let disabled_generation = + PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), disabled.clone()) + .await + .expect("disabling the instance does not reuse its open circuit"); + assert_eq!(startup_count(&marker), 3); + + let mut repaired = disabled; + repaired.config_revision = Revision::new(3).unwrap(); + repaired.instances[0].revision = Revision::new(3).unwrap(); + repaired.instances[0].enabled = true; + repaired.instances[0].configuration = serde_json::json!({"startup_marker":marker}); + let replacement = PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), repaired) + .await + .expect("a new instance revision has an independent failure budget"); + assert!(replacement.is_ready()); + assert_eq!(startup_count(&marker), 4); + + drop(active); + drop(disabled_generation); + drop(replacement); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_resets_after_a_stable_incarnation() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::new(3).unwrap(), + stability_window: Duration::from_millis(80), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("stable-reset-starts.jsonl"); + let exit = controls.path().join("stable-exit"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker":marker, + "startup_failures":[true,true,false,true,true], + "exit_after_ready_signals":[null,null,exit], + }); + // 握手前失败的有效运行时间固定为零;不能用短 sleep 假定宿主一定及时观察到退出。 + for _ in 0..2 { + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err() + ); + } + let active = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("the third incarnation starts before the circuit opens"); + assert!(active.is_ready()); + // 明确等到稳定窗口之后才允许第三个进程退出;调度变慢不会把短命进程误变为稳定进程。 + tokio::time::sleep(restart_circuit.stability_window).await; + tokio::fs::write(exit, b"exit").await.unwrap(); + wait_until_unready(&active).await; + for expected_start in 4..=5 { + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err() + ); + assert_eq!(startup_count(&marker), expected_start); + } + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot) + .await + .is_err(), + "three new failures after the stable incarnation reopen the circuit" + ); + assert_eq!(startup_count(&marker), 5); + + drop(active); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_ignores_planned_generation_shutdown() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::MIN, + stability_window: Duration::from_secs(60), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("planned-shutdown-starts.jsonl"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({"startup_marker":marker}); + let first = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("first generation"); + let instance = &snapshot.instances[0]; + PluginPreparation::quiesce_instance( + &runtime, + &instance.id, + &instance.artifact_sha256, + instance.revision, + ) + .await; + assert!(!first.is_ready()); + + let second = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot) + .await + .expect("planned shutdown must not open the one-failure circuit"); + assert!(second.is_ready()); + assert_eq!(startup_count(&marker), 2); + + drop(first); + drop(second); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_counts_exit_before_the_candidate_is_indexed() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::new(3).unwrap(), + stability_window: Duration::from_secs(1), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("registration-exits.jsonl"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker":marker, + "exit_during_registration":true, + }); + for _ in 0..3 { + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err() + ); + } + assert_eq!(startup_count(&marker), 3); + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err() + ); + assert_eq!( + startup_count(&marker), + 3, + "open circuit must gate process spawn" + ); + + let diagnostics = PluginPreparation::runtime_diagnostics(&runtime, &snapshot, None, None) + .await + .expect("runtime diagnostics"); + assert_eq!( + diagnostics["instance-one"] + .failure + .as_ref() + .map(|failure| failure.code.as_str()), + Some("restart_circuit_open") + ); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_counts_a_started_process_that_exits_during_handshake() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::new(2).unwrap(), + stability_window: Duration::from_millis(10), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("handshake-exits.jsonl"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker":marker, + "startup":"fail", + "startup_fail_delay_ms":50, + }); + for _ in 0..2 { + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .is_err() + ); + } + assert_eq!(startup_count(&marker), 2); + assert!( + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot) + .await + .is_err() + ); + assert_eq!( + startup_count(&marker), + 2, + "host-side gating must happen before another process spawn" + ); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_does_not_let_an_older_generation_clear_newer_failures() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::new(3).unwrap(), + stability_window: Duration::from_millis(50), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("overlapping-generations.jsonl"); + let old_exit = controls.path().join("old-generation-exit"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker":marker, + "exit_after_ready_delays_ms":[null,15,15,15], + "exit_after_ready_signals":[old_exit,null,null,null], + }); + let mut other = snapshot.instances[0].clone(); + other.id = "other-instance".into(); + other.name = "Other".into(); + other.configuration = serde_json::json!({}); + snapshot.instances.push(other); + let published = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("published generation"); + assert!(published.is_ready()); + + let mut failed_candidate = None; + for revision in 2..=4 { + snapshot.config_revision = Revision::new(revision).unwrap(); + snapshot.instances[1].revision = Revision::new(revision).unwrap(); + snapshot.instances[1].configuration = serde_json::json!({"revision":revision}); + let candidate = + PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), snapshot.clone()) + .await + .expect("newer candidate starts before its controlled exit"); + wait_until_unready(&candidate).await; + drop(failed_candidate.replace(candidate)); + assert!(published.is_ready(), "the older generation remains healthy"); + } + drop(failed_candidate.take()); + std::fs::write(&old_exit, b"exit").unwrap(); + wait_until_unready(&published).await; + snapshot.config_revision = Revision::new(5).unwrap(); + snapshot.instances[1].revision = Revision::new(5).unwrap(); + snapshot.instances[1].configuration = serde_json::json!({"revision":5}); + assert!( + PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), snapshot) + .await + .is_err(), + "the older healthy session must not erase newer failures for the same identity" + ); + assert_eq!(startup_count(&marker), 4); + + drop(published); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn restart_circuit_restores_an_older_failure_after_candidate_shutdown() { + let restart_circuit = gateway_plugin_runtime::PluginRestartCircuitConfig { + maximum_failures: NonZeroUsize::MIN, + stability_window: Duration::from_secs(1), + }; + let (cache, store, runtime) = super::setup_with_restart_circuit(restart_circuit).await; + let controls = tempfile::tempdir().unwrap(); + let marker = controls.path().join("abandoned-candidate.jsonl"); + let old_exit = controls.path().join("abandoned-candidate-old-exit"); + let mut snapshot = store.snapshot.lock().unwrap().clone(); + snapshot.instances[0].configuration = serde_json::json!({ + "startup_marker":marker, + "exit_after_ready_delays_ms":[null,null], + "exit_after_ready_signals":[old_exit,null], + }); + let mut other = snapshot.instances[0].clone(); + other.id = "other-instance".into(); + other.name = "Other".into(); + other.configuration = serde_json::json!({}); + snapshot.instances.push(other); + let published = + PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .expect("published generation"); + + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[1].revision = Revision::new(2).unwrap(); + snapshot.instances[1].configuration = serde_json::json!({"revision":2}); + let candidate = + PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), snapshot.clone()) + .await + .expect("healthy replacement candidate"); + std::fs::write(&old_exit, b"exit").unwrap(); + wait_until_unready(&published).await; + assert!(candidate.is_ready()); + drop(candidate); + + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let diagnostics = PluginPreparation::runtime_diagnostics( + &runtime, + &snapshot, + Some(1), + Some(&published), + ) + .await + .expect("runtime diagnostics"); + if diagnostics["instance-one"] + .failure + .as_ref() + .is_some_and(|failure| failure.code == "restart_circuit_open") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("older failure is restored after candidate shutdown"); + + snapshot.config_revision = Revision::new(3).unwrap(); + snapshot.instances[1].revision = Revision::new(3).unwrap(); + snapshot.instances[1].configuration = serde_json::json!({"revision":3}); + assert!( + PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), snapshot) + .await + .is_err() + ); + assert_eq!(startup_count(&marker), 2); + + drop(published); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn failed_candidate_leaves_the_previous_generation_ready() { + let (cache, store, runtime) = super::setup().await; + let snapshot = store.snapshot.lock().unwrap().clone(); + let active = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot.clone()) + .await + .unwrap(); + let mut candidate = snapshot; + candidate.instances[0].configuration = serde_json::json!({"startup":"fail"}); + assert!( + PluginPreparation::prepare(&runtime, candidate.config_revision, candidate) + .await + .is_err() + ); + assert!(active.is_ready()); + drop(active); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn disabled_missing_artifacts_do_not_block_the_remaining_generation_or_offline_recovery() { + let (cache, store, runtime) = super::setup().await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + let mut missing = snapshot.instances[0].clone(); + missing.id = "missing".into(); + missing.enabled = false; + missing.artifact_sha256 = "0".repeat(64); + snapshot.instances.push(missing); + let active = PluginPreparation::prepare(&runtime, snapshot.config_revision, snapshot) + .await + .unwrap(); + assert!(active.is_ready()); + drop(active); + super::wait_until_empty(cache.path()).await; + let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!(restored.is_ready()); + drop(restored); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn preparation_cannot_mix_a_newer_persisted_revision_with_an_older_snapshot() { + let (_cache, _store, runtime) = super::setup().await; + assert!( + ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(2).unwrap()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn restoring_a_failed_plugin_keeps_other_instances_ready_and_reports_only_its_failure() { + let (cache, store, runtime) = super::setup().await; + let snapshot = { + let mut snapshot = store.snapshot.lock().unwrap(); + let mut failed = snapshot.instances[0].clone(); + failed.id = "failed-plugin".into(); + failed.configuration = serde_json::json!({"startup":"fail"}); + snapshot.instances.insert(0, failed); + snapshot.clone() + }; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .expect("one failed plugin must not stop restoration"); + assert!(generation.can_serve()); + let diagnostics = + PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation)) + .await + .unwrap(); + assert_eq!( + diagnostics["failed-plugin"].status, + PluginInstanceRuntimeStatus::PreparationFailed + ); + assert!(diagnostics["failed-plugin"].failure.is_some()); + assert_eq!( + diagnostics["instance-one"].status, + PluginInstanceRuntimeStatus::Running + ); + let reused = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert_eq!( + generation.id(), + reused.id(), + "quarantined failures do not restart on every request" + ); + drop(reused); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn a_published_process_crash_preserves_the_serving_snapshot_and_other_plugin_status() { + let (cache, store, runtime) = super::setup().await; + let snapshot = { + let mut snapshot = store.snapshot.lock().unwrap(); + let mut failed = snapshot.instances[0].clone(); + failed.id = "crashing-plugin".into(); + failed.configuration = serde_json::json!({"exit_after_ready_ms":100}); + snapshot.instances.push(failed); + snapshot.clone() + }; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + wait_until_unready(&generation).await; + assert!(generation.can_serve()); + assert!( + runtime + .provider_registry() + .for_extensions(Some(&generation)) + .is_ok() + ); + assert!(!generation.is_ready()); + let diagnostics = + PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&generation)) + .await + .unwrap(); + assert_eq!( + diagnostics["instance-one"].status, + PluginInstanceRuntimeStatus::Running + ); + assert_eq!( + diagnostics["crashing-plugin"].status, + PluginInstanceRuntimeStatus::Faulted + ); + drop(generation); + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn an_incompatible_host_quarantines_the_plugin_with_a_specific_version_error() { + use std::sync::Arc; + let (cache, store, _) = super::setup().await; + let runtime = gateway_plugin_runtime::PluginRuntime::new( + store.clone(), + store.clone(), + gateway_plugin_runtime::PluginRuntimeConfig { + cache_directory: cache.path().to_owned(), + host_version: "3.0.0".parse().unwrap(), + package_limits: Default::default(), + rpc_limits: Default::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + Default::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::default()), + ); + let restored = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + assert!(restored.can_serve()); + let snapshot = store.snapshot.lock().unwrap().clone(); + let diagnostics = + PluginPreparation::runtime_diagnostics(&runtime, &snapshot, Some(1), Some(&restored)) + .await + .unwrap(); + let failure = diagnostics["instance-one"].failure.as_ref().unwrap(); + assert!( + failure + .message + .contains("插件要求宿主 >=1.0.0, <2.0.0,当前为 3.0.0") + ); + assert!( + std::fs::read_dir(cache.path()).unwrap().next().is_none(), + "incompatible packages never start" + ); +} + +#[tokio::test] +async fn an_existing_failed_plugin_does_not_prevent_editing_an_unrelated_instance() { + let (cache, store, runtime) = super::setup().await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + let mut failed = snapshot.instances[0].clone(); + failed.id = "failed-plugin".into(); + failed.configuration = serde_json::json!({"startup":"fail"}); + snapshot.instances.push(failed); + snapshot.config_revision = Revision::new(2).unwrap(); + snapshot.instances[0].revision = snapshot.config_revision; + let candidate = PluginPreparation::prepare(&runtime, Revision::new(1).unwrap(), snapshot) + .await + .expect("only the instance being changed must successfully prepare"); + assert!(candidate.can_serve()); + drop(candidate); + super::wait_until_empty(cache.path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs new file mode 100644 index 000000000..f588d3766 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/private_state.rs @@ -0,0 +1,346 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, SystemTime}, +}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + Revision, + plugins::{ + instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginInstance, + PluginInstanceSnapshot, + }, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration, + PluginStateMigrationBatch, PluginStateNamespaceOwner, PluginStateOwner, + PluginStateOwnerRequest, PluginStateRecord, PluginStateTransition, + PluginStateWrite, PutPluginState, + }, + }, + }, + ports::plugins::{ + PluginPackageInspector, PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind, + PluginStateStoreResult, + }, +}; +use gateway_core::{ + engine::{ + ModelRequestId, + observation::{RequestObservation, RequestObservationOutcome}, + provider::ProviderRegistry, + }, + identity::ProviderKind, + operation::OperationKind, + routing::{ConfigRevision, PublicModelId}, + runtime::extensions::ExtensionPreparationPort, + upstream::UpstreamSendState, +}; +use gateway_plugin_runtime::{ + PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, RpcLimits, +}; +use gateway_plugin_sdk::{Capability, Contributions, Stage, StateNamespace}; +use serde_json::json; + +use crate::support::store::Store; + +struct StoredRecord { + value: serde_json::Value, + version: u64, +} + +#[derive(Default)] +struct StateStore { + records: Mutex>, + calls: Mutex>, + next_version: AtomicU64, +} + +impl StateStore { + fn error(kind: PluginStateStoreErrorKind) -> PluginStateStoreError { + PluginStateStoreError::new(kind) + } + + fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + + fn is_empty(&self) -> bool { + self.records.lock().unwrap().is_empty() + } +} + +#[async_trait] +impl PluginStateStore for StateStore { + async fn load_owner( + &self, + request: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + let namespaces = request + .configuration + .namespaces + .iter() + .map(|schema| { + ( + schema.namespace.clone(), + PluginStateNamespaceOwner::from_store( + format!("generation-{}", schema.namespace), + format!("fence-{}", schema.namespace), + schema.schema_version, + ), + ) + }) + .collect(); + Ok(Some(PluginStateOwner::from_store( + request.instance_id, + request.artifact_sha256, + request.instance_revision, + namespaces, + ))) + } + + async fn get( + &self, + owner: &PluginStateOwner, + namespace: &str, + key: &str, + ) -> PluginStateStoreResult> { + let namespace_owner = owner + .namespace(namespace) + .ok_or_else(|| Self::error(PluginStateStoreErrorKind::PermissionDenied))?; + self.calls.lock().unwrap().push(format!("get:{namespace}")); + Ok(self + .records + .lock() + .unwrap() + .get(&(namespace.into(), key.into())) + .map(|record| PluginStateRecord { + key: key.into(), + value: record.value.clone(), + version: record.version, + schema_version: namespace_owner.schema_version(), + })) + } + + async fn put( + &self, + owner: &PluginStateOwner, + command: PutPluginState, + ) -> PluginStateStoreResult { + owner + .namespace(&command.namespace) + .ok_or_else(|| Self::error(PluginStateStoreErrorKind::PermissionDenied))?; + let key = (command.namespace.clone(), command.key.clone()); + let mut records = self.records.lock().unwrap(); + let current = records.get(&key).map(|record| record.version); + if command.expected_version != current { + return Err(Self::error(PluginStateStoreErrorKind::Conflict)); + } + let version = self.next_version.fetch_add(1, Ordering::Relaxed) + 1; + records.insert( + key, + StoredRecord { + value: command.value, + version, + }, + ); + self.calls + .lock() + .unwrap() + .push(format!("put:{}", command.namespace)); + Ok(PluginStateWrite { version }) + } + + async fn delete( + &self, + owner: &PluginStateOwner, + command: DeletePluginState, + ) -> PluginStateStoreResult { + owner + .namespace(&command.namespace) + .ok_or_else(|| Self::error(PluginStateStoreErrorKind::PermissionDenied))?; + let key = (command.namespace.clone(), command.key); + let mut records = self.records.lock().unwrap(); + let Some(current) = records.get(&key) else { + return Ok(false); + }; + if current.version != command.expected_version { + return Err(Self::error(PluginStateStoreErrorKind::Conflict)); + } + records.remove(&key); + self.calls + .lock() + .unwrap() + .push(format!("delete:{}", command.namespace)); + Ok(true) + } + + async fn transition_required( + &self, + _: &str, + _: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + Ok(false) + } + + async fn begin_transition( + &self, + _: &str, + _: Revision, + _: &str, + _: PluginStateConfiguration, + ) -> PluginStateStoreResult { + Err(Self::error(PluginStateStoreErrorKind::Unavailable)) + } + + async fn migration_batch( + &self, + _: &str, + _: &str, + _: u32, + ) -> PluginStateStoreResult { + Err(Self::error(PluginStateStoreErrorKind::Unavailable)) + } + + async fn apply_migration_batch( + &self, + _: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + Err(Self::error(PluginStateStoreErrorKind::Unavailable)) + } + + async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> { + Ok(()) + } +} + +#[tokio::test] +async fn observation_callbacks_use_typed_state_crud_and_reject_undeclared_namespaces() { + let cache = tempfile::tempdir().unwrap(); + let marker = cache.path().join("state.jsonl"); + let contributes = Contributions::from([crate::support::contribution( + Capability::RequestLifecycle, + vec![Stage::Observation], + vec![], + vec![], + )]); + let state_namespace = StateNamespace { + namespace: "cache".into(), + schema_version: 1, + schema: json!({ + "type": "object", + "required": ["value"], + "properties": {"value": {"type": "integer"}}, + "additionalProperties": false, + }), + maximum_records: 8, + maximum_bytes: 1024, + maximum_value_bytes: 128, + migrates_from: vec![], + }; + let archive = crate::support::package_with_contributions_and_state( + crate::support::worker(), + vec![], + contributes, + vec![state_namespace], + ); + let artifact = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()) + .inspect(archive, None) + .await + .unwrap(); + let digest = artifact.metadata.sha256.clone(); + let instance = PluginInstance { + id: "state-instance".into(), + name: "state-instance".into(), + artifact_sha256: digest.clone(), + enabled: true, + trusted_process: true, + configuration: json!({ + "state_fixture": { + "namespace": "cache", + "denied_namespace": "private", + "key": "request", + "value": {"value": 7}, + }, + "state_marker": marker, + }), + secrets: BTreeMap::new(), + grants: vec![], + bindings: vec![PluginCapabilityBinding { + contribution: "test.example.requestLifecycle".into(), + stage: "observation".into(), + order: 0, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec!["openai".into()], + models: vec!["gpt-state".into()], + identity_bindings: vec![], + }], + revision: Revision::new(1).unwrap(), + }; + let store = Arc::new(Store { + artifacts: BTreeMap::from([(digest, artifact)]), + snapshot: Mutex::new(PluginInstanceSnapshot { + config_revision: Revision::new(1).unwrap(), + instances: vec![instance], + }), + }); + let state = Arc::new(StateStore::default()); + let runtime = PluginRuntime::new( + store, + state.clone(), + PluginRuntimeConfig { + cache_directory: cache.path().join("packages"), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain: Default::default(), + restart_circuit: Default::default(), + }, + ProviderRegistry::default(), + gateway_admin::ports::provider::ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(16).unwrap(), + )), + ); + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let plan = runtime.observer_registry().resolve(&generation).unwrap(); + plan.dispatch( + generation.clone(), + RequestObservation::new( + ModelRequestId::new("req_state").unwrap(), + ConfigRevision::new(1).unwrap(), + OperationKind::Generate, + RequestObservationOutcome::Succeeded, + UpstreamSendState::Sent, + SystemTime::now(), + ) + .with_requested_model(PublicModelId::new("gpt-state").unwrap()) + .with_provider(ProviderKind::new("openai").unwrap()), + ); + tokio::time::timeout(Duration::from_secs(3), async { + while !marker.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!(state.calls(), ["put:cache", "get:cache", "delete:cache"]); + assert!(state.is_empty()); + let marker = std::fs::read_to_string(marker).unwrap(); + assert!(marker.contains("\"version\":1")); + assert!(marker.contains("\"schema_version\":1")); + + drop(plan); + drop(generation); + super::wait_until_empty(cache.path().join("packages").as_path()).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/worker.rs b/backend/crates/gateway-plugin/runtime/tests/generation/worker.rs new file mode 100644 index 000000000..0fd1180b1 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/worker.rs @@ -0,0 +1,554 @@ +use std::{sync::Arc, time::Duration}; + +use gateway_admin::{ + CredentialsService, + model::{ + plugins::instances::{PluginCapabilityBinding, PluginFailurePolicy}, + provider_credentials::{ + CredentialMutation, ImportCredentials, ProviderDocument, RotateCredential, + }, + }, +}; +use gateway_core::{ + account::OpaqueProviderData, + lifecycle::CancellationToken, + routing::{ConfigRevision, ProviderKind}, + task::{ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerRunnable}, +}; +use serde_json::{Value, json}; + +use crate::support::environment::{Environment, account_grant, mutation}; + +fn task( + runtime: &Arc, + core: &gateway_core::CoreBundle, + owner: &str, +) -> (Box, WorkerCycleContext) { + let definitions = runtime.worker_contributions(core.snapshots()).unwrap(); + assert_eq!(definitions.len(), 4); + for definition in definitions { + definition.validate().unwrap(); + let WorkerContribution::Registration(registration) = definition else { + panic!("scheduled worker"); + }; + if registration.id.owner() != owner { + continue; + } + let WorkerRunnable::Scheduled { + schedule, + lease, + task, + } = registration.runnable + else { + panic!("scheduled worker"); + }; + assert!(lease.is_some()); + assert!(schedule.leader_lease_renewal_interval() < schedule.leader_lease_ttl()); + return ( + task, + WorkerCycleContext::new(registration.id, None, CancellationToken::new()), + ); + } + panic!("missing worker {owner}") +} + +fn http_grants() -> Vec { + vec![account_grant("accounts"), account_grant("network")] +} + +async fn enable_maintenance( + environment: &Environment, + core: &gateway_core::CoreBundle, + enabled: bool, +) { + let store = environment.store.admin_ports().plugins(); + let snapshot = store.load_instances().await.unwrap(); + let mut instance = snapshot.instances.into_iter().next().unwrap(); + instance + .bindings + .retain(|binding| binding.contribution != "test.example.maintenance"); + if enabled { + instance.bindings.push(PluginCapabilityBinding { + contribution: "test.example.maintenance".into(), + stage: "maintenance".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }); + } + let saved = store + .save_instance(instance, snapshot.config_revision, &mutation()) + .await + .unwrap(); + core.snapshot_control() + .publish_committed(ConfigRevision::new(saved.config_revision.get()).unwrap()) + .await; +} + +#[tokio::test] +async fn maintenance_requires_binding_and_stops_dispatch_after_revocation() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::header( + "authorization", + "Bearer test-only", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + json!({"plan_type":"maintained", "access":{"kind":"allowed"}, "windows":[]}), + )) + .expect(1) + .mount(&server) + .await; + let account = environment.account(None).await; + let (runtime, core) = environment.provider(json!({"maintenance":true,"quota_url":server.uri(),"expected_maintenance_methods":["provider.quota"]}), http_grants()).await; + let (task, context) = task(&runtime, &core, "plugin-quota"); + enable_maintenance(&environment, &core, false).await; + task.run_cycle(context.clone()).await.unwrap(); + assert!(server.received_requests().await.unwrap().is_empty()); + enable_maintenance(&environment, &core, true).await; + task.run_cycle(context.clone()).await.unwrap(); + let quotas = environment + .store + .provider_ports() + .accounts() + .get_quotas(std::slice::from_ref(&account)) + .await + .unwrap(); + assert_eq!(quotas.len(), 1); + assert_eq!(quotas[0].plan_type.as_deref(), Some("maintained")); + enable_maintenance(&environment, &core, false).await; + task.run_cycle(context).await.unwrap(); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + drop(task); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn cancelled_maintenance_drops_rpc_and_does_not_commit_late_quota() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_delay(Duration::from_secs(2)) + .set_body_json(json!({"access":{"kind":"allowed"},"windows":[]})), + ) + .expect(1) + .mount(&server) + .await; + let account = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"maintenance":true,"quota_url":server.uri()}), + http_grants(), + ) + .await; + let (task, context) = task(&runtime, &core, "plugin-quota"); + let cancellation = context.cancellation().clone(); + let running = tokio::spawn(async move { task.run_cycle(context).await }); + tokio::time::timeout(Duration::from_secs(5), async { + while server.received_requests().await.unwrap().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(1), running) + .await + .unwrap() + .unwrap() + .unwrap(); + tokio::time::sleep(Duration::from_millis(2100)).await; + assert!( + environment + .store + .provider_ports() + .accounts() + .get_quotas(&[account]) + .await + .unwrap() + .is_empty() + ); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn late_maintenance_quota_cannot_overwrite_rotated_credentials() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let mut server = crate::support::http::GatedResponse::start(json!({ + "access":{"kind":"exhausted","evidence":"usage_limit_reached"},"windows":[] + })) + .await; + let account = environment.account(None).await; + let (runtime, core) = environment + .provider( + json!({"maintenance":true,"quota_url":server.uri()}), + http_grants(), + ) + .await; + let (task, context) = task(&runtime, &core, "plugin-quota"); + let running = tokio::spawn(async move { task.run_cycle(context).await }); + server.received().await; + let service = CredentialsService::new( + runtime.admin_registry(core.snapshots()), + environment.store.admin_ports().accounts(), + environment.store.admin_ports().proxies(), + core.snapshot_control(), + ); + service + .for_provider(&ProviderKind::new("example").unwrap()) + .unwrap() + .rotate(RotateCredential { + mutation: CredentialMutation { + account_id: account.clone(), + context: mutation(), + }, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"key":"new-test-key"}).as_object().unwrap().clone(), + )), + settings: None, + }) + .await + .unwrap(); + server.respond().await; + // 旧修订的结果属于正常竞争;本周期结束,但不能把旧额度写到新凭据上。 + running.await.unwrap().unwrap(); + let accounts = environment.store.provider_ports().accounts(); + let current = accounts.load_current_credential(&account).await.unwrap(); + assert_eq!(current.account.revision().get(), 2); + assert_eq!( + current.credential.expose_to_provider()["key"], + "new-test-key" + ); + assert!(accounts.get_quotas(&[account]).await.unwrap().is_empty()); + drop(accounts); + drop(service); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn disabled_accounts_are_not_dispatched_from_an_already_loaded_page() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let mut server = crate::support::http::GatedResponse::start_batch( + json!({"access":{"kind":"allowed"},"windows":[]}), + 4, + ) + .await; + let mut account_ids = Vec::new(); + for _ in 0..8 { + account_ids.push(environment.account(None).await.as_str().to_owned()); + } + let marker = environment + .directory + .path() + .join("queued-maintenance.jsonl"); + let (runtime, core) = environment.provider( + json!({"maintenance":true,"quota_url":server.uri(),"maintenance_context_marker":marker}), + http_grants(), + ).await; + let (task, context) = task(&runtime, &core, "plugin-quota"); + let running = tokio::spawn(async move { task.run_cycle(context).await }); + server.received().await; + // 第一组已发出,剩余账号还在这一页的并发队列中;持久化停用事实先于快照通知。 + environment + .store + .admin_ports() + .accounts() + .batch_update_accounts( + gateway_admin::model::accounts::BatchUpdateAccounts { + account_ids, + enabled: Some(false), + concurrency_limit: None, + weight: None, + model_access: None, + group_ids: None, + outbound_proxy: None, + }, + &mutation(), + ) + .await + .unwrap(); + server.respond().await; + tokio::time::timeout(Duration::from_secs(5), running) + .await + .unwrap() + .unwrap() + .unwrap(); + let calls = std::fs::read_to_string(marker) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|entry| entry["method"] == "provider.quota" && entry["stage"] == "maintenance") + .count(); + assert_eq!(calls, 4, "已停用的排队账号不得收到新的插件调用"); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn maintenance_refresh_commits_due_credentials_and_preserves_admin_profile() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment.provider(json!({"maintenance":true,"expected_maintenance_methods":["provider.credentials.refresh"]}), vec![account_grant("accounts"), account_grant("accounts")]).await; + let service = CredentialsService::new( + runtime.admin_registry(core.snapshots()), + environment.store.admin_ports().accounts(), + environment.store.admin_ports().proxies(), + core.snapshot_control(), + ); + let kind = ProviderKind::new("example").unwrap(); + let now = chrono::Utc::now().timestamp_millis(); + let imported = service.for_provider(&kind).unwrap().import_document(ImportCredentials { + outbound_proxy_id: None, settings: None, context: mutation(), document: ProviderDocument::new(OpaqueProviderData::new(json!({"accounts":[ + {"name":"kept admin profile","authentication_kind":"oauth","material":{"key":"original-test-key"},"has_refresh_token":true,"access_token_expires_at_ms":now-1000,"next_refresh_at_ms":now-1000}, + {"name":"scheduled without expiry","authentication_kind":"oauth","material":{"key":"original-test-key"},"has_refresh_token":true,"next_refresh_at_ms":now-1000}, + {"name":"not due","authentication_kind":"oauth","material":{"key":"future-test-key"},"has_refresh_token":true,"access_token_expires_at_ms":now+86_400_000,"next_refresh_at_ms":now+86_400_000} + ]}).as_object().unwrap().clone())), + }).await.unwrap(); + assert_eq!(imported.credential_ids.len(), 3); + let (task, context) = task(&runtime, &core, "plugin-credentials"); + task.run_cycle(context).await.unwrap(); + let accounts = environment.store.provider_ports().accounts(); + for id in imported.credential_ids { + let current = accounts.load_current_credential(&id).await.unwrap(); + if matches!( + current.account.name(), + "kept admin profile" | "scheduled without expiry" + ) { + assert_eq!(current.account.revision().get(), 2); + assert_eq!( + current.credential.expose_to_provider()["key"], + "refreshed-test-key" + ); + } else { + assert_eq!(current.account.name(), "not due"); + assert_eq!(current.account.revision().get(), 1); + assert_eq!( + current.credential.expose_to_provider()["key"], + "future-test-key" + ); + } + } + drop(accounts); + drop(task); + drop(service); + drop(core); + drop(runtime); + environment.close().await; +} + +fn profiles(version: &str) -> Value { + json!({"default_configuration":{"channel":"stable"},"options":[{ + "id":"stable","label":"稳定版","configuration":{"channel":"stable"},"resolved":{"version":version}, + "presentation":{"product":"Maintenance Fixture","version":version,"target":{"os_type":"linux","os_version":"6","arch":"x86_64","terminal":"test"},"user_agent":format!("Fixture/{version}"),"attributes":[]} + }]}) +} + +#[tokio::test] +async fn profile_maintenance_requires_an_initial_stable_directory() { + use gateway_admin::{ + model::{AdminErrorKind, Revision}, + ports::plugins::PluginPreparation as _, + }; + + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let (runtime, core) = environment + .provider( + json!({"maintenance":true,"request_profiles":profiles("1"),"request_profile_refresh":{"sequence":2,"profiles":profiles("2")}}), + vec![account_grant("accounts")], + ) + .await; + let mut candidate = environment + .store + .admin_ports() + .plugins() + .load_instances() + .await + .unwrap(); + let source = candidate.config_revision; + candidate.config_revision = Revision::new(source.get() + 1).unwrap(); + candidate.instances[0].revision = candidate.config_revision; + candidate.instances[0] + .configuration + .as_object_mut() + .unwrap() + .remove("request_profiles"); + let error = runtime.prepare(source, candidate).await.unwrap_err(); + assert_eq!(error.kind(), AdminErrorKind::Invalid); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn model_maintenance_pages_all_provider_accounts_without_prebinding() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + for _ in 0..20 { + environment.account(None).await; + } + let extra_account = environment.account(None).await; + let marker = environment + .directory + .path() + .join("maintenance-context.jsonl"); + let credentials = account_grant("accounts"); + let (runtime, core) = environment.provider(json!({ + "maintenance":true,"maintenance_context_marker":marker, + "model_discovery":{"include_static":false,"cache_ttl_seconds":60}, + "discovered_catalog":{"models":[{"id":"discovered-model","operations":["generate"]}],"exhaustive":true} + }), vec![credentials]).await; + let (task, context) = task(&runtime, &core, "plugin-models"); + let calls = || { + std::fs::read_to_string(&marker) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|entry| entry["method"] == "provider.models" && entry["stage"] == "maintenance") + .collect::>() + }; + task.run_cycle(context.clone()).await.unwrap(); + assert_eq!(calls().len(), 16); + task.run_cycle(context).await.unwrap(); + let calls = calls(); + assert_eq!(calls.len(), 21); + let visited = calls + .iter() + .map(|entry| entry["account_id"].as_str().unwrap()) + .collect::>(); + assert_eq!(visited.len(), 21); + assert!(visited.contains(extra_account.as_str())); + for call in calls { + assert_eq!(call["credential_revision"], 1); + assert!(call["request_id"].as_str().unwrap().starts_with("worker:")); + } + drop(task); + drop(core); + drop(runtime); + environment.close().await; +} + +#[tokio::test] +async fn refreshed_profiles_are_monotonic_cached_and_do_not_change_frozen_requests() { + let Some(environment) = Environment::create().await else { + eprintln!("SKIP: plugin integration environment absent"); + return; + }; + let mut changed_choice = profiles("3"); + changed_choice["default_configuration"]["channel"] = json!("changed"); + changed_choice["options"][0]["configuration"]["channel"] = json!("changed"); + let mut oversized = profiles("3"); + oversized["options"][0]["resolved"]["large"] = json!("x".repeat(16 * 1024)); + let invalid = vec![ + json!({"sequence":2,"profiles":profiles("illegal-same-sequence")}), + json!({"sequence":3,"profiles":changed_choice}), + json!({"sequence":0,"profiles":profiles("3")}), + json!({"sequence":1_u64 << 53,"profiles":profiles("3")}), + json!({"sequence":3,"profiles":oversized}), + ]; + let invalid_count = invalid.len(); + let mut refreshes = vec![ + json!({"sequence":2,"profiles":profiles("2")}), + json!({"sequence":1,"profiles":profiles("1")}), + ]; + refreshes.extend(invalid); + let (runtime, core) = environment.provider(json!({"maintenance":true,"request_profiles":profiles("1"),"request_profile_refresh":{"sequence":2,"profiles":profiles("2")}, + "request_profile_refresh_responses":refreshes, + "expected_maintenance_methods":["provider.request_profiles.refresh"]}), vec![account_grant("accounts")]).await; + let kind = ProviderKind::new("example").unwrap(); + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + let choice = OpaqueProviderData::new(json!({"channel":"stable"}).as_object().unwrap().clone()); + let snapshot = core.snapshots().acquire().unwrap(); + let executors = runtime + .provider_registry() + .for_extensions(snapshot.extensions()) + .unwrap(); + let executor = executors.get(&kind).unwrap(); + let frozen = executor.resolve_request_profile(&choice).unwrap(); + let (task, context) = task(&runtime, &core, "plugin-request-profiles"); + task.run_cycle(context.clone()).await.unwrap(); + assert_eq!(frozen.expose_to_provider()["version"], "1"); + assert_eq!( + provider + .preview_client_profile(&choice) + .unwrap() + .expose_to_provider()["version"], + "2" + ); + assert_eq!(provider.default_client_profile(), Some(choice.clone())); + task.run_cycle(context.clone()).await.unwrap(); + assert_eq!( + executor + .resolve_request_profile(&choice) + .unwrap() + .expose_to_provider()["version"], + "2" + ); + for _ in 0..invalid_count { + assert!(task.run_cycle(context.clone()).await.is_err()); + assert_eq!( + executor + .resolve_request_profile(&choice) + .unwrap() + .expose_to_provider()["version"], + "2" + ); + } + task.run_cycle(context).await.unwrap(); + drop(executors); + drop(snapshot); + drop(task); + drop(provider); + drop(core); + drop(runtime); + let (runtime, core) = environment.runtime().await; + let provider = runtime + .admin_registry(core.snapshots()) + .require(&kind) + .unwrap(); + assert_eq!( + provider + .preview_client_profile(&choice) + .unwrap() + .expose_to_provider()["version"], + "2" + ); + drop(provider); + drop(core); + drop(runtime); + environment.close().await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/main.rs b/backend/crates/gateway-plugin/runtime/tests/main.rs new file mode 100644 index 000000000..ccbc3aafb --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/main.rs @@ -0,0 +1,7 @@ +mod adapter; +mod callback; +mod generation; +mod package; +#[cfg(unix)] +mod rpc; +mod support; diff --git a/backend/crates/gateway-plugin/runtime/tests/package/cache.rs b/backend/crates/gateway-plugin/runtime/tests/package/cache.rs new file mode 100644 index 000000000..0e6aee392 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/package/cache.rs @@ -0,0 +1,27 @@ +use std::sync::Arc; + +use gateway_plugin_runtime::{PackageLimits, ValidatedPackage}; + +#[test] +fn new_preparation_never_modifies_an_existing_version_and_cache_is_reclaimable() { + let cache = tempfile::tempdir().unwrap(); + let package = Arc::new( + ValidatedPackage::read( + crate::support::package(b"hello"), + None, + PackageLimits::default(), + ) + .unwrap(), + ); + let first = package + .prepare(cache.path(), &"1.0.0".parse().unwrap()) + .unwrap(); + let second = package + .prepare(cache.path(), &"1.0.0".parse().unwrap()) + .unwrap(); + assert_ne!(first.directory(), second.directory()); + let first_path = first.directory().to_owned(); + drop(first); + assert!(!first_path.exists()); + assert_eq!(std::fs::read(second.executable()).unwrap(), b"hello"); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs new file mode 100644 index 000000000..cdd08ab6b --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/package/compatibility.rs @@ -0,0 +1,125 @@ +use gateway_admin::{ + model::plugins::PluginHostCompatibility, ports::plugins::PluginPackageInspector as _, +}; +use gateway_plugin_runtime::{PackageInspector, PackageLimits}; +use gateway_plugin_sdk::{Capability, Contributions, Permission, Stage}; +use sha2::{Digest as _, Sha256}; + +#[test] +fn host_compatibility_covers_every_sdk_capability_and_permission() { + let compatibility: PluginHostCompatibility = + serde_json::from_str(include_str!("../../plugin-host-compatibility.json")) + .expect("compatibility JSON"); + assert!(compatibility.is_valid()); + + for capability in all_capabilities() { + assert_exhaustive_capability(capability); + let identifier = identifier(capability); + assert!( + compatibility.supports_capability(&identifier, 1), + "missing capability {identifier}" + ); + } + for permission in Permission::ALL { + assert_exhaustive_permission(permission); + let identifier = identifier(permission); + assert!( + compatibility.supports_permission(&identifier), + "missing permission {identifier}" + ); + } +} + +#[tokio::test] +async fn package_inspector_returns_static_requirements_without_starting_the_plugin() { + let archive = crate::support::package_with_contributions( + b"not-an-executable", + vec![], + Contributions::from([crate::support::contribution( + Capability::Executor, + vec![Stage::Execution], + vec!["openai".into()], + vec!["openai".into()], + )]), + ); + let digest = hex::encode(Sha256::digest(archive.as_ref())); + let inspector = PackageInspector::new(PackageLimits::default(), semver::Version::new(3, 13, 0)); + + let requirements = inspector + .compatibility(archive, digest) + .await + .expect("compatibility requirements"); + assert_eq!(requirements.host_version, ">=1.0.0, <2.0.0"); + assert_eq!( + requirements.manifest_schema_version, + gateway_plugin_sdk::MANIFEST_VERSION + ); + assert_eq!( + requirements.protocol_version, + gateway_plugin_sdk::PROTOCOL_VERSION + ); + assert_eq!(requirements.capabilities, vec![("executor".into(), 1)]); + assert!(requirements.permissions.is_empty()); +} + +fn identifier(value: impl serde::Serialize) -> String { + serde_json::to_value(value) + .expect("serialize identifier") + .as_str() + .expect("string identifier") + .to_owned() +} + +fn assert_exhaustive_capability(capability: Capability) { + match capability { + Capability::Models + | Capability::Authentication + | Capability::FrontendAuthentication + | Capability::Scheduler + | Capability::ModelRouter + | Capability::Executor + | Capability::Middleware + | Capability::RequestLifecycle + | Capability::WebSocketObserver + | Capability::Usage + | Capability::CommandLine + | Capability::Management + | Capability::Quota + | Capability::RequestProfile + | Capability::AccountManagement + | Capability::Billing + | Capability::Maintenance => {} + } +} + +fn assert_exhaustive_permission(permission: Permission) { + match permission { + Permission::Network + | Permission::Requests + | Permission::Models + | Permission::Accounts + | Permission::PublicEndpoints => {} + } +} + +fn all_capabilities() -> [Capability; 17] { + [ + Capability::Models, + Capability::Authentication, + Capability::FrontendAuthentication, + Capability::Scheduler, + Capability::ModelRouter, + Capability::Executor, + Capability::Middleware, + Capability::RequestLifecycle, + Capability::WebSocketObserver, + Capability::Usage, + Capability::CommandLine, + Capability::Management, + Capability::Quota, + Capability::RequestProfile, + Capability::AccountManagement, + Capability::Billing, + Capability::Maintenance, + ] +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs b/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs new file mode 100644 index 000000000..52ab31669 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/package/inspection.rs @@ -0,0 +1,18 @@ +use gateway_admin::ports::plugins::PluginPackageInspector; +use gateway_plugin_runtime::{PackageInspector, PackageLimits}; +use gateway_plugin_sdk::Permission; + +#[test] +fn permission_presentations_come_from_the_current_sdk_without_an_archive() { + let inspector = PackageInspector::new(PackageLimits::default(), "3.14.0".parse().unwrap()); + let permissions = vec!["models".to_owned(), "future_permission".to_owned()]; + let descriptions = inspector.permission_descriptions(&permissions); + assert_eq!(descriptions[0].permission, "models"); + assert_eq!(descriptions[0].label, Permission::Models.label()); + assert_eq!( + descriptions[0].description, + Permission::Models.description() + ); + assert_eq!(descriptions[1].label, "future_permission"); + assert!(descriptions[1].description.is_empty()); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/package/mod.rs b/backend/crates/gateway-plugin/runtime/tests/package/mod.rs new file mode 100644 index 000000000..aabaed817 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/package/mod.rs @@ -0,0 +1,4 @@ +mod cache; +mod compatibility; +mod inspection; +mod validation; diff --git a/backend/crates/gateway-plugin/runtime/tests/package/validation.rs b/backend/crates/gateway-plugin/runtime/tests/package/validation.rs new file mode 100644 index 000000000..7c992c960 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/package/validation.rs @@ -0,0 +1,356 @@ +use std::collections::BTreeMap; + +use gateway_admin::{ + model::plugins::{PluginArtifactIcon, PluginIconTheme}, + ports::plugins::PluginPackageInspector as _, +}; +use gateway_plugin_runtime::{PackageError, PackageInspector, PackageLimits, ValidatedPackage}; +use image::{ + DynamicImage, ExtendedColorType, Frame, ImageEncoder as _, ImageFormat, RgbaImage, + codecs::{gif::GifEncoder, png::PngEncoder}, +}; +use sha2::{Digest as _, Sha256}; + +fn png(width: u32, height: u32) -> Vec { + let mut encoded = Vec::new(); + PngEncoder::new(&mut encoded) + .write_image( + &vec![0; width as usize * height as usize * 4], + width, + height, + ExtendedColorType::Rgba8, + ) + .unwrap(); + encoded +} + +fn raster(format: ImageFormat) -> Vec { + let mut output = std::io::Cursor::new(Vec::new()); + let image = if format == ImageFormat::Jpeg { + DynamicImage::new_rgb8(2, 3) + } else { + DynamicImage::new_rgba8(2, 3) + }; + image.write_to(&mut output, format).unwrap(); + output.into_inner() +} + +fn gif(frames: usize) -> Vec { + let mut output = Vec::new(); + { + let mut encoder = GifEncoder::new(&mut output); + for _ in 0..frames { + encoder + .encode_frame(Frame::new(RgbaImage::new(2, 3))) + .unwrap(); + } + } + output +} + +fn package_with_icon(path: &str, content_type: &str, icon: Vec) -> std::sync::Arc<[u8]> { + let worker = b"worker".to_vec(); + let package_files = BTreeMap::from([ + ( + "bin/worker".to_owned(), + hex::encode(Sha256::digest(&worker)), + ), + (path.to_owned(), hex::encode(Sha256::digest(&icon))), + ]); + let resources = BTreeMap::from([(path.to_owned(), content_type.to_owned())]); + let manifest = serde_json::json!({ + "manifestVersion": 3, + "name": "icon", + "displayName": "Icon", + "publisher": "test", + "version": "1.0.0", + "description": "Icon fixture", + "license": "MIT", + "engines": {"codex-proxy-rs": ">=1.0.0, <2.0.0"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": {}, + "resources": resources, + "icon": path, + "package": { + "protocolVersion": 4, + "target": { + "os": std::env::consts::OS, + "architecture": std::env::consts::ARCH + }, + "files": package_files + } + }); + crate::support::archive(BTreeMap::from([ + ("plugin.json".into(), serde_json::to_vec(&manifest).unwrap()), + ("bin/worker".into(), worker), + (path.to_owned(), icon), + ])) +} + +#[test] +fn expected_digest_is_checked_before_archive_parsing() { + assert!(matches!( + ValidatedPackage::read( + b"invalid".as_slice().into(), + Some(&"0".repeat(64)), + PackageLimits::default() + ), + Err(PackageError::Digest) + )); +} + +#[test] +fn unlisted_content_is_rejected() { + let bytes = crate::support::archive(BTreeMap::from([("surprise".into(), b"code".to_vec())])); + assert!(matches!( + ValidatedPackage::read(bytes, None, PackageLimits::default()), + Err(PackageError::Archive) + )); +} + +#[test] +fn decompression_limit_includes_archive_overhead() { + let bytes = crate::support::package(&[0; 4096]); + assert!(matches!( + ValidatedPackage::read( + bytes, + None, + PackageLimits { + expanded_bytes: 1024, + ..PackageLimits::default() + } + ), + Err(PackageError::Limit) + )); +} + +#[test] +fn package_retains_the_exact_verified_archive_for_recovery() { + let bytes = crate::support::package(b"hello"); + let package = ValidatedPackage::read(bytes.clone(), None, PackageLimits::default()).unwrap(); + assert_eq!(package.archive(), bytes); + assert_eq!(package.manifest().plugin_id().unwrap(), "test.example"); +} + +#[tokio::test] +async fn validated_icon_is_exposed_from_the_digest_bound_archive() { + let bytes = package_with_icon("assets/icon.png", "image/png", png(2, 3)); + let digest = hex::encode(Sha256::digest(&bytes)); + let inspector = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()); + let artifact = inspector + .inspect(bytes, Some(digest.clone())) + .await + .unwrap(); + assert_eq!( + artifact.metadata.icon, + Some(PluginArtifactIcon::Path("assets/icon.png".into())) + ); + let icon = inspector + .icon(artifact.archive, digest, PluginIconTheme::Dark) + .await + .unwrap() + .unwrap(); + assert_eq!(icon.content_type, "image/png"); + assert!(!icon.body.is_empty()); +} + +#[tokio::test] +async fn common_icon_formats_are_verified_and_returned_without_transcoding() { + for (path, mime, bytes) in [ + ("assets/icon.PNG", "image/png", png(2, 3)), + ("assets/icon.jpeg", "image/jpeg", raster(ImageFormat::Jpeg)), + ("assets/icon.jfif", "image/jpeg", raster(ImageFormat::Jpeg)), + ("assets/icon.WebP", "image/webp", raster(ImageFormat::WebP)), + ("assets/icon.gif", "image/gif", gif(2)), + ("assets/icon.ico", "image/x-icon", raster(ImageFormat::Ico)), + ( + "assets/icon.ICO", + "image/vnd.microsoft.icon", + raster(ImageFormat::Ico), + ), + ("assets/icon.bmp", "image/bmp", raster(ImageFormat::Bmp)), + ( + "assets/icon.BMP", + "image/x-ms-bmp", + raster(ImageFormat::Bmp), + ), + ( + "assets/icon.SVG", + "image/svg+xml", + br##" + + + + + + "## + .to_vec(), + ), + ] { + let archive = package_with_icon(path, mime, bytes.clone()); + let digest = hex::encode(Sha256::digest(&archive)); + let inspector = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()); + let artifact = inspector + .inspect(archive, Some(digest.clone())) + .await + .unwrap_or_else(|error| panic!("{path}: {error:?}")); + for theme in [PluginIconTheme::Light, PluginIconTheme::Dark] { + let icon = inspector + .icon(artifact.archive.clone(), digest.clone(), theme) + .await + .unwrap() + .unwrap(); + assert_eq!(icon.content_type, mime, "{path}"); + assert_eq!(icon.body, bytes, "{path}"); + } + } +} + +#[test] +fn svg_icons_reject_malformed_xml_non_svg_roots_and_external_entities() { + for bytes in [ + b"not an image".as_slice(), + b"", + b"", + b"", + br#"]> + &external;"#, + br#" + "#, + ] { + let archive = package_with_icon("assets/icon.svg", "image/svg+xml", bytes.to_vec()); + assert_eq!( + ValidatedPackage::read(archive, None, PackageLimits::default()).err(), + Some(PackageError::Archive), + "{}", + String::from_utf8_lossy(bytes) + ); + } +} + +#[test] +fn icon_validation_keeps_byte_node_and_animation_budgets() { + let large_svg = format!( + "", + "x".repeat(512 * 1024) + ); + let many_nodes = format!( + "{}", + "".repeat(16_384) + ); + for (path, mime, bytes) in [ + ("assets/icon.svg", "image/svg+xml", large_svg.into_bytes()), + ("assets/icon.svg", "image/svg+xml", many_nodes.into_bytes()), + ("assets/icon.gif", "image/gif", gif(257)), + ] { + let archive = package_with_icon(path, mime, bytes); + assert_eq!( + ValidatedPackage::read(archive, None, PackageLimits::default()).err(), + Some(PackageError::Limit), + "{path}" + ); + } +} + +#[test] +fn icon_content_must_fully_decode_match_its_type_and_fit_dimensions() { + let valid_png = png(2, 3); + let wrong_type = package_with_icon("assets/icon.jpg", "image/jpeg", valid_png.clone()); + assert_eq!( + ValidatedPackage::read(wrong_type, None, PackageLimits::default()).err(), + Some(PackageError::Archive) + ); + + let truncated = package_with_icon("assets/icon.png", "image/png", valid_png[..32].to_vec()); + assert_eq!( + ValidatedPackage::read(truncated, None, PackageLimits::default()).err(), + Some(PackageError::Archive) + ); + + let oversized = package_with_icon("assets/icon.png", "image/png", png(4097, 1)); + assert_eq!( + ValidatedPackage::read(oversized, None, PackageLimits::default()).err(), + Some(PackageError::Limit) + ); +} + +#[test] +fn obsolete_manifest_fields_are_rejected_without_aliases() { + let manifest = serde_json::json!({ + "manifestVersion": 3, + "name": "example", + "displayName": "Example", + "publisher": "test", + "version": "1.0.0", + "description": "Example", + "license": "MIT", + "engines": {"codex-proxy-rs": ">=1.0.0, <2.0.0"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "capabilities": [{"capability":"request_normalizer"}] + }); + let archive = crate::support::archive(BTreeMap::from([( + "plugin.json".to_owned(), + serde_json::to_vec(&manifest).unwrap(), + )])); + assert_eq!( + ValidatedPackage::read(archive, None, PackageLimits::default()).err(), + Some(PackageError::Archive) + ); +} + +#[test] +fn source_manifest_cannot_be_installed_without_package_metadata() { + let manifest = serde_json::json!({ + "manifestVersion": 3, + "name": "example", + "displayName": "Example", + "publisher": "test", + "version": "1.0.0", + "description": "Example", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": {} + }); + let archive = crate::support::archive(BTreeMap::from([( + "plugin.json".to_owned(), + serde_json::to_vec(&manifest).unwrap(), + )])); + assert_eq!( + ValidatedPackage::read(archive, None, PackageLimits::default()).err(), + Some(PackageError::Manifest( + gateway_plugin_sdk::ManifestError::Invalid + )) + ); +} + +#[test] +fn duplicate_contribution_keys_are_rejected_before_map_overwrite() { + let manifest = br#"{ + "manifestVersion":3, + "name":"example", + "displayName":"Example", + "publisher":"test", + "version":"1.0.0", + "description":"Example", + "license":"MIT", + "engines":{"codex-proxy-rs":"*"}, + "main":"bin/worker", + "runtime":"trustedProcess", + "contributes":{ + "middleware":{"id":"test.example.first","version":1,"stages":["request"]}, + "middleware":{"id":"test.example.second","version":1,"stages":["request"]} + } + }"#; + let archive = crate::support::archive(BTreeMap::from([( + "plugin.json".to_owned(), + manifest.to_vec(), + )])); + assert_eq!( + ValidatedPackage::read(archive, None, PackageLimits::default()).err(), + Some(PackageError::Archive) + ); +} diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs new file mode 100644 index 000000000..605d3df60 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/dispatch.rs @@ -0,0 +1,238 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use futures::future::BoxFuture; +use gateway_plugin_runtime::{ + CallbackHandler, PackageLimits, RpcError, RpcLimits, RpcReply, RpcSession, ValidatedPackage, +}; +use gateway_plugin_sdk::{CallContext, ErrorCode, Handshake, Permission, PluginFault, Stage}; +use serde_json::json; + +const CALLBACK_PERMISSIONS: [(&str, Permission); 8] = [ + ("host.http.do", Permission::Network), + ("host.model.execute", Permission::Models), + ("host.auth.list", Permission::Accounts), + ("host.auth.get", Permission::Accounts), + ("host.auth.save", Permission::Accounts), + ("host.affinity.lookup", Permission::Requests), + ("host.models.list", Permission::Models), + ("host.keys.list", Permission::Models), +]; + +#[derive(Default)] +struct Callbacks { + called: AtomicUsize, +} + +impl CallbackHandler for Callbacks { + fn call( + &self, + _context: CallContext, + _method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result> { + self.called.fetch_add(1, Ordering::Relaxed); + Box::pin(async move { + Ok(RpcReply { + result: params, + payload, + }) + }) + } +} + +async fn session(callbacks: Arc) -> (tempfile::TempDir, Arc) { + session_with_permissions(callbacks, Permission::ALL.to_vec()).await +} + +async fn session_with_permissions( + callbacks: Arc, + permissions: Vec, +) -> (tempfile::TempDir, Arc) { + let cache = tempfile::tempdir().unwrap(); + let package = Arc::new( + ValidatedPackage::read( + crate::support::package_with_permissions(crate::support::worker(), permissions.clone()), + None, + PackageLimits::default(), + ) + .unwrap(), + ); + let prepared = Arc::new( + package + .prepare(cache.path(), &"1.0.0".parse().unwrap()) + .unwrap(), + ); + let handshake = Handshake { + protocol_version: gateway_plugin_sdk::PROTOCOL_VERSION, + artifact_sha256: package.digest().into(), + plugin_id: package.manifest().plugin_id().unwrap(), + instance_id: "test-instance".into(), + generation: 1, + incarnation: uuid::Uuid::new_v4().to_string(), + configuration: json!({}), + contributes: gateway_plugin_sdk::Contributions::new(), + permissions, + }; + let processes = + gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(128).unwrap()); + let session = RpcSession::start( + prepared, + handshake, + RpcLimits::default(), + &processes, + callbacks, + ) + .await + .unwrap(); + (cache, Arc::new(session)) +} + +async fn invoke_callback( + session: &RpcSession, + stage: Stage, + method: &str, +) -> Result { + session + .call( + "callback_method", + session.context(stage, Duration::from_secs(2)), + json!({"method": method}), + vec![], + ) + .await +} + +fn assert_permission_denied(reply: Result) { + assert!(matches!( + reply, + Err(RpcError::Remote(PluginFault { + code: ErrorCode::PermissionDenied, + .. + })) + )); +} + +#[tokio::test] +async fn public_management_cannot_inherit_any_granted_host_callback_permission() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(Arc::clone(&callbacks)).await; + for (method, _) in CALLBACK_PERMISSIONS { + assert_permission_denied(invoke_callback(&session, Stage::PublicManagement, method).await); + } + for method in ["host.log", "host.state.get"] { + assert_permission_denied(invoke_callback(&session, Stage::PublicManagement, method).await); + } + assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn registration_and_configuration_reject_network_and_model_callbacks() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(Arc::clone(&callbacks)).await; + + for stage in [Stage::Registration, Stage::Configuration] { + assert_permission_denied(invoke_callback(&session, stage, "host.http.do").await); + assert_permission_denied(invoke_callback(&session, stage, "host.model.execute").await); + } + assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); + + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn active_stages_use_resource_domains_without_operation_whitelists() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(Arc::clone(&callbacks)).await; + + for stage in [ + Stage::Routing, + Stage::Scheduling, + Stage::Request, + Stage::Attempt, + Stage::Observation, + Stage::Authentication, + Stage::Execution, + Stage::Management, + Stage::Maintenance, + Stage::CommandLine, + ] { + for (method, _) in CALLBACK_PERMISSIONS { + assert!( + invoke_callback(&session, stage, method).await.is_ok(), + "{method} domain should be available during {stage:?}" + ); + } + } + assert_eq!( + callbacks.called.load(Ordering::Relaxed), + 10 * CALLBACK_PERMISSIONS.len() + ); + + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn missing_domains_are_denied_before_dispatch() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), vec![]).await; + + for (method, _) in CALLBACK_PERMISSIONS { + assert_permission_denied(invoke_callback(&session, Stage::Observation, method).await); + } + assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); + + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn logs_and_own_state_are_infrastructure_without_permissions() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session_with_permissions(Arc::clone(&callbacks), vec![]).await; + + for stage in [ + Stage::Registration, + Stage::Configuration, + Stage::Observation, + Stage::Management, + Stage::Authentication, + ] { + assert!(invoke_callback(&session, stage, "host.log").await.is_ok()); + if matches!(stage, Stage::Registration | Stage::Configuration) { + assert_permission_denied(invoke_callback(&session, stage, "host.state.get").await); + } else { + assert!( + invoke_callback(&session, stage, "host.state.get") + .await + .is_ok() + ); + } + } + assert_eq!(callbacks.called.load(Ordering::Relaxed), 8); + + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn execution_allows_each_declared_callback_permission() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(Arc::clone(&callbacks)).await; + + for (method, _) in CALLBACK_PERMISSIONS { + let reply = invoke_callback(&session, Stage::Execution, method).await; + assert!(reply.is_ok(), "{method} should be allowed during execution"); + } + assert_eq!( + callbacks.called.load(Ordering::Relaxed), + CALLBACK_PERMISSIONS.len() + ); + + session.shutdown(Duration::from_secs(1)).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs new file mode 100644 index 000000000..b1f386708 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/mod.rs @@ -0,0 +1,2 @@ +mod dispatch; +mod session; diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs new file mode 100644 index 000000000..f58e5e53b --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs @@ -0,0 +1,648 @@ +use std::{ + sync::{ + Arc, Condvar, Mutex, Weak, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use futures::future::BoxFuture; +use gateway_plugin_runtime::{ + CallbackHandler, PackageLimits, RpcError, RpcLimits, RpcReply, RpcSession, ValidatedPackage, +}; +use gateway_plugin_sdk::{CallContext, ErrorCode, Handshake, Permission, PluginFault, Stage}; +use serde_json::json; +use tokio::sync::{Barrier, Notify}; + +#[derive(Default)] +struct Callbacks { + called: AtomicUsize, + nested: Mutex>>, +} + +#[derive(Default)] +struct BlockingCallbacks { + started: Arc, + dropped: Arc, +} + +#[derive(Default)] +struct LifecycleCallbacks { + begun: AtomicUsize, + finished: AtomicUsize, +} + +struct CallbackDrop(Arc); + +#[derive(Default)] +struct GatedFirstBegin { + entered: Notify, + released: Mutex, + release: Condvar, +} + +impl GatedFirstBegin { + fn release(&self) { + *self.released.lock().unwrap() = true; + self.release.notify_one(); + } +} + +impl Drop for CallbackDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +impl CallbackHandler for BlockingCallbacks { + fn call( + &self, + _context: CallContext, + _method: String, + _params: serde_json::Value, + _payload: Vec, + ) -> BoxFuture<'static, Result> { + let started = Arc::clone(&self.started); + let dropped = Arc::clone(&self.dropped); + Box::pin(async move { + let _drop = CallbackDrop(dropped); + started.notify_one(); + futures::future::pending().await + }) + } +} + +impl CallbackHandler for LifecycleCallbacks { + fn begin(&self, _context: &CallContext) { + self.begun.fetch_add(1, Ordering::Relaxed); + } + + fn finish(&self, _context: &CallContext) { + self.finished.fetch_add(1, Ordering::Relaxed); + } + + fn call( + &self, + _context: CallContext, + _method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + Ok(RpcReply { + result: params, + payload, + }) + }) + } +} + +impl CallbackHandler for GatedFirstBegin { + fn begin(&self, context: &CallContext) { + if context.call_id == 1 { + self.entered.notify_one(); + let released = self.released.lock().unwrap(); + drop( + self.release + .wait_while(released, |released| !*released) + .unwrap(), + ); + } + } + + fn call( + &self, + _context: CallContext, + _method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(async move { + Ok(RpcReply { + result: params, + payload, + }) + }) + } +} + +impl CallbackHandler for Callbacks { + fn call( + &self, + context: CallContext, + _method: String, + params: serde_json::Value, + payload: Vec, + ) -> BoxFuture<'static, Result> { + self.called.fetch_add(1, Ordering::Relaxed); + let nested = self.nested.lock().unwrap().as_ref().and_then(Weak::upgrade); + Box::pin(async move { + if let Some(nested) = nested { + nested + .call("echo", context, params, payload) + .await + .map_err(|_| PluginFault::new(ErrorCode::Fault, "nested call failed")) + } else { + Ok(RpcReply { + result: params, + payload, + }) + } + }) + } +} + +async fn session( + permissions: Vec, + callbacks: Arc, +) -> (tempfile::TempDir, Arc) +where + C: CallbackHandler + 'static, +{ + let cache = tempfile::tempdir().unwrap(); + let package = Arc::new( + ValidatedPackage::read( + crate::support::package_with_permissions(crate::support::worker(), permissions.clone()), + None, + PackageLimits::default(), + ) + .unwrap(), + ); + let prepared = Arc::new( + package + .prepare(cache.path(), &"1.0.0".parse().unwrap()) + .unwrap(), + ); + let handshake = Handshake { + protocol_version: gateway_plugin_sdk::PROTOCOL_VERSION, + artifact_sha256: package.digest().into(), + plugin_id: package.manifest().plugin_id().unwrap(), + instance_id: "test-instance".into(), + generation: 1, + incarnation: uuid::Uuid::new_v4().to_string(), + configuration: json!({}), + contributes: gateway_plugin_sdk::Contributions::new(), + permissions, + }; + let processes = + gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(128).unwrap()); + let session = RpcSession::start( + prepared, + handshake, + RpcLimits::default(), + &processes, + callbacks, + ) + .await + .unwrap(); + (cache, Arc::new(session)) +} + +#[tokio::test] +async fn shutdown_returns_after_in_flight_callback_future_is_dropped() { + let callbacks = Arc::new(BlockingCallbacks::default()); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let callback_started = callbacks.started.notified(); + let calling = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .call( + "callback", + session.context(Stage::Management, Duration::from_secs(30)), + json!({}), + vec![], + ) + .await + }) + }; + callback_started.await; + + session.quiesce(); + tokio::time::timeout( + Duration::from_secs(2), + session.shutdown(Duration::from_secs(1)), + ) + .await + .expect("session shutdown"); + assert!(callbacks.dropped.load(Ordering::Acquire)); + assert!(matches!(calling.await.unwrap(), Err(RpcError::Closed))); +} + +#[tokio::test] +async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + assert_eq!( + session + .context(Stage::Registration, Duration::MAX) + .timeout_ms, + u64::try_from(RpcLimits::default().maximum_call_timeout.as_millis()).unwrap() + ); + *callbacks.nested.lock().unwrap() = Some(Arc::downgrade(&session)); + let context = session.context(Stage::Management, Duration::from_secs(2)); + let reply = session + .call( + "callback", + context, + json!({"hello": "世界"}), + vec![0, 255, 128], + ) + .await + .unwrap(); + assert_eq!(reply.result, json!({"hello": "世界"})); + assert_eq!(reply.payload, [0, 255, 128]); + assert_eq!(callbacks.called.load(Ordering::Relaxed), 1); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn slow_management_call_does_not_block_an_independent_call() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let context = session.context(Stage::Management, Duration::from_secs(2)); + let slow_session = Arc::clone(&session); + let slow = + tokio::spawn(async move { slow_session.call("slow", context, json!({}), vec![]).await }); + tokio::time::sleep(Duration::from_millis(40)).await; + let fast = session.call( + "echo", + session.context(Stage::Execution, Duration::from_millis(250)), + json!({}), + vec![], + ); + assert!(fast.await.is_ok()); + assert!(!slow.is_finished()); + assert!(slow.await.unwrap().is_ok()); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_calls_keep_their_wire_ids_monotonic() { + let callbacks = Arc::new(GatedFirstBegin::default()); + let entered = callbacks.entered.notified(); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let ready = Arc::new(Barrier::new(17)); + let mut calls = Vec::with_capacity(16); + for index in 0..16 { + let session = Arc::clone(&session); + let ready = Arc::clone(&ready); + calls.push(tokio::spawn(async move { + ready.wait().await; + if index % 2 == 0 { + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(5)), + json!({"index": index}), + vec![], + ) + .await?; + } else { + let mut stream = session + .call_stream( + "stream", + session.context(Stage::Execution, Duration::from_secs(5)), + json!({"index": index}), + vec![], + ) + .await?; + while stream.next().await?.is_some() {} + } + Ok::<(), RpcError>(()) + })); + } + ready.wait().await; + entered.await; + // 旧实现会在 begin 前分配后续 ID;等其他线程进入该窗口后再释放首调用。 + tokio::time::sleep(Duration::from_millis(20)).await; + callbacks.release(); + for call in calls { + assert!(call.await.unwrap().is_ok()); + } + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn cancelling_a_call_waiting_for_send_order_does_not_block_following_calls() { + let callbacks = Arc::new(GatedFirstBegin::default()); + let entered = callbacks.entered.notified(); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let first = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(2)), + json!("first"), + vec![], + ) + .await + }) + }; + entered.await; + + let queued = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(2)), + json!("cancelled"), + vec![], + ) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(20)).await; + queued.abort(); + assert!(matches!(queued.await, Err(error) if error.is_cancelled())); + + let following = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(2)), + json!("following"), + vec![], + ) + .await + }) + }; + callbacks.release(); + assert_eq!(first.await.unwrap().unwrap().result, json!("first")); + assert_eq!(following.await.unwrap().unwrap().result, json!("following")); + assert!(session.is_ready()); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn waiting_for_send_order_consumes_the_original_call_deadline() { + let callbacks = Arc::new(GatedFirstBegin::default()); + let entered = callbacks.entered.notified(); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let first = { + let session = Arc::clone(&session); + tokio::spawn(async move { + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(2)), + json!("first"), + vec![], + ) + .await + }) + }; + entered.await; + + let waiting = session + .call( + "echo", + session.context(Stage::Execution, Duration::from_millis(30)), + json!("expired"), + vec![], + ) + .await; + assert!(matches!(waiting, Err(RpcError::Timeout))); + + callbacks.release(); + assert!(first.await.unwrap().is_ok()); + assert!( + session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(1)), + json!("following"), + vec![], + ) + .await + .is_ok() + ); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn undeclared_callback_permission_never_reaches_the_host_handler() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let reply = session + .call( + "callback_method", + session.context(Stage::Management, Duration::from_secs(2)), + json!({"method":"host.http.do"}), + vec![], + ) + .await; + assert!(matches!( + reply, + Err(RpcError::Remote(PluginFault { + code: ErrorCode::PermissionDenied, + .. + })) + )); + assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn unknown_parent_cannot_borrow_another_calls_permissions() { + let callbacks = Arc::new(Callbacks::default()); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let reply = session + .call( + "forged_callback", + session.context(Stage::Management, Duration::from_secs(2)), + json!({}), + vec![], + ) + .await; + assert!(matches!(reply, Err(RpcError::Protocol))); + assert_eq!(callbacks.called.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn explicit_policy_denial_keeps_its_error_kind_and_status() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let reply = session + .call( + "deny", + session.context(Stage::Request, Duration::from_secs(2)), + json!({}), + vec![], + ) + .await; + assert!(matches!( + reply, + Err(RpcError::Remote(PluginFault { + code: ErrorCode::Rejected, + http_status: Some(403), + .. + })) + )); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn ignored_cancellation_closes_the_incarnation_and_settles_other_calls() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let first = session.call( + "hang_uncancellable", + session.context(Stage::Execution, Duration::from_millis(100)), + json!({}), + vec![], + ); + let second = session.call( + "hang", + session.context(Stage::Execution, Duration::from_secs(2)), + json!({}), + vec![], + ); + let (first, second) = tokio::join!(first, second); + assert!(matches!(first, Err(RpcError::Timeout))); + assert!(matches!(second, Err(RpcError::Timeout))); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn child_crash_fails_the_call_without_waiting_for_its_deadline() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let call = session.call( + "crash", + session.context(Stage::Execution, Duration::from_secs(30)), + json!({}), + vec![], + ); + let result = tokio::time::timeout(Duration::from_secs(2), call) + .await + .unwrap(); + assert!(matches!(result, Err(RpcError::Closed))); +} + +#[tokio::test] +async fn malformed_worker_frames_fail_boundedly_and_release_call_resources() { + for method in ["malformed_truncated_frame", "malformed_frame_length"] { + let callbacks = Arc::new(LifecycleCallbacks::default()); + let (_cache, session) = session(vec![], Arc::clone(&callbacks)).await; + let result = tokio::time::timeout( + Duration::from_secs(2), + session.call( + method, + session.context(Stage::Execution, Duration::from_secs(30)), + json!({}), + vec![], + ), + ) + .await + .expect("malformed worker output must fail within the process boundary"); + assert!(matches!(result, Err(RpcError::Closed))); + assert!(!session.is_ready()); + assert_eq!(callbacks.begun.load(Ordering::Relaxed), 1); + assert_eq!(callbacks.finished.load(Ordering::Relaxed), 1); + session.shutdown(Duration::from_secs(1)).await; + } +} + +#[tokio::test] +async fn slow_stream_consumer_backpressures_the_child_without_blocking_control_calls() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let mut stream = session + .call_stream( + "stream", + session.context(Stage::Execution, Duration::from_secs(5)), + json!({}), + vec![], + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + // 消费者停止读取时对端会耗尽窗口;另一个请求仍能通过独立读写循环完成。 + let echo = session + .call( + "echo", + session.context(Stage::Management, Duration::from_millis(500)), + json!("ready"), + vec![], + ) + .await + .unwrap(); + assert_eq!(echo.result, json!("ready")); + let mut count = 0; + while let Some(chunk) = stream.next().await.unwrap() { + assert_eq!(chunk, vec![count as u8; 8192]); + count += 1; + } + assert_eq!(count, 128); + assert!(stream.next().await.unwrap().is_none()); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn stream_window_and_sequence_violations_fail_closed() { + for method in ["stream_overflow", "stream_sequence"] { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let mut stream = session + .call_stream( + method, + session.context(Stage::Execution, Duration::from_secs(2)), + json!({}), + vec![], + ) + .await + .unwrap(); + assert!(matches!(stream.next().await, Err(RpcError::Protocol))); + session.shutdown(Duration::from_secs(1)).await; + } +} + +#[tokio::test] +async fn dropping_a_backpressured_stream_cancels_only_that_call() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let stream = session + .call_stream( + "stream", + session.context(Stage::Execution, Duration::from_secs(5)), + json!({}), + vec![], + ) + .await + .unwrap(); + drop(stream); + let reply = session + .call( + "echo", + session.context(Stage::Execution, Duration::from_secs(1)), + json!({}), + vec![], + ) + .await; + assert!(reply.is_ok()); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(session.is_ready()); + session.shutdown(Duration::from_secs(1)).await; +} + +#[tokio::test] +async fn acknowledged_timeout_does_not_interrupt_another_call() { + let (_cache, session) = session(vec![], Arc::new(Callbacks::default())).await; + let first = session.call( + "hang", + session.context(Stage::Management, Duration::from_millis(100)), + json!({}), + vec![], + ); + let second = session.call( + "slow", + session.context(Stage::Management, Duration::from_secs(2)), + json!("survived"), + vec![], + ); + let (first, second) = tokio::join!(first, second); + assert!(matches!(first, Err(RpcError::Timeout))); + assert_eq!(second.unwrap().result, json!("survived")); + assert!(session.is_ready()); + session.shutdown(Duration::from_secs(1)).await; +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/diagnostics.rs b/backend/crates/gateway-plugin/runtime/tests/support/diagnostics.rs new file mode 100644 index 000000000..8d05e7c76 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/diagnostics.rs @@ -0,0 +1,58 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + sync::{Arc, Mutex}, +}; + +#[derive(Clone, Default)] +pub struct Diagnostics(Arc>>>); + +impl Diagnostics { + pub fn install(&self) -> tracing::subscriber::DefaultGuard { + // 仅当前测试线程,不替换其他测试(尤其插件日志测试)的全局订阅者。 + tracing::subscriber::set_default(self.clone()) + } + + pub fn records(&self) -> Vec> { + self.0.lock().unwrap().iter().cloned().collect() + } +} + +struct Fields(BTreeMap); + +impl tracing::field::Visit for Fields { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().into(), format!("{value:?}")); + } +} + +impl tracing::Subscriber for Diagnostics { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_event() + && metadata.level() <= &tracing::Level::WARN + && metadata.target().starts_with("gateway_plugin_runtime::") + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + if !self.enabled(event.metadata()) { + return; + } + let mut fields = Fields(BTreeMap::new()); + event.record(&mut fields); + eprintln!("{}: {:?}", event.metadata().target(), fields.0); + let mut records = self.0.lock().unwrap(); + // 压测和故障场景只保留最近的诊断,不积累无界日志。 + if records.len() == 128 { + records.pop_front(); + } + records.push_back(fields.0); + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs new file mode 100644 index 000000000..b5912095e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -0,0 +1,1013 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use async_trait::async_trait; +use gateway_admin::{ + AdminConfig, ClientConfig, InitialAdminPassword, + model::{ + AdminError, MutationActor, MutationContext, + client_distribution::CodexDesktopWindowsDownloads, + plugins::{ + PluginSource, + distribution::{ + DownloadedPlugin, GithubReleaseQuery, PluginRelease, PluginUpdateSource, + RemotePluginLocation, SourceCredential, + }, + instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginFrontendIdentityBinding, + PluginInstance, PluginPermissionGrant, + }, + }, + pricing::PricingSyncPreview, + proxies::ProxyTestResult, + system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion}, + }, + ports::{ + client_distribution::ClientDistributionResolver, + plugin_accounts::PluginAccountAccess, + plugins::{PluginDistribution, PluginPackageInspector}, + pricing::PricingSource, + provider::ProviderAdminRegistry, + proxy::ProxyProbe, + system::{SystemOperationError, SystemOperations, SystemUpdateEventStream}, + }, +}; +use gateway_plugin_runtime::{ + ContinuationDrainConfig, PackageInspector, PackageLimits, PluginRuntime, PluginRuntimeConfig, + RpcLimits, +}; +use gateway_plugin_sdk::{Capability, Contributions, Stage}; +use gateway_store::{StoreBundle, StoreConfig}; +use serde_json::json; +use sqlx::postgres::PgPoolOptions; + +pub struct Environment { + pub store: StoreBundle, + pub directory: tempfile::TempDir, + admin: sqlx::PgPool, + schema: String, + plugin_accounts: std::sync::Mutex>>, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ModelRequestLedgerFact { + pub request_id: String, + pub provider_kind: Option, + pub request_kind: Option, + pub subagent_kind: Option, + pub outcome: String, + pub completed: bool, + pub cost_source: String, + pub cost_amount: Option, + pub charged_amount: Option, +} + +type ModelRequestLedgerRow = ( + String, + Option, + Option, + Option, + String, + bool, + String, + Option, + Option, +); + +impl Environment { + pub async fn client_key(&self, id: &str, plaintext: &str) { + self.client_key_with_limits(id, plaintext, gateway_core::policy::RateLimits::unlimited()) + .await; + } + + pub async fn client_key_with_limits( + &self, + id: &str, + plaintext: &str, + limits: gateway_core::policy::RateLimits, + ) { + self.client_key_with_limits_and_groups(id, plaintext, limits, Vec::new()) + .await; + } + + pub async fn client_key_with_limits_and_groups( + &self, + id: &str, + plaintext: &str, + limits: gateway_core::policy::RateLimits, + group_ids: Vec, + ) { + use gateway_admin::model::client_keys::NewClientKey; + use gateway_core::{engine::budget::ClientBudgetLimits, policy::ClientApiKeyId}; + + self.store + .admin_ports() + .client_keys() + .create_client_key( + NewClientKey { + request_profile_overrides: BTreeMap::new(), + id: ClientApiKeyId::new(id).unwrap(), + name: format!("fixture {id}"), + label: None, + group_ids, + limits, + budget: ClientBudgetLimits::default(), + plaintext: plaintext.to_owned(), + }, + &mutation(), + ) + .await + .unwrap(); + } + + pub async fn account_group_with_account( + &self, + account: &gateway_core::account::ProviderAccountId, + ) -> gateway_core::routing::AccountGroupId { + use gateway_admin::model::account_groups::{AccountGroupColor, NewAccountGroup}; + use gateway_core::routing::AccountGroupId; + + let id = AccountGroupId::new(format!("grp_{}", uuid::Uuid::new_v4().simple())).unwrap(); + self.store + .admin_ports() + .account_groups() + .create_account_group( + NewAccountGroup { + disable_fast: false, + id: id.clone(), + name: format!("fixture {}", id.as_str()), + description: None, + color: AccountGroupColor::parse("#2563EBFF").unwrap(), + }, + &mutation(), + ) + .await + .unwrap(); + sqlx::query(sqlx::AssertSqlSafe(format!( + "insert into {}.account_group_accounts + (account_group_id, provider_account_id, created_at) values ($1, $2, now())", + self.schema + ))) + .bind(id.as_str()) + .bind(account.as_str()) + .execute(&self.admin) + .await + .unwrap(); + id + } + + pub async fn set_account_group_enabled( + &self, + id: gateway_core::routing::AccountGroupId, + enabled: bool, + ) { + use gateway_admin::model::account_groups::SetAccountGroupEnabled; + + self.store + .admin_ports() + .account_groups() + .set_account_group_enabled(SetAccountGroupEnabled { id, enabled }, &mutation()) + .await + .unwrap(); + } + + pub async fn set_request_profile( + &self, + provider: &str, + profile: serde_json::Map, + ) -> u64 { + let revision = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!( + "update {}.runtime_settings + set provider_request_profiles_json = jsonb_set( + provider_request_profiles_json, array[$1], $2::jsonb + ), + config_revision = config_revision + 1, + updated_at = now() + where id = 1 + returning config_revision", + self.schema + ))) + .bind(provider) + .bind(sqlx::types::Json(profile)) + .fetch_one(&self.admin) + .await + .unwrap(); + u64::try_from(revision).unwrap() + } + + pub async fn provider( + &self, + configuration: serde_json::Value, + grants: Vec, + ) -> (Arc, gateway_core::CoreBundle) { + self.install_provider(configuration, grants).await; + self.runtime().await + } + + pub async fn install_provider( + &self, + configuration: serde_json::Value, + grants: Vec, + ) { + let plugin_id = configuration + .get("plugin_id") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + format!( + "test.{}", + configuration + .get("provider_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("example") + ) + }); + let permissions = grants + .iter() + .map(|grant| serde_json::from_value(json!(grant.permission)).unwrap()) + .collect(); + let mut contributes = Contributions::from([ + super::contribution_for_id( + &plugin_id, + Capability::Executor, + vec![Stage::Execution], + configuration + .get("input_formats") + .map(|value| serde_json::from_value(value.clone()).unwrap()) + .unwrap_or_else(|| vec!["openai".into()]), + configuration + .get("output_formats") + .map(|value| serde_json::from_value(value.clone()).unwrap()) + .unwrap_or_else(|| vec!["canonical".into()]), + ), + super::contribution_for_id( + &plugin_id, + Capability::Authentication, + Capability::Authentication.fixed_stages().to_vec(), + vec![], + vec![], + ), + ]); + if configuration.get("quota_url").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Quota, + Capability::Quota.fixed_stages().to_vec(), + vec![], + vec![], + )]); + } + let maintenance = configuration["maintenance"].as_bool().unwrap_or(false); + if maintenance { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Maintenance, + vec![Stage::Maintenance], + vec![], + vec![], + )]); + } + if configuration.get("request_profile_refresh").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::RequestProfile, + vec![Stage::Maintenance], + vec![], + vec![], + )]); + } + if configuration.get("account_operations").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::AccountManagement, + Capability::AccountManagement.fixed_stages().to_vec(), + vec![], + vec![], + )]); + } + if configuration.get("billing").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Billing, + vec![Stage::Execution], + vec![], + vec![], + )]); + } + if configuration.get("model_discovery").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Models, + Capability::Models.fixed_stages().to_vec(), + vec![], + vec![], + )]); + } + if configuration.get("command_registration").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::CommandLine, + vec![Stage::CommandLine], + vec![], + vec![], + )]); + } + if configuration.get("management_registration").is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Management, + vec![Stage::Management], + vec![], + vec![], + )]); + } + let has_middleware = configuration.get("middleware_marker").is_some(); + if has_middleware { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Middleware, + vec![Stage::Request], + vec!["openai".into()], + vec!["openai".into()], + )]); + } + let frontend_authentication = configuration + .get("frontend_authentication_result") + .is_some() + .then(|| { + ( + configuration["frontend_authentication_principal"] + .as_str() + .unwrap_or("fixture-principal") + .to_owned(), + configuration["frontend_authentication_client_key_id"] + .as_str() + .expect("frontend auth fixture client key ID") + .to_owned(), + configuration["frontend_authentication_exclusive"] + .as_bool() + .unwrap_or(false), + ) + }); + if frontend_authentication.is_some() { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::FrontendAuthentication, + vec![Stage::Authentication], + vec![], + vec![], + )]); + } + let has_model_router = configuration.get("route_decision").is_some() + || configuration.get("nested_model_fixture").is_some() + || configuration.get("affinity_fixture").is_some(); + if has_model_router { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::ModelRouter, + vec![Stage::Routing], + vec![], + vec![], + )]); + } + let has_scheduler = configuration.get("schedule_decision").is_some() + || configuration.get("schedule_pick_index").is_some() + || configuration.get("schedule_fault").is_some(); + if has_scheduler { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::Scheduler, + vec![Stage::Scheduling], + vec![], + vec![], + )]); + } + let archive = super::package_with_contributions_for_id( + super::worker(), + &plugin_id, + permissions, + contributes, + ); + let inspector = PackageInspector::new(PackageLimits::default(), "1.0.0".parse().unwrap()); + let artifact = inspector + .inspect(Arc::clone(&archive), None) + .await + .unwrap_or_else(|error| { + let validation = gateway_plugin_runtime::ValidatedPackage::read( + archive, + None, + PackageLimits::default(), + ) + .err(); + panic!("测试插件包校验失败:{error:?}; {validation:?}"); + }); + let store = self.store.admin_ports().plugins(); + let mutation = mutation(); + let installed = store + .install_artifact(artifact, PluginSource::Upload, &mutation) + .await + .unwrap(); + let installed = store + .accept_artifact(&installed.artifact.metadata.sha256, &mutation) + .await + .unwrap(); + let grants = installed + .artifact + .metadata + .requested_permissions + .iter() + .cloned() + .map(|permission| PluginPermissionGrant { permission }) + .collect(); + let instance = PluginInstance { + id: uuid::Uuid::new_v4().to_string(), + name: "provider".into(), + artifact_sha256: installed.artifact.metadata.sha256, + enabled: true, + trusted_process: true, + configuration, + secrets: BTreeMap::new(), + grants, + bindings: [ + has_model_router.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.modelRouter"), + stage: "routing".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }), + maintenance.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.maintenance"), + stage: "maintenance".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }), + has_scheduler.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.scheduler"), + stage: "scheduling".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }), + frontend_authentication + .as_ref() + .map(|(principal, key_id, exclusive)| PluginCapabilityBinding { + contribution: format!("{plugin_id}.frontendAuthentication"), + stage: "authentication".into(), + order: 0, + failure_policy: if *exclusive { + PluginFailurePolicy::Reject + } else { + PluginFailurePolicy::Delegate + }, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![PluginFrontendIdentityBinding { + principal: principal.clone(), + client_key_id: key_id.clone(), + }], + }), + has_middleware.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.middleware"), + stage: "request".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + }), + ] + .into_iter() + .flatten() + .collect(), + revision: installed.config_revision, + }; + store + .save_instance(instance, installed.config_revision, &mutation) + .await + .unwrap(); + } + + pub async fn runtime(&self) -> (Arc, gateway_core::CoreBundle) { + self.runtime_with_ports(self.store.provider_ports()).await + } + + pub async fn runtime_with_ports( + &self, + ports: gateway_core::provider_ports::ProviderStorePorts, + ) -> (Arc, gateway_core::CoreBundle) { + self.runtime_with_ports_and_drain(ports, ContinuationDrainConfig::default()) + .await + } + + pub async fn runtime_with_continuation_drain( + &self, + drain: ContinuationDrainConfig, + ) -> (Arc, gateway_core::CoreBundle) { + self.runtime_with_ports_and_drain(self.store.provider_ports(), drain) + .await + } + + async fn runtime_with_ports_and_drain( + &self, + ports: gateway_core::provider_ports::ProviderStorePorts, + continuation_drain: ContinuationDrainConfig, + ) -> (Arc, gateway_core::CoreBundle) { + let runtime = self.plugin_runtime(ports, continuation_drain); + let core = gateway_core::prepare( + self.store.core_ports(), + runtime.provider_registry(), + Some(runtime.clone()), + Some(runtime.observer_registry()), + Some(runtime.policy_registry()), + Some(runtime.middleware_registry()), + Some(runtime.frontend_authentication_registry()), + ); + let access = gateway_admin::initialize_plugin_accounts( + runtime.admin_registry(core.snapshots()), + self.store.admin_ports().accounts(), + core.snapshot_control(), + ); + runtime.bind_account_ports(&access).unwrap(); + self.plugin_accounts + .lock() + .unwrap() + .insert(Arc::as_ptr(&runtime) as usize, access); + let core = core.activate().await.unwrap(); + let nested_models = core.nested_model_execution_port(); + let affinity_lookup = core.affinity_lookup_port(); + runtime + .bind_model_ports(&nested_models, &affinity_lookup) + .unwrap(); + (runtime, core) + } + + pub async fn command_plane( + &self, + ) -> (Arc, gateway_core::CoreCommandPlaneBundle) { + let runtime = self.plugin_runtime( + self.store.provider_ports(), + ContinuationDrainConfig::default(), + ); + let core = gateway_core::prepare_command_plane( + self.store.core_ports(), + runtime.provider_registry(), + Some(runtime.clone()), + Some(runtime.observer_registry()), + Some(runtime.policy_registry()), + Some(runtime.middleware_registry()), + ) + .activate() + .await + .unwrap(); + runtime + .bind_model_ports( + &core.nested_model_execution_port(), + &core.affinity_lookup_port(), + ) + .unwrap(); + (runtime, core) + } + + fn plugin_runtime( + &self, + ports: gateway_core::provider_ports::ProviderStorePorts, + continuation_drain: ContinuationDrainConfig, + ) -> Arc { + Arc::new( + PluginRuntime::new( + self.store.admin_ports().plugins(), + self.store.admin_ports().plugin_state(), + PluginRuntimeConfig { + cache_directory: self.directory.path().join("cache"), + host_version: "1.0.0".parse().unwrap(), + package_limits: PackageLimits::default(), + rpc_limits: RpcLimits::default(), + continuation_drain, + restart_circuit: Default::default(), + }, + gateway_core::engine::provider::ProviderRegistry::default(), + ProviderAdminRegistry::new([]).unwrap(), + Arc::new(gateway_host::outbound::HttpClient::new().unwrap()), + Arc::new(gateway_host::process::ProcessSupervisor::new( + std::num::NonZeroUsize::new(128).unwrap(), + )), + ) + .with_provider_ports(ports) + .with_network_policy( + gateway_host::outbound::NetworkPolicy::new(&[ + "127.0.0.0/8".into(), + "::1/128".into(), + ]) + .unwrap(), + ), + ) + } + + pub async fn bind_admin_accounts( + &self, + runtime: &Arc, + core: &gateway_core::CoreBundle, + ) -> gateway_admin::AdminBundle { + let unavailable = Arc::new(UnusedAdminRuntime); + let access = self + .plugin_accounts + .lock() + .unwrap() + .remove(&(Arc::as_ptr(runtime) as usize)) + .expect("runtime account access"); + gateway_admin::initialize_with_plugin_accounts( + AdminConfig { + session_ttl_minutes: 60, + default_username: "plugin-test-admin".to_owned(), + default_password: InitialAdminPassword::new("plugin-test-password"), + }, + ClientConfig::default(), + self.store.admin_ports(), + gateway_admin::AdminRuntimePorts { + plugin_preparation: runtime.clone(), + plugin_management: runtime.clone(), + published_snapshot: core.snapshots(), + plugin_distribution: unavailable.clone(), + plugin_inspector: Arc::new(PackageInspector::new( + PackageLimits::default(), + "1.0.0".parse().unwrap(), + )), + pricing_source: unavailable.clone(), + providers: runtime.admin_registry(core.snapshots()), + snapshot: core.snapshot_control(), + account_probe: core.account_probe(), + proxy_probe: unavailable.clone(), + client_distribution: unavailable.clone(), + system: unavailable, + client_key_verifier: core.client_key_verifier(), + }, + access, + ) + .await + .unwrap() + } + + pub fn release_plugin_accounts(&self, runtime: &Arc) { + self.plugin_accounts + .lock() + .unwrap() + .remove(&(Arc::as_ptr(runtime) as usize)) + .expect("runtime account access"); + } + + pub async fn account( + &self, + proxy: Option, + ) -> gateway_core::account::ProviderAccountId { + self.account_for("example", proxy).await + } + + pub async fn account_for( + &self, + provider: &str, + proxy: Option, + ) -> gateway_core::account::ProviderAccountId { + self.account_for_with_concurrency(provider, proxy, None) + .await + } + + pub async fn account_for_with_concurrency( + &self, + provider: &str, + proxy: Option, + maximum_concurrency: Option, + ) -> gateway_core::account::ProviderAccountId { + use gateway_core::{ + account::{ + AccountConcurrencyLimit, AccountWeight, CredentialRevision, CredentialState, + NewProviderAccount, PlaintextCredential, ProviderAccount, ProviderAccountId, + QuotaState, + }, + routing::ProviderKind, + }; + let id = ProviderAccountId::new(format!("acct_{}", uuid::Uuid::new_v4().simple())).unwrap(); + let account = ProviderAccount::new( + id.clone(), + ProviderKind::new(provider).unwrap(), + "plugin account".into(), + None, + "api_key".into(), + CredentialRevision::new(1).unwrap(), + None, + ) + .with_outbound_proxy(proxy) + .with_scheduling( + maximum_concurrency.and_then(AccountConcurrencyLimit::new), + AccountWeight::DEFAULT, + ) + .with_account_facts( + true, + CredentialState::Ready, + QuotaState::unknown(), + None, + None, + ); + self.store + .provider_ports() + .accounts() + .create_account(NewProviderAccount { + account, + credential: PlaintextCredential::new( + json!({"key":"test-only"}).as_object().unwrap().clone(), + ), + model_access: None, + }) + .await + .unwrap(); + id + } + + pub async fn create() -> Option { + Self::create_with_store_mode(false).await + } + + pub async fn create_command() -> Option { + Self::create_with_store_mode(true).await + } + + async fn create_with_store_mode(command_line: bool) -> Option { + let database = std::env::var("CPR_PLUGIN_TEST_DATABASE_URL").ok()?; + let redis = std::env::var("CPR_PLUGIN_TEST_REDIS_URL").expect("isolated plugin Redis URL"); + let schema = format!("cpr_plugin_{}", uuid::Uuid::new_v4().simple()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&database) + .await + .unwrap(); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("create schema {schema}"))) + .execute(&admin) + .await + .unwrap(); + let mut database = url::Url::parse(&database).unwrap(); + database + .query_pairs_mut() + .append_pair("options", &format!("-csearch_path={schema}")); + let connection = |mut url: url::Url| { + let password = url.password().expect("test password").to_owned(); + url.set_password(None).unwrap(); + json!({"url":url.as_str(), "password":password}) + }; + let mut config: StoreConfig = serde_json::from_value(json!({ + "database":connection(database), "redis":connection(url::Url::parse(&redis).unwrap()), + })) + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + config.resolve_and_validate(directory.path()).unwrap(); + let store = if command_line { + gateway_store::initialize_command_line(config) + .await + .unwrap() + } else { + gateway_store::initialize(config).await.unwrap() + }; + // 迁移可能已创建默认行;该场景只测租约释放,明确关闭相邻请求的间隔限制。 + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("insert into {schema}.runtime_settings(id, config_revision, request_interval_ms, updated_at) values (1,1,0,now()) on conflict (id) do update set request_interval_ms=0"))) + .execute(&admin).await.unwrap(); + Some(Self { + store, + directory, + admin, + schema, + plugin_accounts: std::sync::Mutex::new(BTreeMap::new()), + }) + } + + pub async fn close(self) { + drop(self.store); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!( + "drop schema {} cascade", + self.schema + ))) + .execute(&self.admin) + .await + .unwrap(); + self.admin.close().await; + } + + pub async fn administrator(&self, id: &str) { + sqlx::query(sqlx::AssertSqlSafe(format!("insert into {}.admin_users(id, password_hash, created_at, updated_at) values ($1, 'test-only-disabled-password', now(), now())", self.schema))) + .bind(id).execute(&self.admin).await.unwrap(); + } + + pub async fn audit_requests(&self, action: &str) -> Vec { + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "select admin_request_id from {}.admin_audit_events where action = $1 order by created_at", + self.schema + ))) + .bind(action) + .fetch_all(&self.admin) + .await + .unwrap() + } + + pub async fn bound_model_requests(&self, client_key_id: &str) -> Vec<(String, String)> { + sqlx::query_as(sqlx::AssertSqlSafe(format!( + "select request_kind, subagent_kind from {}.model_requests + where client_api_key_ref = $1 order by started_at, id", + self.schema + ))) + .bind(client_key_id) + .fetch_all(&self.admin) + .await + .unwrap() + } + + pub async fn model_request_ledger(&self, client_key_id: &str) -> Vec { + let rows: Vec = sqlx::query_as(sqlx::AssertSqlSafe(format!( + "select model_request.id, model_request.provider_kind, model_request.request_kind, + model_request.subagent_kind, model_request.outcome, + model_request.completed_at is not null, model_request.cost_source, + model_request.cost_amount::text, charge.amount_usd::text + from {}.model_requests model_request + left join {}.client_key_charge_events charge + on charge.request_id = model_request.id + where model_request.client_api_key_ref = $1 + order by model_request.started_at, model_request.id", + self.schema, self.schema + ))) + .bind(client_key_id) + .fetch_all(&self.admin) + .await + .unwrap(); + rows.into_iter() + .map( + |( + request_id, + provider_kind, + request_kind, + subagent_kind, + outcome, + completed, + cost_source, + cost_amount, + charged_amount, + )| ModelRequestLedgerFact { + request_id, + provider_kind, + request_kind, + subagent_kind, + outcome, + completed, + cost_source, + cost_amount, + charged_amount, + }, + ) + .collect() + } + + pub async fn wait_for_bound_model_requests( + &self, + client_key_id: &str, + expected: usize, + ) -> Vec<(String, String)> { + tokio::time::timeout(std::time::Duration::from_secs(3), async { + loop { + let requests = self.bound_model_requests(client_key_id).await; + if requests.len() >= expected { + break requests; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .unwrap() + } +} + +struct UnusedAdminRuntime; + +#[async_trait] +impl PluginDistribution for UnusedAdminRuntime { + fn validate_source(&self, _: &PluginUpdateSource) -> Result<(), AdminError> { + Err(AdminError::unavailable("unused plugin distribution")) + } + + fn validate_credential(&self, _: &SourceCredential) -> Result<(), AdminError> { + Err(AdminError::unavailable("unused plugin distribution")) + } + + async fn query_release( + &self, + _: GithubReleaseQuery, + _: Vec, + _: Option, + ) -> Result { + Err(AdminError::unavailable("unused plugin distribution")) + } + + async fn download( + &self, + _: RemotePluginLocation, + _: Vec, + _: Option, + ) -> Result { + Err(AdminError::unavailable("unused plugin distribution")) + } +} + +#[async_trait] +impl PricingSource for UnusedAdminRuntime { + async fn fetch(&self) -> Result { + Err(AdminError::unavailable("unused pricing source")) + } +} + +#[async_trait] +impl ProxyProbe for UnusedAdminRuntime { + async fn test(&self, _: &gateway_core::account::OutboundProxy) -> ProxyTestResult { + panic!("unexpected proxy probe") + } +} + +#[async_trait] +impl ClientDistributionResolver for UnusedAdminRuntime { + async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads { + CodexDesktopWindowsDownloads { + resolved_at: chrono::Utc::now(), + cached: false, + warning: None, + packages: Vec::new(), + } + } +} + +#[async_trait] +impl SystemOperations for UnusedAdminRuntime { + async fn version(&self) -> Result { + Err(unused_system()) + } + + async fn update_detail(&self, _: bool) -> Result { + Err(unused_system()) + } + + fn update_events(&self) -> SystemUpdateEventStream { + Box::pin(futures::stream::empty()) + } + + async fn perform_update( + &self, + _: Option, + _: Arc, + ) -> Result { + Err(unused_system()) + } + + async fn update_status(&self) -> Result { + Err(unused_system()) + } + + async fn rollback( + &self, + _: Arc, + ) -> Result { + Err(unused_system()) + } + + async fn restart(&self) -> Result { + Err(unused_system()) + } +} + +fn unused_system() -> SystemOperationError { + SystemOperationError::new( + gateway_admin::ports::system::SystemOperationErrorKind::Internal, + "unused system operation", + ) +} + +pub fn mutation() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "plugin-provider-test".into(), + } +} + +pub fn account_grant(permission: &str) -> PluginPermissionGrant { + PluginPermissionGrant { + permission: permission.into(), + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/http.rs b/backend/crates/gateway-plugin/runtime/tests/support/http.rs new file mode 100644 index 000000000..7ff868f03 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/http.rs @@ -0,0 +1,91 @@ +use std::time::Duration; + +use serde_json::Value; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::TcpListener, + sync::oneshot, + task::JoinHandle, +}; + +/// 用明确的响应屏障验证并发顺序,不依赖数据库操作恰好快于固定延迟。 +pub struct GatedResponse { + uri: String, + received: Option>, + release: Option>, + task: Option>, +} + +impl GatedResponse { + pub async fn start(body: Value) -> Self { + Self::start_batch(body, 1).await + } + + pub async fn start_batch(body: Value, requests: usize) -> Self { + assert!((1..=16).contains(&requests)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let uri = format!("http://{}", listener.local_addr().unwrap()); + let (received_tx, received) = oneshot::channel(); + let (release, release_rx) = oneshot::channel(); + let task = tokio::spawn(async move { + let sockets = futures::future::join_all((0..requests).map(|_| async { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0, "测试请求未发送完整请求头"); + request.extend_from_slice(&buffer[..count]); + assert!(request.len() <= 16 * 1024, "测试请求头超出预算"); + } + socket + })) + .await; + received_tx.send(()).unwrap(); + release_rx.await.unwrap(); + let body = serde_json::to_vec(&body).unwrap(); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + for mut socket in sockets { + socket.write_all(headers.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + socket.shutdown().await.unwrap(); + } + }); + Self { + uri, + received: Some(received), + release: Some(release), + task: Some(task), + } + } + + pub fn uri(&self) -> &str { + &self.uri + } + + pub async fn received(&mut self) { + tokio::time::timeout(Duration::from_secs(5), self.received.take().unwrap()) + .await + .expect("上游应收到请求后再进行并发变更") + .unwrap(); + } + + pub async fn respond(&mut self) { + self.release.take().unwrap().send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), self.task.take().unwrap()) + .await + .expect("上游应在屏障解除后发送响应") + .unwrap(); + } +} + +impl Drop for GatedResponse { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs b/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs new file mode 100644 index 000000000..290d34db3 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/middleware.rs @@ -0,0 +1,38 @@ +use gateway_plugin_sdk::{ + Capability, ContributionDeclaration, Contributions, Stage, + client::{MiddlewareCall, MiddlewarePlugin, PluginSession, SessionConfig}, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let plugin = MiddlewarePlugin::new( + &Contributions::from([( + Capability::Middleware, + ContributionDeclaration { + id: "test.example.middleware".into(), + version: 1, + stages: vec![Stage::Request], + input_formats: vec!["openai".into()], + output_formats: vec!["openai".into()], + }, + )]), + |call: MiddlewareCall| async move { + let mut response = call.next.run(call.request).await?; + response.append_header("x-sdk-plugin", b"active".to_vec()); + response.body = response.body.map_frames(|mut frame| { + frame.payload.push(b' '); + Ok(vec![frame]) + })?; + Ok(response) + }, + )?; + PluginSession::accept( + tokio::io::stdin(), + tokio::io::stdout(), + SessionConfig::default(), + ) + .await? + .run(plugin) + .await?; + Ok(()) +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs new file mode 100644 index 000000000..e7e405a0a --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/mod.rs @@ -0,0 +1,212 @@ +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + io::Write as _, + sync::{Arc, Mutex, OnceLock}, +}; + +use flate2::{Compression, write::GzEncoder}; +use gateway_plugin_sdk::{ + Capability, ContributionDeclaration, Contributions, Engines, Manifest, PROTOCOL_VERSION, + Package, PackageTarget, RuntimeKind, Stage, +}; +use sha2::{Digest as _, Sha256}; +pub mod diagnostics; +pub mod environment; +pub mod http; +pub mod store; + +pub const DEFAULT_PLUGIN_ID: &str = "test.example"; + +pub fn contribution( + capability: Capability, + stages: Vec, + input_formats: Vec, + output_formats: Vec, +) -> (Capability, ContributionDeclaration) { + contribution_for_id( + DEFAULT_PLUGIN_ID, + capability, + stages, + input_formats, + output_formats, + ) +} + +pub fn contribution_for_id( + plugin_id: &str, + capability: Capability, + stages: Vec, + input_formats: Vec, + output_formats: Vec, +) -> (Capability, ContributionDeclaration) { + let local_id = match capability { + Capability::Models => "models", + Capability::Authentication => "authentication", + Capability::FrontendAuthentication => "frontendAuthentication", + Capability::Scheduler => "scheduler", + Capability::ModelRouter => "modelRouter", + Capability::Executor => "executor", + Capability::Middleware => "middleware", + Capability::RequestLifecycle => "requestLifecycle", + Capability::WebSocketObserver => "webSocketObserver", + Capability::Usage => "usage", + Capability::CommandLine => "commandLine", + Capability::Management => "management", + Capability::Quota => "quota", + Capability::RequestProfile => "requestProfile", + Capability::AccountManagement => "accountManagement", + Capability::Billing => "billing", + Capability::Maintenance => "maintenance", + }; + ( + capability, + ContributionDeclaration { + id: format!("{plugin_id}.{local_id}"), + version: 1, + stages, + input_formats, + output_formats, + }, + ) +} + +struct WorkerFixture { + bytes: Vec, + digest: String, +} + +fn worker_fixture() -> &'static WorkerFixture { + static WORKER: OnceLock = OnceLock::new(); + WORKER.get_or_init(|| { + let bytes = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-worker")) + .expect("Cargo 应为集成测试构建 Rust 测试插件"); + let digest = hex::encode(Sha256::digest(&bytes)); + WorkerFixture { bytes, digest } + }) +} + +pub fn worker() -> &'static [u8] { + &worker_fixture().bytes +} + +pub fn package(worker: &[u8]) -> Arc<[u8]> { + package_with_permissions(worker, Vec::new()) +} + +pub fn package_with_permissions( + worker: &[u8], + permissions: Vec, +) -> Arc<[u8]> { + package_with_contributions(worker, permissions, Contributions::new()) +} + +pub fn package_with_contributions( + worker: &[u8], + permissions: Vec, + contributes: Contributions, +) -> Arc<[u8]> { + package_with_contributions_and_state(worker, permissions, contributes, vec![]) +} + +pub fn package_with_contributions_for_id( + worker: &[u8], + plugin_id: &str, + permissions: Vec, + contributes: Contributions, +) -> Arc<[u8]> { + package_with_identity_and_state(worker, plugin_id, permissions, contributes, vec![]) +} + +pub fn package_with_contributions_and_state( + worker: &[u8], + permissions: Vec, + contributes: Contributions, + state: Vec, +) -> Arc<[u8]> { + package_with_identity_and_state(worker, DEFAULT_PLUGIN_ID, permissions, contributes, state) +} + +fn package_with_identity_and_state( + worker: &[u8], + plugin_id: &str, + permissions: Vec, + contributes: Contributions, + state: Vec, +) -> Arc<[u8]> { + let (publisher, name) = plugin_id + .split_once('.') + .expect("测试插件 ID 必须使用 publisher.name"); + let digest = if std::ptr::eq(worker, self::worker()) { + worker_fixture().digest.clone() + } else { + hex::encode(Sha256::digest(worker)) + }; + let files = BTreeMap::from([("bin/worker".to_owned(), digest)]); + let manifest = Manifest { + manifest_version: gateway_plugin_sdk::MANIFEST_VERSION, + name: name.to_owned(), + display_name: "示例".to_owned(), + publisher: publisher.to_owned(), + version: "1.0.0".parse().unwrap(), + description: "测试插件".to_owned(), + license: "MIT".to_owned(), + author: Some("test".to_owned()), + engines: Engines { + codex_proxy_rs: ">=1.0.0, <2.0.0".parse().unwrap(), + }, + main: "bin/worker".to_owned(), + runtime: RuntimeKind::TrustedProcess, + contributes, + permissions: permissions.into_iter().collect::>(), + configuration_schema: serde_json::json!({}), + secret_fields: BTreeSet::new(), + resources: BTreeMap::new(), + icon: None, + state, + package: Some(Package { + protocol_version: PROTOCOL_VERSION, + target: PackageTarget { + os: std::env::consts::OS.to_owned(), + architecture: std::env::consts::ARCH.to_owned(), + }, + files, + }), + }; + let manifest = serde_json::to_vec(&manifest).unwrap(); + let key: [u8; 32] = Sha256::digest(&manifest).into(); + type PackageCache = VecDeque<([u8; 32], Arc<[u8]>)>; + static PACKAGES: Mutex = Mutex::new(VecDeque::new()); + // 缓存的只是不可变制品;各用例仍独立校验、解包、启动进程和创建数据库 schema。 + // 同时保留至多 8 个制品,避免复杂清单用例累积常驻内存。 + let mut packages = PACKAGES.lock().unwrap(); + if let Some(index) = packages.iter().position(|(existing, _)| *existing == key) { + let entry = packages.remove(index).unwrap(); + let package = entry.1.clone(); + packages.push_back(entry); + return package; + } + let package = archive(BTreeMap::from([ + ("plugin.json".into(), manifest), + ("bin/worker".into(), worker.to_vec()), + ])); + if packages.len() == 8 { + packages.pop_front(); + } + packages.push_back((key, package.clone())); + package +} + +pub fn archive(files: BTreeMap>) -> Arc<[u8]> { + let mut tar = tar::Builder::new(Vec::new()); + for (path, bytes) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o700); + header.set_cksum(); + tar.append_data(&mut header, path, bytes.as_slice()) + .unwrap(); + } + let mut gzip = GzEncoder::new(Vec::new(), Compression::fast()); + gzip.write_all(&tar.into_inner().unwrap()).unwrap(); + gzip.finish().unwrap().into() +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/store.rs b/backend/crates/gateway-plugin/runtime/tests/support/store.rs new file mode 100644 index 000000000..6f7c1f93e --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/store.rs @@ -0,0 +1,238 @@ +use std::{collections::BTreeMap, sync::Mutex}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMutation, PluginSource, + distribution::{PluginSourceBinding, SourceCredential, SourceCredentialInfo}, + instances::{PluginInstance, PluginInstanceMutation, PluginInstanceSnapshot}, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateConfiguration, + PluginStateMigrationBatch, PluginStateOwner, PluginStateOwnerRequest, + PluginStateRecord, PluginStateTransition, PluginStateWrite, PutPluginState, + }, + }, + }, + ports::{ + plugins::{PluginStateStore, PluginStateStoreResult, PluginStore}, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; + +pub struct Store { + pub artifacts: BTreeMap, + pub snapshot: Mutex, +} + +#[async_trait] +impl PluginStateStore for Store { + async fn load_owner( + &self, + request: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + if !request.configuration.namespaces.is_empty() { + return Ok(None); + } + Ok(Some(PluginStateOwner::from_store( + request.instance_id, + request.artifact_sha256, + request.instance_revision, + BTreeMap::new(), + ))) + } + + async fn get( + &self, + _: &PluginStateOwner, + _: &str, + _: &str, + ) -> PluginStateStoreResult> { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn put( + &self, + _: &PluginStateOwner, + _: PutPluginState, + ) -> PluginStateStoreResult { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn delete( + &self, + _: &PluginStateOwner, + _: DeletePluginState, + ) -> PluginStateStoreResult { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn transition_required( + &self, + _: &str, + _: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + Ok(false) + } + + async fn begin_transition( + &self, + _: &str, + _: Revision, + _: &str, + _: PluginStateConfiguration, + ) -> PluginStateStoreResult { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn migration_batch( + &self, + _: &str, + _: &str, + _: u32, + ) -> PluginStateStoreResult { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn apply_migration_batch( + &self, + _: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + Err(gateway_admin::ports::plugins::PluginStateStoreError::new( + gateway_admin::ports::plugins::PluginStateStoreErrorKind::Unavailable, + )) + } + + async fn abort_transition(&self, _: &str) -> PluginStateStoreResult<()> { + Ok(()) + } +} + +fn unused() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin", + "unused test operation", + ) +} + +#[async_trait] +impl PluginStore for Store { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + Ok(self + .load_instances() + .await? + .instances + .iter() + .any(|instance| { + instance.enabled + && instance.trusted_process + && instance.id == target.instance_id + && instance.artifact_sha256 == target.artifact_sha256 + && instance.revision.get() == target.revision + })) + } + async fn accept_artifact( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + async fn list_artifacts(&self) -> AdminStoreResult> { + Err(unused()) + } + async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + self.artifacts.get(digest).cloned().ok_or_else(unused) + } + async fn install_artifact( + &self, + _: InspectedPluginArtifact, + _: PluginSource, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult { + Err(unused()) + } + async fn list_source_credentials(&self) -> AdminStoreResult> { + Err(unused()) + } + async fn load_source_credential(&self, _: &str) -> AdminStoreResult { + Err(unused()) + } + async fn save_source_credential( + &self, + _: SourceCredential, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + async fn delete_source_credential( + &self, + _: &str, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + async fn list_update_sources(&self) -> AdminStoreResult> { + Err(unused()) + } + async fn change_update_source( + &self, + _: PluginSourceBinding, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } + async fn load_instances(&self) -> AdminStoreResult { + Ok(self.snapshot.lock().unwrap().clone()) + } + async fn save_instance( + &self, + mut instance: PluginInstance, + expected: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + let mut snapshot = self.snapshot.lock().unwrap(); + if snapshot.config_revision != expected { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Conflict, + "plugin", + "configuration changed", + )); + } + let next = Revision::new(expected.get() + 1).unwrap(); + instance.revision = next; + snapshot.config_revision = next; + snapshot.instances.retain(|item| item.id != instance.id); + snapshot.instances.push(instance.clone()); + Ok(PluginInstanceMutation { + config_revision: next, + instance, + }) + } + async fn delete_instance( + &self, + _: &str, + _: Revision, + _: &MutationContext, + ) -> AdminStoreResult { + Err(unused()) + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs new file mode 100644 index 000000000..6afd04025 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker.rs @@ -0,0 +1,2192 @@ +//! 真实子进程协议对端;只依赖公开 SDK,Cargo 为集成测试构建此辅助二进制。 + +use std::{ + collections::BTreeMap, + io::Write as _, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use gateway_plugin_sdk::{ + ErrorCode, Frame, Message, PluginFault, + client::{read_frame, write_frame}, +}; +use serde_json::{Value, json}; +use tokio::io::AsyncWriteExt as _; +use tokio::sync::{Mutex, Notify, mpsc, oneshot}; + +type CallbackResult = Result<(Value, Vec), PluginFault>; +struct PendingCallback { + parent: u64, + response: Option>, +} + +fn record_startup(configuration: &Value) -> usize { + let Some(path) = configuration.get("startup_marker").and_then(Value::as_str) else { + return 0; + }; + let startup = std::fs::read_to_string(path) + .map(|content| content.lines().count()) + .unwrap_or_default(); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + serde_json::to_writer(&mut file, &json!({"startup":startup + 1})).unwrap(); + file.write_all(b"\n").unwrap(); + startup +} + +#[derive(Default)] +struct Credits { + available: Mutex<(u64, u64)>, + changed: Notify, +} + +impl Credits { + async fn take(&self, bytes: u64) { + loop { + let changed = self.changed.notified(); + { + let mut available = self.available.lock().await; + if available.0 >= bytes && available.1 > 0 { + available.0 -= bytes; + available.1 -= 1; + return; + } + } + changed.await; + } + } + + async fn grant(&self, bytes: u32, frames: u32) { + let mut available = self.available.lock().await; + available.0 += u64::from(bytes); + available.1 += u64::from(frames); + self.changed.notify_one(); + } +} + +struct Peer { + registration: gateway_plugin_sdk::call::provider::Registration, + configuration: Value, + prepared: Mutex>, + output: mpsc::Sender

) -> Fut + Send + Sync + 'static, + Fut: Future, PluginFault>> + Send + 'static, + { + let declared = if method.capabilities.is_empty() { + method.name == methods::STATE_MIGRATE.name + && self + .manifest + .state + .iter() + .any(|namespace| !namespace.migrates_from.is_empty()) + } else { + method + .capabilities + .iter() + .any(|capability| self.manifest.contributes.contains_key(capability)) + }; + if !declared { + return Err(AuthorError::Method(method.name)); + } + let handler = Arc::new(handler); + self.insert( + method.name, + Arc::new(move |call| { + let handler = Arc::clone(&handler); + Box::pin(async move { + let call = (method.decode)(call, method.stages)?; + (method.encode)(handler(call).await?) + }) + }), + )?; + Ok(self) + } + + /// 组合洋葱中间件,沿用 single-use next 与惰性正文,不建立另一套流式机制。 + /// + /// # Errors + /// + /// 未声明中间件或重复注册时失败。 + pub fn middleware(mut self, handler: F) -> Result + where + F: Fn(MiddlewareCall) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let declaration = self + .manifest + .contributes + .get(&Capability::Middleware) + .ok_or(AuthorError::Method(crate::call::middleware::HANDLE_METHOD))?; + let stages = declaration.stages.clone(); + let handler = Arc::new(handler); + self.insert( + crate::call::middleware::HANDLE_METHOD, + Arc::new(move |call| { + let handler = Arc::clone(&handler); + let allowed = stages.contains(&call.context.stage); + Box::pin(async move { + if !allowed { + return Err(typed::invalid_input()); + } + handler(MiddlewareCall::try_from(call)?).await?.into_reply() + }) + }), + )?; + Ok(self) + } + + /// 冻结管理页面/路由描述并绑定类型化业务函数。 + /// + /// # Errors + /// + /// 未声明管理能力或重复注册时失败。 + pub fn management( + self, + registration: ManagementRegistration, + handler: F, + ) -> Result + where + F: Fn(TypedCall) -> Fut + Send + Sync + 'static, + Fut: Future, PluginFault>> + Send + 'static, + { + self.on(methods::MANAGEMENT_REGISTER, move |_| { + let registration = registration.clone(); + async move { Ok(TypedReply::new(registration)) } + })? + .on(methods::MANAGEMENT_HANDLE, handler) + } + + /// 冻结命令帮助并绑定类型化命令执行函数。 + /// + /// # Errors + /// + /// 未声明命令行能力或重复注册时失败。 + pub fn command_line( + self, + registration: CommandRegistration, + handler: F, + ) -> Result + where + F: Fn(TypedCall) -> Fut + Send + Sync + 'static, + Fut: Future, PluginFault>> + Send + 'static, + { + self.on(methods::COMMAND_LINE_REGISTER, move |_| { + let registration = registration.clone(); + async move { Ok(TypedReply::new(registration)) } + })? + .on(methods::COMMAND_LINE_EXECUTE, handler) + } + + /// 检查声明与实际处理器后生成可交给 `PluginSession::run` 的插件。 + /// + /// # Errors + /// + /// 声明缺少处理器,或 Provider 的功能描述与能力不一致时失败。 + pub fn build(self) -> Result { + validation::validate(&self.manifest, self.provider.as_ref(), &self.handlers)?; + Ok(ComposedPlugin { + registration: Registration { + contributes: self.manifest.contributes, + provider: self.provider, + }, + handlers: self.handlers, + }) + } + + fn insert(&mut self, method: &'static str, handler: Handler) -> Result<(), AuthorError> { + if self.handlers.contains_key(method) { + return Err(AuthorError::Method(method)); + } + self.handlers.insert(method, handler); + Ok(()) + } +} + +/// 已校验的业务处理器集合;会话仍负责取消、回调和流控。 +pub struct ComposedPlugin { + registration: Registration, + handlers: BTreeMap<&'static str, Handler>, +} + +impl PluginHandler for ComposedPlugin { + fn call(&self, call: PluginCall) -> CallFuture<'_> { + Box::pin(async move { + if call.method == "plugin.register" { + if call.context.stage != Stage::Registration + || !call.payload.is_empty() + || !call + .params + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + return Err(typed::invalid_input()); + } + return Ok(CallReply::unary( + serde_json::to_value(&self.registration).map_err(|_| typed::invalid_input())?, + Vec::new(), + )); + } + let handler = self.handlers.get(call.method.as_str()).ok_or_else(|| { + PluginFault::new(ErrorCode::Unsupported, "plugin method is not supported") + })?; + handler(call).await + }) + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs new file mode 100644 index 000000000..b391decbe --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/typed.rs @@ -0,0 +1,251 @@ +use std::marker::PhantomData; + +use serde::{Deserialize, Serialize, de::DeserializeOwned}; + +use crate::{ + CallContext, Capability, ErrorCode, PluginFault, Stage, call::provider::ExecutionInput, +}; + +use super::super::{CallCancellation, CallReply, HostClient, PluginCall, ResponseStream}; + +/// RPC 的空控制对象,不使用会编码为 `null` 的 Rust 单元类型。 +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Empty {} + +pub(super) type Decoder

= fn(PluginCall, &'static [Stage]) -> Result, PluginFault>; +pub(super) type Encoder = fn(TypedReply) -> Result; + +/// SDK 方法标识与请求/响应类型;只能使用 [`super::methods`] 中的合同常量。 +pub struct Method { + pub(super) name: &'static str, + pub(super) capabilities: &'static [Capability], + pub(super) stages: &'static [Stage], + pub(super) decode: Decoder

, + pub(super) encode: Encoder, + marker: PhantomData R>, +} + +impl Copy for Method {} + +impl Clone for Method { + fn clone(&self) -> Self { + *self + } +} + +impl Method { + pub(super) const fn new( + name: &'static str, + capabilities: &'static [Capability], + stages: &'static [Stage], + decode: Decoder

, + encode: Encoder, + ) -> Self { + Self { + name, + capabilities, + stages, + decode, + encode, + marker: PhantomData, + } + } +} + +/// 业务请求、宿主上下文与当前父调用内的服务句柄;不实现内容型 `Debug`。 +pub struct TypedCall

{ + pub request: P, + /// 只有合同明确保留原始正文的方法才会在此提供字节。 + pub payload: Vec, + pub context: CallContext, + pub host: HostClient, + pub cancellation: CallCancellation, +} + +/// 类型化结果,可复用现有有界响应流;敏感 JSON 按方法合同写入二进制载荷。 +pub struct TypedReply { + pub result: R, + pub payload: Vec, + pub stream: Option, +} + +impl TypedReply { + #[must_use] + pub const fn new(result: R) -> Self { + Self { + result, + payload: Vec::new(), + stream: None, + } + } + + #[must_use] + pub fn with_payload(mut self, payload: Vec) -> Self { + self.payload = payload; + self + } + + #[must_use] + pub fn with_stream(mut self, stream: ResponseStream) -> Self { + self.stream = Some(stream); + self + } +} + +pub(super) fn decode_metadata( + mut call: PluginCall, + stages: &'static [Stage], +) -> Result, PluginFault> { + validate_stage(&call, stages)?; + let request = + serde_json::from_value(std::mem::take(&mut call.params)).map_err(|_| invalid_input())?; + Ok(typed_call(call, request, true)) +} + +pub(super) fn decode_metadata_without_payload( + mut call: PluginCall, + stages: &'static [Stage], +) -> Result, PluginFault> { + validate_stage(&call, stages)?; + if !call.payload.is_empty() { + return Err(invalid_input()); + } + let request = + serde_json::from_value(std::mem::take(&mut call.params)).map_err(|_| invalid_input())?; + Ok(typed_call(call, request, false)) +} + +pub(super) fn decode_payload( + call: PluginCall, + stages: &'static [Stage], +) -> Result, PluginFault> { + validate_stage(&call, stages)?; + if !empty_object(&call.params) { + return Err(invalid_input()); + } + let request = serde_json::from_slice(&call.payload).map_err(|_| invalid_input())?; + Ok(typed_call(call, request, false)) +} + +pub(super) fn decode_execution_input( + call: PluginCall, + stages: &'static [Stage], +) -> Result, PluginFault> { + validate_stage(&call, stages)?; + if !empty_object(&call.params) { + return Err(invalid_input()); + } + let request = ExecutionInput::decode(&call.payload).map_err(|_| invalid_input())?; + Ok(typed_call(call, request, false)) +} + +pub(super) fn encode_metadata( + reply: TypedReply, +) -> Result { + let TypedReply { + result, + payload, + stream, + } = reply; + if !payload.is_empty() || stream.is_some() { + return Err(invalid_input()); + } + let result = serde_json::to_value(result).map_err(|_| invalid_input())?; + Ok(CallReply::unary(result, Vec::new())) +} + +pub(super) fn encode_metadata_with_payload( + reply: TypedReply, +) -> Result { + let TypedReply { + result, + payload, + stream, + } = reply; + if stream.is_some() { + return Err(invalid_input()); + } + let result = serde_json::to_value(result).map_err(|_| invalid_input())?; + Ok(CallReply::unary(result, payload)) +} + +pub(super) fn encode_payload(reply: TypedReply) -> Result { + let TypedReply { + result, + payload, + stream, + } = reply; + if !payload.is_empty() || stream.is_some() { + return Err(invalid_input()); + } + let payload = serde_json::to_vec(&result).map_err(|_| invalid_input())?; + Ok(CallReply::unary(serde_json::json!({}), payload)) +} + +pub(super) fn encode_metadata_stream( + reply: TypedReply, +) -> Result { + let TypedReply { + result, + payload, + stream, + } = reply; + if !payload.is_empty() { + return Err(invalid_input()); + } + let stream = stream.ok_or_else(invalid_input)?; + let result = serde_json::to_value(result).map_err(|_| invalid_input())?; + Ok(CallReply::stream(result, Vec::new(), stream)) +} + +pub(super) fn encode_execution_stream(reply: TypedReply) -> Result { + let TypedReply { + result: _, + payload, + stream, + } = reply; + if !payload.is_empty() { + return Err(invalid_input()); + } + let stream = stream.ok_or_else(invalid_input)?; + Ok(CallReply::stream( + serde_json::json!({"stream": true}), + Vec::new(), + stream, + )) +} + +fn typed_call

(mut call: PluginCall, request: P, keep_payload: bool) -> TypedCall

{ + let payload = if keep_payload { + std::mem::take(&mut call.payload) + } else { + Vec::new() + }; + TypedCall { + request, + payload, + context: call.context, + host: call.host, + cancellation: call.cancellation, + } +} + +fn validate_stage(call: &PluginCall, stages: &[Stage]) -> Result<(), PluginFault> { + if stages.contains(&call.context.stage) { + Ok(()) + } else { + Err(invalid_input()) + } +} + +fn empty_object(value: &serde_json::Value) -> bool { + value.as_object().is_some_and(serde_json::Map::is_empty) +} + +pub(super) fn invalid_input() -> PluginFault { + PluginFault::new( + ErrorCode::InvalidInput, + "plugin typed method input or result is invalid", + ) +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs b/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs new file mode 100644 index 000000000..d93b7650f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/plugin/validation.rs @@ -0,0 +1,178 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{ + Capability as C, Manifest, + call::{ + auth::CredentialOperation, + provider::{ProviderDescriptor, account::AccountOperation}, + }, +}; + +use super::{AuthorError, Handler, methods}; + +pub(super) fn validate( + manifest: &Manifest, + provider: Option<&ProviderDescriptor>, + handlers: &BTreeMap<&'static str, Handler>, +) -> Result<(), AuthorError> { + let required = |method| { + handlers + .contains_key(method) + .then_some(()) + .ok_or(AuthorError::MissingMethod(method)) + }; + let matches_handler = |method, expected| match (handlers.contains_key(method), expected) { + (true, true) | (false, false) => Ok(()), + (false, true) => Err(AuthorError::MissingMethod(method)), + (true, false) => Err(AuthorError::Provider), + }; + let has = |capability| manifest.contributes.contains_key(&capability); + + for capability in manifest.contributes.keys() { + match capability { + C::Middleware => required(crate::call::middleware::HANDLE_METHOD)?, + C::ModelRouter => required(methods::ROUTE_MODEL.name)?, + C::Scheduler => required(methods::SCHEDULE_ACCOUNT.name)?, + C::RequestLifecycle | C::Usage => required(methods::OBSERVE_REQUEST.name)?, + C::WebSocketObserver => required(methods::OBSERVE_WEBSOCKET.name)?, + C::Management => { + required(methods::MANAGEMENT_REGISTER.name)?; + required(methods::MANAGEMENT_HANDLE.name)?; + } + C::CommandLine => { + required(methods::COMMAND_LINE_REGISTER.name)?; + required(methods::COMMAND_LINE_EXECUTE.name)?; + } + C::FrontendAuthentication => { + required(methods::FRONTEND_IDENTIFIER.name)?; + required(methods::FRONTEND_AUTHENTICATE.name)?; + } + C::Executor => { + required(methods::PREPARE_EXECUTION.name)?; + required(methods::EXECUTE.name)?; + required(methods::DISCARD_EXECUTION.name)?; + required(methods::CONNECTION_TEST.name)?; + } + C::Models => required(methods::MODELS.name)?, + C::Quota => required(methods::QUOTA.name)?, + C::RequestProfile => required(methods::REFRESH_REQUEST_PROFILES.name)?, + C::Authentication | C::AccountManagement | C::Billing | C::Maintenance => {} + } + } + if manifest + .state + .iter() + .any(|namespace| !namespace.migrates_from.is_empty()) + { + required(methods::STATE_MIGRATE.name)?; + } + + let has_provider_capability = manifest.contributes.keys().any(|capability| { + matches!( + capability, + C::Models + | C::Authentication + | C::Executor + | C::Quota + | C::AccountManagement + | C::Billing + | C::RequestProfile + | C::Maintenance + ) + }); + if provider.is_some() != has(C::Executor) || provider.is_none() && has_provider_capability { + return Err(AuthorError::Provider); + } + let Some(provider) = provider else { + return Ok(()); + }; + validate_provider(provider, &has, &matches_handler) +} + +fn validate_provider( + provider: &ProviderDescriptor, + has: &impl Fn(C) -> bool, + matches_handler: &impl Fn(&'static str, bool) -> Result<(), AuthorError>, +) -> Result<(), AuthorError> { + let credential_operations = provider + .credential_operations + .iter() + .copied() + .collect::>(); + let account_operations = provider + .account_operations + .iter() + .copied() + .collect::>(); + let supports_maintenance = credential_operations.contains(&CredentialOperation::Refresh) + || has(C::Quota) + || has(C::Models) + || has(C::RequestProfile); + if provider.id.trim().is_empty() + || credential_operations.len() != provider.credential_operations.len() + || account_operations.len() != provider.account_operations.len() + || credential_operations.is_empty() == has(C::Authentication) + || account_operations.is_empty() == has(C::AccountManagement) + || provider.billing.is_some() != has(C::Billing) + || provider.model_discovery.is_some() != has(C::Models) + || has(C::RequestProfile) && provider.request_profiles.is_none() + || has(C::Maintenance) && !supports_maintenance + || provider + .credential_input_schemas + .keys() + .any(|operation| !credential_operations.contains(operation)) + || provider.account_configuration.is_some() + && !credential_operations.contains(&CredentialOperation::Rotate) + { + return Err(AuthorError::Provider); + } + + matches_handler( + methods::ACCOUNT_CONFIGURATION.name, + provider.account_configuration.is_some(), + )?; + matches_handler( + methods::IMPORT_CREDENTIALS.name, + credential_operations.contains(&CredentialOperation::Import), + )?; + matches_handler( + methods::EXPORT_CREDENTIALS.name, + credential_operations.contains(&CredentialOperation::Export), + )?; + matches_handler( + methods::ROTATE_CREDENTIALS.name, + credential_operations.contains(&CredentialOperation::Rotate), + )?; + matches_handler( + methods::REFRESH_CREDENTIALS.name, + credential_operations.contains(&CredentialOperation::Refresh), + )?; + let login = credential_operations.contains(&CredentialOperation::Login); + matches_handler(methods::LOGIN_START.name, login)?; + matches_handler(methods::LOGIN_POLL.name, login)?; + + let invalidation = account_operations.contains(&AccountOperation::Unavailable) + || account_operations.contains(&AccountOperation::FactsChanged); + matches_handler(methods::ACCOUNT_CHANGED.name, invalidation)?; + matches_handler( + methods::PROFILE.name, + account_operations.contains(&AccountOperation::Profile), + )?; + matches_handler( + methods::SUBSCRIPTION.name, + account_operations.contains(&AccountOperation::Subscription), + )?; + matches_handler( + methods::AVATAR.name, + account_operations.contains(&AccountOperation::Avatar), + )?; + matches_handler( + methods::RESET_CREDITS.name, + account_operations.contains(&AccountOperation::ResetCredits), + )?; + matches_handler( + methods::CONSUME_RESET_CREDIT.name, + account_operations.contains(&AccountOperation::ConsumeResetCredit), + )?; + Ok(()) +} diff --git a/backend/crates/gateway-plugin/sdk/src/client/session.rs b/backend/crates/gateway-plugin/sdk/src/client/session.rs new file mode 100644 index 000000000..ecd5db823 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/client/session.rs @@ -0,0 +1,1448 @@ +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + future::Future, + num::NonZeroUsize, + pin::Pin, + sync::{Arc, Mutex}, + time::Duration, +}; + +use serde_json::Value; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch}, + task::JoinHandle, + time::Instant, +}; + +use crate::{ + CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION, PluginFault, +}; + +use super::frame::{read_frame, validate_frame, validate_message, write_frame}; + +const MAXIMUM_FRAME_BYTES: usize = 16 * 1024 * 1024; +const MAXIMUM_CONCURRENCY: usize = 256; +const MAXIMUM_BUFFERED_STREAM_CHUNKS: usize = 65_536; + +/// 插件侧会话的内存、并发与期限边界。 +#[derive(Debug, Clone, Copy)] +pub struct SessionConfig { + pub maximum_frame_bytes: usize, + pub maximum_calls: usize, + pub maximum_callbacks: usize, + pub maximum_buffered_stream_chunks: usize, + pub handshake_timeout: Duration, + pub maximum_call_timeout: Duration, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + // 与宿主默认帧预算一致,避免正文回调以本地较大上限申请后被拒绝。 + maximum_frame_bytes: 1024 * 1024, + maximum_calls: 32, + maximum_callbacks: 32, + maximum_buffered_stream_chunks: 1_024, + handshake_timeout: Duration::from_secs(5), + maximum_call_timeout: Duration::from_secs(120), + } + } +} + +impl SessionConfig { + fn validate(self) -> Result { + if self.maximum_frame_bytes < 1_024 + || self.maximum_frame_bytes > MAXIMUM_FRAME_BYTES + || self.maximum_calls == 0 + || self.maximum_calls > MAXIMUM_CONCURRENCY + || self.maximum_callbacks == 0 + || self.maximum_callbacks > MAXIMUM_CONCURRENCY + || self.maximum_buffered_stream_chunks == 0 + || self.maximum_buffered_stream_chunks > MAXIMUM_BUFFERED_STREAM_CHUNKS + || self.handshake_timeout.is_zero() + || self.maximum_call_timeout.is_zero() + { + return Err(SessionError::Configuration); + } + Ok(self) + } +} + +/// 插件侧传输和会话错误。 +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("plugin session configuration is invalid")] + Configuration, + #[error("plugin handshake is invalid")] + Handshake, + #[error("plugin protocol is invalid")] + Protocol, + #[error("plugin transport is closed")] + Closed, + #[error("plugin operation exceeded its deadline")] + Timeout, + #[error("plugin call was cancelled")] + Cancelled, + #[error("plugin session capacity is exhausted")] + Capacity, + #[error("plugin handler stopped unexpectedly")] + HandlerStopped, + #[error("host callback returned an error: {0:?}")] + Remote(PluginFault), + #[error(transparent)] + Frame(#[from] FrameError), +} + +impl SessionError { + /// 将本地会话失败收敛为可返回宿主的稳定插件错误。 + #[must_use] + pub fn into_plugin_fault(self) -> PluginFault { + match self { + Self::Remote(fault) => fault, + Self::Timeout => PluginFault::new(ErrorCode::Timeout, "host callback timed out"), + Self::Cancelled => PluginFault::new(ErrorCode::Cancelled, "parent call was cancelled"), + Self::Capacity => PluginFault::new(ErrorCode::Capacity, "plugin capacity is exhausted"), + Self::Configuration | Self::Protocol | Self::Handshake | Self::Frame(_) => { + PluginFault::new(ErrorCode::InvalidInput, "plugin session input is invalid") + } + Self::Closed | Self::HandlerStopped => { + PluginFault::new(ErrorCode::Fault, "plugin session is unavailable") + } + } + } +} + +/// 一次宿主调用的可克隆取消信号。 +#[derive(Clone)] +pub struct CallCancellation { + receiver: watch::Receiver, +} + +impl CallCancellation { + #[must_use] + pub fn is_cancelled(&self) -> bool { + *self.receiver.borrow() + } + + pub async fn cancelled(&self) { + let mut receiver = self.receiver.clone(); + if *receiver.borrow() { + return; + } + while receiver.changed().await.is_ok() { + if *receiver.borrow() { + return; + } + } + } +} + +struct CancellationSource { + sender: watch::Sender, +} + +impl CancellationSource { + fn new() -> (Self, CallCancellation) { + let (sender, receiver) = watch::channel(false); + (Self { sender }, CallCancellation { receiver }) + } + + fn cancel(&self) { + self.sender.send_replace(true); + } +} + +/// 宿主回调的成功结果;不实现 `Debug`,避免载荷进入普通诊断。 +pub struct HostReply { + pub result: Value, + pub payload: Vec, +} + +/// 绑定父调用、期限和取消信号的宿主回调客户端。 +#[derive(Clone)] +pub struct HostClient { + parent_id: u64, + deadline: Instant, + cancellation: CallCancellation, + callbacks: Arc, + output: Output, + maximum_frame_bytes: usize, +} + +impl HostClient { + pub(crate) const fn maximum_frame_bytes(&self) -> usize { + self.maximum_frame_bytes + } + + /// 发起一次与父调用关联的宿主回调。 + /// + /// # Errors + /// + /// 方法或帧无效、容量耗尽、父调用取消、期限到达、连接关闭或宿主返回错误时失败。 + pub async fn call( + &self, + method: impl Into, + params: Value, + payload: Vec, + ) -> Result { + let method = method.into(); + if method.is_empty() || method.len() > 128 { + return Err(SessionError::Protocol); + } + let ordered = self.callbacks.order.lock().await; + let (id, received) = self.callbacks.begin(self.parent_id)?; + let frame = Frame { + message: Message::Callback { + id, + parent_id: self.parent_id, + method, + params, + }, + payload, + }; + if validate_frame(&frame, self.maximum_frame_bytes).is_err() { + self.callbacks.retire(id); + return Err(SessionError::Protocol); + } + let sent = tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(SessionError::Cancelled), + () = tokio::time::sleep_until(self.deadline) => Err(SessionError::Timeout), + result = self.output.send_data(frame) => result, + }; + if let Err(error) = sent { + self.callbacks.retire(id); + return Err(error); + } + drop(ordered); + tokio::select! { + biased; + () = self.cancellation.cancelled() => { + self.callbacks.retire(id); + Err(SessionError::Cancelled) + } + () = tokio::time::sleep_until(self.deadline) => { + self.callbacks.retire(id); + Err(SessionError::Timeout) + } + result = received => result.map_err(|_| SessionError::Closed)?, + } + } +} + +/// 交给插件业务处理器的一次宿主调用;不实现 `Debug`,避免敏感载荷泄露。 +pub struct PluginCall { + pub method: String, + pub context: CallContext, + pub params: Value, + pub payload: Vec, + pub host: HostClient, + pub cancellation: CallCancellation, +} + +/// 插件业务调用 future。 +pub type CallFuture<'a> = Pin> + Send + 'a>>; + +/// 插件只实现业务调用;会话负责握手、关联、流控、取消和关闭。 +pub trait PluginHandler: Send + Sync + 'static { + fn call(&self, call: PluginCall) -> CallFuture<'_>; + + /// 取消时释放只属于该调用的临时业务状态。实现必须幂等、快速且不得执行阻塞 I/O。 + fn cancel(&self, _context: &CallContext) {} + + /// 会话停止接收新调用时触发。实现必须幂等、快速且不得执行阻塞 I/O。 + fn quiesce(&self) {} + + /// 会话关闭时释放插件自有临时状态。实现必须幂等、快速且不得执行阻塞 I/O。 + fn shutdown(&self) {} +} + +/// 一次业务成功结果;`stream` 存在时 SDK 自动产生单一流终态。 +pub struct CallReply { + result: Value, + payload: Vec, + stream: Option, +} + +impl CallReply { + #[must_use] + pub fn unary(result: Value, payload: Vec) -> Self { + Self { + result, + payload, + stream: None, + } + } + + #[must_use] + pub fn stream(result: Value, payload: Vec, stream: ResponseStream) -> Self { + Self { + result, + payload, + stream: Some(stream), + } + } +} + +enum StreamSource { + Buffered(VecDeque>), + Channel(mpsc::Receiver, PluginFault>>), + Pull(Box), +} + +pub(crate) type PullResponseFuture<'a> = + Pin, PluginFault>>> + Send + 'a>>; + +pub(crate) trait PullResponseStream: Send { + fn next(&mut self) -> PullResponseFuture<'_>; +} + +/// SDK 管理 sequence、Credit 和终态的响应流。 +pub struct ResponseStream { + source: StreamSource, + declared_capacity: usize, +} + +impl ResponseStream { + /// 从已完成业务校验的有限分块构造流;SDK 会在发送 `Result` 前预检全部分块。 + #[must_use] + pub fn from_chunks(chunks: Vec>) -> Self { + let declared_capacity = chunks.len().max(1); + Self { + source: StreamSource::Buffered(chunks.into()), + declared_capacity, + } + } + + /// 创建由生产者驱动的有界流队列。 + #[must_use] + pub fn channel(capacity: NonZeroUsize) -> (StreamSender, Self) { + let (sender, receiver) = mpsc::channel(capacity.get()); + ( + StreamSender { sender }, + Self { + source: StreamSource::Channel(receiver), + declared_capacity: capacity.get(), + }, + ) + } + + pub(crate) fn pull(source: Box) -> Self { + Self { + source: StreamSource::Pull(source), + declared_capacity: 1, + } + } + + fn validate_buffered( + &self, + maximum_chunks: usize, + maximum_frame_bytes: usize, + window_bytes: u64, + ) -> Result<(), PluginFault> { + if self.declared_capacity > maximum_chunks { + return Err(capacity_fault( + "response stream exceeds the local queue limit", + )); + } + if let StreamSource::Buffered(chunks) = &self.source { + for chunk in chunks { + validate_stream_chunk(chunk, maximum_frame_bytes, window_bytes)?; + } + } + Ok(()) + } + + async fn next(&mut self) -> Option, PluginFault>> { + match &mut self.source { + StreamSource::Buffered(chunks) => chunks.pop_front().map(Ok), + StreamSource::Channel(receiver) => receiver.recv().await, + StreamSource::Pull(source) => source.next().await, + } + } +} + +/// 动态响应流的有界生产端;最后一个 sender 被释放表示成功终态。 +#[derive(Clone)] +pub struct StreamSender { + sender: mpsc::Sender, PluginFault>>, +} + +impl StreamSender { + /// 排队一个业务分块;实际线协议流控由会话完成。 + pub async fn send(&self, payload: Vec) -> Result<(), SessionError> { + self.sender + .send(Ok(payload)) + .await + .map_err(|_| SessionError::Closed) + } + + /// 以业务错误结束动态流。 + pub async fn fail(&self, fault: PluginFault) -> Result<(), SessionError> { + self.sender + .send(Err(fault)) + .await + .map_err(|_| SessionError::Closed) + } +} + +/// 已读取并校验 Hello、尚未向宿主发送 Ready 的插件会话。 +pub struct PluginSession { + reader: R, + writer: W, + handshake: Handshake, + config: SessionConfig, +} + +impl PluginSession +where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + /// 读取并校验宿主握手。业务处理器可以在 `run` 前读取不可变 Handshake 构造注册描述。 + /// + /// # Errors + /// + /// 配置无效、握手超时、传输关闭或 Hello 不符合协议时失败。 + pub async fn accept( + mut reader: R, + writer: W, + config: SessionConfig, + ) -> Result { + let config = config.validate()?; + let frame = tokio::time::timeout( + config.handshake_timeout, + read_frame(&mut reader, config.maximum_frame_bytes), + ) + .await + .map_err(|_| SessionError::Timeout)??; + let Message::Hello { handshake } = frame.message else { + return Err(SessionError::Handshake); + }; + if !frame.payload.is_empty() + || handshake.protocol_version != PROTOCOL_VERSION + || handshake.plugin_id.is_empty() + || handshake.instance_id.is_empty() + || handshake.generation == 0 + || handshake.incarnation.is_empty() + { + return Err(SessionError::Handshake); + } + Ok(Self { + reader, + writer, + handshake, + config, + }) + } + + #[must_use] + pub const fn handshake(&self) -> &Handshake { + &self.handshake + } + + /// 发送 Ready 并持续处理调用、回调、流控、取消与关闭。 + /// + /// # Errors + /// + /// 帧损坏、协议违规、处理器异常停止或传输关闭时失败。 + pub async fn run(mut self, handler: H) -> Result<(), SessionError> + where + H: PluginHandler, + { + let data_capacity = self + .config + .maximum_calls + .checked_add(self.config.maximum_callbacks) + .ok_or(SessionError::Configuration)?; + let control_reserve = self + .config + .maximum_calls + .checked_add(8) + .ok_or(SessionError::Configuration)?; + let queue_capacity = data_capacity + .checked_add(control_reserve) + .ok_or(SessionError::Configuration)?; + let (outbound, received) = mpsc::channel(queue_capacity); + let output = Output { + sender: outbound, + data_slots: Arc::new(Semaphore::new(data_capacity)), + maximum_frame_bytes: self.config.maximum_frame_bytes, + }; + let callbacks = Arc::new(CallbackRegistry::new(self.config.maximum_callbacks)); + let handler: Arc = Arc::new(handler); + let lifecycle = LifecycleHooks::new(Arc::clone(&handler), control_reserve)?; + write_frame( + &mut self.writer, + &Frame::control(Message::Ready { + protocol_version: PROTOCOL_VERSION, + incarnation: self.handshake.incarnation.clone(), + }), + self.config.maximum_frame_bytes, + ) + .await?; + let writer = tokio::spawn(writer_loop( + self.writer, + received, + self.config.maximum_frame_bytes, + )); + let (completed, completions) = mpsc::channel(self.config.maximum_calls); + let mut driver = SessionDriver { + config: self.config, + instance_id: self.handshake.instance_id, + generation: self.handshake.generation, + incarnation: self.handshake.incarnation, + output, + callbacks, + handler, + lifecycle, + writer, + completed, + completions, + active: BTreeMap::new(), + last_call: 0, + quiescing: false, + }; + let result = driver.drive(self.reader).await; + driver.close().await; + result + } +} + +struct Output { + sender: mpsc::Sender, + data_slots: Arc, + maximum_frame_bytes: usize, +} + +impl Clone for Output { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + data_slots: Arc::clone(&self.data_slots), + maximum_frame_bytes: self.maximum_frame_bytes, + } + } +} + +impl Output { + async fn send_data(&self, frame: Frame) -> Result<(), SessionError> { + validate_frame(&frame, self.maximum_frame_bytes)?; + let permit = Arc::clone(&self.data_slots) + .acquire_owned() + .await + .map_err(|_| SessionError::Closed)?; + self.sender + .send(Outbound { + frame, + _data_slot: Some(permit), + }) + .await + .map_err(|_| SessionError::Closed) + } + + async fn send_data_until( + &self, + frame: Frame, + deadline: Instant, + cancellation: &CallCancellation, + ) -> Result<(), SessionError> { + tokio::select! { + biased; + () = cancellation.cancelled() => Err(SessionError::Cancelled), + () = tokio::time::sleep_until(deadline) => Err(SessionError::Timeout), + result = self.send_data(frame) => result, + } + } + + fn send_control(&self, frame: Frame) -> Result<(), SessionError> { + validate_frame(&frame, self.maximum_frame_bytes)?; + self.sender + .try_send(Outbound { + frame, + _data_slot: None, + }) + .map_err(|_| SessionError::Capacity) + } +} + +struct Outbound { + frame: Frame, + _data_slot: Option, +} + +async fn writer_loop( + mut writer: W, + mut received: mpsc::Receiver, + maximum_frame_bytes: usize, +) -> Result<(), SessionError> { + while let Some(outbound) = received.recv().await { + write_frame(&mut writer, &outbound.frame, maximum_frame_bytes).await?; + } + Ok(()) +} + +struct CallbackRegistry { + state: Mutex, + order: tokio::sync::Mutex<()>, + maximum: usize, + maximum_retired: usize, +} + +struct CallbackState { + next_id: u64, + pending: BTreeMap, + retired: BTreeSet, + retired_order: VecDeque, +} + +struct PendingCallback { + parent_id: u64, + result: oneshot::Sender>, +} + +impl CallbackRegistry { + fn new(maximum: usize) -> Self { + Self { + state: Mutex::new(CallbackState { + next_id: 2, + pending: BTreeMap::new(), + retired: BTreeSet::new(), + retired_order: VecDeque::new(), + }), + order: tokio::sync::Mutex::new(()), + maximum, + maximum_retired: maximum.saturating_mul(2), + } + } + + fn begin( + &self, + parent_id: u64, + ) -> Result<(u64, oneshot::Receiver>), SessionError> { + let mut state = lock_unpoisoned(&self.state); + if state.pending.len() >= self.maximum { + return Err(SessionError::Capacity); + } + let id = state.next_id; + state.next_id = state.next_id.checked_add(2).ok_or(SessionError::Capacity)?; + let (result, received) = oneshot::channel(); + state + .pending + .insert(id, PendingCallback { parent_id, result }); + Ok((id, received)) + } + + fn resolve(&self, frame: Frame) -> Result<(), SessionError> { + let id = match &frame.message { + Message::Result { id, .. } | Message::Error { id, .. } => *id, + _ => return Err(SessionError::Protocol), + }; + let pending = { + let mut state = lock_unpoisoned(&self.state); + if let Some(pending) = state.pending.remove(&id) { + Some(pending) + } else if state.retired.remove(&id) { + state.retired_order.retain(|retired| *retired != id); + None + } else { + return Err(SessionError::Protocol); + } + }; + let Some(pending) = pending else { + return Ok(()); + }; + let result = match frame.message { + Message::Result { result, .. } => Ok(HostReply { + result, + payload: frame.payload, + }), + Message::Error { error, .. } if frame.payload.is_empty() => { + Err(SessionError::Remote(error)) + } + _ => return Err(SessionError::Protocol), + }; + let _ = pending.result.send(result); + Ok(()) + } + + fn retire(&self, id: u64) { + let mut state = lock_unpoisoned(&self.state); + if state.pending.remove(&id).is_some() { + retire_callback(&mut state, id, self.maximum_retired); + } + } + + fn finish_parent(&self, parent_id: u64) { + let pending = { + let mut state = lock_unpoisoned(&self.state); + let mut remaining = BTreeMap::new(); + let mut finished = Vec::new(); + for (id, pending) in std::mem::take(&mut state.pending) { + if pending.parent_id == parent_id { + retire_callback(&mut state, id, self.maximum_retired); + finished.push(pending); + } else { + remaining.insert(id, pending); + } + } + state.pending = remaining; + finished + }; + for pending in pending { + let _ = pending.result.send(Err(SessionError::Cancelled)); + } + } + + fn close(&self) { + let pending = { + let mut state = lock_unpoisoned(&self.state); + std::mem::take(&mut state.pending) + }; + for pending in pending.into_values() { + let _ = pending.result.send(Err(SessionError::Closed)); + } + } +} + +fn retire_callback(state: &mut CallbackState, id: u64, maximum: usize) { + if state.retired.insert(id) { + state.retired_order.push_back(id); + } + while state.retired_order.len() > maximum { + if let Some(expired) = state.retired_order.pop_front() { + state.retired.remove(&expired); + } + } +} + +enum LifecycleEvent { + Cancel(CallContext), + Quiesce, + Shutdown, +} + +#[derive(Clone)] +struct LifecycleHooks { + events: std::sync::mpsc::SyncSender, +} + +impl LifecycleHooks { + fn new(handler: Arc, capacity: usize) -> Result { + let (events, received) = std::sync::mpsc::sync_channel(capacity); + std::thread::Builder::new() + .name("gateway-plugin-sdk-lifecycle".into()) + .spawn(move || { + while let Ok(event) = received.recv() { + match event { + LifecycleEvent::Cancel(context) => handler.cancel(&context), + LifecycleEvent::Quiesce => handler.quiesce(), + LifecycleEvent::Shutdown => { + handler.shutdown(); + break; + } + } + } + }) + .map_err(|_| SessionError::Closed)?; + Ok(Self { events }) + } + + fn cancel(&self, context: CallContext) { + let _ = self.events.try_send(LifecycleEvent::Cancel(context)); + } + + fn quiesce(&self) { + let _ = self.events.try_send(LifecycleEvent::Quiesce); + } + + fn shutdown(&self) { + let _ = self.events.try_send(LifecycleEvent::Shutdown); + } +} + +struct ActiveCall { + context: CallContext, + credits: Arc, + cancellation: CancellationSource, + task: JoinHandle<()>, +} + +#[derive(Clone, Copy)] +enum CallTaskOutcome { + Complete, + TransportFailed, + HandlerStopped, +} + +struct CallFinished { + id: u64, + outcome: CallTaskOutcome, +} + +struct CompletionGuard { + id: u64, + completed: mpsc::Sender, + armed: bool, +} + +impl CompletionGuard { + fn new(id: u64, completed: mpsc::Sender) -> Self { + Self { + id, + completed, + armed: true, + } + } + + fn finish(mut self, outcome: CallTaskOutcome) { + self.armed = false; + let _ = self.completed.try_send(CallFinished { + id: self.id, + outcome, + }); + } +} + +impl Drop for CompletionGuard { + fn drop(&mut self) { + if self.armed { + let _ = self.completed.try_send(CallFinished { + id: self.id, + outcome: CallTaskOutcome::HandlerStopped, + }); + } + } +} + +struct SessionDriver { + config: SessionConfig, + instance_id: String, + generation: u64, + incarnation: String, + output: Output, + callbacks: Arc, + handler: Arc, + lifecycle: LifecycleHooks, + writer: JoinHandle>, + completed: mpsc::Sender, + completions: mpsc::Receiver, + active: BTreeMap, + last_call: u64, + quiescing: bool, +} + +impl SessionDriver { + async fn drive(&mut self, mut reader: R) -> Result<(), SessionError> { + loop { + // read_exact 不是取消安全的;完成通知可以穿插处理,但必须保留同一个半帧读取 future。 + let incoming = read_frame(&mut reader, self.config.maximum_frame_bytes); + tokio::pin!(incoming); + let frame = loop { + tokio::select! { + biased; + writer = &mut self.writer => { + return writer.map_err(|_| SessionError::Closed)?; + } + Some(finished) = self.completions.recv() => { + self.finish_call(finished)?; + } + frame = &mut incoming => { + break frame.map_err(|error| match error { + FrameError::Io(io_error) + if io_error.kind() == std::io::ErrorKind::UnexpectedEof => + { + SessionError::Closed + } + error => SessionError::Frame(error), + })?; + } + } + }; + if self.handle_frame(frame)? { + return Ok(()); + } + } + } + + fn handle_frame(&mut self, frame: Frame) -> Result { + match frame.message { + Message::Call { + id, + method, + context, + params, + } => { + self.start_call(id, method, context, params, frame.payload)?; + Ok(false) + } + Message::Credit { id, bytes, frames } if frame.payload.is_empty() => { + if id == 0 + || id.is_multiple_of(2) + || id > self.last_call + || bytes == 0 + || frames == 0 + { + return Err(SessionError::Protocol); + } + // End/Cancelled 已进入同一 FIFO 后,宿主先前为已消费分块排队的 + // Credit 仍可能稍晚抵达。此时调用资源已经回收,忽略迟到信用即可; + // 未来或回调 ID 仍按协议错误处理。 + if let Some(active) = self.active.get(&id) { + active.credits.grant(bytes, frames)?; + } + Ok(false) + } + Message::Cancel { id } if frame.payload.is_empty() => { + self.cancel_call(id)?; + Ok(false) + } + Message::Result { id, result } => { + self.callbacks.resolve(Frame { + message: Message::Result { id, result }, + payload: frame.payload, + })?; + Ok(false) + } + Message::Error { id, error } => { + self.callbacks.resolve(Frame { + message: Message::Error { id, error }, + payload: frame.payload, + })?; + Ok(false) + } + Message::Quiesce if frame.payload.is_empty() => { + if !self.quiescing { + self.quiescing = true; + self.lifecycle.quiesce(); + } + Ok(false) + } + Message::Shutdown if frame.payload.is_empty() => Ok(true), + _ => Err(SessionError::Protocol), + } + } + + fn start_call( + &mut self, + id: u64, + method: String, + context: CallContext, + params: Value, + payload: Vec, + ) -> Result<(), SessionError> { + if id == 0 + || id.is_multiple_of(2) + || id <= self.last_call + || context.call_id != id + || context.instance_id != self.instance_id + || context.generation != self.generation + || context.incarnation != self.incarnation + || context.timeout_ms == 0 + || Duration::from_millis(context.timeout_ms) > self.config.maximum_call_timeout + || method.is_empty() + || method.len() > 128 + { + return Err(SessionError::Protocol); + } + self.last_call = id; + if self.quiescing { + return self.output.send_control(fault_frame( + id, + PluginFault::new(ErrorCode::Capacity, "plugin is quiescing"), + )); + } + if self.active.len() >= self.config.maximum_calls { + return self.output.send_control(fault_frame( + id, + PluginFault::new(ErrorCode::Capacity, "plugin call capacity is exhausted"), + )); + } + let deadline = Instant::now() + .checked_add(Duration::from_millis(context.timeout_ms)) + .ok_or(SessionError::Protocol)?; + let credits = Arc::new(CreditWindow::default()); + let (cancellation, cancellation_signal) = CancellationSource::new(); + let host = HostClient { + parent_id: id, + deadline, + cancellation: cancellation_signal.clone(), + callbacks: Arc::clone(&self.callbacks), + output: self.output.clone(), + maximum_frame_bytes: self.config.maximum_frame_bytes, + }; + let call = PluginCall { + method, + context: context.clone(), + params, + payload, + host, + cancellation: cancellation_signal, + }; + let handler = Arc::clone(&self.handler); + let output = self.output.clone(); + let completed = self.completed.clone(); + let task_credits = Arc::clone(&credits); + let maximum_frame_bytes = self.config.maximum_frame_bytes; + let maximum_chunks = self.config.maximum_buffered_stream_chunks; + let task_context = context.clone(); + let lifecycle = self.lifecycle.clone(); + let task = tokio::spawn(async move { + let guard = CompletionGuard::new(id, completed); + let result = run_call( + handler, + call, + task_context, + task_credits, + output, + lifecycle, + deadline, + maximum_frame_bytes, + maximum_chunks, + ) + .await; + guard.finish(if result.is_ok() { + CallTaskOutcome::Complete + } else { + CallTaskOutcome::TransportFailed + }); + }); + self.active.insert( + id, + ActiveCall { + context, + credits, + cancellation, + task, + }, + ); + Ok(()) + } + + fn cancel_call(&mut self, id: u64) -> Result<(), SessionError> { + if id == 0 || id.is_multiple_of(2) || id > self.last_call { + return Err(SessionError::Protocol); + } + if let Some(active) = self.active.remove(&id) { + active.cancellation.cancel(); + self.callbacks.finish_parent(id); + self.lifecycle.cancel(active.context); + active.task.abort(); + } + self.output + .send_control(Frame::control(Message::Cancelled { id })) + } + + fn finish_call(&mut self, finished: CallFinished) -> Result<(), SessionError> { + let Some(_active) = self.active.remove(&finished.id) else { + return Ok(()); + }; + self.callbacks.finish_parent(finished.id); + match finished.outcome { + CallTaskOutcome::Complete => Ok(()), + CallTaskOutcome::TransportFailed => Err(SessionError::Closed), + CallTaskOutcome::HandlerStopped => Err(SessionError::HandlerStopped), + } + } + + async fn close(&mut self) { + for (id, active) in std::mem::take(&mut self.active) { + active.cancellation.cancel(); + self.callbacks.finish_parent(id); + self.lifecycle.cancel(active.context); + active.task.abort(); + } + self.callbacks.close(); + self.lifecycle.shutdown(); + self.writer.abort(); + let _ = (&mut self.writer).await; + } +} + +#[expect( + clippy::too_many_arguments, + reason = "调用任务显式接收其完整资源边界,避免隐藏第二份会话状态" +)] +async fn run_call( + handler: Arc, + call: PluginCall, + context: CallContext, + credits: Arc, + output: Output, + lifecycle: LifecycleHooks, + deadline: Instant, + maximum_frame_bytes: usize, + maximum_chunks: usize, +) -> Result<(), SessionError> { + let id = context.call_id; + let cancellation = call.cancellation.clone(); + let response = tokio::select! { + biased; + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep_until(deadline) => { + lifecycle.cancel(context.clone()); + output.send_control(fault_frame( + id, + PluginFault::new(ErrorCode::Timeout, "plugin call timed out"), + ))?; + return Ok(()); + } + response = handler.call(call) => response, + }; + let mut reply = match response { + Ok(reply) => reply, + Err(error) => { + let _ = send_initial( + &output, + bounded_fault_frame(id, error, maximum_frame_bytes), + &context, + &lifecycle, + deadline, + &cancellation, + ) + .await?; + return Ok(()); + } + }; + let initial = Frame { + message: Message::Result { + id, + result: reply.result, + }, + payload: reply.payload, + }; + if validate_frame(&initial, maximum_frame_bytes).is_err() { + output.send_control(fault_frame( + id, + PluginFault::new( + ErrorCode::InvalidInput, + "plugin response exceeds the frame limit", + ), + ))?; + return Ok(()); + } + let Some(mut stream) = reply.stream.take() else { + let _ = send_initial( + &output, + initial, + &context, + &lifecycle, + deadline, + &cancellation, + ) + .await?; + return Ok(()); + }; + let window_bytes = match credits.initial_window(deadline, &cancellation).await { + Ok(window) => window, + Err(SessionError::Timeout) => { + lifecycle.cancel(context.clone()); + output.send_control(fault_frame( + id, + PluginFault::new(ErrorCode::Timeout, "stream credit timed out"), + ))?; + return Ok(()); + } + Err(SessionError::Cancelled) => return Ok(()), + Err(error) => return Err(error), + }; + if let Err(fault) = stream.validate_buffered(maximum_chunks, maximum_frame_bytes, window_bytes) + { + output.send_control(fault_frame(id, fault))?; + return Ok(()); + } + if !send_initial( + &output, + initial, + &context, + &lifecycle, + deadline, + &cancellation, + ) + .await? + { + return Ok(()); + } + let mut sequence = 0_u64; + loop { + let item = tokio::select! { + biased; + () = cancellation.cancelled() => return Ok(()), + () = tokio::time::sleep_until(deadline) => { + lifecycle.cancel(context.clone()); + output.send_control(end_frame( + id, + Some(PluginFault::new(ErrorCode::Timeout, "plugin stream timed out")), + ))?; + return Ok(()); + } + item = stream.next() => item, + }; + let Some(item) = item else { + output.send_control(end_frame(id, None))?; + return Ok(()); + }; + let payload = match item { + Ok(payload) => payload, + Err(fault) => { + output.send_control(bounded_end_frame(id, fault, maximum_frame_bytes))?; + return Ok(()); + } + }; + if let Err(fault) = validate_stream_chunk(&payload, maximum_frame_bytes, window_bytes) { + output.send_control(end_frame(id, Some(fault)))?; + return Ok(()); + } + match credits + .take(payload.len() as u64, deadline, &cancellation) + .await + { + Ok(()) => {} + Err(SessionError::Timeout) => { + lifecycle.cancel(context.clone()); + output.send_control(end_frame( + id, + Some(PluginFault::new( + ErrorCode::Timeout, + "stream credit timed out", + )), + ))?; + return Ok(()); + } + Err(SessionError::Cancelled) => return Ok(()), + Err(error) => return Err(error), + } + let frame = Frame { + message: Message::Stream { id, sequence }, + payload, + }; + if !send_stream_data( + &output, + frame, + id, + &context, + &lifecycle, + deadline, + &cancellation, + ) + .await? + { + return Ok(()); + } + sequence = sequence.checked_add(1).ok_or(SessionError::Protocol)?; + } +} + +async fn send_initial( + output: &Output, + frame: Frame, + context: &CallContext, + lifecycle: &LifecycleHooks, + deadline: Instant, + cancellation: &CallCancellation, +) -> Result { + match output.send_data_until(frame, deadline, cancellation).await { + Ok(()) => Ok(true), + Err(SessionError::Cancelled) => Ok(false), + Err(SessionError::Timeout) => { + lifecycle.cancel(context.clone()); + output.send_control(fault_frame( + context.call_id, + PluginFault::new(ErrorCode::Timeout, "plugin call timed out"), + ))?; + Ok(false) + } + Err(error) => Err(error), + } +} + +async fn send_stream_data( + output: &Output, + frame: Frame, + id: u64, + context: &CallContext, + lifecycle: &LifecycleHooks, + deadline: Instant, + cancellation: &CallCancellation, +) -> Result { + match output.send_data_until(frame, deadline, cancellation).await { + Ok(()) => Ok(true), + Err(SessionError::Cancelled) => Ok(false), + Err(SessionError::Timeout) => { + lifecycle.cancel(context.clone()); + output.send_control(end_frame( + id, + Some(PluginFault::new( + ErrorCode::Timeout, + "plugin stream timed out", + )), + ))?; + Ok(false) + } + Err(error) => Err(error), + } +} + +#[derive(Default)] +struct CreditWindow { + state: Mutex, + changed: tokio::sync::Notify, +} + +#[derive(Default)] +struct CreditState { + bytes: u64, + frames: u64, + maximum_bytes: u64, +} + +impl CreditWindow { + fn grant(&self, bytes: u32, frames: u32) -> Result<(), SessionError> { + if bytes == 0 || frames == 0 { + return Err(SessionError::Protocol); + } + let mut state = lock_unpoisoned(&self.state); + state.bytes = state + .bytes + .checked_add(u64::from(bytes)) + .ok_or(SessionError::Protocol)?; + state.frames = state + .frames + .checked_add(u64::from(frames)) + .ok_or(SessionError::Protocol)?; + state.maximum_bytes = state.maximum_bytes.max(state.bytes); + drop(state); + // 每个调用只有一个顺序消费 Credit 的任务;notify_one 会保留许可,避免 + // grant 恰好发生在状态检查和等待注册之间时丢失唤醒。 + self.changed.notify_one(); + Ok(()) + } + + async fn initial_window( + &self, + deadline: Instant, + cancellation: &CallCancellation, + ) -> Result { + loop { + let changed = self.changed.notified(); + { + let state = lock_unpoisoned(&self.state); + if state.bytes > 0 && state.frames > 0 { + return Ok(state.maximum_bytes); + } + } + tokio::select! { + biased; + () = cancellation.cancelled() => return Err(SessionError::Cancelled), + () = tokio::time::sleep_until(deadline) => return Err(SessionError::Timeout), + () = changed => {} + } + } + } + + async fn take( + &self, + bytes: u64, + deadline: Instant, + cancellation: &CallCancellation, + ) -> Result<(), SessionError> { + loop { + let changed = self.changed.notified(); + { + let mut state = lock_unpoisoned(&self.state); + if bytes > state.maximum_bytes { + return Err(SessionError::Protocol); + } + if state.bytes >= bytes && state.frames > 0 { + state.bytes -= bytes; + state.frames -= 1; + return Ok(()); + } + } + tokio::select! { + biased; + () = cancellation.cancelled() => return Err(SessionError::Cancelled), + () = tokio::time::sleep_until(deadline) => return Err(SessionError::Timeout), + () = changed => {} + } + } + } +} + +fn validate_stream_chunk( + payload: &[u8], + maximum_frame_bytes: usize, + window_bytes: u64, +) -> Result<(), PluginFault> { + if payload.is_empty() { + return Err(PluginFault::new( + ErrorCode::InvalidInput, + "stream chunks must not be empty", + )); + } + if payload.len() as u64 > window_bytes { + return Err(PluginFault::new( + ErrorCode::InvalidInput, + "response event exceeds the host stream credit window", + )); + } + if validate_message( + &Message::Stream { id: 1, sequence: 0 }, + payload.len(), + maximum_frame_bytes, + ) + .is_err() + { + return Err(PluginFault::new( + ErrorCode::InvalidInput, + "response event exceeds the frame limit", + )); + } + Ok(()) +} + +fn capacity_fault(message: &'static str) -> PluginFault { + PluginFault::new(ErrorCode::Capacity, message) +} + +fn fault_frame(id: u64, error: PluginFault) -> Frame { + Frame::control(Message::Error { id, error }) +} + +fn end_frame(id: u64, error: Option) -> Frame { + Frame::control(Message::End { id, error }) +} + +fn bounded_fault_frame(id: u64, error: PluginFault, maximum_frame_bytes: usize) -> Frame { + let frame = fault_frame(id, error); + if validate_frame(&frame, maximum_frame_bytes).is_ok() { + frame + } else { + fault_frame( + id, + PluginFault::new(ErrorCode::Fault, "plugin error exceeds the frame limit"), + ) + } +} + +fn bounded_end_frame(id: u64, error: PluginFault, maximum_frame_bytes: usize) -> Frame { + let frame = end_frame(id, Some(error)); + if validate_frame(&frame, maximum_frame_bytes).is_ok() { + frame + } else { + end_frame( + id, + Some(PluginFault::new( + ErrorCode::Fault, + "plugin stream error exceeds the frame limit", + )), + ) + } +} + +fn lock_unpoisoned(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/backend/crates/gateway-plugin/sdk/src/context.rs b/backend/crates/gateway-plugin/sdk/src/context.rs new file mode 100644 index 000000000..f55e8c14a --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/context.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Contributions, Permission, Stage}; + +/// 一次会话绑定不可变制品与配置;重启必须使用新的 incarnation。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Handshake { + pub protocol_version: u32, + pub artifact_sha256: String, + pub plugin_id: String, + pub instance_id: String, + pub generation: u64, + pub incarnation: String, + pub configuration: serde_json::Value, + pub permissions: Vec, + #[serde(deserialize_with = "crate::capability::deserialize_contributions")] + pub contributes: Contributions, +} + +impl std::fmt::Debug for Handshake { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Handshake") + .field("protocol_version", &self.protocol_version) + .field("plugin_id", &self.plugin_id) + .field("instance_id", &self.instance_id) + .field("generation", &self.generation) + .field("incarnation", &self.incarnation) + .finish_non_exhaustive() + } +} + +/// 仅携带安全关联信息;这些标识本身不是宿主资源的访问凭证。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CallContext { + pub call_id: u64, + pub instance_id: String, + pub generation: u64, + pub incarnation: String, + pub stage: Stage, + pub timeout_ms: u64, + pub resource_scope_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential_revision: Option, +} diff --git a/backend/crates/gateway-plugin/sdk/src/error.rs b/backend/crates/gateway-plugin/sdk/src/error.rs new file mode 100644 index 000000000..61c9759ba --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/error.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + Unsupported, + Rejected, + InvalidInput, + PermissionDenied, + Upstream, + Fault, + Timeout, + Cancelled, + Uncertain, + Capacity, + Conflict, +} + +/// 发送事实由宿主取单调上界,不能由插件降回未发送。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SendState { + NotSent, + Sent, + Ambiguous, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginFault { + pub code: ErrorCode, + pub message: String, + pub send_state: SendState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_status: Option, +} + +impl std::fmt::Debug for PluginFault { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PluginFault") + .field("code", &self.code) + .field("send_state", &self.send_state) + .field("http_status", &self.http_status) + .finish_non_exhaustive() + } +} + +impl PluginFault { + #[must_use] + pub fn new(code: ErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + send_state: SendState::NotSent, + http_status: None, + } + } +} diff --git a/backend/crates/gateway-plugin/sdk/src/lib.rs b/backend/crates/gateway-plugin/sdk/src/lib.rs new file mode 100644 index 000000000..b08afd6f5 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/lib.rs @@ -0,0 +1,21 @@ +//! 实验性插件线协议;不依赖网关领域或基础设施类型。 + +pub mod call; +mod capability; +#[cfg(feature = "io")] +pub mod client; +mod context; +mod error; +mod manifest; +mod message; + +pub use capability::{ + Capability, ContributionDeclaration, Contributions, FailurePolicy, Permission, Stage, +}; +pub use context::{CallContext, Handshake}; +pub use error::{ErrorCode, PluginFault, SendState}; +pub use manifest::{ + Engines, MANIFEST_VERSION, Manifest, ManifestError, Package, PackageTarget, PluginIcon, + PluginIconVariants, RuntimeKind, StateNamespace, valid_package_path, +}; +pub use message::{Frame, FrameError, Message, PROTOCOL_VERSION}; diff --git a/backend/crates/gateway-plugin/sdk/src/manifest.rs b/backend/crates/gateway-plugin/sdk/src/manifest.rs new file mode 100644 index 000000000..ddfa23296 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/manifest.rs @@ -0,0 +1,699 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt, +}; + +use semver::{Version, VersionReq}; +use serde::{ + Deserialize, Deserializer, Serialize, + de::{self, MapAccess, SeqAccess, Visitor}, +}; +use serde_json::Value; + +use crate::{ + Capability, ContributionDeclaration, Contributions, PROTOCOL_VERSION, Permission, Stage, +}; + +/// 当前插件清单格式版本。 +pub const MANIFEST_VERSION: u32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RuntimeKind { + TrustedProcess, +} + +/// 插件展示图标;路径始终引用同一清单中声明的包资源。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginIcon { + Path(String), + Themed(PluginIconVariants), +} + +/// 为浅色与深色界面分别声明的插件图标。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginIconVariants { + pub light: String, + pub dark: String, +} + +/// 宿主版本兼容范围。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Engines { + #[serde(rename = "codex-proxy-rs")] + pub codex_proxy_rs: VersionReq, +} + +/// 安装包的唯一目标平台。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackageTarget { + pub os: String, + pub architecture: String, +} + +/// 构建生成的安装包元数据;作者源清单可以省略。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Package { + pub protocol_version: u32, + pub target: PackageTarget, + pub files: BTreeMap, +} + +/// 插件私有状态的单个命名空间声明;宿主仍会施加更严格的全局上限。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StateNamespace { + pub namespace: String, + pub schema_version: u32, + pub schema: serde_json::Value, + pub maximum_records: u32, + pub maximum_bytes: u64, + pub maximum_value_bytes: u32, + #[serde(default)] + pub migrates_from: Vec, +} + +impl fmt::Debug for StateNamespace { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("StateNamespace") + .field("namespace", &self.namespace) + .field("schema_version", &self.schema_version) + .field("schema", &"[REDACTED]") + .field("maximum_records", &self.maximum_records) + .field("maximum_bytes", &self.maximum_bytes) + .field("maximum_value_bytes", &self.maximum_value_bytes) + .field("migrates_from", &self.migrates_from) + .finish() + } +} + +/// 插件作者清单。`package` 缺省时表示尚未构建的源清单。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Manifest { + pub manifest_version: u32, + pub name: String, + pub display_name: String, + pub publisher: String, + pub version: Version, + pub description: String, + pub license: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub author: Option, + pub engines: Engines, + pub main: String, + pub runtime: RuntimeKind, + #[serde(deserialize_with = "crate::capability::deserialize_contributions")] + pub contributes: Contributions, + #[serde(default)] + pub permissions: BTreeSet, + #[serde(default = "empty_configuration_schema")] + pub configuration_schema: serde_json::Value, + #[serde(default)] + pub secret_fields: BTreeSet, + #[serde(default)] + pub resources: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default)] + pub state: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package: Option, +} + +fn empty_configuration_schema() -> serde_json::Value { + serde_json::json!({}) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ManifestError { + #[error("plugin manifest is invalid")] + Invalid, + #[error("plugin protocol or manifest version is incompatible")] + Incompatible, + #[error("plugin does not support the host version or platform")] + Platform, +} + +impl Manifest { + /// 读取作者清单并生成与 CLI、运行注册一致的完整声明。 + /// + /// # Errors + /// + /// JSON、版本、能力声明或安装包字段不符合作者合同时返回错误。 + pub fn from_author_slice(bytes: &[u8]) -> Result { + let UniqueValue(mut source) = + serde_json::from_slice(bytes).map_err(|_| ManifestError::Invalid)?; + expand_author_provider(&mut source)?; + let mut manifest: Self = + serde_json::from_value(source).map_err(|_| ManifestError::Invalid)?; + manifest.normalize_author()?; + Ok(manifest) + } + + /// 作者清单允许省略扩展项 ID、能力版本和固定阶段,安装清单不隐式补全。 + /// + /// # Errors + /// + /// 已含构建元数据、阶段与固定合同冲突或规范化后清单无效时返回错误。 + pub fn normalize_author(&mut self) -> Result<(), ManifestError> { + if self.package.is_some() { + return Err(ManifestError::Invalid); + } + let plugin_id = self.plugin_id()?; + for (capability, declaration) in &mut self.contributes { + if declaration.id.is_empty() { + declaration.id = + format!("{plugin_id}.{}", capability.identifier().replace('_', "-")); + } + if *capability != Capability::Middleware { + if !declaration.stages.is_empty() && declaration.stages != capability.fixed_stages() + { + return Err(ManifestError::Invalid); + } + declaration.stages = capability.fixed_stages().to_vec(); + } + } + self.validate() + } + + /// 派生并校验稳定插件 ID。 + /// + /// # Errors + /// + /// 发布者、机器短名或派生后的完整 ID 不符合命名边界时返回错误。 + pub fn plugin_id(&self) -> Result { + if !valid_plugin_segment(&self.publisher) || !valid_plugin_segment(&self.name) { + return Err(ManifestError::Invalid); + } + let plugin_id = format!("{}.{}", self.publisher, self.name); + if plugin_id.len() > 64 { + return Err(ManifestError::Invalid); + } + Ok(plugin_id) + } + + /// 校验源清单,并在 `package` 存在时同时执行严格安装包结构校验。 + /// + /// # Errors + /// + /// 清单版本、身份、声明、路径、摘要、状态或安装包元数据不符合合同时返回错误。 + pub fn validate(&self) -> Result<(), ManifestError> { + if self.manifest_version != MANIFEST_VERSION { + return Err(ManifestError::Incompatible); + } + let plugin_id = self.plugin_id()?; + if [&self.display_name, &self.description, &self.license] + .iter() + .any(|text| !valid_text(text)) + || self.author.as_ref().is_some_and(|text| !valid_text(text)) + || !valid_package_path(&self.main) + || self.main == "plugin.json" + || self.contributes.len() > 64 + || self.resources.len() > 128 + || self.secret_fields.len() > 64 + || self.state.len() > 16 + { + return Err(ManifestError::Invalid); + } + self.validate_contributions(&plugin_id)?; + self.validate_resources()?; + self.validate_icon()?; + self.validate_state()?; + if let Some(package) = &self.package { + self.validate_package(package)?; + } + Ok(()) + } + + /// 返回与当前宿主及平台匹配的安装包元数据。 + /// + /// # Errors + /// + /// 源清单尚无安装包、清单无效、宿主版本不匹配或目标平台不同时返回错误。 + pub fn package_for( + &self, + host: &Version, + os: &str, + architecture: &str, + ) -> Result<&Package, ManifestError> { + self.validate()?; + let package = self.package.as_ref().ok_or(ManifestError::Invalid)?; + if !self.engines.codex_proxy_rs.matches(host) + || package.target.os != os + || package.target.architecture != architecture + { + return Err(ManifestError::Platform); + } + Ok(package) + } + + fn validate_contributions(&self, plugin_id: &str) -> Result<(), ManifestError> { + let mut contribution_ids = BTreeSet::new(); + let prefix = format!("{plugin_id}."); + for (capability, declaration) in &self.contributes { + let Some(local_id) = declaration.id.strip_prefix(&prefix) else { + return Err(ManifestError::Invalid); + }; + let stages = declaration.stages.iter().copied().collect::>(); + let input_formats = declaration.input_formats.iter().collect::>(); + let output_formats = declaration.output_formats.iter().collect::>(); + if declaration.version != 1 + || declaration.id.len() > 128 + || local_id.is_empty() + || !local_id.is_ascii() + || !local_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + || !contribution_ids.insert(&declaration.id) + || declaration.stages.len() > 32 + || stages.len() != declaration.stages.len() + || input_formats.len() != declaration.input_formats.len() + || output_formats.len() != declaration.output_formats.len() + || declaration + .input_formats + .iter() + .chain(&declaration.output_formats) + .any(|format| !platform_component(format)) + || (*capability == Capability::Middleware + && (declaration.stages.is_empty() + || declaration + .stages + .iter() + .any(|stage| !matches!(stage, Stage::Request | Stage::Attempt)))) + || (*capability != Capability::Middleware + && declaration.stages != capability.fixed_stages()) + || (*capability == Capability::Executor + && (declaration.input_formats.is_empty() + || declaration.output_formats.is_empty())) + { + return Err(ManifestError::Invalid); + } + } + if !self.contributes.contains_key(&Capability::Executor) + && self.contributes.keys().any(|capability| { + matches!( + capability, + Capability::Models + | Capability::Authentication + | Capability::Quota + | Capability::RequestProfile + | Capability::AccountManagement + | Capability::Billing + | Capability::Maintenance + ) + }) + { + return Err(ManifestError::Invalid); + } + Ok(()) + } + + fn validate_resources(&self) -> Result<(), ManifestError> { + for (path, content_type) in &self.resources { + if !valid_package_path(path) + || path == "plugin.json" + || content_type.is_empty() + || content_type.len() > 128 + || !content_type.is_ascii() + || content_type.chars().any(char::is_control) + { + return Err(ManifestError::Invalid); + } + } + if self + .secret_fields + .iter() + .any(|field| !platform_component(field)) + { + return Err(ManifestError::Invalid); + } + Ok(()) + } + + fn validate_icon(&self) -> Result<(), ManifestError> { + let Some(icon) = &self.icon else { + return Ok(()); + }; + let valid = |path: &str| { + let extension = path.rsplit('.').next().unwrap_or_default(); + let content_types: &[&str] = match extension.to_ascii_lowercase().as_str() { + "svg" => &["image/svg+xml"], + "png" => &["image/png"], + "jpg" | "jpeg" | "jfif" => &["image/jpeg"], + "webp" => &["image/webp"], + "gif" => &["image/gif"], + "ico" => &["image/vnd.microsoft.icon", "image/x-icon"], + "bmp" => &["image/bmp", "image/x-ms-bmp"], + _ => &[], + }; + valid_package_path(path) + && self + .resources + .get(path) + .is_some_and(|actual| content_types.contains(&actual.as_str())) + }; + let valid = match icon { + PluginIcon::Path(path) => valid(path), + PluginIcon::Themed(variants) => valid(&variants.light) && valid(&variants.dark), + }; + valid.then_some(()).ok_or(ManifestError::Invalid) + } + + fn validate_state(&self) -> Result<(), ManifestError> { + let mut state_namespaces = BTreeSet::new(); + for state in &self.state { + let schema_bytes = + serde_json::to_vec(&state.schema).map_err(|_| ManifestError::Invalid)?; + let migrations = state.migrates_from.iter().copied().collect::>(); + if !valid_namespace(&state.namespace) + || !state_namespaces.insert(&state.namespace) + || state.schema_version == 0 + || !state.schema.is_object() + || schema_bytes.len() > 32 * 1024 + || state.maximum_records == 0 + || state.maximum_records > 10_000 + || state.maximum_bytes == 0 + || state.maximum_bytes > 16 * 1024 * 1024 + || state.maximum_value_bytes == 0 + || state.maximum_value_bytes > 256 * 1024 + || u64::from(state.maximum_value_bytes) > state.maximum_bytes + || migrations.len() != state.migrates_from.len() + || migrations.contains(&0) + || migrations.contains(&state.schema_version) + { + return Err(ManifestError::Invalid); + } + } + Ok(()) + } + + fn validate_package(&self, package: &Package) -> Result<(), ManifestError> { + if package.protocol_version != PROTOCOL_VERSION { + return Err(ManifestError::Incompatible); + } + // semver 将全版本 `*` 表示为空 comparator;`1.*` 仍有明确的主版本边界。 + if self.engines.codex_proxy_rs.comparators.is_empty() + || !platform_component(&package.target.os) + || !platform_component(&package.target.architecture) + || package.files.is_empty() + || package.files.len() > 256 + || !package.files.contains_key(&self.main) + || self + .resources + .keys() + .any(|path| !package.files.contains_key(path)) + { + return Err(ManifestError::Invalid); + } + for (path, digest) in &package.files { + if !valid_package_path(path) + || path == "plugin.json" + || digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(ManifestError::Invalid); + } + // 文件不能同时充当父目录,避免跨平台路径覆盖与解压顺序歧义。 + if path + .split('/') + .scan(String::new(), |parent, part| { + if !parent.is_empty() { + parent.push('/'); + } + parent.push_str(part); + Some(parent.clone()) + }) + .any(|parent| parent != *path && package.files.contains_key(&parent)) + { + return Err(ManifestError::Invalid); + } + } + Ok(()) + } +} + +/// `serde_json::Value` 默认保留重复对象键的最后一个值;作者清单不能让同一声明因解析入口而改变含义。 +struct UniqueValue(Value); + +impl<'de> Deserialize<'de> for UniqueValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(UniqueValueVisitor) + } +} + +struct UniqueValueVisitor; + +impl<'de> Visitor<'de> for UniqueValueVisitor { + type Value = UniqueValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("JSON without duplicate object keys") + } + + fn visit_bool(self, value: bool) -> Result { + Ok(UniqueValue(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(UniqueValue(Value::Number(value.into()))) + } + + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + serde_json::Number::from_f64(value) + .map(Value::Number) + .map(UniqueValue) + .ok_or_else(|| E::custom("JSON number is not finite")) + } + + fn visit_str(self, value: &str) -> Result { + Ok(UniqueValue(Value::String(value.to_owned()))) + } + + fn visit_string(self, value: String) -> Result { + Ok(UniqueValue(Value::String(value))) + } + + fn visit_none(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_unit(self) -> Result { + Ok(UniqueValue(Value::Null)) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + UniqueValue::deserialize(deserializer) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + while let Some(UniqueValue(value)) = sequence.next_element()? { + values.push(value); + } + Ok(UniqueValue(Value::Array(values))) + } + + fn visit_map(self, mut entries: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = serde_json::Map::new(); + while let Some((key, UniqueValue(value))) = entries.next_entry()? { + if values.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate JSON object key")); + } + } + Ok(UniqueValue(Value::Object(values))) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AuthorProviderContribution { + #[serde(default)] + capabilities: Vec, + #[serde(default)] + input_formats: Vec, + #[serde(default)] + output_formats: Vec, +} + +fn expand_author_provider(source: &mut Value) -> Result<(), ManifestError> { + let Some(contributes) = source + .as_object_mut() + .and_then(|manifest| manifest.get_mut("contributes")) + .and_then(Value::as_object_mut) + else { + return Err(ManifestError::Invalid); + }; + let Some(provider) = contributes.remove("provider") else { + return Ok(()); + }; + let provider: AuthorProviderContribution = + serde_json::from_value(provider).map_err(|_| ManifestError::Invalid)?; + let capabilities = provider + .capabilities + .iter() + .copied() + .collect::>(); + if capabilities.len() != provider.capabilities.len() + || provider.input_formats.is_empty() + || provider.output_formats.is_empty() + || capabilities.iter().any(|capability| { + !matches!( + capability, + Capability::Models + | Capability::Authentication + | Capability::Quota + | Capability::RequestProfile + | Capability::AccountManagement + | Capability::Billing + | Capability::Maintenance + ) + }) + { + return Err(ManifestError::Invalid); + } + let executor = ContributionDeclaration { + id: String::new(), + version: 1, + stages: Vec::new(), + input_formats: provider.input_formats, + output_formats: provider.output_formats, + }; + insert_author_contribution(contributes, Capability::Executor, executor)?; + for capability in capabilities { + insert_author_contribution( + contributes, + capability, + ContributionDeclaration { + id: String::new(), + version: 1, + stages: Vec::new(), + input_formats: Vec::new(), + output_formats: Vec::new(), + }, + )?; + } + Ok(()) +} + +fn insert_author_contribution( + contributes: &mut serde_json::Map, + capability: Capability, + declaration: ContributionDeclaration, +) -> Result<(), ManifestError> { + let identifier = capability.identifier().to_owned(); + if contributes.contains_key(&identifier) { + return Err(ManifestError::Invalid); + } + let declaration = serde_json::to_value(declaration).map_err(|_| ManifestError::Invalid)?; + contributes.insert(identifier, declaration); + Ok(()) +} + +fn valid_text(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 4096 && !value.chars().any(char::is_control) +} + +fn valid_plugin_segment(value: &str) -> bool { + let Some(first) = value.as_bytes().first() else { + return false; + }; + let Some(last) = value.as_bytes().last() else { + return false; + }; + first.is_ascii_alphanumeric() + && last.is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +fn valid_namespace(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.as_bytes()[0].is_ascii_lowercase() + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_.".contains(&byte) + }) +} + +fn platform_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte)) +} + +/// 逐个检查原始路径段,不能让操作系统先规范化掉 `.`、空段或反斜线。 +#[must_use] +pub fn valid_package_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 240 + && path.is_ascii() + && path.split('/').all(|part| { + !part.is_empty() + && part != "." + && part != ".." + && !part.ends_with(['.', ' ']) + && part + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte)) + && !matches!( + part.split('.') + .next() + .unwrap_or("") + .to_ascii_uppercase() + .as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + }) +} diff --git a/backend/crates/gateway-plugin/sdk/src/message.rs b/backend/crates/gateway-plugin/sdk/src/message.rs new file mode 100644 index 000000000..645117237 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/message.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; + +use crate::{CallContext, Handshake, PluginFault}; + +pub const PROTOCOL_VERSION: u32 = 4; + +/// 元数据与二进制载荷分开,流分块不经过 JSON/base64。 +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Message { + Hello { + handshake: Handshake, + }, + Ready { + protocol_version: u32, + incarnation: String, + }, + Call { + id: u64, + method: String, + context: CallContext, + params: serde_json::Value, + }, + Result { + id: u64, + result: serde_json::Value, + }, + Error { + id: u64, + error: PluginFault, + }, + Callback { + id: u64, + parent_id: u64, + method: String, + params: serde_json::Value, + }, + Cancel { + id: u64, + }, + Cancelled { + id: u64, + }, + Stream { + id: u64, + sequence: u64, + }, + Credit { + id: u64, + bytes: u32, + frames: u32, + }, + End { + id: u64, + error: Option, + }, + Quiesce, + Shutdown, +} + +#[derive(Clone, PartialEq)] +pub struct Frame { + pub message: Message, + pub payload: Vec, +} + +impl std::fmt::Debug for Message { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // 配置、调用参数和结果都可能包含凭据;诊断仅暴露消息类别及关联 ID。 + let (kind, id) = match self { + Self::Hello { .. } => ("Hello", None), + Self::Ready { .. } => ("Ready", None), + Self::Call { id, .. } => ("Call", Some(id)), + Self::Result { id, .. } => ("Result", Some(id)), + Self::Error { id, .. } => ("Error", Some(id)), + Self::Callback { id, .. } => ("Callback", Some(id)), + Self::Cancel { id } => ("Cancel", Some(id)), + Self::Cancelled { id } => ("Cancelled", Some(id)), + Self::Stream { id, .. } => ("Stream", Some(id)), + Self::Credit { id, .. } => ("Credit", Some(id)), + Self::End { id, .. } => ("End", Some(id)), + Self::Quiesce => ("Quiesce", None), + Self::Shutdown => ("Shutdown", None), + }; + formatter + .debug_struct(kind) + .field("id", &id) + .finish_non_exhaustive() + } +} + +impl std::fmt::Debug for Frame { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Frame") + .field("message", &self.message) + .field("payload_bytes", &self.payload.len()) + .finish() + } +} + +impl Frame { + #[must_use] + pub fn control(message: Message) -> Self { + Self { + message, + payload: Vec::new(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("plugin frame exceeds its limit or has invalid lengths")] + Length, + #[error("plugin frame metadata is invalid")] + Metadata, + #[error("plugin transport is closed or incomplete")] + Io(#[from] std::io::Error), +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs b/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs new file mode 100644 index 000000000..fbf1d09d8 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs @@ -0,0 +1,28 @@ +use gateway_plugin_sdk::call::frontend_authentication::{ + FrontendAuthenticationRequest, FrontendAuthenticationResult, +}; +use serde_json::json; + +#[test] +fn authentication_contract_never_accepts_a_plugin_selected_client_key() { + let result = serde_json::from_value::(json!({ + "outcome":"authenticated", + "principal":"controlled-principal", + "client_key_id":"attacker-selected-key" + })); + assert!(result.is_err()); +} + +#[test] +fn authentication_debug_output_redacts_credentials_and_principals() { + let request = FrontendAuthenticationRequest { + authorization: "Bearer controlled-secret".into(), + }; + let result = FrontendAuthenticationResult::Authenticated { + principal: "private-principal".into(), + }; + let request_debug = format!("{request:?}"); + let result_debug = format!("{result:?}"); + assert!(!request_debug.contains("controlled-secret")); + assert!(!result_debug.contains("private-principal")); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/host.rs b/backend/crates/gateway-plugin/sdk/tests/call/host.rs new file mode 100644 index 000000000..f8e69c12c --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/host.rs @@ -0,0 +1,204 @@ +use gateway_plugin_sdk::call::host::{ + AffinityLookupRequest, AuthCredential, AuthListRequest, AuthSaveRequest, LogLevel, LogRequest, + LogResult, ModelEventBatch, ModelExecuteRequest, ModelOperation, StateDeleteRequest, + StateMigrationChange, StateMigrationResult, StatePutRequest, +}; +use gateway_plugin_sdk::call::provider::{CanonicalEvent, ExecutionEvent, WireEvent, WirePayload}; +use serde_json::json; + +#[test] +fn log_contract_defaults_and_closed_fields_are_stable() { + let request: LogRequest = + serde_json::from_value(json!({"event":"provider.connected"})).unwrap(); + assert_eq!(request.level, LogLevel::Info); + assert!(request.fields.is_empty()); + for level in ["debug", "info", "warn", "error"] { + let request: LogRequest = serde_json::from_value( + json!({"event":"provider.connected","level":level,"fields":{"status":200}}), + ) + .unwrap(); + assert_eq!(serde_json::to_value(request).unwrap()["level"], level); + } + assert!( + serde_json::from_value::( + json!({"event":"provider.connected","request_id":"forged"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::(json!({"event":"provider.connected","level":"trace"})) + .is_err() + ); + assert_eq!( + serde_json::to_value(LogResult { recorded: false }).unwrap(), + json!({"recorded":false}) + ); +} + +#[test] +fn private_state_contract_keeps_cas_and_migration_actions_explicit() { + let create: StatePutRequest = serde_json::from_value(json!({ + "namespace":"cache", "key":"answer", "value":{"value":42}, + "expected_version":null + })) + .unwrap(); + assert!(create.expected_version.is_none()); + assert!( + serde_json::from_value::(json!({ + "namespace":"cache", "key":"answer" + })) + .is_err() + ); + let result: StateMigrationResult = serde_json::from_value(json!({"changes":[ + {"action":"keep","key":"a"}, + {"action":"replace","key":"b","value":{"version":2}}, + {"action":"delete","key":"c"} + ]})) + .unwrap(); + assert_eq!( + result + .changes + .iter() + .map(StateMigrationChange::key) + .collect::>(), + ["a", "b", "c"] + ); +} + +#[test] +fn account_callbacks_keep_credentials_in_the_binary_contract_and_require_cas() { + let list: AuthListRequest = serde_json::from_value(json!({"cursor":null,"limit":20})).unwrap(); + assert_eq!(list.limit, 20); + assert_eq!(list.provider_id, None); + let filtered: AuthListRequest = + serde_json::from_value(json!({"cursor":null,"limit":20,"provider_id":"example"})).unwrap(); + assert_eq!(filtered.provider_id.as_deref(), Some("example")); + assert!( + serde_json::from_value::( + json!({"cursor":null,"limit":20,"grants":["forged"]}) + ) + .is_err() + ); + + let credential: AuthCredential = serde_json::from_value(json!({ + "account_id":"acct_test", + "provider_id":"example", + "credential_revision":3, + "facts":{ + "name":"test", + "authentication_kind":"api_key", + "material":{"key":"controlled-test-value"}, + "email":null, + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":null, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null + } + })) + .unwrap(); + assert_eq!(credential.credential_revision, 3); + + let replace: AuthSaveRequest = serde_json::from_value(json!({ + "action":"replace", + "account_id":"acct_test", + "credential_revision":3, + "facts":{ + "name":"test", + "authentication_kind":"api_key", + "material":{"key":"controlled-test-value"}, + "email":null, + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":null, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null + } + })) + .unwrap(); + assert!(matches!( + replace, + AuthSaveRequest::Replace { + credential_revision: 3, + .. + } + )); + assert!( + serde_json::from_value::(json!({ + "action":"replace", + "account_id":"acct_test", + "facts":{ + "name":"test", + "authentication_kind":"api_key", + "material":{"key":"controlled-test-value"}, + "email":null, + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":null, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null + } + })) + .is_err() + ); +} + +#[test] +fn model_and_affinity_callbacks_keep_targets_explicit_and_closed() { + let request: ModelExecuteRequest = serde_json::from_value(json!({ + "model":"gpt-test", + "protocol":"openai", + "operation":"generate", + "provider":"example", + "account_id":"acct_test", + "client_key_id":"key_fixture", + "previous_response_id":null + })) + .unwrap(); + assert_eq!(request.operation, ModelOperation::Generate); + assert_eq!(request.provider.as_deref(), Some("example")); + assert_eq!(request.client_key_id.as_deref(), Some("key_fixture")); + assert!( + serde_json::from_value::(json!({ + "model":"gpt-test", "protocol":"openai", "operation":"generate", + "execution_identity":"forged" + })) + .is_err() + ); + + let affinity: AffinityLookupRequest = + serde_json::from_value(json!({"provider":"example","key":"opaque-hash"})).unwrap(); + assert_eq!(affinity.provider, "example"); + assert_eq!(affinity.key, "opaque-hash"); +} + +#[test] +fn model_event_batch_preserves_binary_wire_and_rejects_trailing_data() { + let batch = ModelEventBatch { + events: vec![ExecutionEvent { + facts: vec![CanonicalEvent::TextDelta { + index: 0, + text: "hello".to_owned(), + }], + wire: Some(WireEvent { + protocol: "openai".to_owned(), + payload: WirePayload::RawJson { + body: br#"{"output":"hello"}"#.to_vec(), + }, + }), + ..ExecutionEvent::default() + }], + }; + let encoded = batch.encode().unwrap(); + let decoded = ModelEventBatch::decode(&encoded).unwrap(); + assert_eq!(decoded.events.len(), 1); + let wire = decoded.events[0].wire.as_ref().unwrap(); + assert!(matches!( + &wire.payload, + WirePayload::RawJson { body } if body == br#"{"output":"hello"}"# + )); + + let mut trailing = encoded; + trailing.push(0); + assert!(ModelEventBatch::decode(&trailing).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/management.rs b/backend/crates/gateway-plugin/sdk/tests/call/management.rs new file mode 100644 index 000000000..641cb4175 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/management.rs @@ -0,0 +1,86 @@ +use gateway_plugin_sdk::call::management::{CommandRegistration, CommandResult, CommandValue}; +use serde_json::json; + +#[test] +fn management_contract_keeps_http_bodies_separate_and_rejects_authority_headers() { + use gateway_plugin_sdk::call::management::{ + ManagementRegistration, ManagementRequest, ManagementResponse, + }; + let registration: ManagementRegistration = serde_json::from_value(json!({ + "routes":[{"method":"POST","path":"report","response_content_types":["application/json"]}], + "resources":[{"path":"index.html"}],"pages":[{"id":"report","title":"报告","entry":"index.html"}], + })).unwrap(); + assert!(!registration.resources[0].public); + assert!(registration.pages[0].description.is_none()); + let request = + json!({"method":"POST","path":"report","query":"","content_type":"application/json"}); + assert!(serde_json::from_value::(request.clone()).is_ok()); + let mut forged = request; + forged["headers"] = json!({"Cookie":"must-not-cross-wire"}); + assert!(serde_json::from_value::(forged).is_err()); + assert!( + serde_json::from_value::( + json!({"status":200,"content_type":"text/html","set_cookie":"forged"}) + ) + .is_err() + ); +} + +#[test] +fn management_page_description_round_trips_with_the_directory() { + use gateway_plugin_sdk::call::management::ManagementPage; + + let page = json!({ + "id":"report", "title":"报告", "description":"查看请求处理状态", + "entry":"index.html", "icon":null + }); + let parsed: ManagementPage = serde_json::from_value(page.clone()).unwrap(); + assert_eq!(parsed.description.as_deref(), Some("查看请求处理状态")); + assert_eq!(serde_json::to_value(parsed).unwrap(), page); +} + +#[test] +fn command_values_preserve_types_and_portable_numeric_boundaries() { + for value in [ + CommandValue::Bool(false), + CommandValue::String("private-argument".into()), + CommandValue::Int(i32::MIN), + CommandValue::Int64(i64::MAX), + CommandValue::Float64(0.25), + CommandValue::Duration(-1_500_000_000), + ] { + let encoded = serde_json::to_vec(&value).unwrap(); + assert!(serde_json::from_slice::(&encoded).unwrap() == value); + } + for invalid in [ + json!({"type":"int","value":2147483648_i64}), + json!({"type":"duration","value":0.5}), + json!({"type":"bool","value":"true"}), + json!({"type":"string","value":"test","authority":"admin"}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn command_registration_and_result_reject_unknown_authority_fields() { + let registration = json!({"commands":[{ + "name":"login", "description":"登录", "parameters":[{ + "name":"timeout", "description":"等待时长", "value_type":"duration", + "default":{"type":"duration","value":30_000_000_000_i64} + }] + }]}); + let value: CommandRegistration = serde_json::from_value(registration.clone()).unwrap(); + assert_eq!(value.commands[0].name, "login"); + let mut invalid = registration; + invalid["commands"][0]["admin_id"] = json!("forged"); + assert!(serde_json::from_value::(invalid).is_err()); + let result: CommandResult = + serde_json::from_value(json!({"stdout":"out\n","stderr":"err\n","exit_code":17})).unwrap(); + assert_eq!(result.exit_code, 17); + assert!(result.accounts.is_empty()); + assert!( + serde_json::from_value::(json!({"stdout":"","stderr":"","exit_code":256})) + .is_err() + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs new file mode 100644 index 000000000..d1eb1405c --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware.rs @@ -0,0 +1,168 @@ +use gateway_plugin_sdk::{ + Capability, Stage, + call::middleware::{ + MiddlewareBodyDisposition, MiddlewareBodyFrame, MiddlewareBodyFraming, + MiddlewareBodyHandle, MiddlewareBodyReadResult, MiddlewareHeader, MiddlewareHeaderMutation, + MiddlewareMount, MiddlewareNextRequest, MiddlewareNextResponse, MiddlewareRequestBody, + MiddlewareRequestHead, MiddlewareResponseBody, MiddlewareResponseHead, MiddlewareTransport, + }, +}; +use serde_json::json; + +#[test] +fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() { + assert_eq!( + serde_json::to_value(Capability::Middleware).unwrap(), + "middleware" + ); + assert_eq!(serde_json::to_value(Stage::Request).unwrap(), "request"); + assert_eq!(serde_json::to_value(Stage::Attempt).unwrap(), "attempt"); + assert_eq!( + serde_json::to_value(MiddlewareMount::Request).unwrap(), + "request" + ); + assert_eq!( + serde_json::to_value(MiddlewareMount::Attempt).unwrap(), + "attempt" + ); +} + +#[test] +fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() { + let request = MiddlewareRequestHead { + request_id: "request-1".into(), + mount: MiddlewareMount::Attempt, + attempt_index: Some(2), + operation: "generate".into(), + protocol: "openai".into(), + endpoint: "responses".into(), + transport: MiddlewareTransport::HttpSse, + provider: Some("openai".into()), + model: Some("gpt-5".into()), + account_id: Some("account-1".into()), + headers: vec![MiddlewareHeader { + name: "x-feature".into(), + value: b"enabled".to_vec(), + }], + body_visible: true, + }; + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!(wire["mount"], "attempt"); + assert_eq!(wire["transport"], "http_sse"); + assert_eq!( + wire["headers"][0]["value"], + json!([101, 110, 97, 98, 108, 101, 100]) + ); + assert!(wire.get("body").is_none()); + assert!(serde_json::from_value::(wire.clone()).is_ok()); + + let mut unknown = wire; + unknown["credential_revision"] = json!(7); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[test] +fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() { + let preserve = MiddlewareNextRequest { + protocol: None, + header_mutations: Vec::new(), + body: MiddlewareRequestBody::Preserve, + }; + assert_eq!( + serde_json::to_value(preserve).unwrap(), + json!({"header_mutations":[],"body":"preserve"}) + ); + + let replace = MiddlewareNextRequest { + protocol: Some("xai".into()), + header_mutations: vec![ + MiddlewareHeaderMutation::Remove { + name: "x-old".into(), + }, + MiddlewareHeaderMutation::Append { + name: "x-new".into(), + value: b"new".to_vec(), + }, + ], + body: MiddlewareRequestBody::Replace, + }; + assert_eq!( + serde_json::to_value(replace).unwrap(), + json!({ + "protocol":"xai", + "header_mutations":[ + {"operation":"remove","name":"x-old"}, + {"operation":"append","name":"x-new","value":[110,101,119]} + ], + "body":"replace" + }) + ); +} + +#[test] +fn response_body_modes_distinguish_transfer_empty_and_plugin_stream() { + let handle = MiddlewareBodyHandle { + handle: "opaque-response-body".into(), + framing: MiddlewareBodyFraming::SseEvent, + }; + let next = MiddlewareNextResponse { + response: "opaque-response".into(), + protocol: "openai".into(), + status: 200, + headers: Vec::new(), + body: Some(handle.clone()), + }; + let next_wire = serde_json::to_value(next).unwrap(); + assert_eq!(next_wire["body"]["framing"], "sse_event"); + assert!(next_wire.get("usage").is_none()); + + let pass_through = MiddlewareResponseHead { + response: Some("opaque-response".into()), + protocol: None, + status: None, + header_mutations: Vec::new(), + body: MiddlewareResponseBody::PassThrough { body: handle }, + }; + assert_eq!( + serde_json::to_value(pass_through).unwrap()["body"]["kind"], + "pass_through" + ); + assert_eq!( + serde_json::to_value(MiddlewareResponseBody::Empty).unwrap(), + json!({"kind":"empty"}) + ); + assert_eq!( + serde_json::to_value(MiddlewareResponseBody::Stream { + framing: MiddlewareBodyFraming::JsonDocument, + }) + .unwrap(), + json!({"kind":"stream","framing":"json_document"}) + ); +} + +#[test] +fn body_read_metadata_and_binary_frame_keep_raw_bytes_out_of_json() { + let metadata = MiddlewareBodyReadResult { + framing: MiddlewareBodyFraming::RawBytes, + source_id: 9, + eof: false, + terminal: true, + }; + assert_eq!( + serde_json::to_value(metadata).unwrap(), + json!({"framing":"raw_bytes","source_id":9,"eof":false,"terminal":true}) + ); + + let encoded = MiddlewareBodyFrame::new(vec![0, 255, 10], true).encode(); + assert_eq!( + &encoded[..14], + b"GMB1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + ); + let decoded = MiddlewareBodyFrame::decode(&encoded).unwrap(); + assert_eq!(decoded.payload, vec![0, 255, 10]); + assert!(decoded.terminal); + assert_eq!(decoded.source_id(), 0); + assert_eq!(decoded.disposition(), MiddlewareBodyDisposition::Standalone); + assert!(MiddlewareBodyFrame::decode(b"GMB1\x06bad").is_err()); + assert!(MiddlewareBodyFrame::decode(b"GMB").is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/mod.rs new file mode 100644 index 000000000..558548916 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/mod.rs @@ -0,0 +1,7 @@ +mod frontend_authentication; +mod host; +mod management; +mod middleware; +mod observation; +mod policy; +mod provider; diff --git a/backend/crates/gateway-plugin/sdk/tests/call/observation.rs b/backend/crates/gateway-plugin/sdk/tests/call/observation.rs new file mode 100644 index 000000000..c6e2f46d4 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/observation.rs @@ -0,0 +1,46 @@ +use gateway_plugin_sdk::call::observation::ObserveWebSocketResponse; +use serde_json::json; + +#[test] +fn websocket_response_observation_keeps_payload_out_of_json_and_rejects_unknown_fields() { + let event = ObserveWebSocketResponse { + event_id: "req_observed:websocket:2:7".into(), + request_id: "req_observed".into(), + config_revision: 9, + operation: "generate".into(), + protocol: "openai".into(), + provider: "openai".into(), + attempt_index: 2, + sequence: 7, + payload_included: false, + requested_model: Some("public-model".into()), + account_id: None, + event_type: None, + }; + let encoded = serde_json::to_value(&event).unwrap(); + assert_eq!(encoded["payload_included"], false); + assert!(encoded.get("account_id").is_none()); + assert!(encoded.get("event_type").is_none()); + assert!(encoded.get("headers").is_none()); + assert!(encoded.get("body").is_none()); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + event + ); + + assert!( + serde_json::from_value::(json!({ + "event_id":"req_observed:websocket:2:7", + "request_id":"req_observed", + "config_revision":9, + "operation":"generate", + "protocol":"openai", + "provider":"openai", + "attempt_index":2, + "sequence":7, + "payload_included":false, + "content_hint":"must not be accepted" + })) + .is_err() + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs new file mode 100644 index 000000000..e67c0f812 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/policy/mod.rs @@ -0,0 +1,288 @@ +use gateway_plugin_sdk::{ + SendState, + call::policy::{ + AccountScheduleCandidate, AccountScheduleDecision, AccountScheduleRequest, + ModelRouteDecision, ModelRouteRequest, ObserveRequest, PolicyHeader, RequestCost, + RequestCostSource, RequestCostStatus, RequestFailure, RequestMoney, RequestOutcome, + RequestTerminal, RequestTimings, RequestUsage, + }, +}; +use serde_json::json; + +fn observation() -> ObserveRequest { + ObserveRequest { + event_id: "request-42:terminal".into(), + request_id: "request-42".into(), + config_revision: 7, + operation: "generate".into(), + requested_model: Some("public-model".into()), + client_key_id: Some("key_fixture".into()), + account_id: Some("acct_fixture".into()), + upstream_model: Some("upstream-model".into()), + response_model: Some("reported-model".into()), + service_tier: Some("default".into()), + provider: None, + completed_at_ms: 1234, + terminal: None, + usage: None, + } +} + +#[test] +fn terminal_outcomes_have_stable_wire_names() { + for (outcome, name) in [ + (RequestOutcome::Succeeded, "succeeded"), + (RequestOutcome::Failed, "failed"), + (RequestOutcome::Rejected, "rejected"), + (RequestOutcome::Cancelled, "cancelled"), + (RequestOutcome::Incomplete, "incomplete"), + ] { + let terminal = RequestTerminal { + outcome, + send_state: SendState::NotSent, + attempt_count: 0, + client_status_code: None, + error_code: None, + }; + let wire = serde_json::to_value(&terminal).unwrap(); + assert_eq!( + wire, + json!({"outcome": name, "send_state": "not_sent", "attempt_count": 0}) + ); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + terminal + ); + } +} + +#[test] +fn observation_exposes_selected_and_reported_facts_without_inventing_values() { + let mut request = observation(); + let wire = serde_json::to_value(&request).unwrap(); + assert_eq!(wire["client_key_id"], "key_fixture"); + assert_eq!(wire["account_id"], "acct_fixture"); + assert_eq!(wire["upstream_model"], "upstream-model"); + assert_eq!(wire["response_model"], "reported-model"); + assert_eq!(wire["service_tier"], "default"); + request.account_id = None; + request.upstream_model = None; + request.response_model = None; + request.service_tier = None; + let wire = serde_json::to_value(request).unwrap(); + for field in [ + "account_id", + "upstream_model", + "response_model", + "service_tier", + ] { + assert!(wire.get(field).is_none(), "unknown {field} must be omitted"); + } +} + +#[test] +fn observation_sections_are_independent_and_omitted_when_not_subscribed() { + let mut request = observation(); + request.usage = Some(RequestUsage { + input_tokens: Some(0), + output_tokens: Some(12), + ..RequestUsage::default() + }); + let usage_only = serde_json::to_value(&request).unwrap(); + assert!(usage_only.get("terminal").is_none()); + assert!(usage_only.get("provider").is_none()); + assert_eq!( + usage_only["usage"], + json!({"input_tokens": 0, "output_tokens": 12}) + ); + assert_eq!( + serde_json::from_value::(usage_only).unwrap(), + request + ); + + request.usage = None; + request.terminal = Some(RequestTerminal { + outcome: RequestOutcome::Rejected, + send_state: SendState::NotSent, + attempt_count: 0, + client_status_code: None, + error_code: Some("invalid_request".into()), + }); + let lifecycle_only = serde_json::to_value(&request).unwrap(); + assert!(lifecycle_only.get("usage").is_none()); + assert_eq!( + serde_json::from_value::(lifecycle_only).unwrap(), + request + ); + + request.provider = Some("example-provider".into()); + request.usage = Some(RequestUsage::default()); + let combined = serde_json::to_value(&request).unwrap(); + assert!(combined.get("terminal").is_some()); + assert_eq!(combined["usage"], json!({})); + assert_eq!( + serde_json::from_value::(combined).unwrap(), + request + ); +} + +#[test] +fn missing_usage_is_not_invented_as_zero_or_derived_totals() { + let usage: RequestUsage = serde_json::from_value(json!({"input_tokens": 0})).unwrap(); + assert_eq!(usage.input_tokens, Some(0)); + assert_eq!(usage.output_tokens, None); + assert_eq!(usage.total_tokens, None); + assert_eq!( + serde_json::to_value(usage).unwrap(), + json!({"input_tokens": 0}) + ); + + assert!(serde_json::from_value::(json!({"output_tokens": -1})).is_err()); +} + +#[test] +fn observation_contract_rejects_unrecognized_fields_and_outcomes() { + let mut request = serde_json::to_value(observation()).unwrap(); + request["request_body"] = json!({}); + assert!(serde_json::from_value::(request).is_err()); + assert!(serde_json::from_value::(json!({"custom_tokens": 1})).is_err()); + assert!( + serde_json::from_value::(json!({ + "outcome": "succeeded", "send_state": "sent", "attempt_count": 1, + "internal_error": "not a public observation field" + })) + .is_err() + ); + assert!(serde_json::from_value::(json!("running")).is_err()); +} + +#[test] +fn cost_timings_and_failure_keep_unknown_facts_explicit_and_safe() { + let unknown = RequestCost { + status: RequestCostStatus::Unknown, + source: RequestCostSource::Unavailable, + total: None, + }; + assert_eq!( + serde_json::to_value(&unknown).unwrap(), + json!({"status":"unknown","source":"unavailable"}) + ); + let known = RequestCost { + status: RequestCostStatus::Known, + source: RequestCostSource::ProviderReported, + total: Some(RequestMoney { + amount: "0.0123".into(), + currency: "USD".into(), + }), + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&known).unwrap()).unwrap(), + known + ); + + let timings = RequestTimings { + first_text_ms: Some(12), + latency_ms: Some(34), + ..RequestTimings::default() + }; + assert_eq!( + serde_json::to_value(&timings).unwrap(), + json!({"first_text_ms":12,"latency_ms":34}) + ); + + let failure = RequestFailure { + outcome: RequestOutcome::Failed, + send_state: SendState::Sent, + attempt_count: 2, + client_status_code: Some(502), + upstream_status_code: Some(503), + error_code: Some("upstream_unavailable".into()), + retry_after_ms: Some(1_000), + }; + let wire = serde_json::to_value(&failure).unwrap(); + assert!(wire.get("message").is_none()); + assert!(wire.get("raw_upstream_error").is_none()); + let mut unsafe_wire = wire.clone(); + unsafe_wire["provider_error_code"] = json!("opaque-upstream-value"); + assert!(serde_json::from_value::(unsafe_wire).is_err()); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + failure + ); + + let usage = RequestUsage { + cost: Some(known), + timings: Some(timings), + failure: Some(failure), + ..RequestUsage::default() + }; + let wire = serde_json::to_value(&usage).unwrap(); + assert!(wire.get("input_tokens").is_none()); + assert_eq!(wire["cost"]["total"]["amount"], "0.0123"); + assert_eq!(wire["timings"]["latency_ms"], 34); + assert!(wire["failure"].get("provider_error_code").is_none()); + assert_eq!(serde_json::from_value::(wire).unwrap(), usage); +} + +#[test] +fn route_and_scheduler_decisions_are_explicit_and_strict() { + let route = ModelRouteRequest { + request_id: "req_policy".into(), + operation: "generate".into(), + protocol: "openai".into(), + model: "public-model".into(), + available_providers: vec!["openai".into()], + headers: vec![PolicyHeader { + name: "x-feature".into(), + value_base64: "b24=".into(), + }], + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&route).unwrap()).unwrap(), + route + ); + assert_eq!( + serde_json::to_value(ModelRouteDecision::Route { + provider: Some("openai".into()), + model: None, + }) + .unwrap(), + json!({"decision":"route","provider":"openai"}) + ); + assert!( + serde_json::from_value::( + json!({"decision":"route","provider":"openai","unexpected":true}) + ) + .is_err() + ); + + let schedule = AccountScheduleRequest { + request_id: "req_policy".into(), + attempt_index: 2, + provider: "openai".into(), + model: Some("gpt-5".into()), + candidates: vec![AccountScheduleCandidate { + account_id: "acct_candidate".into(), + weight: 10, + in_flight: 1, + maximum_concurrency: 4, + last_started_at_ms: None, + quota_reset_at_ms: None, + quota_remaining_rank: Some(9_000), + failure_rate_basis_points: None, + first_output_latency_ms: Some(20), + }], + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&schedule).unwrap()) + .unwrap(), + schedule + ); + assert_eq!( + serde_json::to_value(AccountScheduleDecision::Pick { + account_id: "acct_candidate".into(), + }) + .unwrap(), + json!({"decision":"pick","account_id":"acct_candidate"}) + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/account.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/account.rs new file mode 100644 index 000000000..656dd836f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/account.rs @@ -0,0 +1,35 @@ +use gateway_plugin_sdk::call::provider::{ + ProviderDescriptor, + account::{AccountConfiguration, AccountConfigurationDescriptor}, +}; +use serde_json::json; + +#[test] +fn account_configuration_is_optional_and_preserves_only_the_declared_shape() { + let legacy: ProviderDescriptor = + serde_json::from_value(json!({"id":"example", "models":[]})).unwrap(); + assert!(legacy.account_configuration.is_none()); + + let descriptor = AccountConfigurationDescriptor { + public_fields: vec!["base_url".into(), "transport".into()], + }; + assert_eq!( + serde_json::to_value(&descriptor).unwrap(), + json!({"public_fields":["base_url", "transport"]}) + ); + assert!( + serde_json::from_value::( + json!({"public_fields":[], "unexpected":true}) + ) + .is_err() + ); + + let result: AccountConfiguration = serde_json::from_value(json!({ + "values":{"base_url":"https://example.test", "transport":"http"} + })) + .unwrap(); + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({"values":{"base_url":"https://example.test", "transport":"http"}}) + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/billing.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/billing.rs new file mode 100644 index 000000000..7a696cd78 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/billing.rs @@ -0,0 +1,37 @@ +use gateway_plugin_sdk::call::provider::{CanonicalEvent, ProviderDescriptor}; +use serde_json::json; + +#[test] +fn billing_prices_are_strings_and_the_algorithm_is_versioned() { + let value = json!({ + "id":"example", "models":[], + "billing":{"rule":"token_v1", "prices":{"model":{ + "standard":{"input":"1.25", "output":"2", "cache_read":"0", "cache_write":"0.5"} + }}} + }); + let descriptor: ProviderDescriptor = serde_json::from_value(value.clone()).unwrap(); + assert_eq!( + serde_json::to_value(descriptor).unwrap()["billing"], + value["billing"] + ); + let mut invalid = value.clone(); + invalid["billing"]["rule"] = json!("token_v2"); + assert!(serde_json::from_value::(invalid).is_err()); + let mut invalid = value; + invalid["billing"]["prices"]["model"]["standard"]["input"] = json!(1.25); + assert!(serde_json::from_value::(invalid).is_err()); +} + +#[test] +fn billable_usage_does_not_fill_unknown_token_facts() { + let value = json!({ + "type":"billable_usage", "band":"long_fast", + "usage":{"input_tokens":10, "output_tokens":20} + }); + let event: CanonicalEvent = serde_json::from_value(value).unwrap(); + let serialized = serde_json::to_value(event).unwrap(); + assert_eq!(serialized["band"], "long_fast"); + assert_eq!(serialized["usage"]["input_tokens"], 10); + assert!(serialized["usage"]["cached_tokens"].is_null()); + assert!(serialized["usage"]["cache_write_tokens"].is_null()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/codec.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/codec.rs new file mode 100644 index 000000000..cb97d83ed --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/codec.rs @@ -0,0 +1,109 @@ +use gateway_plugin_sdk::{ + SendState, + call::provider::{ + CanonicalEvent, ExecutionEvent, ExecutionFailure, ExecutionFailureKind, FailureResponse, + MAX_EXECUTION_PAYLOAD_BYTES, WireEvent, WirePayload, + }, +}; +use serde_json::json; + +#[test] +fn raw_wire_and_failure_body_are_independent_binary_segments() { + let wire = b": comment\r\nevent: error\r\ndata: {\"error\":true}\r\n\r\n".to_vec(); + let body = (0..=255).cycle().take(64 * 1024).collect::>(); + let event = ExecutionEvent::wire(WireEvent { + protocol: "openai".into(), + payload: WirePayload::RawSse { + frame: wire.clone(), + }, + }) + .with_failure(ExecutionFailure { + kind: ExecutionFailureKind::Unavailable, + send_state: SendState::Sent, + code: None, + retry_after_ms: None, + response: Some(FailureResponse { + status: 502, + content_type: None, + body: body.clone(), + }), + }); + let bytes = event.encode().unwrap(); + assert_eq!(&bytes[..4], b"GPE1"); + assert!( + bytes.len() < body.len() + wire.len() + 512, + "原始字节不能膨胀为 JSON 数组" + ); + assert!(bytes.ends_with(&body)); + let decoded = ExecutionEvent::decode(&bytes).unwrap(); + let WirePayload::RawSse { frame } = decoded.wire.unwrap().payload else { + panic!("SSE expected") + }; + assert_eq!(frame, wire); + assert_eq!(decoded.failure.unwrap().response.unwrap().body, body); +} + +#[test] +fn malformed_headers_lengths_truncation_and_extra_segments_are_rejected() { + let bytes = ExecutionEvent::canonical(CanonicalEvent::Started { + id: "response".into(), + model: None, + }) + .encode() + .unwrap(); + for length in 0..bytes.len() { + assert!(ExecutionEvent::decode(&bytes[..length]).is_err()); + } + let mut invalid = bytes.clone(); + invalid.push(0); + assert!(ExecutionEvent::decode(&invalid).is_err()); + // 即使总长度自洽,元数据没有声明 wire 也不能附加字节。 + invalid[8..12].copy_from_slice(&1u32.to_be_bytes()); + assert!(ExecutionEvent::decode(&invalid).is_err()); + for position in [4, 8, 12] { + let mut invalid = bytes.clone(); + invalid[position..position + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(ExecutionEvent::decode(&invalid).is_err()); + } + let mut invalid = bytes; + invalid[3] = b'2'; + assert!(ExecutionEvent::decode(&invalid).is_err()); +} + +#[test] +fn metadata_cannot_hide_binary_arrays_or_bypass_the_total_limit() { + let metadata = serde_json::to_vec( + &json!({"wire":{"protocol":"openai","payload":{"kind":"raw_json","body":[1,2,3]}}}), + ) + .unwrap(); + let mut bytes = b"GPE1".to_vec(); + bytes.extend_from_slice(&u32::try_from(metadata.len()).unwrap().to_be_bytes()); + bytes.extend_from_slice(&[0; 8]); + bytes.extend_from_slice(&metadata); + assert!(ExecutionEvent::decode(&bytes).is_err()); + let event = ExecutionEvent::wire(WireEvent { + protocol: "openai".into(), + payload: WirePayload::RawJson { + body: vec![b' '; MAX_EXECUTION_PAYLOAD_BYTES], + }, + }); + assert!(event.encode().is_err()); + assert!(ExecutionEvent::decode(&vec![0; MAX_EXECUTION_PAYLOAD_BYTES + 1]).is_err()); +} + +#[test] +fn raw_http_body_uses_the_binary_segment_without_json_validation() { + let body = vec![0, 255, b'\n', b'{']; + let bytes = ExecutionEvent::wire(WireEvent { + protocol: "provider-http".into(), + payload: WirePayload::RawBody { body: body.clone() }, + }) + .encode() + .unwrap(); + assert!(bytes.ends_with(&body)); + let decoded = ExecutionEvent::decode(&bytes).unwrap(); + let WirePayload::RawBody { body: decoded } = decoded.wire.unwrap().payload else { + panic!("raw HTTP body expected") + }; + assert_eq!(decoded, body); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/event.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/event.rs new file mode 100644 index 000000000..1ffb3ded0 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/event.rs @@ -0,0 +1,72 @@ +use gateway_plugin_sdk::call::provider::{ + CanonicalEvent, ExecutionEvent, FinishReason, ResponseObservation, SessionUpdate, WireEvent, + WirePayload, +}; +use serde_json::json; + +#[test] +fn wire_facts_observation_and_checkpoint_share_one_envelope() { + let raw = + b"id: upstream\r\nevent: response.completed\r\ndata: {\"result\":true}\r\n\r\n".to_vec(); + let event = ExecutionEvent::wire(WireEvent { + protocol: "openai".into(), + payload: WirePayload::Json { + event: Some("response.completed".into()), + data: json!({"result":true}), + id: Some("upstream".into()), + retry: None, + raw_sse: Some(raw.clone()), + }, + }) + .with_fact(CanonicalEvent::Completed { + id: "upstream-response".into(), + model: None, + reason: FinishReason::Stop, + }) + .with_observation(ResponseObservation { + status: Some(200), + ..ResponseObservation::default() + }) + .with_session_update(SessionUpdate { + payload: json!({"turn":"opaque"}).as_object().unwrap().clone(), + }); + let bytes = serde_json::to_vec(&event).unwrap(); + let decoded: ExecutionEvent = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded.facts.len(), 1); + assert_eq!(decoded.observation.unwrap().status, Some(200)); + assert_eq!(decoded.session_update.unwrap().payload["turn"], "opaque"); + let WirePayload::Json { raw_sse, .. } = decoded.wire.unwrap().payload else { + panic!("JSON wire expected") + }; + assert_eq!(raw_sse.unwrap(), raw); +} + +#[test] +fn unknown_envelope_and_host_identity_fields_are_rejected() { + for value in [ + json!({"type":"started","id":"old"}), + json!({"facts":[],"account_id":"forged"}), + json!({"session_update":{"payload":{},"credential_revision":2}}), + json!({"observation":{"transport":"forged"}}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } +} + +#[test] +fn raw_json_remains_bytes_and_missing_usage_is_not_synthesized() { + let event = ExecutionEvent::wire(WireEvent { + protocol: "openai".into(), + payload: WirePayload::RawJson { + body: b"{ \"z\": 1, \"a\": 2 }\n".to_vec(), + }, + }); + let serialized = serde_json::to_value(event).unwrap(); + assert!(serialized.get("facts").is_none()); + assert!(serialized["wire"]["payload"]["body"].is_array()); + let decoded: ExecutionEvent = serde_json::from_value(serialized).unwrap(); + let WirePayload::RawJson { body } = decoded.wire.unwrap().payload else { + panic!("raw JSON expected") + }; + assert_eq!(body, b"{ \"z\": 1, \"a\": 2 }\n"); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/http.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/http.rs new file mode 100644 index 000000000..565e5ef49 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/http.rs @@ -0,0 +1,59 @@ +use gateway_plugin_sdk::call::provider::{ + OperationKind, PrepareExecution, ProviderDescriptor, ProviderHttpEndpoint, ProviderHttpMethod, + ProviderHttpRequest, +}; +use serde_json::json; + +#[test] +fn http_contract_defaults_to_closed_and_preserves_binary_headers() { + let descriptor: ProviderDescriptor = serde_json::from_value(json!({ + "id": "example", + "models": [] + })) + .unwrap(); + assert!(descriptor.http_endpoints.is_empty()); + + let endpoint = ProviderHttpEndpoint { + id: "models".into(), + methods: vec![ProviderHttpMethod::Get], + }; + let encoded = serde_json::to_value(&endpoint).unwrap(); + assert_eq!(encoded, json!({"id":"models","methods":["get"]})); + + let request: PrepareExecution = serde_json::from_value(json!({ + "protocol": "provider-http", + "operation": "provider_http", + "model": null, + "account_id": "account", + "credential_revision": 7, + "credential": {}, + "context": {}, + "request_profile": null, + "http_request": { + "endpoint": "models", + "method": "get", + "query": "client_version=1", + "headers": [{"name":"x-opaque","value":[255,0,1]}] + } + })) + .unwrap(); + assert_eq!(request.operation, OperationKind::ProviderHttp); + let http = request.http_request.unwrap(); + assert_eq!(http.endpoint, "models"); + assert_eq!(http.method, ProviderHttpMethod::Get); + assert_eq!(http.headers.len(), 1); + assert_eq!(http.headers[0].name, "x-opaque"); + assert_eq!(http.headers[0].value, vec![255, 0, 1]); +} + +#[test] +fn unknown_http_fields_remain_rejected() { + assert!( + serde_json::from_value::(json!({ + "endpoint":"models", + "method":"get", + "url":"https://example.invalid" + })) + .is_err() + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/input.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/input.rs new file mode 100644 index 000000000..cb786981d --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/input.rs @@ -0,0 +1,45 @@ +use gateway_plugin_sdk::call::provider::{ExecutionEvent, ExecutionInput, PrepareExecution}; +use serde_json::json; + +#[test] +fn preparation_round_trips_large_checkpoint_credentials_and_raw_body_outside_control_metadata() { + let request: PrepareExecution = serde_json::from_value(json!({ + "protocol":"openai","operation":"generate_image","model":"model","account_id":"account","credential_revision":9, + "credential":{"key":"test-only-credential"},"context":{"trace":"test-context"}, + "provider_session_state":{"large":"x".repeat(65524)} + })).unwrap(); + let body: Vec = (0..=255).cycle().take(32768).collect(); + let bytes = ExecutionInput { + request, + body: body.clone(), + } + .encode() + .unwrap(); + assert!(bytes.len() > 64 * 1024); + assert!(bytes.len() < 64 * 1024 + body.len() + 512); + assert_eq!(&bytes[..4], b"GPQ1"); + assert!(bytes.ends_with(&body)); + let decoded = ExecutionInput::decode(&bytes).unwrap(); + assert_eq!(decoded.body, body); + assert_eq!(decoded.request.credential["key"], "test-only-credential"); + assert_eq!( + decoded.request.provider_session_state.unwrap()["large"] + .as_str() + .unwrap() + .len(), + 65524 + ); + assert!( + ExecutionEvent::decode(&bytes).is_err(), + "输入和输出不能混用消息类型" + ); + for length in [0, 4, 11, bytes.len() - 1] { + assert!(ExecutionInput::decode(&bytes[..length]).is_err()); + } + let mut invalid = bytes.clone(); + invalid[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(ExecutionInput::decode(&invalid).is_err()); + let mut invalid = bytes; + invalid.push(0); + assert!(ExecutionInput::decode(&invalid).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/invalidation.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/invalidation.rs new file mode 100644 index 000000000..fe4bcad43 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/invalidation.rs @@ -0,0 +1,16 @@ +use gateway_plugin_sdk::call::provider::account::AccountInvalidation; +use serde_json::json; + +#[test] +fn invalidations_contain_only_account_identifiers() { + for value in [ + json!({"kind":"unavailable", "account_id":"acct_a"}), + json!({"kind":"facts_changed", "account_ids":["acct_a", "acct_b"]}), + ] { + let event: AccountInvalidation = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(event).unwrap(), value); + let mut invalid = value; + invalid["credential"] = json!({"key":"test-only"}); + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/maintenance.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/maintenance.rs new file mode 100644 index 000000000..41d55ac9f --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/maintenance.rs @@ -0,0 +1,32 @@ +use gateway_plugin_sdk::call::provider::RequestProfileRefresh; +use serde_json::Value; + +#[test] +fn refresh_roundtrips_without_exposing_provider_documents_in_debug() { + let refresh = RequestProfileRefresh { + sequence: 2, + profiles: super::request_profile::descriptor(), + }; + let encoded = serde_json::to_value(&refresh).unwrap(); + assert_eq!(encoded["sequence"], 2); + let decoded: RequestProfileRefresh = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, refresh); + let debug = format!("{refresh:?}"); + assert!(debug.contains("sequence: 2")); + assert!(!debug.contains("sensitive-shape")); + assert!(!debug.contains("authorizationShape")); +} + +#[test] +fn refresh_rejects_unknown_fields() { + let mut encoded = serde_json::to_value(RequestProfileRefresh { + sequence: 2, + profiles: super::request_profile::descriptor(), + }) + .unwrap(); + encoded + .as_object_mut() + .unwrap() + .insert("unexpected".into(), Value::Bool(true)); + assert!(serde_json::from_value::(encoded).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/mod.rs new file mode 100644 index 000000000..bfadb46c9 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/mod.rs @@ -0,0 +1,11 @@ +mod account; +mod billing; +mod codec; +mod event; +mod http; +mod input; +mod invalidation; +mod maintenance; +mod models; +mod quota; +mod request_profile; diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/models.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/models.rs new file mode 100644 index 000000000..eb7d8c305 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/models.rs @@ -0,0 +1,133 @@ +use gateway_plugin_sdk::call::provider::{ProviderDescriptor, models::AccountModels}; +use serde_json::json; + +#[test] +fn native_continuation_is_an_explicit_model_feature() { + use gateway_plugin_sdk::call::provider::{ModelDescriptor, ModelFeature}; + + let descriptor: ModelDescriptor = serde_json::from_value(json!({ + "id":"continuation-model", "operations":["generate"], "features":["native_continuation"] + })) + .unwrap(); + assert_eq!(descriptor.features, [ModelFeature::NativeContinuation]); + assert_eq!( + serde_json::to_value(&descriptor).unwrap()["features"], + json!(["native_continuation"]) + ); + let plain: ModelDescriptor = serde_json::from_value(json!({ + "id":"plain-model", "operations":["generate"] + })) + .unwrap(); + assert!(plain.features.is_empty()); +} + +#[test] +fn account_discovery_is_optional_and_cannot_smuggle_account_writes() { + let static_provider: ProviderDescriptor = + serde_json::from_value(json!({"id":"example", "models":[]})).unwrap(); + assert!(static_provider.model_discovery.is_none()); + let provider: ProviderDescriptor = serde_json::from_value(json!({"id":"example", "models":[], "model_discovery":{"include_static":false,"cache_ttl_seconds":60}})).unwrap(); + assert!(!provider.model_discovery.unwrap().include_static); + let mut catalog = json!({"models":[], "exhaustive":true}); + assert!( + serde_json::from_value::(catalog.clone()) + .unwrap() + .exhaustive + ); + catalog["credential"] = json!({"key":"test-only"}); + assert!(serde_json::from_value::(catalog).is_err()); +} + +#[test] +fn continuation_state_compatibility_is_optional_and_closed() { + let legacy: ProviderDescriptor = + serde_json::from_value(json!({"id":"example", "models":[]})).unwrap(); + assert!(legacy.continuation_state.is_none()); + + let provider: ProviderDescriptor = serde_json::from_value(json!({ + "id":"example", + "models":[], + "continuation_state":{ + "format":"example.responses", + "write_version":2, + "readable_versions":[1,2] + } + })) + .unwrap(); + let state = provider.continuation_state.unwrap(); + assert_eq!(state.format, "example.responses"); + assert_eq!(state.write_version, 2); + assert_eq!(state.readable_versions, [1, 2]); + + assert!( + serde_json::from_value::(json!({ + "id":"example", + "models":[], + "continuation_state":{ + "format":"example.responses", + "write_version":1, + "readable_versions":[1], + "artifact":"must-not-be-plugin-selected" + } + })) + .is_err() + ); +} + +#[test] +fn catalog_completeness_must_be_explicit() { + assert!(serde_json::from_value::(json!({"models":[]})).is_err()); + let discovered: AccountModels = + serde_json::from_value(json!({"models":[],"exhaustive":false})).unwrap(); + assert!(!discovered.exhaustive); +} + +#[test] +fn prepared_account_facts_are_optional_and_closed() { + let legacy: AccountModels = + serde_json::from_value(json!({"models":[],"exhaustive":true})).unwrap(); + assert!(legacy.prepared_account_facts.is_none()); + + let prepared: AccountModels = serde_json::from_value(json!({ + "models":[], + "exhaustive":true, + "prepared_account_facts":{ + "name":"refreshed", + "authentication_kind":"oauth", + "material":{"access_token":"controlled-test-value"}, + "email":null, + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":null, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null + } + })) + .unwrap(); + assert_eq!( + prepared + .prepared_account_facts + .as_ref() + .map(|facts| facts.name.as_str()), + Some("refreshed") + ); + assert!( + serde_json::from_value::(json!({ + "models":[], + "exhaustive":true, + "prepared_account_facts":{ + "name":"refreshed", + "authentication_kind":"oauth", + "material":{}, + "email":null, + "upstream_user_id":null, + "upstream_account_id":null, + "plan_type":null, + "access_token_expires_at_ms":null, + "next_refresh_at_ms":null, + "account_id":"forged" + } + })) + .is_err() + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/quota.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/quota.rs new file mode 100644 index 000000000..d4ba3e4bc --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/quota.rs @@ -0,0 +1,48 @@ +use gateway_plugin_sdk::call::provider::{ExecutionEvent, ResponseObservation}; +use serde_json::json; + +#[test] +fn quota_forecast_round_trips_without_changing_execution_facts() { + let value = json!({"observation":{"quota_forecast":{ + "plan_type":"service-plan", + "windows":[{"key":"week","group":"chat","limit_id":"chat","role":"secondary", + "account_wide":true,"window_seconds":604800,"used_percent":0.0,"reset_at_ms":1800000000000_i64}] + }}}); + let event: ExecutionEvent = serde_json::from_value(value.clone()).unwrap(); + let decoded = ExecutionEvent::decode(&event.encode().unwrap()).unwrap(); + assert!(decoded.facts.is_empty()); + assert!(decoded.wire.is_none()); + let observation = decoded.observation.unwrap(); + let forecast = observation.quota_forecast.as_ref().unwrap(); + assert_eq!(forecast.windows[0].used_percent, 0.0); + assert_eq!( + serde_json::to_value(observation).unwrap()["quota_forecast"], + value["observation"]["quota_forecast"] + ); +} + +#[test] +fn quota_forecast_is_optional_and_rejects_arbitrary_identity_or_raw_documents() { + let legacy: ResponseObservation = serde_json::from_value(json!({"status":200})).unwrap(); + assert!(legacy.quota_forecast.is_none()); + assert!( + serde_json::to_value(legacy) + .unwrap() + .get("quota_forecast") + .is_none() + ); + for field in [ + "account_id", + "provider_id", + "credential", + "provider_data", + "headers", + ] { + let mut value = json!({"plan_type":null,"windows":[]}); + value[field] = json!("private"); + assert!( + serde_json::from_value::(json!({"quota_forecast":value})).is_err(), + "{field}" + ); + } +} diff --git a/backend/crates/gateway-plugin/sdk/tests/call/provider/request_profile.rs b/backend/crates/gateway-plugin/sdk/tests/call/provider/request_profile.rs new file mode 100644 index 000000000..0edda7cef --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/call/provider/request_profile.rs @@ -0,0 +1,77 @@ +use gateway_plugin_sdk::call::provider::{ + RequestProfileAttribute, RequestProfileDescriptor, RequestProfileOption, + RequestProfilePresentation, RequestProfileRelease, RequestProfileReleaseStatus, + RequestProfileTarget, +}; +use serde_json::{Map, Value, json}; + +fn object(value: Value) -> Map { + value.as_object().expect("object fixture").clone() +} + +pub(super) fn descriptor() -> RequestProfileDescriptor { + let configuration = object(json!({"channel": "stable"})); + RequestProfileDescriptor { + default_configuration: configuration.clone(), + options: vec![RequestProfileOption { + id: "stable".to_owned(), + label: "稳定版".to_owned(), + description: Some("跟随已核验版本".to_owned()), + configuration, + resolved: object(json!({"authorizationShape": "sensitive-shape"})), + presentation: RequestProfilePresentation { + product: "Example CLI".to_owned(), + version: "1.2.3".to_owned(), + build: Some("123".to_owned()), + target: RequestProfileTarget { + os_type: "linux".to_owned(), + os_version: "6.8".to_owned(), + arch: "x86_64".to_owned(), + terminal: "headless".to_owned(), + }, + user_agent: "Example CLI/1.2.3".to_owned(), + attributes: vec![RequestProfileAttribute { + label: "通道".to_owned(), + value: "stable".to_owned(), + }], + verified_at_ms: Some(1_700_000_000_000), + release: Some(RequestProfileRelease { + status: RequestProfileReleaseStatus::Current, + checked_at_ms: Some(1_700_000_000_100), + latest_version: Some("1.2.3".to_owned()), + latest_build: Some("123".to_owned()), + published_at_ms: None, + minimum_system_version: None, + hardware_requirements: None, + download_url: None, + download_size: None, + signature_present: Some(true), + error: None, + }), + }, + }], + } +} + +#[test] +fn request_profile_descriptor_roundtrips_without_exposing_provider_documents_in_debug() { + let descriptor = descriptor(); + let encoded = serde_json::to_value(&descriptor).expect("encode descriptor"); + let decoded: RequestProfileDescriptor = + serde_json::from_value(encoded).expect("decode descriptor"); + assert_eq!(decoded, descriptor); + + let debug = format!("{descriptor:?}"); + assert!(!debug.contains("sensitive-shape")); + assert!(debug.contains("[REDACTED]")); +} + +#[test] +fn request_profile_descriptor_rejects_unknown_fields() { + let mut encoded = serde_json::to_value(descriptor()).expect("encode descriptor"); + encoded + .as_object_mut() + .expect("descriptor object") + .insert("unexpected".to_owned(), Value::Bool(true)); + assert!(serde_json::from_value::(encoded).is_err()); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs new file mode 100644 index 000000000..37047b2dc --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/mod.rs @@ -0,0 +1,2 @@ +mod plugin; +mod session; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs new file mode 100644 index 000000000..9cce7751e --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/plugin/mod.rs @@ -0,0 +1,562 @@ +use std::{ + collections::BTreeMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use gateway_plugin_sdk::{ + CallContext, Contributions, ErrorCode, Frame, Handshake, Message, PROTOCOL_VERSION, Stage, + call::{ + auth::ImportedCredentials, + host::StateMigrationResult, + management::{ + CommandDescriptor, CommandInvocation, CommandRegistration, CommandResult, + ManagementRegistration, ManagementRequest, ManagementResponse, ManagementRoute, + }, + provider::{PreparedExecution, ProbeOperation, ProviderDescriptor, Registration}, + }, + client::{ + AuthorError, Empty, PluginBuilder, PluginHandler, PluginSession, ResponseStream, + SessionConfig, SessionError, TypedReply, methods, read_frame, write_frame, + }, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{DuplexStream, ReadHalf, WriteHalf}, + task::JoinHandle, +}; + +const MAXIMUM_FRAME_BYTES: usize = 64 * 1024; + +fn author_manifest(contributes: Value, state: Value) -> Vec { + serde_json::to_vec(&json!({ + "manifestVersion": 3, + "name": "composed", + "displayName": "Composed", + "publisher": "9acme", + "version": "1.0.0", + "description": "Composed plugin", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/plugin", + "runtime": "trustedProcess", + "contributes": contributes, + "state": state, + })) + .unwrap() +} + +fn management_registration() -> ManagementRegistration { + ManagementRegistration { + routes: vec![ManagementRoute { + method: "POST".into(), + path: "echo".into(), + request_content_types: vec!["application/octet-stream".into()], + response_content_types: vec!["application/octet-stream".into()], + }], + resources: Vec::new(), + pages: Vec::new(), + callbacks: Vec::new(), + } +} + +fn command_registration() -> CommandRegistration { + CommandRegistration { + commands: vec![CommandDescriptor { + name: "echo".into(), + description: "Echo a value".into(), + parameters: Vec::new(), + }], + } +} + +fn with_executor_handlers(builder: PluginBuilder) -> PluginBuilder { + builder + .on(methods::PREPARE_EXECUTION, |_| async { + Ok(TypedReply::new(PreparedExecution { + token: "prepared".into(), + transport: "http_sse".into(), + })) + }) + .unwrap() + .on(methods::EXECUTE, |_| async { + Ok(TypedReply::new(Empty {}).with_stream(ResponseStream::from_chunks(Vec::new()))) + }) + .unwrap() + .on(methods::DISCARD_EXECUTION, |_| async { + Ok(TypedReply::new(Empty {})) + }) + .unwrap() + .on(methods::CONNECTION_TEST, |_| async { + Ok(TypedReply::new(ProbeOperation { + protocol: "openai".into(), + body: serde_json::Map::new(), + })) + }) + .unwrap() +} + +#[tokio::test] +async fn composed_plugin_registers_and_dispatches_multiple_typed_entries() { + let source = author_manifest( + json!({ + "management": {}, + "command_line": {}, + }), + json!([]), + ); + let manifest = gateway_plugin_sdk::Manifest::from_author_slice(&source).unwrap(); + let contributions = manifest.contributes.clone(); + let management_calls = Arc::new(AtomicUsize::new(0)); + let management_calls_for_handler = Arc::clone(&management_calls); + let plugin = PluginBuilder::from_manifest(manifest) + .unwrap() + .management(management_registration(), move |call| { + let management_calls = Arc::clone(&management_calls_for_handler); + async move { + management_calls.fetch_add(1, Ordering::Relaxed); + Ok(TypedReply::new(ManagementResponse { + status: 201, + content_type: call + .request + .content_type + .unwrap_or_else(|| "application/octet-stream".into()), + }) + .with_payload(call.payload)) + } + }) + .unwrap() + .command_line(command_registration(), |call| async move { + Ok(TypedReply::new(CommandResult { + stdout: call.request.name, + stderr: String::new(), + exit_code: 0, + accounts: Vec::new(), + })) + }) + .unwrap() + .build() + .unwrap(); + let (mut host, task) = start_session(plugin, contributions.clone()).await; + + send_call( + &mut host, + 1, + "plugin.register", + Stage::Registration, + json!({}), + Vec::new(), + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 1); + let registration: Registration = serde_json::from_value(result).unwrap(); + assert_eq!(registration.contributes, contributions); + assert!(registration.provider.is_none()); + assert!(payload.is_empty()); + + send_call( + &mut host, + 3, + "management.register", + Stage::Registration, + json!({}), + Vec::new(), + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 3); + assert_eq!(result, json!({})); + let registration: ManagementRegistration = serde_json::from_slice(&payload).unwrap(); + assert_eq!(registration.routes.len(), 1); + assert_eq!(registration.routes[0].path, "echo"); + + send_call( + &mut host, + 5, + "management.handle", + Stage::Management, + serde_json::to_value(ManagementRequest { + method: "POST".into(), + path: "echo".into(), + query: String::new(), + content_type: Some("text/plain".into()), + }) + .unwrap(), + b"typed body".to_vec(), + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 5); + let response: ManagementResponse = serde_json::from_value(result).unwrap(); + assert_eq!(response.status, 201); + assert_eq!(response.content_type, "text/plain"); + assert_eq!(payload, b"typed body"); + assert_eq!(management_calls.load(Ordering::Relaxed), 1); + + send_call( + &mut host, + 7, + "command_line.register", + Stage::Registration, + json!({}), + Vec::new(), + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 7); + assert_eq!(result, json!({})); + let registration: CommandRegistration = serde_json::from_slice(&payload).unwrap(); + assert_eq!(registration.commands.len(), 1); + assert_eq!(registration.commands[0].name, "echo"); + + let invocation = CommandInvocation { + name: "echo".into(), + arguments: BTreeMap::new(), + }; + send_call( + &mut host, + 9, + "command_line.execute", + Stage::CommandLine, + json!({}), + serde_json::to_vec(&invocation).unwrap(), + ) + .await; + let (result, payload) = unwrap_result(receive(&mut host).await, 9); + assert_eq!(result, json!({})); + let output: CommandResult = serde_json::from_slice(&payload).unwrap(); + assert_eq!(output.stdout, "echo"); + assert_eq!(output.exit_code, 0); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn typed_dispatch_rejects_wrong_stage_and_payload_shape_before_handler() { + let source = author_manifest(json!({"command_line": {}}), json!([])); + let manifest = gateway_plugin_sdk::Manifest::from_author_slice(&source).unwrap(); + let contributions = manifest.contributes.clone(); + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_handler = Arc::clone(&calls); + let plugin = PluginBuilder::from_manifest(manifest) + .unwrap() + .command_line(command_registration(), move |_| { + let calls = Arc::clone(&calls_for_handler); + async move { + calls.fetch_add(1, Ordering::Relaxed); + Ok(TypedReply::new(CommandResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + accounts: Vec::new(), + })) + } + }) + .unwrap() + .build() + .unwrap(); + let (mut host, task) = start_session(plugin, contributions).await; + let invocation = serde_json::to_vec(&CommandInvocation { + name: "echo".into(), + arguments: BTreeMap::new(), + }) + .unwrap(); + + send_call( + &mut host, + 1, + "command_line.execute", + Stage::Management, + json!({}), + invocation.clone(), + ) + .await; + assert_invalid_input(receive(&mut host).await, 1); + + send_call( + &mut host, + 3, + "command_line.execute", + Stage::CommandLine, + json!({"name": "must-not-be-control-metadata"}), + invocation, + ) + .await; + assert_invalid_input(receive(&mut host).await, 3); + + send_call( + &mut host, + 5, + "command_line.execute", + Stage::CommandLine, + json!({}), + b"not json".to_vec(), + ) + .await; + assert_invalid_input(receive(&mut host).await, 5); + assert_eq!(calls.load(Ordering::Relaxed), 0); + + shutdown(&mut host, task).await; +} + +#[test] +fn build_reports_missing_declared_handler() { + let source = author_manifest(json!({"management": {}}), json!([])); + let result = PluginBuilder::from_json(&source).unwrap().build(); + assert!(matches!( + result, + Err(AuthorError::MissingMethod("management.register")) + )); +} + +#[test] +fn state_migration_is_a_base_method_gated_by_the_state_declaration() { + let state = json!([{ + "namespace": "documents", + "schemaVersion": 2, + "schema": {"type": "object"}, + "maximumRecords": 16, + "maximumBytes": 4096, + "maximumValueBytes": 1024, + "migratesFrom": [1] + }]); + let source = author_manifest(json!({}), state); + let missing = PluginBuilder::from_json(&source).unwrap().build(); + assert!(matches!( + missing, + Err(AuthorError::MissingMethod("plugin.state.migrate")) + )); + + let plugin = PluginBuilder::from_json(&source) + .unwrap() + .on(methods::STATE_MIGRATE, |_| async { + Ok(TypedReply::new(StateMigrationResult { + changes: Vec::new(), + })) + }) + .unwrap() + .build(); + assert!(plugin.is_ok()); + + let source_without_migration = author_manifest(json!({}), json!([])); + let undeclared = PluginBuilder::from_json(&source_without_migration) + .unwrap() + .on(methods::STATE_MIGRATE, |_| async { + Ok(TypedReply::new(StateMigrationResult { + changes: Vec::new(), + })) + }); + assert!(matches!( + undeclared, + Err(AuthorError::Method("plugin.state.migrate")) + )); +} + +#[test] +fn provider_group_requires_both_descriptor_and_execution_handlers() { + let source = author_manifest( + json!({ + "provider": { + "capabilities": [], + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + }), + json!([]), + ); + let descriptor: ProviderDescriptor = serde_json::from_value(json!({ + "id": "echo", + "models": [] + })) + .unwrap(); + let missing_handler = PluginBuilder::from_json(&source) + .unwrap() + .provider(descriptor) + .build(); + assert!(matches!( + missing_handler, + Err(AuthorError::MissingMethod("provider.prepare")) + )); + + let missing_descriptor = + with_executor_handlers(PluginBuilder::from_json(&source).unwrap()).build(); + assert!(matches!(missing_descriptor, Err(AuthorError::Provider))); +} + +#[test] +fn provider_descriptor_rejects_handlers_for_unadvertised_operations() { + let source = author_manifest( + json!({ + "provider": { + "capabilities": ["authentication"], + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + }), + json!([]), + ); + let descriptor: ProviderDescriptor = serde_json::from_value(json!({ + "id": "echo", + "models": [], + "credential_operations": ["import"] + })) + .unwrap(); + let plugin = with_executor_handlers(PluginBuilder::from_json(&source).unwrap()) + .on(methods::IMPORT_CREDENTIALS, |_| async { + Ok(TypedReply::new(ImportedCredentials { + accounts: Vec::new(), + })) + }) + .unwrap() + .on(methods::EXPORT_CREDENTIALS, |_| async { + Ok(TypedReply::new(serde_json::Map::new())) + }) + .unwrap() + .provider(descriptor) + .build(); + assert!(matches!(plugin, Err(AuthorError::Provider))); +} + +struct HostPeer { + reader: ReadHalf, + writer: WriteHalf, +} + +async fn start_session( + handler: H, + contributes: Contributions, +) -> (HostPeer, JoinHandle>) { + let (host, plugin) = tokio::io::duplex(MAXIMUM_FRAME_BYTES * 2); + let (plugin_reader, plugin_writer) = tokio::io::split(plugin); + let task = tokio::spawn(async move { + PluginSession::accept( + plugin_reader, + plugin_writer, + SessionConfig { + maximum_frame_bytes: MAXIMUM_FRAME_BYTES, + maximum_calls: 4, + maximum_callbacks: 4, + maximum_buffered_stream_chunks: 16, + handshake_timeout: Duration::from_secs(1), + maximum_call_timeout: Duration::from_secs(2), + }, + ) + .await? + .run(handler) + .await + }); + let (mut reader, mut writer) = tokio::io::split(host); + write_frame( + &mut writer, + &Frame::control(Message::Hello { + handshake: Handshake { + protocol_version: PROTOCOL_VERSION, + artifact_sha256: "a".repeat(64), + plugin_id: "9acme.composed".into(), + instance_id: "test-instance".into(), + generation: 1, + incarnation: "test-incarnation".into(), + configuration: json!({}), + permissions: Vec::new(), + contributes, + }, + }), + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + let ready = read_frame(&mut reader, MAXIMUM_FRAME_BYTES).await.unwrap(); + assert!(matches!( + ready, + Frame { + message: Message::Ready { + protocol_version: PROTOCOL_VERSION, + ref incarnation, + }, + ref payload, + } if incarnation == "test-incarnation" && payload.is_empty() + )); + (HostPeer { reader, writer }, task) +} + +async fn send_call( + host: &mut HostPeer, + id: u64, + method: &str, + stage: Stage, + params: Value, + payload: Vec, +) { + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id, + method: method.into(), + context: CallContext { + call_id: id, + instance_id: "test-instance".into(), + generation: 1, + incarnation: "test-incarnation".into(), + stage, + timeout_ms: 1_000, + resource_scope_id: format!("scope-{id}"), + request_id: None, + attempt_id: None, + account_id: None, + credential_revision: None, + }, + params, + }, + payload, + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); +} + +async fn receive(host: &mut HostPeer) -> Frame { + tokio::time::timeout( + Duration::from_secs(1), + read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES), + ) + .await + .expect("plugin response timed out") + .expect("plugin response frame must be valid") +} + +fn unwrap_result(frame: Frame, expected_id: u64) -> (Value, Vec) { + match frame { + Frame { + message: Message::Result { id, result }, + payload, + } if id == expected_id => (result, payload), + unexpected => panic!("unexpected plugin response: {unexpected:?}"), + } +} + +fn assert_invalid_input(frame: Frame, expected_id: u64) { + assert!(matches!( + frame, + Frame { + message: Message::Error { id, error }, + ref payload, + } if id == expected_id && error.code == ErrorCode::InvalidInput && payload.is_empty() + )); +} + +async fn shutdown(host: &mut HostPeer, task: JoinHandle>) { + write_frame( + &mut host.writer, + &Frame::control(Message::Shutdown), + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("plugin session did not shut down") + .expect("plugin session task panicked") + .expect("plugin session shutdown failed"); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session.rs b/backend/crates/gateway-plugin/sdk/tests/client/session.rs new file mode 100644 index 000000000..3a69087b1 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/client/session.rs @@ -0,0 +1,1422 @@ +use std::{ + future::pending, + num::NonZeroUsize, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use gateway_plugin_sdk::{ + CallContext, Capability, ContributionDeclaration, Contributions, ErrorCode, Frame, FrameError, + Handshake, Message, PROTOCOL_VERSION, PluginFault, Stage, + call::middleware::{ + BODY_READ_METHOD, HANDLE_METHOD, MiddlewareBodyDisposition, MiddlewareBodyFrame, + MiddlewareBodyFraming, MiddlewareBodyHandle, MiddlewareBodyReadResult, MiddlewareHeader, + MiddlewareHeaderMutation, MiddlewareMount, MiddlewareNextRequest, MiddlewareNextResponse, + MiddlewareRequestBody, MiddlewareRequestHead, MiddlewareResponseBody, + MiddlewareResponseHead, MiddlewareTransport, NEXT_METHOD, + }, + client::{ + CallFuture, CallReply, MiddlewareCall, MiddlewarePlugin, PluginCall, PluginHandler, + PluginSession, ResponseStream, SessionConfig, SessionError, read_frame, write_frame, + }, +}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncWriteExt, DuplexStream, ReadHalf, WriteHalf}, + task::JoinHandle, +}; + +const MAXIMUM_FRAME_BYTES: usize = 64 * 1024; + +#[tokio::test] +async fn binary_payload_round_trips_without_json_encoding() { + let frame = Frame { + message: Message::Stream { + id: 17, + sequence: 2, + }, + payload: vec![0, 255, 13, 10, 128], + }; + let mut bytes = Vec::new(); + write_frame(&mut bytes, &frame, 1024).await.unwrap(); + assert!(bytes.ends_with(&frame.payload)); + assert_eq!( + read_frame(&mut bytes.as_slice(), 1024).await.unwrap(), + frame + ); +} + +#[tokio::test] +async fn malicious_lengths_fail_before_reading_or_allocating_payload() { + let bytes = [0, 0, 0, 1, 255, 255, 255, 255]; + assert!(matches!( + read_frame(&mut bytes.as_slice(), 1024).await, + Err(FrameError::Length) + )); +} + +#[tokio::test] +async fn truncated_frame_never_becomes_a_successful_message() { + let mut bytes = Vec::new(); + write_frame(&mut bytes, &Frame::control(Message::Cancel { id: 3 }), 1024) + .await + .unwrap(); + bytes.pop(); + assert!(matches!( + read_frame(&mut bytes.as_slice(), 1024).await, + Err(FrameError::Io(_)) + )); +} + +#[derive(Clone, Default)] +struct TestHandler { + lifecycle: Arc, + cancel_delay: Option, +} + +#[derive(Default)] +struct LifecycleState { + cancel_started: AtomicBool, + cancelled: AtomicUsize, + quiesced: AtomicBool, + shutdown: AtomicBool, +} + +impl PluginHandler for TestHandler { + fn call(&self, call: PluginCall) -> CallFuture<'_> { + Box::pin(async move { + match call.method.as_str() { + "echo" => Ok(CallReply::unary(call.params, call.payload)), + "slow" => { + tokio::time::sleep(Duration::from_millis(80)).await; + Ok(CallReply::unary(call.params, call.payload)) + } + "callback" => { + let reply = call + .host + .call("host.log", call.params, call.payload) + .await + .map_err(SessionError::into_plugin_fault)?; + Ok(CallReply::unary(reply.result, reply.payload)) + } + "stream" => Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + ResponseStream::from_chunks(vec![b"abcd".to_vec(), b"efgh".to_vec()]), + )), + "long_stream" => Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + ResponseStream::from_chunks(vec![b"x".to_vec(); 16]), + )), + "oversized_stream" => Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + ResponseStream::from_chunks(vec![b"abcde".to_vec()]), + )), + "too_many_chunks" => Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + ResponseStream::from_chunks(vec![b"x".to_vec(); 17]), + )), + "dynamic_oversized_stream" => { + let (sender, stream) = ResponseStream::channel(NonZeroUsize::new(1).unwrap()); + tokio::spawn(async move { + let _ = sender.send(b"abcde".to_vec()).await; + }); + Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + stream, + )) + } + "oversized_fault" => Err(PluginFault::new( + ErrorCode::Upstream, + "x".repeat(MAXIMUM_FRAME_BYTES), + )), + "oversized_result" => Ok(CallReply::unary( + Value::String("x".repeat(MAXIMUM_FRAME_BYTES)), + Vec::new(), + )), + "dynamic_oversized_fault" => { + let (sender, stream) = ResponseStream::channel(NonZeroUsize::new(1).unwrap()); + tokio::spawn(async move { + let _ = sender + .fail(PluginFault::new( + ErrorCode::Upstream, + "x".repeat(MAXIMUM_FRAME_BYTES), + )) + .await; + }); + Ok(CallReply::stream( + json!({"stream": true}), + Vec::new(), + stream, + )) + } + "pending" => pending::>().await, + _ => Err(PluginFault::new( + ErrorCode::Unsupported, + "unsupported test method", + )), + } + }) + } + + fn cancel(&self, _context: &CallContext) { + self.lifecycle.cancel_started.store(true, Ordering::Release); + if let Some(delay) = self.cancel_delay { + std::thread::sleep(delay); + } + self.lifecycle.cancelled.fetch_add(1, Ordering::Relaxed); + } + + fn quiesce(&self) { + self.lifecycle.quiesced.store(true, Ordering::Release); + } + + fn shutdown(&self) { + self.lifecycle.shutdown.store(true, Ordering::Release); + } +} + +struct HostPeer { + reader: ReadHalf, + writer: WriteHalf, +} + +async fn start_session( + handler: H, +) -> (HostPeer, JoinHandle>) { + start_session_with_capacity(handler, MAXIMUM_FRAME_BYTES * 2).await +} + +async fn start_session_with_capacity( + handler: H, + capacity: usize, +) -> (HostPeer, JoinHandle>) { + let (host, plugin) = tokio::io::duplex(capacity); + let (plugin_reader, plugin_writer) = tokio::io::split(plugin); + let task = tokio::spawn(async move { + let session = PluginSession::accept( + plugin_reader, + plugin_writer, + SessionConfig { + maximum_frame_bytes: MAXIMUM_FRAME_BYTES, + maximum_calls: 4, + maximum_callbacks: 4, + maximum_buffered_stream_chunks: 16, + handshake_timeout: Duration::from_secs(1), + maximum_call_timeout: Duration::from_secs(2), + }, + ) + .await?; + session.run(handler).await + }); + let (mut reader, mut writer) = tokio::io::split(host); + write_frame( + &mut writer, + &Frame::control(Message::Hello { + handshake: handshake(), + }), + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + let ready = read_frame(&mut reader, MAXIMUM_FRAME_BYTES).await.unwrap(); + assert!(matches!( + ready, + Frame { + message: Message::Ready { + protocol_version: PROTOCOL_VERSION, + ref incarnation, + }, + ref payload, + } if incarnation == "test-incarnation" && payload.is_empty() + )); + (HostPeer { reader, writer }, task) +} + +fn middleware_contributions() -> Contributions { + Contributions::from([( + Capability::Middleware, + ContributionDeclaration { + id: "test.example.middleware".into(), + version: 1, + stages: vec![Stage::Request], + input_formats: vec!["openai".into()], + output_formats: vec!["openai".into()], + }, + )]) +} + +fn middleware_plugin(map_response: bool) -> impl PluginHandler { + MiddlewarePlugin::new( + &middleware_contributions(), + move |call: MiddlewareCall| async move { + let MiddlewareCall { + mut request, next, .. + } = call; + if map_response { + request.body.push(b'!'); + request.remove_header("x-old"); + request.head.headers.push(MiddlewareHeader { + name: "x-direct".into(), + value: b"request".to_vec(), + }); + } + let mut response = next.run(request).await?; + if map_response { + response.remove_header("x-upstream"); + response.headers.push(MiddlewareHeader { + name: "x-direct-response".into(), + value: b"response".to_vec(), + }); + response.body = response.body.map_frames(|mut frame| { + frame.payload.make_ascii_uppercase(); + Ok(vec![frame]) + })?; + } + Ok(response) + }, + ) + .unwrap() +} + +#[test] +fn middleware_plugin_rejects_declarations_without_matching_handlers() { + let declaration = middleware_contributions() + .remove(&Capability::Middleware) + .unwrap(); + let mut invalid = vec![Contributions::new()]; + invalid.push(Contributions::from([( + Capability::Executor, + declaration.clone(), + )])); + invalid.push(Contributions::from([ + (Capability::Middleware, declaration.clone()), + (Capability::Executor, declaration.clone()), + ])); + let mut unsupported = declaration.clone(); + unsupported.version = 2; + invalid.push(Contributions::from([(Capability::Middleware, unsupported)])); + for stages in [ + vec![], + vec![Stage::Execution], + vec![Stage::Request, Stage::Request], + vec![Stage::Request, Stage::Attempt, Stage::Request], + ] { + let mut invalid_stage = declaration.clone(); + invalid_stage.stages = stages; + invalid.push(Contributions::from([( + Capability::Middleware, + invalid_stage, + )])); + } + for contributes in invalid { + let result = MiddlewarePlugin::new(&contributes, |call: MiddlewareCall| async move { + call.next.run(call.request).await + }); + assert!(matches!(result, Err(SessionError::Configuration))); + } + let mut both_mounts = declaration; + both_mounts.stages.push(Stage::Attempt); + let contributes = Contributions::from([(Capability::Middleware, both_mounts)]); + assert!( + MiddlewarePlugin::new(&contributes, |call: MiddlewareCall| async move { + call.next.run(call.request).await + }) + .is_ok() + ); +} + +#[tokio::test] +async fn middleware_plugin_registers_without_invoking_business_handler() { + let contributes = middleware_contributions(); + let plugin = MiddlewarePlugin::new(&contributes, |_| async { + panic!("registration must not invoke middleware") + }) + .unwrap(); + let (mut host, task) = start_session(plugin).await; + let mut registration_context = context(1, Duration::from_secs(1)); + registration_context.stage = Stage::Registration; + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id: 1, + method: "plugin.register".into(), + context: registration_context, + params: json!({}), + }, + payload: vec![], + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + let reply = receive(&mut host).await; + let Message::Result { id: 1, result } = reply.message else { + panic!("expected registration result") + }; + let registration: gateway_plugin_sdk::call::provider::Registration = + serde_json::from_value(result).unwrap(); + assert_eq!(registration.contributes, contributes); + assert!(registration.provider.is_none()); + assert!(reply.payload.is_empty()); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn middleware_plugin_rejects_invalid_calls_before_business_dispatch() { + let plugin = MiddlewarePlugin::new(&middleware_contributions(), |_| async { + panic!("invalid call must not invoke middleware") + }) + .unwrap(); + let (mut host, task) = start_session(plugin).await; + let cases = [ + ("plugin.register", Stage::Request, json!({}), vec![]), + ("plugin.register", Stage::Registration, json!(null), vec![]), + ( + "plugin.register", + Stage::Registration, + json!({"extra":true}), + vec![], + ), + ("plugin.register", Stage::Registration, json!({}), vec![1]), + (HANDLE_METHOD, Stage::Attempt, json!({}), vec![]), + (HANDLE_METHOD, Stage::Request, json!({}), vec![]), + ("provider.execute", Stage::Execution, json!({}), vec![]), + ]; + for (index, (method, stage, params, payload)) in cases.into_iter().enumerate() { + let id = u64::try_from(index).unwrap() * 2 + 1; + let mut call_context = middleware_context(id); + call_context.stage = stage; + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id, + method: method.into(), + context: call_context, + params, + }, + payload, + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + let reply = receive(&mut host).await; + let Message::Error { + id: reply_id, + error, + } = reply.message + else { + panic!("expected invalid call error") + }; + assert_eq!(reply_id, id); + assert_eq!( + error.code, + if method == "provider.execute" { + ErrorCode::Unsupported + } else { + ErrorCode::InvalidInput + } + ); + } + shutdown(&mut host, task).await; +} + +fn handshake() -> Handshake { + Handshake { + protocol_version: PROTOCOL_VERSION, + artifact_sha256: "a".repeat(64), + plugin_id: "test.example".into(), + instance_id: "test-instance".into(), + generation: 7, + incarnation: "test-incarnation".into(), + configuration: json!({}), + permissions: Vec::new(), + contributes: Contributions::new(), + } +} + +fn context(id: u64, timeout: Duration) -> CallContext { + CallContext { + call_id: id, + instance_id: "test-instance".into(), + generation: 7, + incarnation: "test-incarnation".into(), + stage: Stage::Execution, + timeout_ms: timeout.as_millis() as u64, + resource_scope_id: format!("scope-{id}"), + request_id: None, + attempt_id: None, + account_id: None, + credential_revision: None, + } +} + +fn middleware_context(id: u64) -> CallContext { + CallContext { + stage: Stage::Request, + request_id: Some("request-middleware".into()), + ..context(id, Duration::from_secs(1)) + } +} + +fn middleware_request() -> MiddlewareRequestHead { + MiddlewareRequestHead { + request_id: "request-middleware".into(), + mount: MiddlewareMount::Request, + attempt_index: None, + operation: "generate".into(), + protocol: "openai".into(), + endpoint: "responses".into(), + transport: MiddlewareTransport::HttpSse, + provider: None, + model: Some("public-model".into()), + account_id: None, + headers: vec![MiddlewareHeader { + name: "x-old".into(), + value: b"old".to_vec(), + }], + body_visible: true, + } +} + +async fn send_call(host: &mut HostPeer, id: u64, method: &str, params: Value, payload: Vec) { + send_call_with_timeout(host, id, method, params, payload, Duration::from_secs(1)).await; +} + +async fn send_call_with_timeout( + host: &mut HostPeer, + id: u64, + method: &str, + params: Value, + payload: Vec, + timeout: Duration, +) { + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id, + method: method.into(), + context: context(id, timeout), + params, + }, + payload, + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); +} + +async fn send_control(host: &mut HostPeer, message: Message) { + write_frame( + &mut host.writer, + &Frame::control(message), + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); +} + +async fn receive(host: &mut HostPeer) -> Frame { + tokio::time::timeout( + Duration::from_secs(1), + read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES), + ) + .await + .expect("plugin response timed out") + .expect("plugin response frame must be valid") +} + +async fn shutdown(host: &mut HostPeer, task: JoinHandle>) { + send_control(host, Message::Shutdown).await; + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("plugin session did not shut down") + .expect("plugin session task panicked") + .expect("plugin session shutdown failed"); +} + +#[tokio::test] +async fn independent_calls_complete_concurrently() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "slow", json!("slow"), vec![1]).await; + send_call(&mut host, 3, "echo", json!("fast"), vec![3]).await; + + let fast = receive(&mut host).await; + let slow = receive(&mut host).await; + assert!(matches!( + fast, + Frame { + message: Message::Result { id: 3, result }, + payload, + } if result == json!("fast") && payload == [3] + )); + assert!(matches!( + slow, + Frame { + message: Message::Result { id: 1, result }, + payload, + } if result == json!("slow") && payload == [1] + )); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn completed_call_does_not_discard_a_partially_read_next_frame() { + let next = Frame { + message: Message::Call { + id: 3, + method: "echo".into(), + context: context(3, Duration::from_secs(1)), + params: json!({"fragmented": true}), + }, + payload: vec![0, 255, 13, 10, 128], + }; + let mut encoded = Vec::new(); + write_frame(&mut encoded, &next, MAXIMUM_FRAME_BYTES) + .await + .unwrap(); + let payload_start = encoded.len() - next.payload.len(); + for split in [2, 6, 10, payload_start + 2] { + tokio::time::timeout(Duration::from_secs(2), async { + // 单字节缓冲保证前缀已经进入读取 future,而不是仍滞留在传输队列。 + let (mut host, task) = start_session_with_capacity(TestHandler::default(), 1).await; + send_call(&mut host, 1, "slow", json!("first"), Vec::new()).await; + host.writer.write_all(&encoded[..split]).await.unwrap(); + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + host.writer.write_all(&encoded[split..]).await.unwrap(); + assert_eq!( + receive(&mut host).await, + Frame { + message: Message::Result { + id: 3, + result: json!({"fragmented": true}) + }, + payload: next.payload.clone(), + } + ); + shutdown(&mut host, task).await; + }) + .await + .expect("fragmented frame must survive concurrent call completion"); + } +} + +#[tokio::test] +async fn host_callback_round_trips_while_parent_call_is_waiting() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "callback", json!({"event": "ready"}), vec![7]).await; + + let callback = receive(&mut host).await; + let callback_id = match callback.message { + Message::Callback { + id, + parent_id: 1, + ref method, + ref params, + } if id.is_multiple_of(2) + && method == "host.log" + && params == &json!({"event": "ready"}) => + { + id + } + message => panic!("unexpected callback frame: {message:?}"), + }; + assert_eq!(callback.payload, [7]); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: callback_id, + result: json!({"recorded": true}), + }, + payload: vec![9], + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + + let result = receive(&mut host).await; + assert!(matches!( + result, + Frame { + message: Message::Result { id: 1, result }, + payload, + } if result == json!({"recorded": true}) && payload == [9] + )); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn callbacks_from_concurrent_calls_remain_correlated() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "callback", json!({"parent": 1}), vec![1]).await; + send_call(&mut host, 3, "callback", json!({"parent": 3}), vec![3]).await; + + let mut callbacks = Vec::new(); + for _ in 0..2 { + let frame = receive(&mut host).await; + let Message::Callback { + id, + parent_id, + method, + params, + } = frame.message + else { + panic!("expected callback") + }; + assert_eq!(method, "host.log"); + assert_eq!(params, json!({"parent": parent_id})); + assert_eq!(frame.payload, [parent_id as u8]); + callbacks.push((id, parent_id)); + } + assert!(callbacks[0].0 < callbacks[1].0); + assert!(callbacks.iter().all(|(id, _)| id.is_multiple_of(2))); + + for (callback_id, parent_id) in callbacks.into_iter().rev() { + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: callback_id, + result: json!({"parent": parent_id}), + }, + payload: vec![parent_id as u8], + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + } + let mut completed = Vec::new(); + for _ in 0..2 { + let frame = receive(&mut host).await; + let Message::Result { id, result } = frame.message else { + panic!("expected parent result") + }; + assert_eq!(result, json!({"parent": id})); + assert_eq!(frame.payload, [id as u8]); + completed.push(id); + } + completed.sort_unstable(); + assert_eq!(completed, [1, 3]); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn middleware_next_and_body_mapping_reuse_callback_and_credit_flow() { + let (mut host, task) = start_session(middleware_plugin(true)).await; + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id: 1, + method: HANDLE_METHOD.into(), + context: middleware_context(1), + params: serde_json::to_value(middleware_request()).unwrap(), + }, + payload: b"request".to_vec(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + + let next = receive(&mut host).await; + let next_id = match next.message { + Message::Callback { + id, + parent_id: 1, + ref method, + ref params, + } if method == NEXT_METHOD => { + let request: MiddlewareNextRequest = serde_json::from_value(params.clone()).unwrap(); + assert_eq!(request.body, MiddlewareRequestBody::Replace); + assert!(request.protocol.is_none()); + assert_eq!(request.header_mutations.len(), 2); + assert!(matches!( + &request.header_mutations[0], + MiddlewareHeaderMutation::Remove { name } if name == "x-old" + )); + assert!(matches!( + &request.header_mutations[1], + MiddlewareHeaderMutation::Append { name, value } + if name == "x-direct" && value == b"request" + )); + id + } + message => panic!("unexpected middleware next frame: {message:?}"), + }; + assert_eq!(next.payload, b"request!"); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: next_id, + result: serde_json::to_value(MiddlewareNextResponse { + response: "response-1".into(), + protocol: "openai".into(), + status: 200, + headers: vec![MiddlewareHeader { + name: "x-upstream".into(), + value: b"old".to_vec(), + }], + body: Some(MiddlewareBodyHandle { + handle: "body-1".into(), + framing: MiddlewareBodyFraming::SseEvent, + }), + }) + .unwrap(), + }, + payload: Vec::new(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 1_024, + frames: 2, + }, + ) + .await; + + let initial = receive(&mut host).await; + let Message::Result { id: 1, result } = initial.message else { + panic!("expected middleware result") + }; + let head: MiddlewareResponseHead = serde_json::from_value(result).unwrap(); + assert_eq!(head.header_mutations.len(), 2); + assert!(matches!( + &head.header_mutations[0], + MiddlewareHeaderMutation::Remove { name } if name == "x-upstream" + )); + assert!(matches!( + &head.header_mutations[1], + MiddlewareHeaderMutation::Append { name, value } + if name == "x-direct-response" && value == b"response" + )); + assert!(matches!( + head.body, + MiddlewareResponseBody::Stream { + framing: MiddlewareBodyFraming::SseEvent + } + )); + + let read = receive(&mut host).await; + let read_id = match read.message { + Message::Callback { + id, + parent_id: 1, + ref method, + .. + } if method == BODY_READ_METHOD => id, + message => panic!("unexpected middleware body read: {message:?}"), + }; + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: read_id, + result: serde_json::to_value(MiddlewareBodyReadResult { + framing: MiddlewareBodyFraming::SseEvent, + source_id: 1, + eof: false, + terminal: true, + }) + .unwrap(), + }, + payload: b"data: done\n\n".to_vec(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + let mapped = receive(&mut host).await; + let Frame { + message: Message::Stream { id: 1, sequence: 0 }, + payload, + } = mapped + else { + panic!("expected mapped middleware frame") + }; + let mapped = MiddlewareBodyFrame::decode(&payload).unwrap(); + assert_eq!(mapped.payload, b"DATA: DONE\n\n"); + assert!(!mapped.terminal); + assert_eq!(mapped.source_id(), 1); + assert_eq!(mapped.disposition(), MiddlewareBodyDisposition::Only); + + let eof = receive(&mut host).await; + let eof_id = match eof.message { + Message::Callback { + id, + parent_id: 1, + ref method, + .. + } if method == BODY_READ_METHOD => id, + message => panic!("unexpected middleware eof read: {message:?}"), + }; + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: eof_id, + result: serde_json::to_value(MiddlewareBodyReadResult { + framing: MiddlewareBodyFraming::SseEvent, + source_id: 0, + eof: true, + terminal: false, + }) + .unwrap(), + }, + payload: Vec::new(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + assert!(matches!( + receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn untouched_middleware_response_transfers_opaque_body_without_reading_it() { + let (mut host, task) = start_session(middleware_plugin(false)).await; + write_frame( + &mut host.writer, + &Frame { + message: Message::Call { + id: 1, + method: HANDLE_METHOD.into(), + context: middleware_context(1), + params: serde_json::to_value(middleware_request()).unwrap(), + }, + payload: b"hidden-by-preserve".to_vec(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + + let next = receive(&mut host).await; + let next_id = match next.message { + Message::Callback { + id, + parent_id: 1, + ref method, + ref params, + } if method == NEXT_METHOD => { + let request: MiddlewareNextRequest = serde_json::from_value(params.clone()).unwrap(); + assert_eq!(request.body, MiddlewareRequestBody::Preserve); + id + } + message => panic!("unexpected middleware next frame: {message:?}"), + }; + assert!(next.payload.is_empty()); + write_frame( + &mut host.writer, + &Frame { + message: Message::Result { + id: next_id, + result: serde_json::to_value(MiddlewareNextResponse { + response: "response-1".into(), + protocol: "openai".into(), + status: 200, + headers: Vec::new(), + body: Some(MiddlewareBodyHandle { + handle: "body-pass-through".into(), + framing: MiddlewareBodyFraming::RawBytes, + }), + }) + .unwrap(), + }, + payload: Vec::new(), + }, + MAXIMUM_FRAME_BYTES, + ) + .await + .unwrap(); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 1, + frames: 1, + }, + ) + .await; + + let initial = receive(&mut host).await; + let Message::Result { id: 1, result } = initial.message else { + panic!("expected middleware result") + }; + let head: MiddlewareResponseHead = serde_json::from_value(result).unwrap(); + assert!(matches!( + head.body, + MiddlewareResponseBody::PassThrough { + body: MiddlewareBodyHandle { ref handle, .. } + } if handle == "body-pass-through" + )); + assert!(matches!( + receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn stream_waits_for_credit_and_rejects_uncreditable_buffered_chunk_before_result() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "stream", json!({}), Vec::new()).await; + assert!( + tokio::time::timeout( + Duration::from_millis(25), + read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES) + ) + .await + .is_err() + ); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 4, + frames: 1, + }, + ) + .await; + + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + assert!(matches!( + receive(&mut host).await, + Frame { + message: Message::Stream { id: 1, sequence: 0 }, + payload, + } if payload == b"abcd" + )); + assert!( + tokio::time::timeout( + Duration::from_millis(25), + read_frame(&mut host.reader, MAXIMUM_FRAME_BYTES) + ) + .await + .is_err() + ); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 4, + frames: 1, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await, + Frame { + message: Message::Stream { id: 1, sequence: 1 }, + payload, + } if payload == b"efgh" + )); + assert!(matches!( + receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + + send_call(&mut host, 3, "oversized_stream", json!({}), Vec::new()).await; + send_control( + &mut host, + Message::Credit { + id: 3, + bytes: 4, + frames: 1, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 3, + error: PluginFault { + code: ErrorCode::InvalidInput, + .. + }, + } + )); + + send_call(&mut host, 5, "too_many_chunks", json!({}), Vec::new()).await; + send_control( + &mut host, + Message::Credit { + id: 5, + bytes: 4, + frames: 1, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 5, + error: PluginFault { + code: ErrorCode::Capacity, + .. + }, + } + )); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn late_credit_after_a_long_stream_terminal_does_not_close_the_session() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "long_stream", json!({}), Vec::new()).await; + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 1, + frames: 1, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + for sequence in 0..16 { + assert!(matches!( + receive(&mut host).await, + Frame { + message: Message::Stream { id: 1, sequence: observed }, + payload, + } if observed == sequence && payload == b"x" + )); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 1, + frames: 1, + }, + ) + .await; + } + assert!(matches!( + receive(&mut host).await.message, + Message::End { id: 1, error: None } + )); + + send_call(&mut host, 3, "echo", json!("still-ready"), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 3, result } if result == json!("still-ready") + )); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn malformed_late_credit_remains_a_protocol_error() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "echo", json!({}), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 0, + frames: 1, + }, + ) + .await; + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("protocol error did not close the session") + .expect("plugin session task panicked"), + Err(SessionError::Protocol) + )); +} + +#[tokio::test] +async fn dynamic_stream_ends_with_one_error_when_a_later_chunk_cannot_fit_credit() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call( + &mut host, + 1, + "dynamic_oversized_stream", + json!({}), + Vec::new(), + ) + .await; + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 4, + frames: 1, + }, + ) + .await; + + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 1, .. } + )); + assert!(matches!( + receive(&mut host).await.message, + Message::End { + id: 1, + error: Some(PluginFault { + code: ErrorCode::InvalidInput, + .. + }), + } + )); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn oversized_handler_faults_are_replaced_without_closing_the_session() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "oversized_result", json!({}), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 1, + error: PluginFault { + code: ErrorCode::InvalidInput, + .. + }, + } + )); + + send_call(&mut host, 3, "oversized_fault", json!({}), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 3, + error: PluginFault { + code: ErrorCode::Fault, + ref message, + .. + }, + } if message == "plugin error exceeds the frame limit" + )); + + send_call( + &mut host, + 5, + "dynamic_oversized_fault", + json!({}), + Vec::new(), + ) + .await; + send_control( + &mut host, + Message::Credit { + id: 5, + bytes: 1_024, + frames: 1, + }, + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 5, .. } + )); + assert!(matches!( + receive(&mut host).await.message, + Message::End { + id: 5, + error: Some(PluginFault { + code: ErrorCode::Fault, + ref message, + .. + }), + } if message == "plugin stream error exceeds the frame limit" + )); + + send_call(&mut host, 7, "echo", json!("still-ready"), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 7, result } if result == json!("still-ready") + )); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn call_deadline_cancels_handler_and_keeps_the_session_available() { + let handler = TestHandler::default(); + let state = Arc::clone(&handler.lifecycle); + let (mut host, task) = start_session(handler).await; + send_call_with_timeout( + &mut host, + 1, + "pending", + json!({}), + Vec::new(), + Duration::from_millis(20), + ) + .await; + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 1, + error: PluginFault { + code: ErrorCode::Timeout, + .. + }, + } + )); + tokio::time::timeout(Duration::from_millis(100), async { + while !state.cancel_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("deadline did not notify the lifecycle hook"); + + send_call(&mut host, 3, "echo", json!("still-ready"), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 3, result } if result == json!("still-ready") + )); + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn cancelling_parent_retires_callback_and_late_reply_does_not_close_session() { + let (mut host, task) = start_session(TestHandler::default()).await; + send_call(&mut host, 1, "callback", json!({}), Vec::new()).await; + let callback = receive(&mut host).await; + let Message::Callback { + id: callback_id, .. + } = callback.message + else { + panic!("expected callback") + }; + + send_control(&mut host, Message::Cancel { id: 1 }).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Cancelled { id: 1 } + )); + send_control( + &mut host, + Message::Result { + id: callback_id, + result: json!({}), + }, + ) + .await; + send_call(&mut host, 3, "echo", json!("still-ready"), Vec::new()).await; + assert!(matches!( + receive(&mut host).await.message, + Message::Result { id: 3, result } if result == json!("still-ready") + )); + + shutdown(&mut host, task).await; +} + +#[tokio::test] +async fn blocking_cancel_hook_cannot_delay_cancelled_or_eof_settlement() { + let handler = TestHandler { + cancel_delay: Some(Duration::from_millis(200)), + ..TestHandler::default() + }; + let state = Arc::clone(&handler.lifecycle); + let (mut host, task) = start_session(handler).await; + send_call(&mut host, 1, "pending", json!({}), Vec::new()).await; + send_control(&mut host, Message::Cancel { id: 1 }).await; + + let cancelled = tokio::time::timeout(Duration::from_millis(50), receive(&mut host)) + .await + .expect("blocking lifecycle hook delayed Cancelled"); + assert!(matches!(cancelled.message, Message::Cancelled { id: 1 })); + tokio::time::timeout(Duration::from_millis(50), async { + while !state.cancel_started.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancel hook never started"); + + host.writer.shutdown().await.unwrap(); + let result = tokio::time::timeout(Duration::from_millis(50), task) + .await + .expect("blocking lifecycle hook delayed EOF settlement") + .expect("plugin session task panicked"); + assert!(matches!(result, Err(SessionError::Closed))); +} + +#[tokio::test] +async fn quiesce_rejects_new_calls_and_shutdown_closes_the_session() { + let handler = TestHandler::default(); + let state = Arc::clone(&handler.lifecycle); + let (mut host, task) = start_session(handler).await; + send_control(&mut host, Message::Quiesce).await; + send_call(&mut host, 1, "echo", json!({}), Vec::new()).await; + + assert!(matches!( + receive(&mut host).await.message, + Message::Error { + id: 1, + error: PluginFault { + code: ErrorCode::Capacity, + .. + }, + } + )); + send_control( + &mut host, + Message::Credit { + id: 1, + bytes: 1, + frames: 1, + }, + ) + .await; + tokio::time::timeout(Duration::from_millis(50), async { + while !state.quiesced.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("quiesce hook was not signalled"); + + shutdown(&mut host, task).await; +} diff --git a/backend/crates/gateway-plugin/sdk/tests/main.rs b/backend/crates/gateway-plugin/sdk/tests/main.rs new file mode 100644 index 000000000..8d18b3e02 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/main.rs @@ -0,0 +1,5 @@ +mod call; +#[cfg(feature = "io")] +mod client; +mod manifest; +mod message; diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs new file mode 100644 index 000000000..2fc3a7ba5 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -0,0 +1,487 @@ +use gateway_plugin_sdk::{ + Capability, ContributionDeclaration, Engines, Handshake, MANIFEST_VERSION, Manifest, + ManifestError, PROTOCOL_VERSION, Package, PackageTarget, PluginIcon, PluginIconVariants, Stage, + call::provider::Registration, valid_package_path, +}; +use serde_json::json; + +fn source_manifest() -> Manifest { + serde_json::from_value(json!({ + "manifestVersion": 3, + "name": "request-tags", + "displayName": "请求标签", + "publisher": "9acme", + "version": "1.0.0", + "description": "测试插件", + "license": "MIT", + "author": "test", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": { + "middleware": { + "id": "9acme.request-tags.tagRequest", + "version": 1, + "stages": ["request"], + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + } + })) + .unwrap() +} + +fn packaged_manifest() -> Manifest { + let mut manifest = source_manifest(); + manifest.engines = Engines { + codex_proxy_rs: ">=1.0.0, <2.0.0".parse().unwrap(), + }; + manifest.package = Some(Package { + protocol_version: PROTOCOL_VERSION, + target: PackageTarget { + os: "linux".into(), + architecture: "x86_64".into(), + }, + files: [("bin/worker".into(), "a".repeat(64))] + .into_iter() + .collect(), + }); + manifest +} + +#[test] +fn package_paths_reject_platform_escapes_and_ambiguous_names() { + for invalid in [ + "/tmp/code", + "../code", + "a/../b", + "a/./b", + "a//b", + "C:/code", + "a\\b", + "NUL.txt", + "aux", + "bin/a.", + "bin/a ", + ] { + assert!(!valid_package_path(invalid), "{invalid}"); + } + assert!(valid_package_path("bin/example-worker.exe")); +} + +#[test] +fn source_manifest_allows_missing_package_and_wildcard_engine() { + let manifest = source_manifest(); + assert_eq!(manifest.plugin_id().unwrap(), "9acme.request-tags"); + assert!(manifest.validate().is_ok()); + assert_eq!( + manifest.package_for(&"1.2.0".parse().unwrap(), "linux", "x86_64"), + Err(ManifestError::Invalid) + ); +} + +#[test] +fn author_manifest_fills_default_ids_versions_and_fixed_stages() { + let source = json!({ + "manifestVersion": MANIFEST_VERSION, + "name": "request-tags", + "displayName": "请求标签", + "publisher": "9acme", + "version": "1.0.0", + "description": "测试插件", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": { + "middleware": {"stages": ["attempt"]}, + "management": {} + } + }); + let manifest = Manifest::from_author_slice(&serde_json::to_vec(&source).unwrap()).unwrap(); + let middleware = &manifest.contributes[&Capability::Middleware]; + let management = &manifest.contributes[&Capability::Management]; + + assert_eq!(middleware.id, "9acme.request-tags.middleware"); + assert_eq!(middleware.version, 1); + assert_eq!(middleware.stages, [Stage::Attempt]); + assert_eq!(management.id, "9acme.request-tags.management"); + assert_eq!(management.version, 1); + assert_eq!(management.stages, [Stage::Management]); +} + +#[test] +fn author_provider_group_expands_once_with_protocols_on_executor() { + let source = json!({ + "manifestVersion": MANIFEST_VERSION, + "name": "echo", + "displayName": "Echo", + "publisher": "9acme", + "version": "1.0.0", + "description": "Echo provider", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": { + "provider": { + "capabilities": ["models", "authentication", "quota", "maintenance"], + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + }, + "permissions": ["accounts", "network"] + }); + let manifest = Manifest::from_author_slice(&serde_json::to_vec(&source).unwrap()).unwrap(); + let executor = &manifest.contributes[&Capability::Executor]; + + assert_eq!(manifest.contributes.len(), 5); + assert_eq!(executor.id, "9acme.echo.executor"); + assert_eq!(executor.stages, [Stage::Execution]); + assert_eq!(executor.input_formats, ["openai"]); + assert_eq!(executor.output_formats, ["openai"]); + assert_eq!( + manifest.contributes[&Capability::Models].stages, + [Stage::Management, Stage::Maintenance] + ); +} + +#[test] +fn author_provider_group_rejects_duplicate_or_non_provider_capabilities() { + for capabilities in [ + json!(["models", "models"]), + json!(["management"]), + json!(["executor"]), + ] { + let source = json!({ + "manifestVersion": MANIFEST_VERSION, + "name": "echo", + "displayName": "Echo", + "publisher": "9acme", + "version": "1.0.0", + "description": "Echo provider", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": { + "provider": { + "capabilities": capabilities, + "inputFormats": ["openai"], + "outputFormats": ["openai"] + } + } + }); + assert_eq!( + Manifest::from_author_slice(&serde_json::to_vec(&source).unwrap()), + Err(ManifestError::Invalid) + ); + } + + for (input_formats, output_formats) in [ + (json!([]), json!(["openai"])), + (json!(["openai"]), json!([])), + (json!(["openai", "openai"]), json!(["openai"])), + (json!(["openai"]), json!(["openai", "openai"])), + ] { + let source = json!({ + "manifestVersion": MANIFEST_VERSION, + "name": "echo", + "displayName": "Echo", + "publisher": "9acme", + "version": "1.0.0", + "description": "Echo provider", + "license": "MIT", + "engines": {"codex-proxy-rs": "*"}, + "main": "bin/worker", + "runtime": "trustedProcess", + "contributes": { + "provider": { + "inputFormats": input_formats, + "outputFormats": output_formats + } + } + }); + assert_eq!( + Manifest::from_author_slice(&serde_json::to_vec(&source).unwrap()), + Err(ManifestError::Invalid) + ); + } +} + +#[test] +fn installation_checks_protocol_engine_and_target() { + let mut manifest = packaged_manifest(); + let host = "1.2.0".parse().unwrap(); + assert!(manifest.package_for(&host, "linux", "x86_64").is_ok()); + assert_eq!( + manifest.package_for(&host, "windows", "aarch64"), + Err(ManifestError::Platform) + ); + manifest.package.as_mut().unwrap().protocol_version = 2; + assert_eq!(manifest.validate(), Err(ManifestError::Incompatible)); + manifest.package.as_mut().unwrap().protocol_version = PROTOCOL_VERSION; + manifest.engines.codex_proxy_rs = "*".parse().unwrap(); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn package_rejects_file_directory_conflicts_and_missing_declared_files() { + let mut manifest = packaged_manifest(); + manifest + .package + .as_mut() + .unwrap() + .files + .insert("bin".into(), "b".repeat(64)); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + + let mut manifest = packaged_manifest(); + manifest + .resources + .insert("ui/index.html".into(), "text/html".into()); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + + let mut manifest = packaged_manifest(); + manifest.main = "bin/missing".into(); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn icon_accepts_declared_image_resources_and_rejects_unsafe_shapes() { + let mut manifest = source_manifest(); + manifest + .resources + .insert("assets/icon.png".into(), "image/png".into()); + manifest.icon = Some(PluginIcon::Path("assets/icon.png".into())); + assert!(manifest.validate().is_ok()); + + manifest + .resources + .insert("assets/icon-dark.webp".into(), "image/webp".into()); + manifest.icon = Some(PluginIcon::Themed(PluginIconVariants { + light: "assets/icon.png".into(), + dark: "assets/icon-dark.webp".into(), + })); + assert!(manifest.validate().is_ok()); + + manifest.icon = Some(PluginIcon::Path("assets/missing.png".into())); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + manifest + .resources + .insert("assets/icon.svg".into(), "image/svg+xml".into()); + manifest.icon = Some(PluginIcon::Path("assets/icon.svg".into())); + assert!(manifest.validate().is_ok()); + manifest.icon = Some(PluginIcon::Path("assets/icon.png".into())); + manifest + .resources + .insert("assets/icon.png".into(), "image/jpeg".into()); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn icon_accepts_common_extensions_and_mime_aliases() { + for (extension, mime) in [ + ("SVG", "image/svg+xml"), + ("PNG", "image/png"), + ("jpeg", "image/jpeg"), + ("jfif", "image/jpeg"), + ("WebP", "image/webp"), + ("gif", "image/gif"), + ("ico", "image/x-icon"), + ("ICO", "image/vnd.microsoft.icon"), + ("bmp", "image/bmp"), + ("BMP", "image/x-ms-bmp"), + ] { + let mut manifest = source_manifest(); + let path = format!("assets/icon.{extension}"); + manifest.resources.insert(path.clone(), mime.into()); + manifest.icon = Some(PluginIcon::Path(path)); + assert!(manifest.validate().is_ok(), "{extension}: {mime}"); + } +} + +#[test] +fn icon_rejects_non_image_resources_even_when_declared() { + for (path, mime) in [ + ("assets/icon.html", "text/html"), + ("assets/icon.svg", "text/xml"), + ] { + let mut manifest = source_manifest(); + manifest.resources.insert(path.into(), mime.into()); + manifest.icon = Some(PluginIcon::Path(path.into())); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + } +} + +#[test] +fn icon_wire_is_a_path_or_exact_light_dark_object() { + let mut manifest = source_manifest(); + manifest + .resources + .insert("assets/icon.png".into(), "image/png".into()); + manifest.icon = Some(PluginIcon::Path("assets/icon.png".into())); + assert_eq!( + serde_json::to_value(&manifest).unwrap()["icon"], + json!("assets/icon.png") + ); + + let mut value = serde_json::to_value(&manifest).unwrap(); + value["icon"] = json!({ + "light": "assets/icon.png", + "dark": "assets/icon.png", + "builtin": "puzzle" + }); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn contribution_ids_are_owned_unique_and_stage_bounded() { + let mut manifest = source_manifest(); + let middleware = manifest + .contributes + .get(&Capability::Middleware) + .unwrap() + .clone(); + manifest + .contributes + .insert(Capability::Executor, middleware); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + + let mut manifest = source_manifest(); + manifest + .contributes + .get_mut(&Capability::Middleware) + .unwrap() + .id = "other.request-tags.tagRequest".into(); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); + + let mut manifest = source_manifest(); + manifest + .contributes + .get_mut(&Capability::Middleware) + .unwrap() + .stages = vec![Stage::Execution]; + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn installed_manifest_rejects_non_middleware_stage_drift() { + let mut manifest = source_manifest(); + manifest.contributes.insert( + Capability::Management, + ContributionDeclaration { + id: "9acme.request-tags.management".into(), + version: 1, + stages: vec![Stage::Registration], + input_formats: Vec::new(), + output_formats: Vec::new(), + }, + ); + + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn plugin_identity_rejects_invalid_segments_and_overlong_ids() { + let mut manifest = source_manifest(); + manifest.publisher = "Acme".into(); + assert_eq!(manifest.plugin_id(), Err(ManifestError::Invalid)); + + let mut manifest = source_manifest(); + manifest.name = "request-tags-".into(); + assert_eq!(manifest.plugin_id(), Err(ManifestError::Invalid)); + + let mut manifest = source_manifest(); + manifest.publisher = "a".repeat(32); + manifest.name = "b".repeat(32); + assert_eq!(manifest.plugin_id(), Err(ManifestError::Invalid)); +} + +#[test] +fn state_namespaces_are_base_infrastructure_with_bounded_versions() { + let mut manifest = source_manifest(); + manifest.state = vec![gateway_plugin_sdk::StateNamespace { + namespace: "cache.primary".into(), + schema_version: 2, + schema: json!({"type":"object"}), + maximum_records: 10, + maximum_bytes: 4096, + maximum_value_bytes: 1024, + migrates_from: vec![1], + }]; + assert!(manifest.validate().is_ok()); + manifest.state[0].migrates_from.push(2); + assert_eq!(manifest.validate(), Err(ManifestError::Invalid)); +} + +#[test] +fn public_manifest_fields_use_only_the_v3_camel_case_shape() { + let value = serde_json::to_value(source_manifest()).unwrap(); + assert!(value.get("manifestVersion").is_some()); + assert!(value.get("displayName").is_some()); + assert!(value.get("configurationSchema").is_some()); + assert!(value.get("schema_version").is_none()); + + let mut old = value; + old.as_object_mut() + .unwrap() + .insert("schema_version".into(), json!(3)); + assert!(serde_json::from_value::(old).is_err()); +} + +#[test] +fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { + let declaration = r#"{ + "id":"test.example.middleware","version":1,"stages":["request"], + "inputFormats":["openai"],"outputFormats":["openai"] + }"#; + let manifest = format!( + r#"{{ + "manifestVersion":3,"name":"example","displayName":"Example","publisher":"test", + "version":"1.0.0","description":"Example","license":"MIT", + "engines":{{"codex-proxy-rs":"*"}},"main":"bin/worker","runtime":"trustedProcess", + "contributes":{{"middleware":{declaration},"middleware":{declaration}}} + }}"# + ); + assert!(serde_json::from_str::(&manifest).is_err()); + assert_eq!( + Manifest::from_author_slice(manifest.as_bytes()), + Err(ManifestError::Invalid) + ); + + let handshake = format!( + r#"{{ + "protocol_version":4,"artifact_sha256":"digest","plugin_id":"test.example", + "instance_id":"instance","generation":1,"incarnation":"incarnation", + "configuration":{{}},"permissions":[], + "contributes":{{"middleware":{declaration},"middleware":{declaration}}} + }}"# + ); + assert!(serde_json::from_str::(&handshake).is_err()); + + let registration = + format!(r#"{{"contributes":{{"middleware":{declaration},"middleware":{declaration}}}}}"#); + assert!(serde_json::from_str::(®istration).is_err()); +} + +#[test] +fn contribution_declaration_uses_camel_case_description_fields() { + let declaration = ContributionDeclaration { + id: "test.example.executor".into(), + version: 1, + stages: vec![Stage::Execution], + input_formats: vec!["openai".into()], + output_formats: vec!["openai".into()], + }; + assert_eq!( + serde_json::to_value(declaration).unwrap(), + json!({ + "id":"test.example.executor", + "version":1, + "stages":["execution"], + "inputFormats":["openai"], + "outputFormats":["openai"] + }) + ); +} diff --git a/backend/crates/gateway-plugin/sdk/tests/message.rs b/backend/crates/gateway-plugin/sdk/tests/message.rs new file mode 100644 index 000000000..f27fabc80 --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/tests/message.rs @@ -0,0 +1,15 @@ +use gateway_plugin_sdk::{Frame, Message}; + +#[test] +fn diagnostics_do_not_render_peer_payloads_or_error_details() { + let secret = "sensitive-fixture-value"; + let fault = gateway_plugin_sdk::PluginFault::new(gateway_plugin_sdk::ErrorCode::Fault, secret); + let frame = Frame { + message: Message::Result { + id: 1, + result: serde_json::json!({"credential":secret}), + }, + payload: secret.as_bytes().to_vec(), + }; + assert!(!format!("{frame:?} {fault:?}").contains(secret)); +} diff --git a/backend/crates/gateway-protocol/src/openai/headers.rs b/backend/crates/gateway-protocol/src/openai/headers.rs index 6c773af74..8cf4419a4 100644 --- a/backend/crates/gateway-protocol/src/openai/headers.rs +++ b/backend/crates/gateway-protocol/src/openai/headers.rs @@ -27,3 +27,55 @@ pub fn is_transport_managed_request_header(name: &str) -> bool { | "x-request-id" ) } + +/// 判断上游响应头是否可由客户端 HTTP adapter 或插件读取/改写。 +/// +/// `connection_options` 来自同一响应的 `Connection` 字段,调用方应先按逗号拆分并 +/// 转成小写。此函数同时拒绝逐跳头、实体 framing、认证材料和账号身份字段;所有 +/// 响应边界共用这一份分类,避免观测、加工和最终转发出现不同的泄漏面。 +#[must_use] +pub fn response_header_is_forwardable(name: &str, connection_options: &[String]) -> bool { + let name = name.trim().to_ascii_lowercase(); + if connection_options + .iter() + .any(|option| option.eq_ignore_ascii_case(&name)) + || name.starts_with("sec-websocket-") + { + return false; + } + + !matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "content-length" + | "content-type" + | "content-encoding" + | "authorization" + | "x-api-key" + | "www-authenticate" + | "authentication-info" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-authentication-info" + | "cookie" + | "cookie2" + | "set-cookie" + | "set-cookie2" + | "chatgpt-account-id" + | "chatgpt-organization-id" + | "chatgpt-org-id" + | "chatgpt-project-id" + | "openai-organization" + | "openai-project" + | "x-openai-organization" + | "x-openai-project" + | "x-codex-installation-id" + | "x-codex-turn-metadata" + ) +} diff --git a/backend/crates/gateway-protocol/src/openai/mod.rs b/backend/crates/gateway-protocol/src/openai/mod.rs index b5370c886..60963e5b5 100644 --- a/backend/crates/gateway-protocol/src/openai/mod.rs +++ b/backend/crates/gateway-protocol/src/openai/mod.rs @@ -21,4 +21,4 @@ pub use codex::{ CodexResponsesRequestSemantics, codex_responses_request_semantics, codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id, }; -pub use headers::is_transport_managed_request_header; +pub use headers::{is_transport_managed_request_header, response_header_is_forwardable}; diff --git a/backend/crates/gateway-protocol/tests/openai/headers.rs b/backend/crates/gateway-protocol/tests/openai/headers.rs index 546c4a98b..ede8f6bde 100644 --- a/backend/crates/gateway-protocol/tests/openai/headers.rs +++ b/backend/crates/gateway-protocol/tests/openai/headers.rs @@ -1,4 +1,6 @@ -use gateway_protocol::openai::is_transport_managed_request_header; +use gateway_protocol::openai::{ + is_transport_managed_request_header, response_header_is_forwardable, +}; #[test] fn transport_headers_should_include_hop_fields_and_compression() { @@ -66,3 +68,27 @@ fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() { ); } } + +#[test] +fn response_headers_should_reject_hop_identity_and_dynamic_connection_fields() { + let connection_options = vec!["x-hop".to_owned()]; + for name in [ + "connection", + "x-hop", + "content-length", + "authorization", + "set-cookie", + "chatgpt-account-id", + "x-openai-project", + "sec-websocket-accept", + ] { + assert!( + !response_header_is_forwardable(name, &connection_options), + "unexpectedly exposed {name}" + ); + } + assert!(response_header_is_forwardable( + "x-ratelimit-remaining-requests", + &connection_options + )); +} diff --git a/backend/crates/gateway-store/Cargo.toml b/backend/crates/gateway-store/Cargo.toml index 417a8873d..9d814cf10 100644 --- a/backend/crates/gateway-store/Cargo.toml +++ b/backend/crates/gateway-store/Cargo.toml @@ -29,7 +29,7 @@ secrecy = "0.10" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] } sha2 = "0.11.0" -sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "json", "macros", "migrate"] } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "json", "macros", "migrate", "uuid"] } thiserror = "2.0.18" tokio = { version = "1.52", default-features = false, features = ["fs", "io-std", "io-util", "macros", "process", "rt", "sync", "time"] } tracing = "0.1.44" diff --git a/backend/crates/gateway-store/src/admin_adapter.rs b/backend/crates/gateway-store/src/admin_adapter.rs index fa9a4264d..4268d36b6 100644 --- a/backend/crates/gateway-store/src/admin_adapter.rs +++ b/backend/crates/gateway-store/src/admin_adapter.rs @@ -86,8 +86,7 @@ impl SettingsStore for AdminSettingsStoreAdapter { .map_err(|error| admin_store_error("runtime settings", error))?; let replacement = postgres::ControlPlaneReplacement { settings: postgres::RuntimeSettingsUpdate { - openai_client_profile: command.openai_client_profile, - xai_client_profile: command.xai_client_profile, + request_profile_updates: command.request_profile_updates, admin_api_key: current.settings.admin_api_key, refresh_margin_seconds: command.refresh_margin_seconds, refresh_concurrency: command.refresh_concurrency, @@ -232,8 +231,7 @@ pub(crate) fn admin_runtime_settings( }) .collect::>()?; Ok(AdminRuntimeSettings { - openai_client_profile: settings.openai_client_profile, - xai_client_profile: settings.xai_client_profile, + request_profiles: settings.request_profiles, config_revision: admin_revision(settings.config_revision)?, request_location_enabled: settings.request_location_enabled, request_location: settings.request_location, diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index 5d3f9288b..721e597aa 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -13,6 +13,8 @@ pub struct StoreBundle { worker_leader_lease: Arc, health_probes: Vec>, worker_contributions: Vec, + command_writers: Option, + command_drain: Option, } impl StoreBundle { @@ -44,14 +46,62 @@ impl StoreBundle { pub fn take_worker_contributions(&mut self) -> Vec { std::mem::take(&mut self.worker_contributions) } + + /// 在插件命令真正执行前启动四个必要写泵;不注册任何后台业务 Worker。 + pub fn start_command_line_writes(&mut self) -> Result<(), CommandStoreDrainError> { + if self.command_drain.is_some() { + return Err(CommandStoreDrainError); + } + let writers = self.command_writers.take().ok_or(CommandStoreDrainError)?; + self.command_drain = Some(writers.start()); + Ok(()) + } + + /// 命令成功、失败或取消后有界排空账本、Key 使用、准入释放与 circuit 反馈。 + pub async fn shutdown_command_line_writes(&mut self) -> Result<(), CommandStoreDrainError> { + match self.command_drain.take() { + Some(drain) => drain.shutdown().await, + None if self.command_writers.is_some() => Ok(()), + None => Err(CommandStoreDrainError), + } + } +} + +#[derive(Clone, Copy)] +enum StoreMode { + Runtime, + CommandLine, } /// 在返回 Bundle 前完成全部 Store 启动屏障。 -pub async fn initialize(mut config: StoreConfig) -> StoreResult { +pub async fn initialize(config: StoreConfig) -> StoreResult { + connect(config, false, StoreMode::Runtime).await +} + +/// CLI 命令使用同一有界写队列,但不贡献恢复、保留、leader 或维护 Worker。 +pub async fn initialize_command_line(config: StoreConfig) -> StoreResult { + connect(config, false, StoreMode::CommandLine).await +} + +/// 读取现有安装描述,不迁移数据库;该 Bundle 的 PostgreSQL 连接默认只读。 +pub async fn initialize_read_only(config: StoreConfig) -> StoreResult { + connect(config, true, StoreMode::Runtime).await +} + +async fn connect( + mut config: StoreConfig, + read_only: bool, + mode: StoreMode, +) -> StoreResult { const REDIS_NAMESPACE: &str = "codex-proxy-rs"; config.validate_resolved()?; - let pool = postgres::connect_and_migrate(&config.database_url()?, config.pool).await?; + let database_url = config.database_url()?; + let pool = if read_only { + postgres::connect_read_only(&database_url, config.pool).await? + } else { + postgres::connect_and_migrate(&database_url, config.pool).await? + }; let observability_query_budget = postgres::ObservabilityQueryBudget::try_new( config.pool.observability_max_connections(), config.pool.acquire_timeout(), @@ -79,7 +129,9 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { let provider_leases = Arc::new(redis::RedisProviderLeaseCoordinator::new( credential_leases.clone(), )); - let provider_session_affinity = Arc::new(redis::RedisProviderSessionAffinityRepository::new( + let provider_session_affinity: Arc< + dyn gateway_core::provider_ports::ProviderSessionAffinityPort, + > = Arc::new(redis::RedisProviderSessionAffinityRepository::new( redis_connection.clone(), REDIS_NAMESPACE, )?); @@ -97,6 +149,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { REDIS_NAMESPACE, )?); + let plugins = Arc::new(postgres::PgPluginStore::new(pool.clone())); let admin_ports = AdminStorePorts::new( AdminAccountStorePorts::new( Arc::new(postgres::PgAdminAccountStore::new( @@ -125,6 +178,8 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { control_plane: postgres::PgControlPlaneRepository::new(pool.clone()), }), backup_ports(pool.clone(), &config)?, + plugins.clone(), + plugins, ); let execution_repository = Arc::new(postgres::PgExecutionStore::new(pool.clone())); @@ -171,7 +226,8 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { ), Arc::new(client_key_usage), ) - .with_budget(Arc::new(postgres::PgClientBudgetStore::new(pool.clone()))); + .with_budget(Arc::new(postgres::PgClientBudgetStore::new(pool.clone()))) + .with_session_affinity(Arc::clone(&provider_session_affinity)); let provider_ports = ProviderStorePorts::new( account_store, @@ -200,14 +256,28 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { connection: redis_connection, }), ]; - let worker_contributions = store_worker_contributions( - execution_repository, - execution_writer, - client_key_usage_writer, - admission_release_writer, - circuit_feedback_writer, - retention, - )?; + let (worker_contributions, command_writers) = match mode { + StoreMode::Runtime => ( + store_worker_contributions( + execution_repository, + execution_writer, + client_key_usage_writer, + admission_release_writer, + circuit_feedback_writer, + retention, + )?, + None, + ), + StoreMode::CommandLine => ( + Vec::new(), + Some(CommandStoreWriters { + execution: execution_writer, + client_key_usage: client_key_usage_writer, + admission_release: admission_release_writer, + circuit_feedback: circuit_feedback_writer, + }), + ), + }; Ok(StoreBundle { admin_ports, core_ports, @@ -215,6 +285,8 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult { worker_leader_lease, health_probes, worker_contributions, + command_writers, + command_drain: None, }) } diff --git a/backend/crates/gateway-store/src/lib.rs b/backend/crates/gateway-store/src/lib.rs index 28f6e2d0a..935bc2416 100644 --- a/backend/crates/gateway-store/src/lib.rs +++ b/backend/crates/gateway-store/src/lib.rs @@ -43,5 +43,5 @@ pub(crate) use admin_adapter::*; pub use bundle::*; pub use config::*; pub use value::*; -pub use workers::PostgresHealthProbe; pub(crate) use workers::*; +pub use workers::{CommandStoreDrainError, PostgresHealthProbe}; diff --git a/backend/crates/gateway-store/src/postgres/client_keys.rs b/backend/crates/gateway-store/src/postgres/client_keys.rs index 677028789..36c36f5fd 100644 --- a/backend/crates/gateway-store/src/postgres/client_keys.rs +++ b/backend/crates/gateway-store/src/postgres/client_keys.rs @@ -27,6 +27,7 @@ use gateway_admin::{ ports::store::{AdminStoreResult, ClientKeyStore}, }; use gateway_core::{ + account::OpaqueProviderData, engine::budget::{ClientBudgetLimits, ClientBudgetStatus}, engine::execution::ClientApiKeyUsageSink, lifecycle::CancellationToken, @@ -111,8 +112,7 @@ impl fmt::Debug for ClientApiKeySecret { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClientApiKeyRecord { - pub openai_client_profile_override: Option, - pub xai_client_profile_override: Option, + pub request_profile_overrides: BTreeMap, pub id: String, pub name: String, pub label: Option, @@ -273,8 +273,7 @@ pub struct ClientApiKeyPage { #[derive(Clone)] pub struct NewClientApiKey { - pub openai_client_profile_override: Option, - pub xai_client_profile_override: Option, + pub request_profile_overrides: BTreeMap, pub id: String, pub name: String, pub label: Option, @@ -301,14 +300,14 @@ impl NewClientApiKey { require_nonempty(ENTITY, "id", &self.id)?; require_nonempty(ENTITY, "name", &self.name)?; validate_group_ids(&self.group_ids)?; + validate_request_profiles(self.request_profile_overrides.values())?; validate_key(&self.key) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateClientApiKeyDetails { - pub openai_client_profile_override: Option>, - pub xai_client_profile_override: Option>, + pub request_profile_override_updates: BTreeMap>, pub id: String, pub name: String, pub label: Option, @@ -324,6 +323,14 @@ impl UpdateClientApiKeyDetails { require_nonempty(ENTITY, "id", &self.id)?; require_nonempty(ENTITY, "name", &self.name)?; validate_group_ids(&self.group_ids)?; + if self.request_profile_override_updates.len() > 256 { + return Err(invalid("request profiles exceed the supported bounds")); + } + validate_request_profiles( + self.request_profile_override_updates + .values() + .filter_map(Option::as_ref), + )?; to_i64(self.max_concurrency)?; to_i64(self.requests_per_minute)?; Ok(()) @@ -365,8 +372,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { query.validate()?; let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?; let mut statement = QueryBuilder::::new( - "select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override, - k.provider_request_profiles_json -> 'xai' as xai_client_profile_override, + "select k.id, k.name, k.label, k.provider_request_profiles_json as request_profile_overrides, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds @@ -426,8 +432,7 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository { async fn get_client_api_key(&self, id: &str) -> StoreResult> { require_nonempty(ENTITY, "id", id)?; let record = sqlx::query( - "select k.id, k.name, k.label, k.provider_request_profiles_json -> 'openai' as openai_client_profile_override, - k.provider_request_profiles_json -> 'xai' as xai_client_profile_override, + "select k.id, k.name, k.label, k.provider_request_profiles_json as request_profile_overrides, left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled, k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at, k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups, @@ -773,8 +778,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .control_plane .create_client_api_key( NewClientApiKey { - openai_client_profile_override: command.openai_client_profile_override, - xai_client_profile_override: command.xai_client_profile_override, + request_profile_overrides: command.request_profile_overrides, id: id.as_str().to_owned(), name: command.name, label: command.label, @@ -825,8 +829,7 @@ impl ClientKeyStore for PgAdminClientKeyStore { .control_plane .update_client_api_key( UpdateClientApiKeyDetails { - openai_client_profile_override: command.openai_client_profile_override, - xai_client_profile_override: command.xai_client_profile_override, + request_profile_override_updates: command.request_profile_override_updates, id: id.as_str().to_owned(), name: command.name, label: command.label, @@ -984,8 +987,7 @@ fn admin_client_key_cursor(cursor: ClientApiKeyCursor) -> AdminStoreResult AdminStoreResult { Ok(AdminClientKeyRecord { - openai_client_profile_override: record.openai_client_profile_override, - xai_client_profile_override: record.xai_client_profile_override, + request_profile_overrides: record.request_profile_overrides, id: ClientApiKeyId::new(record.id) .map_err(|_| admin_store_error(ENTITY, invalid("invalid client key id")))?, name: record.name, @@ -1033,12 +1035,12 @@ pub(crate) async fn insert_client_api_key_in_transaction( ) -> StoreResult<()> { key.validate()?; ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?; + let request_profile_overrides = encode_request_profiles(&key.request_profile_overrides); sqlx::query( "insert into client_api_keys ( id, name, label, key, enabled, max_concurrency, requests_per_minute, last_used_at, created_at, updated_at, daily_limit_usd, weekly_limit_usd, provider_request_profiles_json - ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end - || case when $10::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $10::jsonb) end)", + ) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric, $9::jsonb)", ) .bind(&key.id) .bind(key.name.trim()) @@ -1048,8 +1050,7 @@ pub(crate) async fn insert_client_api_key_in_transaction( .bind(to_i64(key.requests_per_minute)?) .bind(key.budget.daily_usd.canonical()) .bind(key.budget.weekly_usd.canonical()) - .bind(key.openai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) - .bind(key.xai_client_profile_override.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) + .bind(sqlx::types::Json(request_profile_overrides)) .execute(&mut **transaction) .await .map_err(|error| { @@ -1076,17 +1077,31 @@ pub(crate) async fn update_client_api_key_in_transaction( ) -> StoreResult<()> { key.validate()?; ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?; + let removed_profiles = key + .request_profile_override_updates + .keys() + .map(|provider| provider.as_str().to_owned()) + .collect::>(); + let replacement_profiles = key + .request_profile_override_updates + .iter() + .filter_map(|(provider, profile)| { + profile.as_ref().map(|profile| { + ( + provider.as_str().to_owned(), + profile.expose_to_provider().clone(), + ) + }) + }) + .collect::>(); let result = sqlx::query( "update client_api_keys set name = $2, label = $3, max_concurrency = $4, requests_per_minute = $5, updated_at = now(), daily_limit_usd = coalesce($6::text::numeric, daily_limit_usd), weekly_limit_usd = coalesce($7::text::numeric, weekly_limit_usd), - provider_request_profiles_json = (provider_request_profiles_json - - case when $8 then array['openai'] else array[]::text[] end - - case when $10 then array['xai'] else array[]::text[] end) - || case when $9::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $9::jsonb) end - || case when $11::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $11::jsonb) end + provider_request_profiles_json = (provider_request_profiles_json - $8::text[]) + || $9::jsonb where id = $1", ) .bind(&key.id) @@ -1096,20 +1111,8 @@ pub(crate) async fn update_client_api_key_in_transaction( .bind(to_i64(key.requests_per_minute)?) .bind(key.daily_limit_usd.map(|amount| amount.canonical())) .bind(key.weekly_limit_usd.map(|amount| amount.canonical())) - .bind(key.openai_client_profile_override.is_some()) - .bind( - key.openai_client_profile_override - .as_ref() - .and_then(Option::as_ref) - .map(|profile| sqlx::types::Json(profile.expose_to_provider())), - ) - .bind(key.xai_client_profile_override.is_some()) - .bind( - key.xai_client_profile_override - .as_ref() - .and_then(Option::as_ref) - .map(|profile| sqlx::types::Json(profile.expose_to_provider())), - ) + .bind(removed_profiles) + .bind(sqlx::types::Json(replacement_profiles)) .execute(&mut **transaction) .await .map_err(|_| postgres_unavailable("update client API key in transaction"))?; @@ -1241,6 +1244,48 @@ fn validate_key(key: &str) -> StoreResult<()> { .map_err(|_| invalid("key must contain nonempty visible ASCII characters")) } +fn validate_request_profiles<'a>( + profiles: impl IntoIterator, +) -> StoreResult<()> { + for (index, profile) in profiles.into_iter().enumerate() { + if index >= 256 + || serde_json::to_vec(profile.expose_to_provider()) + .map_or(true, |encoded| encoded.len() > 64 * 1024) + { + return Err(invalid("request profiles exceed the supported bounds")); + } + } + Ok(()) +} + +fn encode_request_profiles( + profiles: &BTreeMap, +) -> BTreeMap> { + profiles + .iter() + .map(|(provider, profile)| { + ( + provider.as_str().to_owned(), + profile.expose_to_provider().clone(), + ) + }) + .collect() +} + +fn decode_request_profiles( + profiles: BTreeMap>, +) -> StoreResult> { + profiles + .into_iter() + .map(|(provider, profile)| { + Ok(( + ProviderKind::new(provider).map_err(|_| invalid("invalid request profile key"))?, + OpaqueProviderData::new(profile), + )) + }) + .collect() +} + fn client_secret_from_row( row: (String, String, bool, i64, i64), ) -> StoreResult { @@ -1258,51 +1303,49 @@ fn client_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult>>, _>( - "openai_client_profile_override", - ) - .map_err(|_| invalid("invalid request profile"))? - .map(|value| gateway_core::account::OpaqueProviderData::new(value.0)), - xai_client_profile_override: row - .try_get::>>, _>( - "xai_client_profile_override", - ) - .map_err(|_| invalid("invalid request profile"))? - .map(|value| gateway_core::account::OpaqueProviderData::new(value.0)), - id: row.try_get("id").map_err(|_| invalid("invalid id"))?, - name: row.try_get("name").map_err(|_| invalid("invalid name"))?, - label: row.try_get("label").map_err(|_| invalid("invalid label"))?, - groups: serde_json::from_value(groups).map_err(|_| invalid("invalid groups"))?, - provider_kinds: row - .try_get("provider_kinds") - .map_err(|_| invalid("invalid provider kinds"))?, - prefix: row - .try_get("prefix") - .map_err(|_| invalid("invalid prefix"))?, - enabled: row - .try_get("enabled") - .map_err(|_| invalid("invalid enabled"))?, - max_concurrency: to_u64( - row.try_get("max_concurrency") - .map_err(|_| invalid("invalid max concurrency"))?, - )?, - requests_per_minute: to_u64( - row.try_get("requests_per_minute") - .map_err(|_| invalid("invalid requests per minute"))?, - )?, - budget: ClientBudgetStatus::default(), - last_used_at: row - .try_get("last_used_at") - .map_err(|_| invalid("invalid last used at"))?, - created_at: row - .try_get("created_at") - .map_err(|_| invalid("invalid created at"))?, - updated_at: row - .try_get("updated_at") - .map_err(|_| invalid("invalid updated at"))?, - }) + Ok( + ClientApiKeyRecord { + request_profile_overrides: + decode_request_profiles( + row.try_get::>, + >, _>("request_profile_overrides") + .map_err(|_| invalid("invalid request profiles"))? + .0, + )?, + id: row.try_get("id").map_err(|_| invalid("invalid id"))?, + name: row.try_get("name").map_err(|_| invalid("invalid name"))?, + label: row.try_get("label").map_err(|_| invalid("invalid label"))?, + groups: serde_json::from_value(groups).map_err(|_| invalid("invalid groups"))?, + provider_kinds: row + .try_get("provider_kinds") + .map_err(|_| invalid("invalid provider kinds"))?, + prefix: row + .try_get("prefix") + .map_err(|_| invalid("invalid prefix"))?, + enabled: row + .try_get("enabled") + .map_err(|_| invalid("invalid enabled"))?, + max_concurrency: to_u64( + row.try_get("max_concurrency") + .map_err(|_| invalid("invalid max concurrency"))?, + )?, + requests_per_minute: to_u64( + row.try_get("requests_per_minute") + .map_err(|_| invalid("invalid requests per minute"))?, + )?, + budget: ClientBudgetStatus::default(), + last_used_at: row + .try_get("last_used_at") + .map_err(|_| invalid("invalid last used at"))?, + created_at: row + .try_get("created_at") + .map_err(|_| invalid("invalid created at"))?, + updated_at: row + .try_get("updated_at") + .map_err(|_| invalid("invalid updated at"))?, + }, + ) } async fn count_client_api_keys(pool: &PgPool, search: Option<&str>) -> StoreResult { diff --git a/backend/crates/gateway-store/src/postgres/execution_buffer.rs b/backend/crates/gateway-store/src/postgres/execution_buffer.rs index bda0856e2..0107671ee 100644 --- a/backend/crates/gateway-store/src/postgres/execution_buffer.rs +++ b/backend/crates/gateway-store/src/postgres/execution_buffer.rs @@ -2,11 +2,12 @@ use std::mem::size_of; use std::num::NonZeroUsize; -use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant, SystemTime}; use async_trait::async_trait; +use futures::future::try_join_all; use gateway_core::engine::{ AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId, NewModelRequest, ProbeFailure, RecoveryReport, @@ -15,10 +16,11 @@ use gateway_core::error::{ProviderError, StoreError}; use gateway_core::lifecycle::CancellationToken; use gateway_core::task::{DaemonTask, WorkerTaskError}; use gateway_core::upstream::UpstreamSendState; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, Notify, mpsc}; const DEFAULT_QUEUE_CAPACITY: usize = 4_096; const DEFAULT_QUEUE_BYTE_CAPACITY: usize = 64 * 1024 * 1024; +const PERSISTENCE_LANES: usize = 4; const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); /// 执行观测缓冲区的进程内累计状态。 @@ -39,6 +41,7 @@ pub struct ExecutionBufferStats { } struct ExecutionBufferState { + maximum_queued_items: usize, maximum_queued_bytes: usize, queued_items: AtomicUsize, queued_bytes: AtomicUsize, @@ -46,11 +49,13 @@ struct ExecutionBufferState { dropped_total: AtomicU64, persisted_total: AtomicU64, write_failure_total: AtomicU64, + idle: Notify, } impl ExecutionBufferState { - fn new(maximum_queued_bytes: NonZeroUsize) -> Self { + fn new(maximum_queued_items: NonZeroUsize, maximum_queued_bytes: NonZeroUsize) -> Self { Self { + maximum_queued_items: maximum_queued_items.get(), maximum_queued_bytes: maximum_queued_bytes.get(), queued_items: AtomicUsize::new(0), queued_bytes: AtomicUsize::new(0), @@ -58,11 +63,19 @@ impl ExecutionBufferState { dropped_total: AtomicU64::new(0), persisted_total: AtomicU64::new(0), write_failure_total: AtomicU64::new(0), + idle: Notify::new(), } } - fn reserve(&self, bytes: usize) -> bool { - let reserved = self + fn reserve(&self, bytes: usize) -> Result<(), ReservationFailure> { + self.queued_items + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + current + .checked_add(1) + .filter(|next| *next <= self.maximum_queued_items) + }) + .map_err(|_| ReservationFailure::ItemCapacity)?; + let bytes_reserved = self .queued_bytes .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { current @@ -70,15 +83,22 @@ impl ExecutionBufferState { .filter(|next| *next <= self.maximum_queued_bytes) }) .is_ok(); - if reserved { - self.queued_items.fetch_add(1, Ordering::Relaxed); + if !bytes_reserved { + self.release_item(); + return Err(ReservationFailure::ByteCapacity); } - reserved + Ok(()) } fn release(&self, bytes: usize) { self.queued_bytes.fetch_sub(bytes, Ordering::AcqRel); - self.queued_items.fetch_sub(1, Ordering::AcqRel); + self.release_item(); + } + + fn release_item(&self) { + if self.queued_items.fetch_sub(1, Ordering::AcqRel) == 1 { + self.idle.notify_waiters(); + } } fn record_enqueued(&self) { @@ -112,6 +132,21 @@ impl ExecutionBufferState { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReservationFailure { + ItemCapacity, + ByteCapacity, +} + +impl ReservationFailure { + const fn reason(self) -> &'static str { + match self { + Self::ItemCapacity => "item_capacity", + Self::ByteCapacity => "byte_capacity", + } + } +} + fn saturating_increment(counter: &AtomicU64, increment: u64) { let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { Some(current.saturating_add(increment)) @@ -124,7 +159,10 @@ fn saturating_increment(counter: &AtomicU64, increment: u64) { /// 等待 PostgreSQL,也不会看到 Store 错误。启动恢复仍直接访问底层 Store。 pub struct BufferedExecutionStore { inner: Arc, - sender: mpsc::Sender, + // lane transport 本身没有独立容量;所有发送只能经 `enqueue` 的全局 item/byte + // 预留进入,`QueuedExecutionObservation::drop` 负责归还,不能增加旁路发送入口。 + senders: Box<[mpsc::UnboundedSender]>, + next_unkeyed_lane: AtomicUsize, state: Arc, } @@ -156,17 +194,20 @@ impl BufferedExecutionStore { capacity: NonZeroUsize, maximum_queued_bytes: NonZeroUsize, ) -> (Self, ExecutionObservationWriter) { - let (sender, receiver) = mpsc::channel(capacity.get()); - let state = Arc::new(ExecutionBufferState::new(maximum_queued_bytes)); + let lane_count = PERSISTENCE_LANES.min(capacity.get()); + let (senders, receivers): (Vec<_>, Vec<_>) = + (0..lane_count).map(|_| mpsc::unbounded_channel()).unzip(); + let state = Arc::new(ExecutionBufferState::new(capacity, maximum_queued_bytes)); ( Self { inner: Arc::clone(&inner), - sender, + senders: senders.into_boxed_slice(), + next_unkeyed_lane: AtomicUsize::new(0), state: Arc::clone(&state), }, ExecutionObservationWriter { inner, - receiver: Mutex::new(receiver), + receivers: receivers.into_iter().map(Mutex::new).collect(), state, }, ) @@ -182,29 +223,30 @@ impl BufferedExecutionStore { .estimated_bytes() .saturating_add(write.request_id().map_or(0, str::len)) .max(1); - if !self.state.reserve(estimated_bytes) { + if let Err(failure) = self.state.reserve(estimated_bytes) { self.state.record_dropped(1); let stats = self.state.snapshot(); tracing::warn!( operation = write.operation(), request_id = ?write.request_id(), - reason = "byte_capacity", + reason = failure.reason(), estimated_bytes, + maximum_queued_items = self.state.maximum_queued_items, maximum_queued_bytes = self.state.maximum_queued_bytes, + queued_items = stats.queued_items, + queued_bytes = stats.queued_bytes, dropped_total = stats.dropped_total, - "执行观测队列字节预算不足,已丢弃本次写入" + "执行观测队列容量不足,已丢弃本次写入" ); return; } let queued = QueuedExecutionObservation::new(write, estimated_bytes, Arc::clone(&self.state)); - match self.sender.try_send(queued) { + let lane = self.lane(queued.request_id()); + match self.senders[lane].send(queued) { Ok(()) => self.state.record_enqueued(), Err(error) => { - let (reason, queued) = match error { - mpsc::error::TrySendError::Full(queued) => ("full", queued), - mpsc::error::TrySendError::Closed(queued) => ("closed", queued), - }; + let queued = error.0; let operation = queued.operation(); let request_id = queued.request_id().map(ToOwned::to_owned); drop(queued); @@ -213,7 +255,8 @@ impl BufferedExecutionStore { tracing::warn!( operation, request_id = ?request_id, - reason, + reason = "closed", + lane, queued_items = stats.queued_items, queued_bytes = stats.queued_bytes, dropped_total = stats.dropped_total, @@ -222,6 +265,23 @@ impl BufferedExecutionStore { } } } + + fn lane(&self, request_id: Option<&str>) -> usize { + request_id.map_or_else( + || self.next_unkeyed_lane.fetch_add(1, Ordering::Relaxed) % self.senders.len(), + |request_id| request_lane(request_id, self.senders.len()), + ) + } +} + +fn request_lane(request_id: &str, lane_count: usize) -> usize { + // request ID 由 Core 生成,不含用户选择的散列输入;固定散列只用于进程内顺序亲和。 + let hash = request_id + .bytes() + .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + usize::try_from(hash % u64::try_from(lane_count).unwrap_or(u64::MAX)).unwrap_or(0) } #[async_trait] @@ -316,10 +376,16 @@ where } } -/// 由 Host 监督的单消费者,保证同一请求的观测命令按入队顺序落库。 +/// 由 Host 监督的固定并行写泵;同一 request ID 固定落在一个 lane 并按入队顺序落库。 +/// +/// lane transport 共享 Store 的全局 item/byte 预留,正在写入的项目同样计入总上限。 pub struct ExecutionObservationWriter { inner: Arc, - receiver: Mutex>, + receivers: Box<[Mutex>]>, + state: Arc, +} + +pub(crate) struct ExecutionBufferIdle { state: Arc, } @@ -328,6 +394,47 @@ impl ExecutionObservationWriter { pub fn stats(&self) -> ExecutionBufferStats { self.state.snapshot() } + + /// 等待所有已接收写入结束一次持久化尝试。 + /// + /// 队列计数包含正在写入的项目;失败沿用现有 fail-open 计数且不会由本方法重试。 + /// 返回 `false` 表示到达截止时间时仍有排队或正在写入的项目。 + pub async fn wait_until_idle(&self, deadline: Instant) -> bool { + self.idle().wait_until(deadline).await + } + + pub(crate) fn idle(&self) -> ExecutionBufferIdle { + ExecutionBufferIdle { + state: Arc::clone(&self.state), + } + } +} + +impl ExecutionBufferIdle { + /// 等待所有已接收写入结束一次持久化尝试;队列计数包含正在写入的项目, + /// 写入失败沿用现有 fail-open 计数且不在关闭路径重试。 + pub(crate) async fn wait_until(&self, deadline: Instant) -> bool { + loop { + if self.state.queued_items.load(Ordering::Acquire) == 0 { + return true; + } + let idle = self.state.idle.notified(); + tokio::pin!(idle); + let _ = idle.as_mut().enable(); + // 在订阅前后各检查一次,覆盖最后一个写入恰好在注册等待时结束的竞态。 + if self.state.queued_items.load(Ordering::Acquire) == 0 { + return true; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() + || tokio::time::timeout(remaining, idle.as_mut()) + .await + .is_err() + { + return false; + } + } + } } impl DaemonTask for ExecutionObservationWriter @@ -339,28 +446,90 @@ where cancellation: CancellationToken, ) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> { Box::pin(async move { - let mut receiver = self.receiver.lock().await; - loop { - let queued = tokio::select! { - () = cancellation.cancelled() => { - drain_on_shutdown( - &mut receiver, - self.inner.as_ref(), - self.state.as_ref(), - ).await; - return Ok(()); - }, - queued = receiver.recv() => queued, - }; - let Some(queued) = queued else { - return Err(WorkerTaskError::safe("execution observation queue closed")); - }; - persist_queued(queued, self.inner.as_ref()).await; - } + let shutdown_deadline = OnceLock::new(); + try_join_all(self.receivers.iter().enumerate().map(|(lane, receiver)| { + run_lane( + lane, + receiver, + self.inner.as_ref(), + self.state.as_ref(), + cancellation.clone(), + &shutdown_deadline, + ) + })) + .await?; + Ok(()) }) } } +async fn run_lane( + lane: usize, + receiver: &Mutex>, + store: &S, + state: &ExecutionBufferState, + cancellation: CancellationToken, + shutdown_deadline: &OnceLock, +) -> Result<(), WorkerTaskError> +where + S: ExecutionStore + ?Sized, +{ + let mut receiver = receiver.lock().await; + loop { + let queued = tokio::select! { + biased; + () = cancellation.cancelled() => { + drain_on_shutdown( + lane, + &mut receiver, + store, + state, + shared_shutdown_deadline(shutdown_deadline), + ).await; + return Ok(()); + }, + queued = receiver.recv() => queued, + }; + let Some(queued) = queued else { + return Err(WorkerTaskError::safe( + "execution observation queue lane closed", + )); + }; + let operation = queued.operation(); + let request_id = queued.request_id().map(ToOwned::to_owned); + let mut persistence = Box::pin(persist_queued(queued, store)); + tokio::select! { + biased; + () = cancellation.cancelled() => { + let deadline = shared_shutdown_deadline(shutdown_deadline); + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() + || tokio::time::timeout(remaining, &mut persistence).await.is_err() + { + state.record_dropped(1); + let stats = state.snapshot(); + tracing::warn!( + operation, + request_id = ?request_id, + lane, + dropped_total = stats.dropped_total, + drain_timeout_ms = u64::try_from(SHUTDOWN_DRAIN_TIMEOUT.as_millis()) + .unwrap_or(u64::MAX), + "执行观测队列关闭时当前写入未在期限内完成,已停止等待" + ); + } + drain_on_shutdown(lane, &mut receiver, store, state, deadline).await; + return Ok(()); + }, + () = &mut persistence => {}, + } + } +} + +fn shared_shutdown_deadline(deadline: &OnceLock) -> Instant { + *deadline.get_or_init(|| Instant::now() + SHUTDOWN_DRAIN_TIMEOUT) +} + async fn persist_queued(mut queued: QueuedExecutionObservation, store: &S) where S: ExecutionStore + ?Sized, @@ -396,19 +565,20 @@ where } async fn drain_on_shutdown( - receiver: &mut mpsc::Receiver, + lane: usize, + receiver: &mut mpsc::UnboundedReceiver, store: &S, state: &ExecutionBufferState, + deadline: Instant, ) where S: ExecutionStore + ?Sized, { receiver.close(); let queued_at_shutdown = receiver.len(); - let started_at = Instant::now(); let mut dropped = 0_usize; while let Some(queued) = receiver.recv().await { - let remaining = SHUTDOWN_DRAIN_TIMEOUT.saturating_sub(started_at.elapsed()); + let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { drop(queued); dropped = dropped.saturating_add(1); @@ -431,6 +601,7 @@ async fn drain_on_shutdown( state.record_dropped(dropped); let stats = state.snapshot(); tracing::warn!( + lane, queued_at_shutdown, dropped, dropped_total = stats.dropped_total, @@ -439,7 +610,7 @@ async fn drain_on_shutdown( "执行观测队列关闭排空超时,剩余写入已丢弃" ); } else if queued_at_shutdown > 0 { - tracing::info!(queued_at_shutdown, "执行观测队列已在关闭前排空"); + tracing::info!(lane, queued_at_shutdown, "执行观测队列已在关闭前排空"); } } diff --git a/backend/crates/gateway-store/src/postgres/mod.rs b/backend/crates/gateway-store/src/postgres/mod.rs index 714926d4c..011e3463b 100644 --- a/backend/crates/gateway-store/src/postgres/mod.rs +++ b/backend/crates/gateway-store/src/postgres/mod.rs @@ -21,6 +21,7 @@ mod execution; mod execution_buffer; mod observability; mod ops_events; +mod plugins; mod pricing; mod provider_accounts; mod proxies; @@ -39,6 +40,7 @@ pub use execution::*; pub use execution_buffer::*; pub use observability::*; pub use ops_events::*; +pub use plugins::PgPluginStore; pub use provider_accounts::*; pub use proxies::PgProxyRepository; pub use retention::*; @@ -81,6 +83,26 @@ pub async fn connect_and_migrate( } migration_pool.close().await; + connect_pool(connect_options, pool_config, false).await +} + +/// 帮助查询不执行迁移,并用连接默认只读事务阻止意外业务写入。 +pub(crate) async fn connect_read_only( + database_url: &str, + pool_config: StorePoolConfig, +) -> StoreResult { + pool_config.validate()?; + let options = database_url + .parse::() + .map_err(|_| postgres_unavailable("parse PostgreSQL connection options"))?; + connect_pool(options, pool_config, true).await +} + +async fn connect_pool( + connect_options: PgConnectOptions, + pool_config: StorePoolConfig, + read_only: bool, +) -> StoreResult { let statement_timeout = postgres_duration_setting(POSTGRES_STATEMENT_TIMEOUT); let lock_timeout = postgres_duration_setting(POSTGRES_LOCK_TIMEOUT); let idle_in_transaction_session_timeout = @@ -98,11 +120,13 @@ pub async fn connect_and_migrate( sqlx::query( "select set_config('statement_timeout', $1, false), set_config('lock_timeout', $2, false), - set_config('idle_in_transaction_session_timeout', $3, false)", + set_config('idle_in_transaction_session_timeout', $3, false), + set_config('default_transaction_read_only', $4, false)", ) .bind(statement_timeout) .bind(lock_timeout) .bind(idle_in_transaction_session_timeout) + .bind(if read_only { "on" } else { "off" }) .execute(connection) .await?; Ok(()) diff --git a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs index 21f7c1421..1c031c9d6 100644 --- a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs @@ -212,6 +212,15 @@ impl ObservabilityRepository for PgObservabilityRepository { .await } + async fn usage_provider_kinds(&self, range: ObservabilityRange) -> StoreResult> { + self.query_budget + .run( + "load usage providers", + usage_provider_kinds(&self.pool, range), + ) + .await + } + async fn usage_record_detail(&self, request_id: &str) -> StoreResult { self.query_budget .run( @@ -411,6 +420,16 @@ impl AdminObservabilityStore for PgAdminObservabilityStore { admin_usage_page(page) } + async fn usage_provider_kinds( + &self, + range: admin_observability::TimeRange, + ) -> AdminStoreResult> { + self.repository + .usage_provider_kinds(store_range(range)?) + .await + .map_err(observability_error) + } + async fn usage_record_detail( &self, request_id: &str, diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index 496f8336a..e3cdc6a7f 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -782,6 +782,7 @@ pub trait ObservabilityRepository: Send + Sync { query: ProviderAccountUsageQuery, ) -> StoreResult>; async fn list_usage_records(&self, query: UsageRecordQuery) -> StoreResult; + async fn usage_provider_kinds(&self, range: ObservabilityRange) -> StoreResult>; async fn usage_record_detail(&self, request_id: &str) -> StoreResult; async fn usage_summary( &self, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs index 4f96344b1..ffe9f4f16 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/ops.rs @@ -68,6 +68,32 @@ left join model_requests mr on mr.id = oe.model_request_id left join client_api_keys client_key on client_key.id = mr.client_api_key_ref where true"; +pub(crate) async fn usage_provider_kinds( + pool: &PgPool, + range: ObservabilityRange, +) -> StoreResult> { + let mut statement = QueryBuilder::::new( + "select provider_kind from ( + select mr.provider_kind from model_requests mr where mr.provider_kind is not null", + ); + push_range(&mut statement, "mr.started_at", range); + // 长请求可能在区间外开始、区间内报错,复用错误列表的完成时刻口径。 + statement.push( + " union select mr.provider_kind from model_requests mr where mr.provider_kind is not null", + ); + push_request_error_predicates(&mut statement, range, &OpsErrorFilter::default()); + statement.push( + " union select oe.provider_kind from ops_events oe where oe.provider_kind is not null", + ); + push_ops_event_predicates(&mut statement, range, &OpsErrorFilter::default()); + statement.push(") providers order by provider_kind"); + statement + .build_query_scalar() + .fetch_all(pool) + .await + .map_err(|_| postgres_unavailable("load usage providers")) +} + pub(crate) async fn list_ops_errors( pool: &PgPool, query: OpsErrorQuery, diff --git a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs new file mode 100644 index 000000000..dfc79c2f0 --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs @@ -0,0 +1,392 @@ +use async_trait::async_trait; +use gateway_admin::model::plugins::distribution::{SourceCredential, SourceCredentialInfo}; +use gateway_admin::{ + model::{ + MutationContext, Revision, + plugins::{ + InspectedPluginArtifact, InstalledPluginArtifact, PluginArtifactMetadata, + PluginArtifactMutation, PluginSource, + }, + }, + ports::{ + plugins::PluginStore, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use sqlx::{PgPool, Row as _}; + +use super::super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; +use crate::{admin_revision, admin_store_error, mutation_audit}; + +pub struct PgPluginStore { + pub(super) pool: PgPool, +} + +impl PgPluginStore { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +pub(super) fn unavailable() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin", + "plugin artifact store is unavailable", + ) +} +pub(super) fn conflict() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Conflict, + "plugin", + "plugin artifact already exists with different content or source", + ) +} +pub(super) fn not_found() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::NotFound, + "plugin", + "plugin artifact does not exist", + ) +} + +pub(super) fn decode_metadata( + row: &sqlx::postgres::PgRow, +) -> AdminStoreResult { + let mut metadata: serde_json::Value = + row.try_get("metadata_json").map_err(|_| unavailable())?; + // 兼容开发期已写入的展示说明;忽略这一旧字段,其他未知字段仍严格拒绝。 + if let Some(metadata) = metadata.as_object_mut() { + metadata.remove("permissionDescriptions"); + } + serde_json::from_value(metadata).map_err(|_| unavailable()) +} + +fn decode(row: &sqlx::postgres::PgRow) -> AdminStoreResult { + let source: sqlx::types::Json = + row.try_get("source_json").map_err(|_| unavailable())?; + Ok(InstalledPluginArtifact { + metadata: decode_metadata(row)?, + source: source.0, + installed_at: row.try_get("installed_at").map_err(|_| unavailable())?, + accepted_at: row.try_get("accepted_at").map_err(|_| unavailable())?, + }) +} + +#[async_trait] +impl PluginStore for PgPluginStore { + async fn management_target_is_current( + &self, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, + ) -> AdminStoreResult { + super::instances::management_target_is_current(&self.pool, target).await + } + async fn load_instances( + &self, + ) -> AdminStoreResult { + super::instances::load(&self.pool).await + } + async fn load_version_configuration( + &self, + id: &str, + digest: &str, + ) -> AdminStoreResult< + Option, + > { + super::instances::load_version_configuration(&self.pool, id, digest).await + } + async fn configuration_versions(&self, id: &str) -> AdminStoreResult> { + super::instances::configuration_versions(&self.pool, id).await + } + async fn save_instance( + &self, + instance: gateway_admin::model::plugins::instances::PluginInstance, + expected: Revision, + context: &MutationContext, + ) -> AdminStoreResult { + super::instances::save(&self.pool, instance, expected, context).await + } + async fn save_instance_with_state( + &self, + instance: gateway_admin::model::plugins::instances::PluginInstance, + expected: Revision, + state: gateway_admin::model::plugins::state::PluginStateCommit, + context: &MutationContext, + ) -> AdminStoreResult { + super::instances::save_with_state(&self.pool, instance, expected, &state, &[], context) + .await + } + async fn save_instance_replacing( + &self, + instance: gateway_admin::model::plugins::instances::PluginInstance, + expected: Revision, + state: gateway_admin::model::plugins::state::PluginStateCommit, + replacements: &[gateway_admin::model::plugins::instances::PluginInstanceReplacement], + context: &MutationContext, + ) -> AdminStoreResult { + super::instances::save_with_state( + &self.pool, + instance, + expected, + &state, + replacements, + context, + ) + .await + } + async fn delete_instance( + &self, + id: &str, + expected: Revision, + context: &MutationContext, + ) -> AdminStoreResult { + super::instances::delete(&self.pool, id, expected, context).await + } + async fn list_update_sources( + &self, + ) -> AdminStoreResult> + { + super::sources::list(&self.pool).await + } + async fn change_update_source( + &self, + binding: gateway_admin::model::plugins::distribution::PluginSourceBinding, + context: &MutationContext, + ) -> AdminStoreResult { + super::sources::change(&self.pool, binding, context).await + } + async fn list_source_credentials(&self) -> AdminStoreResult> { + super::credentials::list(&self.pool).await + } + async fn load_source_credential(&self, id: &str) -> AdminStoreResult { + super::credentials::load(&self.pool, id).await + } + async fn save_source_credential( + &self, + credential: SourceCredential, + context: &MutationContext, + ) -> AdminStoreResult { + super::credentials::save(&self.pool, credential, context).await + } + async fn delete_source_credential( + &self, + id: &str, + context: &MutationContext, + ) -> AdminStoreResult { + super::credentials::delete(&self.pool, id, context).await + } + async fn list_artifacts(&self) -> AdminStoreResult> { + let rows = sqlx::query("select metadata_json, source_json, installed_at, accepted_at from plugin_artifacts order by plugin_id, installed_at, sha256") + .fetch_all(&self.pool).await.map_err(|_| unavailable())?; + rows.iter().map(decode).collect() + } + + async fn load_artifact(&self, digest: &str) -> AdminStoreResult { + let row = + sqlx::query("select metadata_json, archive from plugin_artifacts where sha256 = $1") + .bind(digest) + .fetch_optional(&self.pool) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + let archive: Vec = row.try_get("archive").map_err(|_| unavailable())?; + Ok(InspectedPluginArtifact { + metadata: decode_metadata(&row)?, + archive: archive.into(), + }) + } + + async fn install_artifact( + &self, + artifact: InspectedPluginArtifact, + source: PluginSource, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let current_revision: i64 = sqlx::query_scalar( + "select config_revision from runtime_settings where id=1 for update", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| unavailable())?; + let metadata = artifact.metadata; + let outbound_proxy = source.outbound_proxy().cloned(); + super::sources::bind( + &mut transaction, + &metadata.plugin_id, + (&source).into(), + outbound_proxy.as_ref(), + ) + .await?; + if let Some(row) = sqlx::query( + "select metadata_json, source_json, installed_at, accepted_at from plugin_artifacts where sha256=$1", + ) + .bind(&metadata.sha256) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| unavailable())? + { + let artifact = decode(&row)?; + // 当前允许来源已由 bind 校验。同一包换用已授权的下载入口时保留首次出处, + // 但不能借重复安装把自定义包提升为内置包,或反向改写内置包的信任身份。 + let same_trust_origin = matches!(&artifact.source, PluginSource::Builtin { .. }) + == matches!(&source, PluginSource::Builtin { .. }); + if artifact.metadata != metadata || !same_trust_origin { + return Err(conflict()); + } + transaction.commit().await.map_err(|_| unavailable())?; + return Ok(PluginArtifactMutation { + config_revision: Revision::new( + u64::try_from(current_revision).map_err(|_| unavailable())?, + ) + .map_err(|_| unavailable())?, + artifact, + }); + } + let revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(|error| admin_store_error("plugin", error))?; + let mut platforms = metadata.platforms.clone(); + platforms.sort(); + let row = sqlx::query("insert into plugin_artifacts (sha256, plugin_id, version, metadata_json, source_json, outbound_proxy_id, archive) values ($1,$2,$3,$4,$5,$6,$7) returning metadata_json, source_json, installed_at, accepted_at") + .bind(&metadata.sha256).bind(&metadata.plugin_id).bind(&metadata.version) + .bind(sqlx::types::Json(&metadata)).bind(sqlx::types::Json(&source)) + .bind(outbound_proxy.map(|proxy| proxy.id)).bind(artifact.archive.as_ref()) + .fetch_one(&mut *transaction).await.map_err(|error| { + if error.as_database_error().is_some_and(|error| error.is_unique_violation()) { conflict() } else { unavailable() } + })?; + for id in source.credential_ids() { + let id = uuid::Uuid::parse_str(id).map_err(|_| conflict())?; + sqlx::query("insert into plugin_artifact_credentials(artifact_sha256,credential_id) values ($1,$2)") + .bind(&metadata.sha256).bind(id).execute(&mut *transaction).await.map_err(|_| conflict())?; + } + for platform in platforms { + sqlx::query("insert into plugin_artifact_platforms (plugin_id, version, platform, artifact_sha256) values ($1,$2,$3,$4)") + .bind(&metadata.plugin_id).bind(&metadata.version).bind(platform).bind(&metadata.sha256) + .execute(&mut *transaction).await.map_err(|error| { + if error.as_database_error().is_some_and(|error| error.is_unique_violation()) { conflict() } else { unavailable() } + })?; + } + append_admin_audit_event_in_transaction( + &mut transaction, + mutation_audit( + context, + "install", + "plugin_artifact", + &metadata.plugin_id, + vec!["artifact".into(), "source".into()], + ), + revision, + ) + .await + .map_err(|error| admin_store_error("plugin", error))?; + let artifact = decode(&row)?; + transaction.commit().await.map_err(|_| unavailable())?; + Ok(PluginArtifactMutation { + config_revision: admin_revision(revision)?, + artifact, + }) + } + + async fn accept_artifact( + &self, + digest: &str, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let current: i64 = sqlx::query_scalar( + "select config_revision from runtime_settings where id=1 for update", + ) + .fetch_one(&mut *transaction) + .await + .map_err(|_| unavailable())?; + let row = sqlx::query("select metadata_json, source_json, installed_at, accepted_at from plugin_artifacts where sha256=$1 for update") + .bind(digest).fetch_optional(&mut *transaction).await.map_err(|_| unavailable())? + .ok_or_else(not_found)?; + let mut artifact = decode(&row)?; + let config_revision = if artifact.accepted_at.is_some() { + Revision::new(u64::try_from(current).map_err(|_| unavailable())?) + .map_err(|_| unavailable())? + } else { + let accepted_at: chrono::DateTime = sqlx::query_scalar("update plugin_artifacts set accepted_at=now() where sha256=$1 returning accepted_at") + .bind(digest).fetch_one(&mut *transaction).await.map_err(|_| unavailable())?; + artifact.accepted_at = Some(accepted_at); + let revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(|error| admin_store_error("plugin", error))?; + append_admin_audit_event_in_transaction( + &mut transaction, + mutation_audit( + context, + "accept", + "plugin_artifact", + digest, + vec!["permissions".into()], + ), + revision, + ) + .await + .map_err(|error| admin_store_error("plugin", error))?; + admin_revision(revision)? + }; + transaction.commit().await.map_err(|_| unavailable())?; + Ok(PluginArtifactMutation { + config_revision, + artifact, + }) + } + + async fn delete_artifact( + &self, + digest: &str, + context: &MutationContext, + ) -> AdminStoreResult { + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let revision = bump_config_revision_in_transaction(&mut transaction) + .await + .map_err(|error| admin_store_error("plugin", error))?; + // 制品删除会级联移除下载引用,先固定本次涉及的凭据,不能清扫无关的未使用凭据。 + let credential_ids: Vec = sqlx::query_scalar( + "select credential_id from plugin_artifact_credentials where artifact_sha256=$1", + ) + .bind(digest) + .fetch_all(&mut *transaction) + .await + .map_err(|_| unavailable())?; + let id: String = sqlx::query_scalar( + "delete from plugin_artifacts where sha256 = $1 returning plugin_id", + ) + .bind(digest) + .fetch_optional(&mut *transaction) + .await + .map_err(|error| { + if error.as_database_error().is_some_and(|error| { + error.is_foreign_key_violation() || error.code().as_deref() == Some("23001") + }) { + conflict() + } else { + unavailable() + } + })? + .ok_or_else(not_found)?; + super::sources::delete_if_unused(&mut transaction, &id, context, revision).await?; + super::credentials::delete_unused(&mut transaction, &credential_ids, context, revision) + .await?; + append_admin_audit_event_in_transaction( + &mut transaction, + mutation_audit( + context, + "delete", + "plugin_artifact", + &id, + vec!["artifact".into()], + ), + revision, + ) + .await + .map_err(|error| admin_store_error("plugin", error))?; + transaction.commit().await.map_err(|_| unavailable())?; + admin_revision(revision) + } +} diff --git a/backend/crates/gateway-store/src/postgres/plugins/credentials.rs b/backend/crates/gateway-store/src/postgres/plugins/credentials.rs new file mode 100644 index 000000000..11cec8626 --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/credentials.rs @@ -0,0 +1,169 @@ +use gateway_admin::{ + model::{ + MutationContext, Revision, + plugins::distribution::{SourceAuthentication, SourceCredential, SourceCredentialInfo}, + }, + ports::store::AdminStoreResult, +}; +use secrecy::ExposeSecret as _; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; + +use super::super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; +use super::artifacts::{conflict, not_found, unavailable}; +use crate::{admin_revision, admin_store_error, mutation_audit}; + +pub(super) async fn list(pool: &PgPool) -> AdminStoreResult> { + let rows: Vec> = + sqlx::query_scalar("select info_json from plugin_source_credentials order by id") + .fetch_all(pool) + .await + .map_err(|_| unavailable())?; + Ok(rows.into_iter().map(|row| row.0).collect()) +} + +pub(super) async fn load(pool: &PgPool, id: &str) -> AdminStoreResult { + let id = uuid::Uuid::parse_str(id).map_err(|_| not_found())?; + let row = + sqlx::query("select info_json, secret_json from plugin_source_credentials where id=$1") + .bind(id) + .fetch_optional(pool) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + let info: sqlx::types::Json = + row.try_get("info_json").map_err(|_| unavailable())?; + let authentication: sqlx::types::Json = + row.try_get("secret_json").map_err(|_| unavailable())?; + Ok(SourceCredential { + info: info.0, + authentication: authentication.0, + }) +} + +pub(super) async fn save( + pool: &PgPool, + credential: SourceCredential, + context: &MutationContext, +) -> AdminStoreResult { + let id = uuid::Uuid::parse_str(&credential.info.id).map_err(|_| unavailable())?; + // 只有持久化边界显式展开 secret,不给敏感领域类型实现通用 Serialize。 + let secret = match &credential.authentication { + SourceAuthentication::Github { token } => { + serde_json::json!({"kind":"github", "token":token.expose_secret()}) + } + SourceAuthentication::Bearer { token } => { + serde_json::json!({"kind":"bearer", "token":token.expose_secret()}) + } + SourceAuthentication::Basic { username, password } => { + serde_json::json!({"kind":"basic", "username":username, "password":password.expose_secret()}) + } + SourceAuthentication::Header { name, value } => { + serde_json::json!({"kind":"header", "name":name, "value":value.expose_secret()}) + } + }; + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + sqlx::query( + "insert into plugin_source_credentials(id,info_json,secret_json) values ($1,$2,$3)", + ) + .bind(id) + .bind(sqlx::types::Json(&credential.info)) + .bind(secret) + .execute(&mut *tx) + .await + .map_err(|_| conflict())?; + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "create", + "plugin_source_credential", + &credential.info.id, + vec!["credential".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + tx.commit().await.map_err(|_| unavailable())?; + admin_revision(revision) +} + +/// 只回收本次删除制品使用过且已无引用的凭据,保留其他插件共享的下载认证。 +pub(super) async fn delete_unused( + tx: &mut Transaction<'_, Postgres>, + ids: &[uuid::Uuid], + context: &MutationContext, + revision: crate::Revision, +) -> AdminStoreResult<()> { + let removed: Vec = sqlx::query_scalar( + "delete from plugin_source_credentials c where c.id=any($1) + and not exists (select 1 from plugin_artifact_credentials r where r.credential_id=c.id) + returning c.id", + ) + .bind(ids) + .fetch_all(&mut **tx) + .await + .map_err(|_| unavailable())?; + for id in removed { + append_admin_audit_event_in_transaction( + tx, + mutation_audit( + context, + "delete", + "plugin_source_credential", + &id.to_string(), + vec!["credential".into()], + ), + revision, + ) + .await + .map_err(|error| admin_store_error("plugin", error))?; + } + Ok(()) +} + +pub(super) async fn delete( + pool: &PgPool, + id: &str, + context: &MutationContext, +) -> AdminStoreResult { + let key = uuid::Uuid::parse_str(id).map_err(|_| not_found())?; + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + let result = sqlx::query("delete from plugin_source_credentials where id=$1") + .bind(key) + .execute(&mut *tx) + .await + .map_err(|e| { + if e.as_database_error() + .is_some_and(|e| e.is_foreign_key_violation()) + { + conflict() + } else { + unavailable() + } + })?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "delete", + "plugin_source_credential", + id, + vec!["credential".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + tx.commit().await.map_err(|_| unavailable())?; + admin_revision(revision) +} diff --git a/backend/crates/gateway-store/src/postgres/plugins/instances.rs b/backend/crates/gateway-store/src/postgres/plugins/instances.rs new file mode 100644 index 000000000..710af3669 --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/instances.rs @@ -0,0 +1,422 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gateway_admin::{ + model::{ + MutationContext, Revision, + plugins::{ + instances::{ + PluginInstance, PluginInstanceMutation, PluginInstanceReplacement, + PluginInstanceSnapshot, PluginPermissionGrant, PluginVersionConfiguration, + }, + state::PluginStateCommit, + }, + }, + ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, +}; +use secrecy::ExposeSecret as _; +use sqlx::{PgPool, Postgres, Row as _, Transaction}; + +use super::super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; +use super::artifacts::{conflict, not_found, unavailable}; +use crate::{admin_revision, admin_store_error, mutation_audit}; + +pub(super) async fn management_target_is_current( + pool: &PgPool, + target: &gateway_admin::model::plugins::management::PluginManagementTarget, +) -> AdminStoreResult { + let (Ok(id), Ok(revision)) = ( + uuid::Uuid::parse_str(&target.instance_id), + i64::try_from(target.revision), + ) else { + return Ok(false); + }; + sqlx::query_scalar( + "select exists (select 1 from plugin_instances i \ + join plugin_artifacts a on a.sha256=i.artifact_sha256 \ + where i.id=$1 and i.artifact_sha256=$2 and i.revision=$3 \ + and i.enabled and a.accepted_at is not null)", + ) + .bind(id) + .bind(&target.artifact_sha256) + .bind(revision) + .fetch_one(pool) + .await + .map_err(|_| unavailable()) +} + +pub(super) async fn load(pool: &PgPool) -> AdminStoreResult { + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + sqlx::query("set transaction isolation level repeatable read read only") + .execute(&mut *tx) + .await + .map_err(|_| unavailable())?; + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&mut *tx) + .await + .map_err(|_| unavailable())?; + let rows = sqlx::query("select i.*, a.metadata_json, a.accepted_at, coalesce(s.secrets_json, '{}'::jsonb) as secrets_json from plugin_instances i join plugin_artifacts a on a.sha256=i.artifact_sha256 left join plugin_instance_secrets s on s.instance_id=i.id order by i.id") + .fetch_all(&mut *tx).await.map_err(|_| unavailable())?; + let instances = rows.iter().map(decode).collect::>()?; + tx.commit().await.map_err(|_| unavailable())?; + Ok(PluginInstanceSnapshot { + config_revision: revision_from_i64(revision)?, + instances, + }) +} + +pub(super) async fn load_version_configuration( + pool: &PgPool, + id: &str, + digest: &str, +) -> AdminStoreResult> { + let id = uuid::Uuid::parse_str(id).map_err(|_| not_found())?; + let row = sqlx::query("select configuration_json,secrets_json,bindings_json from plugin_version_configurations where instance_id=$1 and artifact_sha256=$2") + .bind(id).bind(digest).fetch_optional(pool).await.map_err(|_| unavailable())?; + row.map(|row| { + Ok(PluginVersionConfiguration { + configuration: row + .try_get("configuration_json") + .map_err(|_| unavailable())?, + secrets: row + .try_get::, _>("secrets_json") + .map_err(|_| unavailable())? + .0, + bindings: row + .try_get::, _>("bindings_json") + .map_err(|_| unavailable())? + .0, + }) + }) + .transpose() +} + +pub(super) async fn configuration_versions( + pool: &PgPool, + id: &str, +) -> AdminStoreResult> { + let id = uuid::Uuid::parse_str(id).map_err(|_| not_found())?; + sqlx::query_scalar("select artifact_sha256 from plugin_version_configurations where instance_id=$1 order by artifact_sha256") + .bind(id).fetch_all(pool).await.map_err(|_| unavailable()) +} + +fn decode(row: &sqlx::postgres::PgRow) -> AdminStoreResult { + let id: uuid::Uuid = row.try_get("id").map_err(|_| unavailable())?; + let accepted = row + .try_get::>, _>("accepted_at") + .map_err(|_| unavailable())? + .is_some(); + let metadata = super::artifacts::decode_metadata(row)?; + Ok(PluginInstance { + id: id.to_string(), + name: row.try_get("name").map_err(|_| unavailable())?, + artifact_sha256: row.try_get("artifact_sha256").map_err(|_| unavailable())?, + enabled: row.try_get("enabled").map_err(|_| unavailable())?, + trusted_process: accepted, + configuration: row + .try_get("configuration_json") + .map_err(|_| unavailable())?, + secrets: row + .try_get::, _>("secrets_json") + .map_err(|_| unavailable())? + .0, + grants: if accepted { + metadata + .requested_permissions + .into_iter() + .map(|permission| PluginPermissionGrant { permission }) + .collect() + } else { + Vec::new() + }, + bindings: row + .try_get::, _>("bindings_json") + .map_err(|_| unavailable())? + .0, + revision: revision_from_i64(row.try_get("revision").map_err(|_| unavailable())?)?, + }) +} + +async fn check_revision( + tx: &mut Transaction<'_, Postgres>, + expected: Revision, +) -> AdminStoreResult<()> { + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1 for update") + .fetch_one(&mut **tx) + .await + .map_err(|_| unavailable())?; + if revision_from_i64(revision)? != expected { + return Err(conflict()); + } + Ok(()) +} + +async fn validate_artifact_acceptance( + tx: &mut Transaction<'_, Postgres>, + instance: &PluginInstance, +) -> AdminStoreResult<()> { + let row = sqlx::query( + "select metadata_json, accepted_at from plugin_artifacts where sha256=$1 for key share", + ) + .bind(&instance.artifact_sha256) + .fetch_optional(&mut **tx) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + let metadata = super::artifacts::decode_metadata(&row)?; + let accepted = row + .try_get::>, _>("accepted_at") + .map_err(|_| unavailable())? + .is_some(); + let expected_grants = metadata + .requested_permissions + .into_iter() + .map(|permission| PluginPermissionGrant { permission }) + .collect::>(); + if instance.trusted_process != accepted + || instance.grants != expected_grants + || (instance.enabled && !accepted) + { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Invalid, + "plugin", + "plugin instance acceptance facts do not match its artifact", + )); + } + Ok(()) +} + +async fn validate_binding_references( + tx: &mut Transaction<'_, Postgres>, + instance: &PluginInstance, +) -> AdminStoreResult<()> { + let client_key_ids: Vec = instance + .bindings + .iter() + .flat_map(|binding| { + binding.client_key_ids.iter().chain( + binding + .identity_bindings + .iter() + .map(|identity| &identity.client_key_id), + ) + }) + .collect::>() + .into_iter() + .cloned() + .collect(); + let account_group_ids: Vec = instance + .bindings + .iter() + .flat_map(|binding| &binding.account_group_ids) + .collect::>() + .into_iter() + .cloned() + .collect(); + let valid: bool = sqlx::query_scalar( + "select not exists ( + select 1 from unnest($1::text[]) as scope(id) + left join client_api_keys existing_key on existing_key.id = scope.id + where existing_key.id is null + ) and not exists ( + select 1 from unnest($2::text[]) as scope(id) + left join account_groups existing_group on existing_group.id = scope.id + where existing_group.id is null + )", + ) + .bind(client_key_ids) + .bind(account_group_ids) + .fetch_one(&mut **tx) + .await + .map_err(|_| unavailable())?; + if !valid { + return Err(AdminStoreError::new( + AdminStoreErrorKind::Invalid, + "plugin", + "plugin binding references do not exist", + )); + } + Ok(()) +} + +pub(super) async fn save( + pool: &PgPool, + instance: PluginInstance, + expected: Revision, + context: &MutationContext, +) -> AdminStoreResult { + save_inner(pool, instance, expected, None, &[], context).await +} + +pub(super) async fn save_with_state( + pool: &PgPool, + instance: PluginInstance, + expected: Revision, + state: &PluginStateCommit, + replacements: &[PluginInstanceReplacement], + context: &MutationContext, +) -> AdminStoreResult { + save_inner(pool, instance, expected, Some(state), replacements, context).await +} + +async fn save_inner( + pool: &PgPool, + mut instance: PluginInstance, + expected: Revision, + state: Option<&PluginStateCommit>, + replacements: &[PluginInstanceReplacement], + context: &MutationContext, +) -> AdminStoreResult { + let id = uuid::Uuid::parse_str(&instance.id).map_err(|_| conflict())?; + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + check_revision(&mut tx, expected).await?; + validate_artifact_acceptance(&mut tx, &instance).await?; + // 停用是损坏配置的恢复路径,必须原样保留绑定;再次启用时才要求引用仍存在。 + if instance.enabled { + validate_binding_references(&mut tx, &instance).await?; + } + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + sqlx::query("insert into plugin_instances (id,artifact_sha256,name,enabled,configuration_json,bindings_json,revision) values ($1,$2,$3,$4,$5,$6,$7) on conflict (id) do update set artifact_sha256=excluded.artifact_sha256,name=excluded.name,enabled=excluded.enabled,configuration_json=excluded.configuration_json,bindings_json=excluded.bindings_json,revision=excluded.revision") + .bind(id).bind(&instance.artifact_sha256).bind(&instance.name).bind(instance.enabled).bind(&instance.configuration) + .bind(sqlx::types::Json(&instance.bindings)).bind(i64::try_from(revision.get()).map_err(|_| unavailable())?) + .execute(&mut *tx).await.map_err(|error| if error.as_database_error().is_some_and(|e| e.is_foreign_key_violation()) { conflict() } else {unavailable()})?; + let secrets: BTreeMap<_, _> = instance + .secrets + .iter() + .map(|(key, value)| (key.as_str(), value.expose_secret())) + .collect(); + sqlx::query("insert into plugin_instance_secrets(instance_id,secrets_json) values ($1,$2) on conflict (instance_id) do update set secrets_json=excluded.secrets_json") + .bind(id).bind(sqlx::types::Json(secrets)).execute(&mut *tx).await.map_err(|_| unavailable())?; + // 与当前配置及状态一同提交,准备失败或事务冲突不能污染恢复点。 + // 停用草稿可能缺少必填参数,不能覆盖此版本最近的启用配置。 + if instance.enabled { + sqlx::query("insert into plugin_version_configurations (instance_id,artifact_sha256,configuration_json,secrets_json,bindings_json) select i.id,i.artifact_sha256,i.configuration_json,s.secrets_json,i.bindings_json from plugin_instances i join plugin_instance_secrets s on s.instance_id=i.id where i.id=$1 on conflict (instance_id,artifact_sha256) do update set configuration_json=excluded.configuration_json,secrets_json=excluded.secrets_json,bindings_json=excluded.bindings_json") + .bind(id).execute(&mut *tx).await.map_err(|_| unavailable())?; + } + let committed_revision = admin_revision(revision)?; + // 旧配置与新配置共享事务,任一版本检查或状态提交失败都不留下半次切换。 + for replacement in replacements { + let previous_id = uuid::Uuid::parse_str(&replacement.id).map_err(|_| conflict())?; + if previous_id == id || !instance.enabled { + return Err(conflict()); + } + let artifact_sha256: String = sqlx::query_scalar( + "update plugin_instances set enabled=false,revision=$3 \ + where id=$1 and revision=$2 and enabled=true \ + and artifact_sha256 in (select sha256 from plugin_artifacts where \ + metadata_json->>'pluginId'=(select metadata_json->>'pluginId' from plugin_artifacts where sha256=$4)) \ + returning artifact_sha256", + ) + .bind(previous_id) + .bind(i64::try_from(replacement.expected_revision).map_err(|_| conflict())?) + .bind(i64::try_from(revision.get()).map_err(|_| unavailable())?) + .bind(&instance.artifact_sha256) + .fetch_optional(&mut *tx).await.map_err(|_| unavailable())? + .ok_or_else(conflict)?; + super::state::rebind_existing_configuration( + &mut tx, + &replacement.id, + &artifact_sha256, + committed_revision, + ) + .await?; + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "configure", + "plugin_instance", + &replacement.id, + vec!["enabled".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + } + if let Some(state) = state { + super::state::commit_configuration(&mut tx, &instance, committed_revision, state).await?; + } else { + super::state::rebind_existing_configuration( + &mut tx, + &instance.id, + &instance.artifact_sha256, + committed_revision, + ) + .await?; + } + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "configure", + "plugin_instance", + &instance.id, + vec![ + "artifact".into(), + "configuration".into(), + "bindings".into(), + "enabled".into(), + ], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + tx.commit().await.map_err(|_| unavailable())?; + instance.revision = committed_revision; + Ok(PluginInstanceMutation { + config_revision: instance.revision, + instance, + }) +} + +pub(super) async fn delete( + pool: &PgPool, + id: &str, + expected: Revision, + context: &MutationContext, +) -> AdminStoreResult { + let key = uuid::Uuid::parse_str(id).map_err(|_| not_found())?; + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + check_revision(&mut tx, expected).await?; + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + let enabled: bool = sqlx::query_scalar("select enabled from plugin_instances where id=$1") + .bind(key) + .fetch_optional(&mut *tx) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + if enabled { + return Err(conflict()); + } + sqlx::query("delete from plugin_instances where id=$1") + .bind(key) + .execute(&mut *tx) + .await + .map_err(|_| conflict())?; + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "delete", + "plugin_instance", + id, + vec!["instance".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + tx.commit().await.map_err(|_| unavailable())?; + admin_revision(revision) +} + +fn revision_from_i64(value: i64) -> AdminStoreResult { + Revision::new(u64::try_from(value).map_err(|_| unavailable())?).map_err(|_| unavailable()) +} diff --git a/backend/crates/gateway-store/src/postgres/plugins/mod.rs b/backend/crates/gateway-store/src/postgres/plugins/mod.rs new file mode 100644 index 000000000..f186c747e --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/mod.rs @@ -0,0 +1,7 @@ +mod artifacts; +mod credentials; +mod instances; +mod sources; +mod state; + +pub use artifacts::PgPluginStore; diff --git a/backend/crates/gateway-store/src/postgres/plugins/sources.rs b/backend/crates/gateway-store/src/postgres/plugins/sources.rs new file mode 100644 index 000000000..62e9eb803 --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/sources.rs @@ -0,0 +1,166 @@ +use gateway_admin::{ + model::{ + MutationContext, Revision, + plugins::{ + PluginSourceEgress, + distribution::{PluginSourceBinding, PluginUpdatePolicy, PluginUpdateSource}, + }, + }, + ports::store::AdminStoreResult, +}; +use sqlx::{PgPool, Postgres, Transaction}; + +use super::super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; +use super::artifacts::{conflict, not_found, unavailable}; +use crate::{admin_revision, admin_store_error, mutation_audit}; + +#[derive(sqlx::FromRow)] +struct PluginSourceRow { + plugin_id: String, + source_json: sqlx::types::Json, + policy_json: sqlx::types::Json, + outbound_proxy_id: Option, +} + +pub(super) async fn list(pool: &PgPool) -> AdminStoreResult> { + let rows: Vec = sqlx::query_as( + "select plugin_id,source_json,policy_json,outbound_proxy_id from plugin_update_sources order by plugin_id", + ) + .fetch_all(pool) + .await + .map_err(|_| unavailable())?; + Ok(rows + .into_iter() + .map(|row| PluginSourceBinding { + plugin_id: row.plugin_id, + source: row.source_json.0, + policy: row.policy_json.0, + outbound_proxy_id: row.outbound_proxy_id, + }) + .collect()) +} + +pub(super) async fn change( + pool: &PgPool, + binding: PluginSourceBinding, + context: &MutationContext, +) -> AdminStoreResult { + let mut tx = pool.begin().await.map_err(|_| unavailable())?; + let revision = bump_config_revision_in_transaction(&mut tx) + .await + .map_err(|e| admin_store_error("plugin", e))?; + if let Some(id) = binding.outbound_proxy_id.as_deref() { + lock_proxy(&mut tx, id, None).await?; + } + let result = sqlx::query( + "update plugin_update_sources set source_json=$2,policy_json=$3,outbound_proxy_id=$4 where plugin_id=$1", + ) + .bind(&binding.plugin_id) + .bind(sqlx::types::Json(binding.source)) + .bind(sqlx::types::Json(binding.policy)) + .bind(binding.outbound_proxy_id) + .execute(&mut *tx) + .await + .map_err(|_| unavailable())?; + if result.rows_affected() == 0 { + return Err(not_found()); + } + append_admin_audit_event_in_transaction( + &mut tx, + mutation_audit( + context, + "change_source", + "plugin_source", + &binding.plugin_id, + vec!["source".into(), "policy".into(), "outbound_proxy".into()], + ), + revision, + ) + .await + .map_err(|e| admin_store_error("plugin", e))?; + tx.commit().await.map_err(|_| unavailable())?; + admin_revision(revision) +} + +/// 调用方先锁全局 revision,来源确认与制品写入共用同一个事务。 +pub(super) async fn bind( + tx: &mut Transaction<'_, Postgres>, + plugin_id: &str, + source: PluginUpdateSource, + outbound_proxy: Option<&PluginSourceEgress>, +) -> AdminStoreResult<()> { + if let Some(proxy) = outbound_proxy { + lock_proxy(tx, &proxy.id, Some(proxy.revision)).await?; + } + let outbound_proxy_id = outbound_proxy.map(|proxy| proxy.id.as_str()); + sqlx::query("insert into plugin_update_sources(plugin_id,source_json,outbound_proxy_id) values ($1,$2,$3) on conflict do nothing") + .bind(plugin_id).bind(sqlx::types::Json(&source)).bind(outbound_proxy_id).execute(&mut **tx).await.map_err(|_| unavailable())?; + let stored: (sqlx::types::Json, Option) = sqlx::query_as( + "select source_json,outbound_proxy_id from plugin_update_sources where plugin_id=$1", + ) + .bind(plugin_id) + .fetch_one(&mut **tx) + .await + .map_err(|_| unavailable())?; + if stored.0.0 != source || stored.1.as_deref() != outbound_proxy_id { + return Err(conflict()); + } + Ok(()) +} + +/// 与制品删除共用全局 revision 锁,最后一个版本删除后不保留重装限制。 +pub(super) async fn delete_if_unused( + tx: &mut Transaction<'_, Postgres>, + plugin_id: &str, + context: &MutationContext, + revision: crate::Revision, +) -> AdminStoreResult<()> { + let removed = sqlx::query( + "delete from plugin_update_sources where plugin_id=$1 + and not exists (select 1 from plugin_artifacts where plugin_id=$1)", + ) + .bind(plugin_id) + .execute(&mut **tx) + .await + .map_err(|_| unavailable())?; + if removed.rows_affected() > 0 { + append_admin_audit_event_in_transaction( + tx, + mutation_audit( + context, + "delete", + "plugin_source", + plugin_id, + vec!["source".into(), "policy".into(), "outbound_proxy".into()], + ), + revision, + ) + .await + .map_err(|error| admin_store_error("plugin", error))?; + } + Ok(()) +} + +async fn lock_proxy( + tx: &mut Transaction<'_, Postgres>, + id: &str, + expected_revision: Option, +) -> AdminStoreResult<()> { + let revision: Option = + sqlx::query_scalar("select revision from outbound_proxies where id=$1 for share") + .bind(id) + .fetch_optional(&mut **tx) + .await + .map_err(|_| unavailable())?; + let Some(revision) = revision else { + return Err(if expected_revision.is_some() { + conflict() + } else { + not_found() + }); + }; + if expected_revision.is_some_and(|expected| u64::try_from(revision).ok() != Some(expected)) { + return Err(conflict()); + } + Ok(()) +} diff --git a/backend/crates/gateway-store/src/postgres/plugins/state.rs b/backend/crates/gateway-store/src/postgres/plugins/state.rs new file mode 100644 index 000000000..8e74021ec --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/plugins/state.rs @@ -0,0 +1,1149 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use async_trait::async_trait; +use gateway_admin::{ + model::{ + Revision, + plugins::{ + instances::PluginInstance, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, + PluginStateConfiguration, PluginStateMigrationAction, PluginStateMigrationBatch, + PluginStateMigrationNamespace, PluginStateNamespaceOwner, PluginStateOwner, + PluginStateOwnerRequest, PluginStateRecord, PluginStateSchema, + PluginStateTransition, PluginStateWrite, PutPluginState, + }, + }, + }, + ports::{ + plugins::{ + PluginStateStore, PluginStateStoreError, PluginStateStoreErrorKind, + PluginStateStoreResult, + }, + store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, + }, +}; +use sqlx::{Postgres, Row as _, Transaction}; + +use super::artifacts::PgPluginStore; + +const MIGRATION_BATCH_MAXIMUM: u32 = 100; + +struct ActiveGeneration { + id: uuid::Uuid, + namespace: String, + schema_version: u32, + schema_sha256: String, + record_count: u64, + total_bytes: u64, + maximum_record_bytes: u64, +} + +struct WritableGeneration { + id: uuid::Uuid, + maximum_records: u64, + maximum_bytes: u64, + maximum_value_bytes: u64, + next_record_version: u64, + record_count: u64, + total_bytes: u64, +} + +fn state_error(kind: PluginStateStoreErrorKind) -> PluginStateStoreError { + PluginStateStoreError::new(kind) +} + +fn invalid() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::Invalid) +} + +fn not_found() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::NotFound) +} + +fn conflict() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::Conflict) +} + +fn quota() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::Quota) +} + +fn denied() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::PermissionDenied) +} + +fn unavailable() -> PluginStateStoreError { + state_error(PluginStateStoreErrorKind::Unavailable) +} + +fn admin_conflict() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Conflict, + "plugin state", + "plugin state configuration conflicts with the active generation", + ) +} + +fn admin_invalid() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + "plugin state", + "plugin state configuration is invalid", + ) +} + +fn admin_unavailable() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + "plugin state", + "plugin state store is unavailable", + ) +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key.chars().count() <= 256 + && key.len() <= 512 + && !key.chars().any(char::is_control) +} + +fn validate_configuration(configuration: &PluginStateConfiguration) -> PluginStateStoreResult<()> { + if configuration.namespaces.len() > 16 { + return Err(invalid()); + } + let mut namespaces = BTreeSet::new(); + for schema in &configuration.namespaces { + let schema_bytes = serde_json::to_vec(&schema.schema).map_err(|_| invalid())?; + let migrations = schema + .migrates_from + .iter() + .copied() + .collect::>(); + if !valid_namespace(&schema.namespace) + || !namespaces.insert(&schema.namespace) + || schema.schema_version == 0 + || schema.schema_sha256.len() != 64 + || !schema + .schema_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + || !schema.schema.is_object() + || schema_bytes.len() > 32 * 1024 + || schema.maximum_records == 0 + || schema.maximum_records > 10_000 + || schema.maximum_bytes == 0 + || schema.maximum_bytes > 16 * 1024 * 1024 + || schema.maximum_value_bytes == 0 + || schema.maximum_value_bytes > 256 * 1024 + || u64::from(schema.maximum_value_bytes) > schema.maximum_bytes + || migrations.len() != schema.migrates_from.len() + || migrations.contains(&0) + || migrations.contains(&schema.schema_version) + { + return Err(invalid()); + } + } + Ok(()) +} + +fn valid_namespace(namespace: &str) -> bool { + !namespace.is_empty() + && namespace.len() <= 64 + && namespace.as_bytes()[0].is_ascii_lowercase() + && namespace.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-_.".contains(&byte) + }) +} + +fn target_map(configuration: &PluginStateConfiguration) -> BTreeMap<&str, &PluginStateSchema> { + configuration + .namespaces + .iter() + .map(|schema| (schema.namespace.as_str(), schema)) + .collect() +} + +fn revision_i64(revision: Revision) -> Result { + i64::try_from(revision.get()).map_err(|_| invalid()) +} + +fn u64_from_i64(value: i64) -> PluginStateStoreResult { + u64::try_from(value).map_err(|_| unavailable()) +} + +fn u32_from_i64(value: i64) -> PluginStateStoreResult { + u32::try_from(value).map_err(|_| unavailable()) +} + +async fn active_generations( + transaction: &mut Transaction<'_, Postgres>, + instance_id: uuid::Uuid, + lock: bool, +) -> PluginStateStoreResult> { + let query = if lock { + "select g.id, g.namespace, g.schema_version, g.schema_sha256, g.record_count, \ + g.total_bytes, coalesce((select max(r.value_bytes) \ + from plugin_state_records r where r.generation_id=g.id), 0) \ + as maximum_record_bytes \ + from plugin_state_generations g where g.instance_id=$1 and g.status='active' \ + order by g.namespace for update of g" + } else { + "select g.id, g.namespace, g.schema_version, g.schema_sha256, g.record_count, \ + g.total_bytes, coalesce((select max(r.value_bytes) \ + from plugin_state_records r where r.generation_id=g.id), 0) \ + as maximum_record_bytes \ + from plugin_state_generations g where g.instance_id=$1 and g.status='active' \ + order by g.namespace" + }; + sqlx::query(query) + .bind(instance_id) + .fetch_all(&mut **transaction) + .await + .map_err(|_| unavailable())? + .into_iter() + .map(|row| { + Ok(ActiveGeneration { + id: row.try_get("id").map_err(|_| unavailable())?, + namespace: row.try_get("namespace").map_err(|_| unavailable())?, + schema_version: u32_from_i64( + row.try_get("schema_version").map_err(|_| unavailable())?, + )?, + schema_sha256: row.try_get("schema_sha256").map_err(|_| unavailable())?, + record_count: u64_from_i64( + row.try_get("record_count").map_err(|_| unavailable())?, + )?, + total_bytes: u64_from_i64(row.try_get("total_bytes").map_err(|_| unavailable())?)?, + maximum_record_bytes: u64_from_i64( + row.try_get("maximum_record_bytes") + .map_err(|_| unavailable())?, + )?, + }) + }) + .collect() +} + +#[async_trait] +impl PluginStateStore for PgPluginStore { + async fn load_owner( + &self, + request: PluginStateOwnerRequest, + ) -> PluginStateStoreResult> { + validate_configuration(&request.configuration)?; + let instance_id = uuid::Uuid::parse_str(&request.instance_id).map_err(|_| invalid())?; + let revision = revision_i64(request.instance_revision)?; + let current = + sqlx::query("select artifact_sha256, revision from plugin_instances where id=$1") + .bind(instance_id) + .fetch_optional(&self.pool) + .await + .map_err(|_| unavailable())?; + let Some(current) = current else { + return Ok(None); + }; + if current + .try_get::("artifact_sha256") + .map_err(|_| unavailable())? + != request.artifact_sha256 + || current + .try_get::("revision") + .map_err(|_| unavailable())? + != revision + { + return Ok(None); + } + let rows = sqlx::query( + "select id, namespace, artifact_sha256, schema_version, schema_sha256, \ + instance_revision, fence, maximum_records, maximum_bytes, maximum_value_bytes \ + from plugin_state_generations \ + where instance_id=$1 and status='active' order by namespace", + ) + .bind(instance_id) + .fetch_all(&self.pool) + .await + .map_err(|_| unavailable())?; + let target = target_map(&request.configuration); + if rows.len() != target.len() { + return Ok(None); + } + let mut namespaces = BTreeMap::new(); + for row in rows { + let namespace: String = row.try_get("namespace").map_err(|_| unavailable())?; + let Some(schema) = target.get(namespace.as_str()) else { + return Ok(None); + }; + if row + .try_get::("artifact_sha256") + .map_err(|_| unavailable())? + != request.artifact_sha256 + || row + .try_get::("instance_revision") + .map_err(|_| unavailable())? + != revision + || u32_from_i64(row.try_get("schema_version").map_err(|_| unavailable())?)? + != schema.schema_version + || row + .try_get::("schema_sha256") + .map_err(|_| unavailable())? + != schema.schema_sha256 + || u64_from_i64(row.try_get("maximum_records").map_err(|_| unavailable())?)? + != u64::from(schema.maximum_records) + || u64_from_i64(row.try_get("maximum_bytes").map_err(|_| unavailable())?)? + != schema.maximum_bytes + || u64_from_i64( + row.try_get("maximum_value_bytes") + .map_err(|_| unavailable())?, + )? != u64::from(schema.maximum_value_bytes) + { + return Ok(None); + } + let generation: uuid::Uuid = row.try_get("id").map_err(|_| unavailable())?; + let fence: uuid::Uuid = row.try_get("fence").map_err(|_| unavailable())?; + namespaces.insert( + namespace, + PluginStateNamespaceOwner::from_store( + generation.to_string(), + fence.to_string(), + schema.schema_version, + ), + ); + } + Ok(Some(PluginStateOwner::from_store( + request.instance_id, + request.artifact_sha256, + request.instance_revision, + namespaces, + ))) + } + + async fn get( + &self, + owner: &PluginStateOwner, + namespace: &str, + key: &str, + ) -> PluginStateStoreResult> { + if !valid_key(key) { + return Err(invalid()); + } + let namespace_owner = owner.namespace(namespace).ok_or_else(denied)?; + let generation = + uuid::Uuid::parse_str(namespace_owner.generation_id()).map_err(|_| denied())?; + let fence = uuid::Uuid::parse_str(namespace_owner.fence()).map_err(|_| denied())?; + let instance_id = uuid::Uuid::parse_str(owner.instance_id()).map_err(|_| denied())?; + let row = sqlx::query( + "select g.schema_version, r.state_key, r.value_json, r.record_version \ + from plugin_state_generations g \ + join plugin_instances i on i.id=g.instance_id \ + left join plugin_state_records r on r.generation_id=g.id and r.state_key=$7 \ + where g.id=$1 and g.fence=$2 and g.instance_id=$3 and g.namespace=$4 \ + and g.artifact_sha256=$5 and g.instance_revision=$6 and g.status='active' \ + and i.artifact_sha256=g.artifact_sha256 and i.revision=g.instance_revision", + ) + .bind(generation) + .bind(fence) + .bind(instance_id) + .bind(namespace) + .bind(owner.artifact_sha256()) + .bind(revision_i64(owner.instance_revision())?) + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|_| unavailable())? + .ok_or_else(denied)?; + let Some(state_key) = row + .try_get::, _>("state_key") + .map_err(|_| unavailable())? + else { + return Ok(None); + }; + Ok(Some(PluginStateRecord { + key: state_key, + value: row.try_get("value_json").map_err(|_| unavailable())?, + version: u64_from_i64(row.try_get("record_version").map_err(|_| unavailable())?)?, + schema_version: u32_from_i64( + row.try_get("schema_version").map_err(|_| unavailable())?, + )?, + })) + } + + async fn put( + &self, + owner: &PluginStateOwner, + command: PutPluginState, + ) -> PluginStateStoreResult { + if !valid_key(&command.key) { + return Err(invalid()); + } + let value_bytes = serde_json::to_vec(&command.value) + .map_err(|_| invalid())? + .len(); + let value_bytes = u64::try_from(value_bytes).map_err(|_| quota())?; + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let generation = writable_generation(&mut transaction, owner, &command.namespace).await?; + if value_bytes == 0 || value_bytes > generation.maximum_value_bytes { + return Err(quota()); + } + let existing = sqlx::query( + "select record_version, value_bytes from plugin_state_records \ + where generation_id=$1 and state_key=$2", + ) + .bind(generation.id) + .bind(&command.key) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| unavailable())?; + let previous_bytes = match (command.expected_version, existing) { + (None, None) => 0, + (None, Some(_)) | (Some(_), None) => return Err(conflict()), + (Some(expected), Some(row)) => { + if u64_from_i64(row.try_get("record_version").map_err(|_| unavailable())?)? + != expected + { + return Err(conflict()); + } + u64_from_i64(row.try_get("value_bytes").map_err(|_| unavailable())?)? + } + }; + let record_count = generation + .record_count + .checked_add(u64::from(previous_bytes == 0)) + .ok_or_else(quota)?; + let total_bytes = generation + .total_bytes + .checked_sub(previous_bytes) + .and_then(|bytes| bytes.checked_add(value_bytes)) + .ok_or_else(quota)?; + if record_count > generation.maximum_records || total_bytes > generation.maximum_bytes { + return Err(quota()); + } + let version = generation.next_record_version; + let next_version = version.checked_add(1).ok_or_else(quota)?; + sqlx::query( + "insert into plugin_state_records( \ + generation_id,state_key,value_json,value_bytes,record_version \ + ) values ($1,$2,$3,$4,$5) \ + on conflict(generation_id,state_key) do update set \ + value_json=excluded.value_json, value_bytes=excluded.value_bytes, \ + record_version=excluded.record_version", + ) + .bind(generation.id) + .bind(&command.key) + .bind(&command.value) + .bind(i64::try_from(value_bytes).map_err(|_| quota())?) + .bind(i64::try_from(version).map_err(|_| quota())?) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + sqlx::query( + "update plugin_state_generations set next_record_version=$2, record_count=$3, \ + total_bytes=$4 where id=$1", + ) + .bind(generation.id) + .bind(i64::try_from(next_version).map_err(|_| quota())?) + .bind(i64::try_from(record_count).map_err(|_| quota())?) + .bind(i64::try_from(total_bytes).map_err(|_| quota())?) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + transaction.commit().await.map_err(|_| unavailable())?; + Ok(PluginStateWrite { version }) + } + + async fn delete( + &self, + owner: &PluginStateOwner, + command: DeletePluginState, + ) -> PluginStateStoreResult { + if !valid_key(&command.key) || command.expected_version == 0 { + return Err(invalid()); + } + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let generation = writable_generation(&mut transaction, owner, &command.namespace).await?; + let row = sqlx::query( + "select record_version, value_bytes from plugin_state_records \ + where generation_id=$1 and state_key=$2", + ) + .bind(generation.id) + .bind(&command.key) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| unavailable())?; + let Some(row) = row else { + transaction.commit().await.map_err(|_| unavailable())?; + return Ok(false); + }; + if u64_from_i64(row.try_get("record_version").map_err(|_| unavailable())?)? + != command.expected_version + { + return Err(conflict()); + } + let value_bytes = u64_from_i64(row.try_get("value_bytes").map_err(|_| unavailable())?)?; + sqlx::query("delete from plugin_state_records where generation_id=$1 and state_key=$2") + .bind(generation.id) + .bind(&command.key) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + sqlx::query( + "update plugin_state_generations set record_count=$2,total_bytes=$3 where id=$1", + ) + .bind(generation.id) + .bind(i64::try_from(generation.record_count.saturating_sub(1)).map_err(|_| unavailable())?) + .bind( + i64::try_from(generation.total_bytes.saturating_sub(value_bytes)) + .map_err(|_| unavailable())?, + ) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + transaction.commit().await.map_err(|_| unavailable())?; + Ok(true) + } + + async fn transition_required( + &self, + instance_id: &str, + target: &PluginStateConfiguration, + ) -> PluginStateStoreResult { + validate_configuration(target)?; + let instance_id = uuid::Uuid::parse_str(instance_id).map_err(|_| invalid())?; + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let active = active_generations(&mut transaction, instance_id, false).await?; + transaction.commit().await.map_err(|_| unavailable())?; + let target = target_map(target); + let mut required = false; + for generation in active { + let Some(schema) = target.get(generation.namespace.as_str()) else { + if generation.record_count != 0 { + return Err(conflict()); + } + continue; + }; + if schema.schema_version == generation.schema_version + && schema.schema_sha256 == generation.schema_sha256 + { + if generation.record_count > u64::from(schema.maximum_records) + || generation.total_bytes > schema.maximum_bytes + || generation.maximum_record_bytes > u64::from(schema.maximum_value_bytes) + { + return Err(conflict()); + } + continue; + } + if !schema.migrates_from.contains(&generation.schema_version) { + return Err(conflict()); + } + required = true; + } + Ok(required) + } + + async fn begin_transition( + &self, + instance_id: &str, + expected_instance_revision: Revision, + artifact_sha256: &str, + target: PluginStateConfiguration, + ) -> PluginStateStoreResult { + validate_configuration(&target)?; + let instance_uuid = uuid::Uuid::parse_str(instance_id).map_err(|_| invalid())?; + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let current = + sqlx::query("select enabled, revision from plugin_instances where id=$1 for update") + .bind(instance_uuid) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + if current + .try_get::("enabled") + .map_err(|_| unavailable())? + || current + .try_get::("revision") + .map_err(|_| unavailable())? + != revision_i64(expected_instance_revision)? + { + return Err(conflict()); + } + let staging_exists: bool = sqlx::query_scalar( + "select exists(select 1 from plugin_state_generations \ + where instance_id=$1 and status='staging')", + ) + .bind(instance_uuid) + .fetch_one(&mut *transaction) + .await + .map_err(|_| unavailable())?; + if staging_exists { + return Err(conflict()); + } + let active = active_generations(&mut transaction, instance_uuid, true).await?; + let targets = target_map(&target); + let transition_id = uuid::Uuid::new_v4(); + let mut namespaces = Vec::new(); + for generation in &active { + let Some(schema) = targets.get(generation.namespace.as_str()) else { + if generation.record_count != 0 { + return Err(conflict()); + } + continue; + }; + if schema.schema_version == generation.schema_version + && schema.schema_sha256 == generation.schema_sha256 + { + if generation.record_count > u64::from(schema.maximum_records) + || generation.total_bytes > schema.maximum_bytes + || generation.maximum_record_bytes > u64::from(schema.maximum_value_bytes) + { + return Err(conflict()); + } + continue; + } + if !schema.migrates_from.contains(&generation.schema_version) { + return Err(conflict()); + } + let generation_id = uuid::Uuid::new_v4(); + sqlx::query( + "insert into plugin_state_generations( \ + id,transition_id,instance_id,namespace,artifact_sha256,schema_version, \ + schema_sha256,maximum_records,maximum_bytes,maximum_value_bytes, \ + instance_revision,fence,status,source_generation_id \ + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'staging',$13)", + ) + .bind(generation_id) + .bind(transition_id) + .bind(instance_uuid) + .bind(&schema.namespace) + .bind(artifact_sha256) + .bind(i64::from(schema.schema_version)) + .bind(&schema.schema_sha256) + .bind(i64::from(schema.maximum_records)) + .bind(i64::try_from(schema.maximum_bytes).map_err(|_| invalid())?) + .bind(i64::from(schema.maximum_value_bytes)) + .bind(revision_i64(expected_instance_revision)?) + .bind(uuid::Uuid::new_v4()) + .bind(generation.id) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + namespaces.push(PluginStateMigrationNamespace { + namespace: schema.namespace.clone(), + from_schema_version: generation.schema_version, + to_schema_version: schema.schema_version, + }); + } + if namespaces.is_empty() { + return Err(conflict()); + } + transaction.commit().await.map_err(|_| unavailable())?; + Ok(PluginStateTransition { + id: transition_id.to_string(), + instance_id: instance_id.to_owned(), + artifact_sha256: artifact_sha256.to_owned(), + namespaces, + }) + } + + async fn migration_batch( + &self, + transition_id: &str, + namespace: &str, + maximum_records: u32, + ) -> PluginStateStoreResult { + if maximum_records == 0 || maximum_records > MIGRATION_BATCH_MAXIMUM { + return Err(invalid()); + } + let transition_id = uuid::Uuid::parse_str(transition_id).map_err(|_| invalid())?; + let staging = sqlx::query( + "select source_generation_id,migration_cursor,migration_complete \ + from plugin_state_generations \ + where transition_id=$1 and namespace=$2 and status='staging'", + ) + .bind(transition_id) + .bind(namespace) + .fetch_optional(&self.pool) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + if staging + .try_get::("migration_complete") + .map_err(|_| unavailable())? + { + return Err(conflict()); + } + let source: uuid::Uuid = staging + .try_get("source_generation_id") + .map_err(|_| unavailable())?; + let cursor: Option = staging + .try_get("migration_cursor") + .map_err(|_| unavailable())?; + let rows = sqlx::query( + "select r.state_key,r.value_json,r.record_version,g.schema_version \ + from plugin_state_records r \ + join plugin_state_generations g on g.id=r.generation_id \ + where r.generation_id=$1 and ($2::text is null or r.state_key>$2) \ + order by r.state_key limit $3", + ) + .bind(source) + .bind(cursor.as_deref()) + .bind(i64::from(maximum_records)) + .fetch_all(&self.pool) + .await + .map_err(|_| unavailable())?; + let records = rows + .into_iter() + .map(|row| { + Ok(PluginStateRecord { + key: row.try_get("state_key").map_err(|_| unavailable())?, + value: row.try_get("value_json").map_err(|_| unavailable())?, + version: u64_from_i64( + row.try_get("record_version").map_err(|_| unavailable())?, + )?, + schema_version: u32_from_i64( + row.try_get("schema_version").map_err(|_| unavailable())?, + )?, + }) + }) + .collect::>()?; + Ok(PluginStateMigrationBatch { cursor, records }) + } + + async fn apply_migration_batch( + &self, + command: ApplyPluginStateMigration, + ) -> PluginStateStoreResult<()> { + if command.expected_keys.len() > usize::try_from(MIGRATION_BATCH_MAXIMUM).unwrap() + || command.changes.len() != command.expected_keys.len() + || command.expected_keys.iter().any(|key| !valid_key(key)) + || command + .expected_keys + .windows(2) + .any(|pair| pair[0] >= pair[1]) + || command + .changes + .iter() + .zip(&command.expected_keys) + .any(|(change, expected)| change.key != *expected) + { + return Err(invalid()); + } + let transition_id = uuid::Uuid::parse_str(&command.transition_id).map_err(|_| invalid())?; + let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; + let row = sqlx::query( + "select id,source_generation_id,migration_cursor,migration_complete, \ + maximum_records,maximum_bytes,maximum_value_bytes,next_record_version, \ + record_count,total_bytes \ + from plugin_state_generations \ + where transition_id=$1 and namespace=$2 and status='staging' for update", + ) + .bind(transition_id) + .bind(&command.namespace) + .fetch_optional(&mut *transaction) + .await + .map_err(|_| unavailable())? + .ok_or_else(not_found)?; + let cursor: Option = row.try_get("migration_cursor").map_err(|_| unavailable())?; + if cursor != command.cursor + || row + .try_get::("migration_complete") + .map_err(|_| unavailable())? + { + return Err(conflict()); + } + let generation = WritableGeneration { + id: row.try_get("id").map_err(|_| unavailable())?, + maximum_records: u64_from_i64( + row.try_get("maximum_records").map_err(|_| unavailable())?, + )?, + maximum_bytes: u64_from_i64(row.try_get("maximum_bytes").map_err(|_| unavailable())?)?, + maximum_value_bytes: u64_from_i64( + row.try_get("maximum_value_bytes") + .map_err(|_| unavailable())?, + )?, + next_record_version: u64_from_i64( + row.try_get("next_record_version") + .map_err(|_| unavailable())?, + )?, + record_count: u64_from_i64(row.try_get("record_count").map_err(|_| unavailable())?)?, + total_bytes: u64_from_i64(row.try_get("total_bytes").map_err(|_| unavailable())?)?, + }; + let source: uuid::Uuid = row + .try_get("source_generation_id") + .map_err(|_| unavailable())?; + let limit = + i64::try_from(command.expected_keys.len().saturating_add(1)).map_err(|_| invalid())?; + let source_rows = sqlx::query( + "select state_key,value_json from plugin_state_records \ + where generation_id=$1 and ($2::text is null or state_key>$2) \ + order by state_key limit $3", + ) + .bind(source) + .bind(command.cursor.as_deref()) + .bind(limit) + .fetch_all(&mut *transaction) + .await + .map_err(|_| unavailable())?; + if source_rows.len() < command.expected_keys.len() + || source_rows + .iter() + .take(command.expected_keys.len()) + .zip(&command.expected_keys) + .any(|(row, expected)| { + row.try_get::("state_key").ok().as_ref() != Some(expected) + }) + || (command.expected_keys.is_empty() && !source_rows.is_empty()) + { + return Err(conflict()); + } + let mut next_version = generation.next_record_version; + let mut record_count = generation.record_count; + let mut total_bytes = generation.total_bytes; + for ((change, source), expected_key) in command + .changes + .iter() + .zip(source_rows.iter()) + .zip(&command.expected_keys) + { + let value = match &change.action { + PluginStateMigrationAction::Keep => { + Some(source.try_get("value_json").map_err(|_| unavailable())?) + } + PluginStateMigrationAction::Replace(value) => Some(value.clone()), + PluginStateMigrationAction::Delete => None, + }; + let Some(value) = value else { + continue; + }; + let bytes = u64::try_from(serde_json::to_vec(&value).map_err(|_| invalid())?.len()) + .map_err(|_| quota())?; + if bytes == 0 || bytes > generation.maximum_value_bytes { + return Err(quota()); + } + record_count = record_count.checked_add(1).ok_or_else(quota)?; + total_bytes = total_bytes.checked_add(bytes).ok_or_else(quota)?; + if record_count > generation.maximum_records || total_bytes > generation.maximum_bytes { + return Err(quota()); + } + let version = next_version; + next_version = next_version.checked_add(1).ok_or_else(quota)?; + sqlx::query( + "insert into plugin_state_records( \ + generation_id,state_key,value_json,value_bytes,record_version \ + ) values ($1,$2,$3,$4,$5)", + ) + .bind(generation.id) + .bind(expected_key) + .bind(value) + .bind(i64::try_from(bytes).map_err(|_| quota())?) + .bind(i64::try_from(version).map_err(|_| quota())?) + .execute(&mut *transaction) + .await + .map_err(|error| { + if error + .as_database_error() + .is_some_and(|database| database.is_unique_violation()) + { + conflict() + } else { + unavailable() + } + })?; + } + let complete = command.expected_keys.is_empty(); + let next_cursor = command.expected_keys.last().or(command.cursor.as_ref()); + sqlx::query( + "update plugin_state_generations set migration_cursor=$2,migration_complete=$3, \ + next_record_version=$4,record_count=$5,total_bytes=$6 where id=$1", + ) + .bind(generation.id) + .bind(next_cursor) + .bind(complete) + .bind(i64::try_from(next_version).map_err(|_| quota())?) + .bind(i64::try_from(record_count).map_err(|_| quota())?) + .bind(i64::try_from(total_bytes).map_err(|_| quota())?) + .execute(&mut *transaction) + .await + .map_err(|_| unavailable())?; + transaction.commit().await.map_err(|_| unavailable())?; + Ok(()) + } + + async fn abort_transition(&self, transition_id: &str) -> PluginStateStoreResult<()> { + let transition_id = uuid::Uuid::parse_str(transition_id).map_err(|_| invalid())?; + sqlx::query( + "delete from plugin_state_generations where transition_id=$1 and status='staging'", + ) + .bind(transition_id) + .execute(&self.pool) + .await + .map_err(|_| unavailable())?; + Ok(()) + } +} + +async fn writable_generation( + transaction: &mut Transaction<'_, Postgres>, + owner: &PluginStateOwner, + namespace: &str, +) -> PluginStateStoreResult { + let namespace_owner = owner.namespace(namespace).ok_or_else(denied)?; + let generation = + uuid::Uuid::parse_str(namespace_owner.generation_id()).map_err(|_| denied())?; + let fence = uuid::Uuid::parse_str(namespace_owner.fence()).map_err(|_| denied())?; + let instance = uuid::Uuid::parse_str(owner.instance_id()).map_err(|_| denied())?; + let row = sqlx::query( + "select g.id,g.maximum_records,g.maximum_bytes,g.maximum_value_bytes, \ + g.next_record_version,g.record_count,g.total_bytes \ + from plugin_state_generations g join plugin_instances i on i.id=g.instance_id \ + where g.id=$1 and g.fence=$2 and g.instance_id=$3 and g.namespace=$4 \ + and g.artifact_sha256=$5 and g.instance_revision=$6 and g.status='active' \ + and i.artifact_sha256=g.artifact_sha256 and i.revision=g.instance_revision \ + for update of g", + ) + .bind(generation) + .bind(fence) + .bind(instance) + .bind(namespace) + .bind(owner.artifact_sha256()) + .bind(revision_i64(owner.instance_revision())?) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| unavailable())? + .ok_or_else(denied)?; + Ok(WritableGeneration { + id: row.try_get("id").map_err(|_| unavailable())?, + maximum_records: u64_from_i64(row.try_get("maximum_records").map_err(|_| unavailable())?)?, + maximum_bytes: u64_from_i64(row.try_get("maximum_bytes").map_err(|_| unavailable())?)?, + maximum_value_bytes: u64_from_i64( + row.try_get("maximum_value_bytes") + .map_err(|_| unavailable())?, + )?, + next_record_version: u64_from_i64( + row.try_get("next_record_version") + .map_err(|_| unavailable())?, + )?, + record_count: u64_from_i64(row.try_get("record_count").map_err(|_| unavailable())?)?, + total_bytes: u64_from_i64(row.try_get("total_bytes").map_err(|_| unavailable())?)?, + }) +} + +/// 实例与状态代次在同一事务绑定;只有这里会晋升 staging generation。 +pub(super) async fn commit_configuration( + transaction: &mut Transaction<'_, Postgres>, + instance: &PluginInstance, + revision: Revision, + commit: &PluginStateCommit, +) -> AdminStoreResult<()> { + validate_configuration(&commit.configuration).map_err(|_| admin_invalid())?; + let instance_id = uuid::Uuid::parse_str(&instance.id).map_err(|_| admin_invalid())?; + let active = active_generations(transaction, instance_id, true) + .await + .map_err(|_| admin_unavailable())?; + let targets = target_map(&commit.configuration); + let transition = commit + .transition_id + .as_deref() + .map(uuid::Uuid::parse_str) + .transpose() + .map_err(|_| admin_invalid())?; + let revision = i64::try_from(revision.get()).map_err(|_| admin_invalid())?; + let mut handled = BTreeSet::new(); + for generation in active { + let Some(schema) = targets.get(generation.namespace.as_str()) else { + if generation.record_count != 0 { + return Err(admin_conflict()); + } + sqlx::query("delete from plugin_state_generations where id=$1") + .bind(generation.id) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + continue; + }; + handled.insert(generation.namespace.clone()); + if schema.schema_version == generation.schema_version + && schema.schema_sha256 == generation.schema_sha256 + { + if generation.record_count > u64::from(schema.maximum_records) + || generation.total_bytes > schema.maximum_bytes + || generation.maximum_record_bytes > u64::from(schema.maximum_value_bytes) + { + return Err(admin_conflict()); + } + sqlx::query( + "update plugin_state_generations set artifact_sha256=$2, \ + maximum_records=$3,maximum_bytes=$4,maximum_value_bytes=$5, \ + instance_revision=$6,fence=$7 where id=$1", + ) + .bind(generation.id) + .bind(&instance.artifact_sha256) + .bind(i64::from(schema.maximum_records)) + .bind(i64::try_from(schema.maximum_bytes).map_err(|_| admin_invalid())?) + .bind(i64::from(schema.maximum_value_bytes)) + .bind(revision) + .bind(uuid::Uuid::new_v4()) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + continue; + } + let transition = transition.ok_or_else(admin_conflict)?; + let staging = sqlx::query( + "select id,record_count,total_bytes,migration_complete,source_generation_id, \ + schema_version,schema_sha256,artifact_sha256 \ + from plugin_state_generations \ + where transition_id=$1 and instance_id=$2 and namespace=$3 and status='staging' \ + for update", + ) + .bind(transition) + .bind(instance_id) + .bind(&generation.namespace) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| admin_unavailable())? + .ok_or_else(admin_conflict)?; + if !staging + .try_get::("migration_complete") + .map_err(|_| admin_unavailable())? + || staging + .try_get::("source_generation_id") + .map_err(|_| admin_unavailable())? + != generation.id + || u32::try_from( + staging + .try_get::("schema_version") + .map_err(|_| admin_unavailable())?, + ) + .map_err(|_| admin_unavailable())? + != schema.schema_version + || staging + .try_get::("schema_sha256") + .map_err(|_| admin_unavailable())? + != schema.schema_sha256 + || staging + .try_get::("artifact_sha256") + .map_err(|_| admin_unavailable())? + != instance.artifact_sha256 + || u64::try_from( + staging + .try_get::("record_count") + .map_err(|_| admin_unavailable())?, + ) + .map_err(|_| admin_unavailable())? + > u64::from(schema.maximum_records) + || u64::try_from( + staging + .try_get::("total_bytes") + .map_err(|_| admin_unavailable())?, + ) + .map_err(|_| admin_unavailable())? + > schema.maximum_bytes + { + return Err(admin_conflict()); + } + let staging_id: uuid::Uuid = staging.try_get("id").map_err(|_| admin_unavailable())?; + // 先解除迁移来源,再替换活动代次;任一步失败都会连同配置和旧记录一起回滚。 + sqlx::query("update plugin_state_generations set source_generation_id=null where id=$1") + .bind(staging_id) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + sqlx::query("delete from plugin_state_generations where id=$1") + .bind(generation.id) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + sqlx::query( + "update plugin_state_generations set status='active',instance_revision=$2, \ + fence=$3,promoted_at=now(),transition_id=null,migration_cursor=null where id=$1", + ) + .bind(staging_id) + .bind(revision) + .bind(uuid::Uuid::new_v4()) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + } + for schema in &commit.configuration.namespaces { + if handled.contains(&schema.namespace) { + continue; + } + let id = uuid::Uuid::new_v4(); + sqlx::query( + "insert into plugin_state_generations( \ + id,instance_id,namespace,artifact_sha256,schema_version, \ + schema_sha256,maximum_records,maximum_bytes,maximum_value_bytes, \ + instance_revision,fence,status,migration_complete,promoted_at \ + ) values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'active',true,now())", + ) + .bind(id) + .bind(instance_id) + .bind(&schema.namespace) + .bind(&instance.artifact_sha256) + .bind(i64::from(schema.schema_version)) + .bind(&schema.schema_sha256) + .bind(i64::from(schema.maximum_records)) + .bind(i64::try_from(schema.maximum_bytes).map_err(|_| admin_invalid())?) + .bind(i64::from(schema.maximum_value_bytes)) + .bind(revision) + .bind(uuid::Uuid::new_v4()) + .execute(&mut **transaction) + .await + .map_err(|error| { + if error + .as_database_error() + .is_some_and(|database| database.is_unique_violation()) + { + admin_conflict() + } else { + admin_unavailable() + } + })?; + } + if let Some(transition) = transition { + let incomplete: bool = sqlx::query_scalar( + "select exists(select 1 from plugin_state_generations \ + where transition_id=$1 and status='staging')", + ) + .bind(transition) + .fetch_one(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + if incomplete { + return Err(admin_conflict()); + } + } + Ok(()) +} + +/// 紧急停用不依赖损坏包的 schema 解析;只允许原制品续绑并立即轮换 fence。 +pub(super) async fn rebind_existing_configuration( + transaction: &mut Transaction<'_, Postgres>, + instance_id: &str, + artifact_sha256: &str, + revision: Revision, +) -> AdminStoreResult<()> { + let instance_id = uuid::Uuid::parse_str(instance_id).map_err(|_| admin_invalid())?; + let mismatched: bool = sqlx::query_scalar( + "select exists(select 1 from plugin_state_generations \ + where instance_id=$1 and status='active' and artifact_sha256<>$2)", + ) + .bind(instance_id) + .bind(artifact_sha256) + .fetch_one(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + if mismatched { + return Err(admin_conflict()); + } + sqlx::query( + "update plugin_state_generations set instance_revision=$2,fence=$3 \ + where instance_id=$1 and status='active'", + ) + .bind(instance_id) + .bind(i64::try_from(revision.get()).map_err(|_| admin_invalid())?) + .bind(uuid::Uuid::new_v4()) + .execute(&mut **transaction) + .await + .map_err(|_| admin_unavailable())?; + Ok(()) +} diff --git a/backend/crates/gateway-store/src/postgres/pricing.rs b/backend/crates/gateway-store/src/postgres/pricing.rs index 4e4635693..3432e2a86 100644 --- a/backend/crates/gateway-store/src/postgres/pricing.rs +++ b/backend/crates/gateway-store/src/postgres/pricing.rs @@ -1,7 +1,10 @@ //! 价格覆盖与请求费用明细的持久化。 use gateway_admin::model::pricing::{PricingChange, PricingSyncChanges, UpdatePricing}; -use gateway_core::metering::{ModelPriceOverride, PricingOverrides}; +use gateway_core::{ + metering::{ModelPriceOverride, PricingOverrides}, + routing::ProviderKind, +}; use sqlx::types::Json; use super::{ @@ -110,7 +113,7 @@ pub(crate) fn validate_pricing(pricing: &PricingOverrides) -> StoreResult<()> { return Err(invalid_pricing()); } for (provider, models) in pricing { - if !matches!(provider.as_str(), "openai" | "xai") { + if ProviderKind::new(provider.clone()).is_err() { return Err(invalid_pricing()); } for (model, pricing) in models { diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs index 461d7bc24..327bf7ece 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_adapter.rs @@ -3,6 +3,9 @@ use std::collections::BTreeMap; use std::sync::Arc; +use gateway_admin::model::provider_credentials::{ + AuthorizedAccount, CredentialBatchMutationResult, +}; use gateway_core::provider_ports::ProviderCooldownPort; use super::*; @@ -13,7 +16,7 @@ use crate::postgres::ObservabilityQueryBudget; /// 三个 PostgreSQL adapter 都保持私有,调用方只能取得 [`AccountStore`] 暴露的领域能力。 #[derive(Clone)] pub struct PgAdminAccountStore { - pool: PgPool, + pub(super) pool: PgPool, accounts: PgProviderAccountRepository, observability: PgObservabilityRepository, control_plane: PgControlPlaneRepository, @@ -217,65 +220,14 @@ impl PgAdminAccountStore { context: &MutationContext, action: &str, outbound_proxy: Option, - ) -> AdminStoreResult { - let provider_kind = prepared.provider_kind.as_str().to_owned(); - let accounts = prepared - .credentials - .into_iter() - .map(prepared_account) - .collect::>>() - .map_err(|error| admin_store_error(ENTITY, error))?; - let mut changed_fields = vec!["credentials".to_owned()]; - if settings - .as_ref() - .is_some_and(|settings| settings.model_access.is_some()) - || accounts - .iter() - .any(|account| account.model_access.is_some()) - { - changed_fields.push("model_access".to_owned()); - } - if let Some(settings) = &settings { - changed_fields - .extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned)); - if settings.notes.is_some() { - changed_fields.push("notes".to_owned()); - } - } + ) -> AdminStoreResult { + let command = prepare_import(prepared, settings, context, action, outbound_proxy)?; let imported = self .accounts - .import_provider_accounts(ImportProviderAccounts { - settings, - outbound_proxy, - scope: ProviderAccountAdminScope { - provider_kind: provider_kind.clone(), - }, - accounts, - audit: mutation_audit( - context, - action, - "provider_account", - &provider_kind, - changed_fields, - ), - }) + .import_provider_accounts(command) .await .map_err(|error| admin_store_error(ENTITY, error))?; - Ok(CredentialImportResult { - config_revision: admin_revision(imported.config_revision)?, - credential_ids: imported - .account_ids - .into_iter() - .map(CoreProviderAccountId::new) - .collect::, _>>() - .map_err(|_| { - AdminStoreError::new( - AdminStoreErrorKind::Unavailable, - ENTITY, - "provider account import returned an invalid account ID", - ) - })?, - }) + import_result(imported) } async fn commit_prepared_rotation( @@ -286,60 +238,13 @@ impl PgAdminAccountStore { action: &str, ) -> AdminStoreResult { let account_id = prepared.account_id.clone(); - let scope = ProviderAccountAdminScope { - provider_kind: prepared.provider_kind.as_str().to_owned(), - }; - let mut changed_fields = vec!["credentials".to_owned()]; - if let Some(settings) = &settings { - changed_fields - .extend(["enabled", "concurrency_limit", "weight", "groups"].map(str::to_owned)); - if settings.model_access.is_some() { - changed_fields.push("model_access".to_owned()); - } - if settings.outbound_proxy.is_some() { - changed_fields.push("outbound_proxy".to_owned()); - } - if settings.notes.is_some() { - changed_fields.push("notes".to_owned()); - } - } + let command = prepare_rotation(prepared, settings, context, action)?; let rotation = self .accounts - .rotate_provider_account(RotateProviderAccount { - settings, - scope, - profile: UpdateProviderAccount { - id: account_id.as_str().to_owned(), - name: prepared.name, - email: prepared.email, - plan_type: prepared.plan_type, - }, - replacement_identity: prepared.replacement_identity, - credential: ProviderCredentialUpdate { - account_id: account_id.as_str().to_owned(), - expected_revision: store_revision(prepared.expected_credential_revision)?, - provider_credentials_json: provider_document_json(prepared.provider_material) - .map_err(|error| admin_store_error(ENTITY, error))?, - has_refresh_token: prepared.has_refresh_token, - access_token_expires_at: prepared.access_token_expires_at, - next_refresh_at: prepared.next_refresh_at, - preserve_profile: prepared.preserve_profile, - }, - audit: mutation_audit( - context, - action, - "provider_account", - account_id.as_str(), - changed_fields, - ), - }) + .rotate_provider_account(command) .await .map_err(|error| admin_store_error(ENTITY, error))?; - Ok(CredentialMutationResult { - config_revision: admin_revision(rotation.config_revision)?, - account_id, - credential_revision: Some(admin_revision(rotation.credential_revision)?), - }) + rotation_result(rotation, account_id) } async fn account_groups_by_account( @@ -394,6 +299,45 @@ impl PgAdminAccountStore { #[async_trait] impl AccountStore for PgAdminAccountStore { + async fn list_plugin_accounts( + &self, + query: PluginAccountListQuery, + ) -> AdminStoreResult { + let limit = i64::from(query.limit.get()); + let mut accounts = self + .accounts + .list_plugin_accounts( + query.provider_kind.as_ref().map(ProviderKind::as_str), + query.cursor.as_ref().map(CoreProviderAccountId::as_str), + limit + 1, + ) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + let has_more = accounts.len() > usize::from(query.limit.get()); + if has_more { + accounts.pop(); + } + let next_cursor = has_more + .then(|| accounts.last().map(|account| account.id.clone())) + .flatten() + .map(CoreProviderAccountId::new) + .transpose() + .map_err(|_| { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "plugin account cursor is invalid", + ) + })?; + Ok(PluginAccountPage { + accounts: accounts + .into_iter() + .map(admin_account_record) + .collect::>()?, + next_cursor, + }) + } + async fn list_accounts( &self, query: AdminAccountListQuery, @@ -445,6 +389,7 @@ impl AccountStore for PgAdminAccountStore { items, total: page.total, summary: page.summary, + providers: page.providers, }) } @@ -525,6 +470,25 @@ impl AccountStore for PgAdminAccountStore { .transpose() } + async fn credential_details_by_id( + &self, + account_id: &CoreProviderAccountId, + ) -> AdminStoreResult> { + let (control_plane, account) = futures::try_join!( + self.control_plane.load_control_plane(), + self.accounts.load_provider_account(account_id.as_str()), + ) + .map_err(|error| admin_store_error(ENTITY, error))?; + account + .map(|record| { + Ok(CredentialDetails { + config_revision: admin_revision(control_plane.settings.config_revision)?, + credential: admin_account_record(record.summary)?, + }) + }) + .transpose() + } + async fn load_credentials_for_export( &self, provider_kind: &ProviderKind, @@ -566,90 +530,63 @@ impl AccountStore for PgAdminAccountStore { Ok(credentials) } + async fn load_credential_for_plugin( + &self, + account_id: &CoreProviderAccountId, + ) -> AdminStoreResult> { + self.accounts + .load_provider_account(account_id.as_str()) + .await + .map_err(|error| admin_store_error(ENTITY, error))? + .map(|record| { + Ok(ProviderExportCredentialInput { + account: admin_account_record(record.summary)?, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + record.provider_credentials_json.fields().clone(), + )), + }) + }) + .transpose() + } + async fn commit_credential_import( &self, command: CredentialImportCommit, context: &MutationContext, ) -> AdminStoreResult { - self.commit_prepared_import( - command.prepared, - command.settings, - context, - "import_document", - command.outbound_proxy, - ) - .await + let result = self + .commit_prepared_import( + command.prepared, + command.settings, + context, + "import_document", + command.outbound_proxy, + ) + .await?; + Ok(CredentialImportResult { + config_revision: result.config_revision, + credential_ids: result + .accounts + .into_iter() + .map(|account| account.account_id) + .collect(), + }) } async fn commit_authorization( &self, command: AuthorizationCommit, context: &MutationContext, - ) -> AdminStoreResult { - match command.credential { - AuthorizationCredentialCommit::Create(credential) => { - let CredentialImportResult { - config_revision, - credential_ids, - } = self - .commit_prepared_import( - PreparedCredentialImport { - provider_kind: credential.provider_kind.clone(), - credentials: vec![credential], - }, - command.settings, - context, - "authorize", - command - .pending - .outbound_proxy_id() - .zip(command.pending.outbound_proxy()) - .map( - |(id, proxy)| gateway_admin::model::proxies::ImportProxyBinding { - id: id.to_owned(), - proxy: proxy.clone(), - }, - ), - ) - .await?; - let [account_id]: [CoreProviderAccountId; 1] = - credential_ids.try_into().map_err(|_| { - AdminStoreError::new( - AdminStoreErrorKind::Unavailable, - ENTITY, - "authorization import returned an unexpected account count", - ) - })?; - let details = self - .accounts - .load_provider_account(account_id.as_str()) - .await - .map_err(|error| admin_store_error(ENTITY, error))? - .ok_or_else(|| { - AdminStoreError::new( - AdminStoreErrorKind::Unavailable, - ENTITY, - "authorized credential was not visible after commit", - ) - })?; - Ok(CredentialMutationResult { - config_revision, - account_id, - credential_revision: Some(admin_revision(details.summary.credential_revision)?), - }) - } - AuthorizationCredentialCommit::Reauthorize(prepared) => { - if command.settings.is_some() { - return Err(AdminStoreError::new( - AdminStoreErrorKind::Invalid, - ENTITY, - "reauthorization cannot change account settings", - )); - } - self.commit_prepared_rotation(prepared, None, context, "reauthorize") - .await - } - } + ) -> AdminStoreResult + { + self.commit_authorization_once(command, context).await + } + + async fn authorization_receipt( + &self, + key: &gateway_admin::model::provider_credentials::AuthorizationReceiptKey, + ) -> AdminStoreResult> { + self.load_authorization_receipt(key).await } async fn commit_credential_rotation( @@ -999,3 +936,148 @@ impl AccountStore for PgAdminAccountStore { .map_err(|error| admin_store_error(ENTITY, error)) } } + +pub(super) fn prepare_import( + prepared: PreparedCredentialImport, + settings: Option, + context: &MutationContext, + action: &str, + outbound_proxy: Option, +) -> AdminStoreResult { + let provider_kind = prepared.provider_kind.as_str().to_owned(); + let accounts = prepared + .credentials + .into_iter() + .map(prepared_account) + .collect::>>() + .map_err(|error| admin_store_error(ENTITY, error))?; + let mut changed_fields = vec!["credentials".to_owned()]; + if settings + .as_ref() + .is_some_and(|settings| settings.model_access.is_some()) + || accounts + .iter() + .any(|account| account.model_access.is_some()) + { + changed_fields.push("model_access".to_owned()); + } + if let Some(settings) = &settings { + changed_fields + .extend(["enabled", "concurrency_limit", "weight", "group_ids"].map(str::to_owned)); + if settings.notes.is_some() { + changed_fields.push("notes".to_owned()); + } + } + Ok(ImportProviderAccounts { + settings, + outbound_proxy, + scope: ProviderAccountAdminScope { + provider_kind: provider_kind.clone(), + }, + accounts, + audit: mutation_audit( + context, + action, + "provider_account", + &provider_kind, + changed_fields, + ), + }) +} + +pub(super) fn import_result( + imported: ProviderAccountAdminImport, +) -> AdminStoreResult { + Ok(CredentialBatchMutationResult { + config_revision: admin_revision(imported.config_revision)?, + accounts: imported + .account_ids + .into_iter() + .map(|id| { + let revision = imported.credential_revisions.get(&id).ok_or_else(|| { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + ENTITY, + "account import revision is missing", + ) + })?; + let account_id = CoreProviderAccountId::new(id).map_err(|_| { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + ENTITY, + "provider account import returned an invalid account ID", + ) + })?; + Ok(AuthorizedAccount { + account_id, + credential_revision: Some(admin_revision(*revision)?), + }) + }) + .collect::>()?, + }) +} + +pub(super) fn prepare_rotation( + prepared: PreparedCredentialRotationFacts, + settings: Option, + context: &MutationContext, + action: &str, +) -> AdminStoreResult { + let account_id = prepared.account_id.clone(); + let scope = ProviderAccountAdminScope { + provider_kind: prepared.provider_kind.as_str().to_owned(), + }; + let mut changed_fields = vec!["credentials".to_owned()]; + if let Some(settings) = &settings { + changed_fields + .extend(["enabled", "concurrency_limit", "weight", "groups"].map(str::to_owned)); + if settings.model_access.is_some() { + changed_fields.push("model_access".to_owned()); + } + if settings.outbound_proxy.is_some() { + changed_fields.push("outbound_proxy".to_owned()); + } + if settings.notes.is_some() { + changed_fields.push("notes".to_owned()); + } + } + Ok(RotateProviderAccount { + settings, + scope, + profile: UpdateProviderAccount { + id: account_id.as_str().to_owned(), + name: prepared.name, + email: prepared.email, + plan_type: prepared.plan_type, + }, + replacement_identity: prepared.replacement_identity, + credential: ProviderCredentialUpdate { + account_id: account_id.as_str().to_owned(), + expected_revision: store_revision(prepared.expected_credential_revision)?, + provider_credentials_json: provider_document_json(prepared.provider_material) + .map_err(|error| admin_store_error(ENTITY, error))?, + has_refresh_token: prepared.has_refresh_token, + access_token_expires_at: prepared.access_token_expires_at, + next_refresh_at: prepared.next_refresh_at, + preserve_profile: prepared.preserve_profile, + }, + audit: mutation_audit( + context, + action, + "provider_account", + account_id.as_str(), + changed_fields, + ), + }) +} + +pub(super) fn rotation_result( + rotation: ProviderAccountAdminRotation, + account_id: CoreProviderAccountId, +) -> AdminStoreResult { + Ok(CredentialMutationResult { + config_revision: admin_revision(rotation.config_revision)?, + account_id, + credential_revision: Some(admin_revision(rotation.credential_revision)?), + }) +} diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs index 97e9cf88c..7a39aa442 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/admin_queries.rs @@ -7,6 +7,7 @@ pub(crate) struct AdminAccountPageRows { pub(crate) accounts: Vec, pub(crate) total: u64, pub(crate) summary: AccountSummary, + pub(crate) providers: Vec, } pub(crate) async fn load_admin_account_page( @@ -60,7 +61,7 @@ pub(crate) async fn load_admin_account_page( let statement = format!( "with account_statuses as ( - select a.id, + select a.id, a.provider_kind, case when not a.enabled then 'disabled' when a.credential_state <> 'ready' @@ -80,7 +81,8 @@ pub(crate) async fn load_admin_account_page( as summary_rate_limited, count(*) filter (where admin_status = 'disabled')::bigint as summary_disabled, - count(*) filter (where admin_status = 'error')::bigint as summary_error + count(*) filter (where admin_status = 'error')::bigint as summary_error, + coalesce(array_agg(distinct provider_kind order by provider_kind), '{{}}'::text[]) as providers from account_statuses ), filtered as materialized ( @@ -127,6 +129,7 @@ pub(crate) async fn load_admin_account_page( global_summary.summary_total, global_summary.summary_normal, global_summary.summary_quota_exhausted, global_summary.summary_rate_limited, global_summary.summary_disabled, global_summary.summary_error, + global_summary.providers, settings.config_revision from filtered_total cross join global_summary @@ -169,6 +172,13 @@ pub(crate) async fn load_admin_account_page( })?; let config_revision = revision_from_row(metadata)?; let total = unsigned_metadata(metadata, "filtered_total")?; + let providers = metadata.try_get("providers").map_err(|_| { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "persisted account providers are invalid", + ) + })?; let summary = AccountSummary { total: unsigned_metadata(metadata, "summary_total")?, normal: unsigned_metadata(metadata, "summary_normal")?, @@ -193,6 +203,7 @@ pub(crate) async fn load_admin_account_page( accounts, total, summary, + providers, }) } diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs new file mode 100644 index 000000000..84ebfdc1d --- /dev/null +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/authorization.rs @@ -0,0 +1,212 @@ +//! 完成回执与账号、审计共享提交边界;Redis claim 只负责上游准备阶段的互斥。 + +use gateway_admin::model::provider_credentials::{ + AuthorizationCommitResult, AuthorizationMutationTarget, AuthorizationReceiptKey, + AuthorizedAccount, CredentialBatchMutationResult, +}; +use serde::{Deserialize, Serialize}; + +use super::{admin_adapter, repository, *}; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredAccount { + account_id: String, + credential_revision: Option, +} + +impl PgAdminAccountStore { + pub(super) async fn load_authorization_receipt( + &self, + key: &AuthorizationReceiptKey, + ) -> AdminStoreResult> { + load_receipt(&self.pool, key).await + } + + pub(super) async fn commit_authorization_once( + &self, + command: AuthorizationCommit, + context: &MutationContext, + ) -> AdminStoreResult { + if command.key.provider_kind() != command.pending.provider_kind() + || !command.key.matches_context(context) + || !command.pending.owner_binding().matches_context(context) + { + return Err(invalid_receipt()); + } + let mut transaction = self.pool.begin().await.map_err(|_| unavailable_receipt())?; + let outcome = commit_authorization_in_transaction(&mut transaction, command, context).await; + match outcome { + Ok(outcome) => { + transaction + .commit() + .await + .map_err(|_| unavailable_receipt())?; + Ok(outcome) + } + Err(error) => { + transaction + .rollback() + .await + .map_err(|_| unavailable_receipt())?; + Err(error) + } + } + } +} + +async fn commit_authorization_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + command: AuthorizationCommit, + context: &MutationContext, +) -> AdminStoreResult { + let key = &command.key; + // 同一 flow 的竞争者先等待事务结果;Redis 租约过期不能造成第二次账号提交。 + let lock = format!( + "authorization:{}:{}", + key.provider_kind(), + key.flow_digest() + ); + sqlx::query("select pg_advisory_xact_lock(hashtextextended($1, 814037))") + .bind(lock) + .execute(&mut **transaction) + .await + .map_err(|_| unavailable_receipt())?; + if let Some(result) = load_receipt(&mut **transaction, key).await? { + return Ok(AuthorizationCommitResult { + result, + newly_committed: false, + }); + } + let result = match (command.pending.target(), command.credential) { + ( + AuthorizationMutationTarget::Create { .. }, + AuthorizationCredentialCommit::Create(credentials), + ) => { + if credentials.is_empty() + || credentials.len() > 200 + || credentials + .iter() + .any(|credential| &credential.provider_kind != key.provider_kind()) + { + return Err(invalid_receipt()); + } + let prepared = admin_adapter::prepare_import( + PreparedCredentialImport { + provider_kind: key.provider_kind().clone(), + credentials, + }, + command.settings, + context, + "authorize", + command + .pending + .outbound_proxy_id() + .zip(command.pending.outbound_proxy()) + .map( + |(id, proxy)| gateway_admin::model::proxies::ImportProxyBinding { + id: id.into(), + proxy: proxy.clone(), + }, + ), + )?; + let result = repository::import_provider_accounts_in_transaction(transaction, prepared) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + admin_adapter::import_result(result)? + } + ( + AuthorizationMutationTarget::Reauthorize { account_id }, + AuthorizationCredentialCommit::Reauthorize(prepared), + ) => { + if command.settings.is_some() + || &prepared.account_id != account_id + || &prepared.provider_kind != key.provider_kind() + { + return Err(invalid_receipt()); + } + let prepared = + admin_adapter::prepare_rotation(*prepared, None, context, "reauthorize")?; + let result = + repository::rotate_provider_account_admin_in_transaction(transaction, prepared) + .await + .map_err(|error| admin_store_error(ENTITY, error))?; + admin_adapter::rotation_result(result, account_id.clone())?.into() + } + _ => return Err(invalid_receipt()), + }; + let accounts = result + .accounts + .iter() + .map(|account| StoredAccount { + account_id: account.account_id.to_string(), + credential_revision: account.credential_revision.map(|revision| revision.get()), + }) + .collect::>(); + sqlx::query("insert into authorization_receipts(provider_kind, flow_digest, owner_digest, config_revision, accounts_json) values ($1,$2,$3,$4,$5)") + .bind(key.provider_kind().as_str()).bind(key.flow_digest()).bind(key.owner_digest()) + .bind(i64::try_from(result.config_revision.get()).map_err(|_| invalid_receipt())?) + .bind(serde_json::to_value(accounts).map_err(|_| invalid_receipt())?) + .execute(&mut **transaction).await.map_err(|_| unavailable_receipt())?; + // 保留期长于 OAuth pending 上限;每次提交有界回收,避免引入第二套维护任务。 + sqlx::query("delete from authorization_receipts where ctid in (select ctid from authorization_receipts where expires_at <= now() order by expires_at limit 128 for update skip locked)") + .execute(&mut **transaction).await.map_err(|_| unavailable_receipt())?; + Ok(AuthorizationCommitResult { + result, + newly_committed: true, + }) +} + +async fn load_receipt<'a>( + executor: impl sqlx::PgExecutor<'a>, + key: &AuthorizationReceiptKey, +) -> AdminStoreResult> { + let row: Option<(String, i64, serde_json::Value)> = sqlx::query_as("select owner_digest, config_revision, accounts_json from authorization_receipts where provider_kind=$1 and flow_digest=$2 and expires_at>now()") + .bind(key.provider_kind().as_str()).bind(key.flow_digest()).fetch_optional(executor).await.map_err(|_| unavailable_receipt())?; + let Some((owner, revision, accounts)) = row else { + return Ok(None); + }; + if owner != key.owner_digest() { + return Err(invalid_receipt()); + } + let accounts: Vec = + serde_json::from_value(accounts).map_err(|_| unavailable_receipt())?; + let accounts = accounts + .into_iter() + .map(|account| { + Ok(AuthorizedAccount { + account_id: CoreProviderAccountId::new(account.account_id) + .map_err(|_| unavailable_receipt())?, + credential_revision: account + .credential_revision + .map(|revision| AdminRevision::new(revision).map_err(|_| unavailable_receipt())) + .transpose()?, + }) + }) + .collect::>>()?; + if accounts.is_empty() || accounts.len() > 200 { + return Err(unavailable_receipt()); + } + Ok(Some(CredentialBatchMutationResult { + config_revision: AdminRevision::new( + u64::try_from(revision).map_err(|_| unavailable_receipt())?, + ) + .map_err(|_| unavailable_receipt())?, + accounts, + })) +} + +fn invalid_receipt() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Invalid, + ENTITY, + "invalid authorization receipt binding", + ) +} +fn unavailable_receipt() -> AdminStoreError { + AdminStoreError::new( + AdminStoreErrorKind::Unavailable, + ENTITY, + "authorization receipt unavailable", + ) +} diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs index 9c8b40bd7..b29d2d077 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mod.rs @@ -23,8 +23,9 @@ use gateway_admin::{ provider_credentials::{ AuthorizationCommit, AuthorizationCredentialCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult, CredentialMutationResult, - CredentialRotationCommit, PreparedCredentialCreate, PreparedCredentialImport, - PreparedCredentialRotationFacts, ProviderDocument, ProviderExportCredentialInput, + CredentialRotationCommit, PluginAccountListQuery, PluginAccountPage, + PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotationFacts, + ProviderDocument, ProviderExportCredentialInput, }, }, ports::store::{AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, @@ -59,6 +60,7 @@ use super::{ mod admin_adapter; mod admin_queries; +mod authorization; mod core_adapter; mod mapping; mod quota_forecast; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index d264a9696..65355cb63 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -5,6 +5,12 @@ use super::*; #[async_trait] pub trait ProviderAccountRepository: Send + Sync { async fn load_provider_account(&self, id: &str) -> StoreResult>; + async fn list_plugin_accounts( + &self, + provider_kind: Option<&str>, + cursor: Option<&str>, + limit: i64, + ) -> StoreResult>; async fn list_provider_accounts( &self, provider_kind: Option<&str>, @@ -103,6 +109,42 @@ impl ProviderAccountRepository for PgProviderAccountRepository { row.map(account_record_from_row).transpose() } + async fn list_plugin_accounts( + &self, + provider_kind: Option<&str>, + cursor: Option<&str>, + limit: i64, + ) -> StoreResult> { + if provider_kind.is_some_and(str::is_empty) { + return Err(invalid("plugin account provider kind must not be empty")); + } + if limit <= 0 { + return Err(invalid("plugin account page limit must be positive")); + } + let rows = sqlx::query( + "select location_country, location_region, location_city, location_timezone, outbound_proxy_url, id, provider_kind, name, notes, email, upstream_user_id, + upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, + access_token_expires_at, next_refresh_at, enabled, concurrency_limit, weight, model_access_json, credential_state, + credential_observed_at, quota_access_state, quota_evidence, + quota_access_observed_at, quota_reset_at, + quota_observed_at, last_error_reason, last_error_message, created_at, updated_at + from provider_accounts + left join (select id as location_proxy_id, location_country, location_region, location_city, location_timezone from outbound_proxies) proxy_location + on outbound_proxy_id = location_proxy_id + where ($1::text is null or provider_kind = $1) + and ($2::text is null or id > $2) + order by id + limit $3", + ) + .bind(provider_kind) + .bind(cursor) + .bind(limit) + .fetch_all(&self.pool) + .await + .map_err(|_| postgres_unavailable("list plugin accounts"))?; + rows.into_iter().map(account_summary_from_row).collect() + } + async fn list_provider_accounts( &self, provider_kind: Option<&str>, @@ -456,75 +498,12 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { &self, command: ImportProviderAccounts, ) -> StoreResult { - command.validate()?; let mut transaction = self .pool .begin() .await .map_err(|_| postgres_unavailable("begin provider account admin import"))?; - let result = async { - let revision = bump_config_revision_in_transaction(&mut transaction).await?; - if let Some(binding) = &command.outbound_proxy { - let (_, current) = super::super::proxies::resolve_proxy_selection( - &mut transaction, - &gateway_admin::model::proxies::AccountProxySelection::Saved( - binding.id.clone(), - ), - ) - .await?; - if current.as_ref() != Some(&binding.proxy) { - return Err(StoreError::Conflict { - entity: "outbound proxy", - id: binding.id.clone(), - kind: ConflictKind::StaleRevision, - }); - } - } - let mut account_ids = Vec::with_capacity(command.accounts.len()); - for account in &command.accounts { - account_ids - .push(upsert_provider_account_in_transaction(&mut transaction, account).await?); - } - if let Some(settings) = &command.settings { - let unique_ids = account_ids - .iter() - .cloned() - .collect::>() - .into_iter() - .collect::>(); - update_provider_accounts_scheduling_in_transaction( - &mut transaction, - &unique_ids, - Some(settings.enabled), - Some(settings.concurrency_limit), - Some(settings.weight), - settings.model_access.as_ref(), - None, - ) - .await?; - replace_account_group_assignments_in_transaction( - &mut transaction, - &unique_ids, - &settings.group_ids, - ) - .await?; - if let Some(notes) = settings.notes.as_deref() { - update_provider_account_notes_in_transaction( - &mut transaction, - &unique_ids, - notes, - ) - .await?; - } - } - append_admin_audit_event_in_transaction(&mut transaction, command.audit, revision) - .await?; - Ok(ProviderAccountAdminImport { - config_revision: revision, - account_ids, - }) - } - .await; + let result = import_provider_accounts_in_transaction(&mut transaction, command).await; finish_admin_transaction(transaction, result, "provider account admin import").await } @@ -532,78 +511,12 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository { &self, command: RotateProviderAccount, ) -> StoreResult { - command.scope.validate()?; - require_nonempty(ENTITY, "account_id", &command.profile.id)?; - require_nonempty(ENTITY, "name", &command.profile.name)?; - if let Some(identity) = &command.replacement_identity { - require_nonempty(ENTITY, "upstream_user_id", identity.upstream_user_id())?; - if let Some(account_id) = identity.upstream_account_id() { - require_nonempty(ENTITY, "upstream_account_id", account_id)?; - } - } - if command.profile.id != command.credential.account_id { - return Err(invalid("rotated profile and credential account IDs differ")); - } - validate_credential_update(&command.credential)?; - if let Some(settings) = &command.settings { - if settings.account_id != command.profile.id { - return Err(invalid( - "rotated credential and account settings IDs differ", - )); - } - validate_batch_update_group_ids(&settings.group_ids)?; - } let mut transaction = self .pool .begin() .await .map_err(|_| postgres_unavailable("begin provider account admin rotation"))?; - let result = async { - let config_revision = bump_config_revision_in_transaction(&mut transaction).await?; - let credential_revision = rotate_provider_account_in_transaction( - &mut transaction, - &command.scope, - &command.profile, - command.replacement_identity.as_ref(), - &command.credential, - ) - .await?; - // 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新。 - if let Some(settings) = &command.settings { - let ids = std::slice::from_ref(&settings.account_id); - update_provider_accounts_scheduling_in_transaction( - &mut transaction, - ids, - Some(settings.enabled), - Some(settings.concurrency_limit), - Some(settings.weight), - settings.model_access.as_ref(), - settings.outbound_proxy.as_ref(), - ) - .await?; - replace_account_group_assignments_in_transaction( - &mut transaction, - ids, - &settings.group_ids, - ) - .await?; - if let Some(notes) = settings.notes.as_deref() { - update_provider_account_notes_in_transaction(&mut transaction, ids, notes) - .await?; - } - } - append_admin_audit_event_in_transaction( - &mut transaction, - command.audit, - config_revision, - ) - .await?; - Ok(ProviderAccountAdminRotation { - config_revision, - credential_revision, - }) - } - .await; + let result = rotate_provider_account_admin_in_transaction(&mut transaction, command).await; finish_admin_transaction(transaction, result, "provider account admin rotation").await } @@ -799,7 +712,7 @@ async fn replace_account_group_assignments_in_transaction( pub(crate) async fn upsert_provider_account_in_transaction( transaction: &mut Transaction<'_, Postgres>, account: &NewProviderAccount, -) -> StoreResult { +) -> StoreResult<(String, Revision)> { account.validate()?; let credential_state = account.credential_state; let proxy_id = match account.outbound_proxy.as_ref() { @@ -810,7 +723,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( ), None => None, }; - let imported_id = sqlx::query_scalar::<_, String>( + let imported = sqlx::query_as::<_, (String, i64)>( "insert into provider_accounts ( outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, @@ -850,7 +763,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( last_error_reason = null, last_error_message = null, updated_at = greatest(now(), provider_accounts.updated_at, excluded.credential_observed_at) - returning id", + returning id, credential_revision", ) .bind(&account.id) .bind(&account.provider_kind) @@ -893,7 +806,7 @@ pub(crate) async fn upsert_provider_account_in_transaction( id: account.id.clone(), kind: ConflictKind::InvalidTransition, })?; - Ok(imported_id) + Ok((imported.0, Revision::new(to_u64(imported.1)?)?)) } pub(crate) async fn rotate_provider_account_in_transaction( @@ -1132,3 +1045,126 @@ pub(crate) async fn finish_admin_transaction( } } } + +// 授权回执与账号写入需要共享事务;常规导入、刷新和轮换复用同一写入合同。 +pub(super) async fn import_provider_accounts_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + command: ImportProviderAccounts, +) -> StoreResult { + command.validate()?; + let revision = bump_config_revision_in_transaction(transaction).await?; + if let Some(binding) = &command.outbound_proxy { + let (_, current) = super::super::proxies::resolve_proxy_selection( + transaction, + &gateway_admin::model::proxies::AccountProxySelection::Saved(binding.id.clone()), + ) + .await?; + if current.as_ref() != Some(&binding.proxy) { + return Err(StoreError::Conflict { + entity: "outbound proxy", + id: binding.id.clone(), + kind: ConflictKind::StaleRevision, + }); + } + } + let mut account_ids = Vec::with_capacity(command.accounts.len()); + let mut credential_revisions = std::collections::BTreeMap::new(); + for account in &command.accounts { + let (id, revision) = upsert_provider_account_in_transaction(transaction, account).await?; + credential_revisions.insert(id.clone(), revision); + account_ids.push(id); + } + if let Some(settings) = &command.settings { + let unique_ids = account_ids + .iter() + .cloned() + .collect::>() + .into_iter() + .collect::>(); + update_provider_accounts_scheduling_in_transaction( + transaction, + &unique_ids, + Some(settings.enabled), + Some(settings.concurrency_limit), + Some(settings.weight), + settings.model_access.as_ref(), + None, + ) + .await?; + replace_account_group_assignments_in_transaction( + transaction, + &unique_ids, + &settings.group_ids, + ) + .await?; + if let Some(notes) = settings.notes.as_deref() { + update_provider_account_notes_in_transaction(transaction, &unique_ids, notes).await?; + } + } + append_admin_audit_event_in_transaction(transaction, command.audit, revision).await?; + Ok(ProviderAccountAdminImport { + config_revision: revision, + account_ids, + credential_revisions, + }) +} + +pub(super) async fn rotate_provider_account_admin_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + command: RotateProviderAccount, +) -> StoreResult { + command.scope.validate()?; + require_nonempty(ENTITY, "account_id", &command.profile.id)?; + require_nonempty(ENTITY, "name", &command.profile.name)?; + if let Some(identity) = &command.replacement_identity { + require_nonempty(ENTITY, "upstream_user_id", identity.upstream_user_id())?; + if let Some(account_id) = identity.upstream_account_id() { + require_nonempty(ENTITY, "upstream_account_id", account_id)?; + } + } + if command.profile.id != command.credential.account_id { + return Err(invalid("rotated profile and credential account IDs differ")); + } + validate_credential_update(&command.credential)?; + if let Some(settings) = &command.settings { + if settings.account_id != command.profile.id { + return Err(invalid( + "rotated credential and account settings IDs differ", + )); + } + validate_batch_update_group_ids(&settings.group_ids)?; + } + let config_revision = bump_config_revision_in_transaction(transaction).await?; + let credential_revision = rotate_provider_account_in_transaction( + transaction, + &command.scope, + &command.profile, + command.replacement_identity.as_ref(), + &command.credential, + ) + .await?; + // 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新。 + if let Some(settings) = &command.settings { + let ids = std::slice::from_ref(&settings.account_id); + update_provider_accounts_scheduling_in_transaction( + transaction, + ids, + Some(settings.enabled), + Some(settings.concurrency_limit), + Some(settings.weight), + settings.model_access.as_ref(), + settings.outbound_proxy.as_ref(), + ) + .await?; + replace_account_group_assignments_in_transaction(transaction, ids, &settings.group_ids) + .await?; + if let Some(notes) = settings.notes.as_deref() { + update_provider_account_notes_in_transaction(transaction, ids, notes).await?; + } + } + append_admin_audit_event_in_transaction(transaction, command.audit, config_revision).await?; + Ok(ProviderAccountAdminRotation { + config_revision, + credential_revision, + }) +} diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index 645b2dad0..5d4b2d183 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -247,6 +247,8 @@ pub struct ImportProviderAccounts { pub struct ProviderAccountAdminImport { pub config_revision: Revision, pub account_ids: Vec, + /// 与导入同一事务返回的最终凭据版本,避免提交后再查到另一轮写入。 + pub credential_revisions: std::collections::BTreeMap, } impl fmt::Debug for ImportProviderAccounts { diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 8bc3f18f0..5eab65c65 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -19,8 +19,8 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable}; #[derive(Clone, PartialEq, Eq)] pub struct RuntimeSettings { - pub openai_client_profile: Option, - pub xai_client_profile: Option, + pub request_profiles: + BTreeMap, pub config_revision: Revision, pub admin_api_key: Option, pub refresh_margin_seconds: u64, @@ -54,6 +54,7 @@ impl fmt::Debug for RuntimeSettings { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RuntimeSettings") + .field("request_profile_count", &self.request_profiles.len()) .field("config_revision", &self.config_revision) .field( "admin_api_key", @@ -110,8 +111,10 @@ impl fmt::Debug for RuntimeSettings { #[derive(Clone)] pub struct RuntimeSettingsUpdate { - pub openai_client_profile: Option, - pub xai_client_profile: Option, + pub request_profile_updates: BTreeMap< + gateway_core::routing::ProviderKind, + Option, + >, pub admin_api_key: Option, pub refresh_margin_seconds: u64, pub refresh_concurrency: u32, @@ -176,6 +179,15 @@ impl RuntimeSettingsUpdate { || !valid_client_version(self.min_codex_cli_version.as_deref()) || !valid_probe_model(self.account_auto_freeze_probe_model.as_deref()) || RotationStrategy::parse(&self.rotation_strategy).is_none() + || self.request_profile_updates.len() > 256 + || self + .request_profile_updates + .values() + .flatten() + .any(|profile| { + serde_json::to_vec(profile.expose_to_provider()) + .map_or(true, |encoded| encoded.len() > 64 * 1024) + }) { return Err(StoreError::InvalidData { entity: "runtime settings", @@ -278,6 +290,56 @@ impl ProviderRuntimePolicyPort for PgRuntimeSettingsRepository { }) } + fn load_request_profile_configurations<'a>( + &'a self, + revision: gateway_core::routing::ConfigRevision, + provider: &'a gateway_core::routing::ProviderKind, + ) -> futures::future::BoxFuture< + 'a, + Result, ProviderStoreError>, + > { + Box::pin(async move { + // 单条语句共享一个 MVCC 快照:先核对候选 revision,再只投影配置对象; + // Client Key 明文与其它策略字段不会进入插件准备边界。 + let rows = sqlx::query_as::<_, (i64, Option>)>( + "select settings.config_revision, profiles.profile + from runtime_settings settings + cross join lateral ( + select settings.provider_request_profiles_json -> $1 as profile + union + select keys.provider_request_profiles_json -> $1 + from client_api_keys keys + where keys.provider_request_profiles_json ? $1 + ) profiles + where settings.id = 1", + ) + .bind(provider.as_str()) + .fetch_all(&self.pool) + .await + .map_err(|_| provider_unavailable("load Provider request profile configurations"))?; + let expected_revision = i64::try_from(revision.get()) + .map_err(|_| provider_invalid("validate Provider request profile revision"))?; + if rows.is_empty() + || rows + .iter() + .any(|(actual_revision, _)| *actual_revision != expected_revision) + { + return Err(provider_conflict( + "validate Provider request profile revision", + )); + } + rows.into_iter() + .filter_map(|(_, profile)| profile) + .map(|profile| { + let serde_json::Value::Object(profile) = profile.0 else { + return Err(provider_invalid("decode Provider request profile")); + }; + Ok(gateway_core::account::OpaqueProviderData::new(profile)) + }) + .collect() + }) + } + fn load_refresh_policy( &self, ) -> futures::future::BoxFuture<'_, Result> { @@ -346,6 +408,24 @@ pub(crate) async fn update_runtime_settings_in_transaction( update.validate()?; let refresh_margin_seconds = i64::try_from(update.refresh_margin_seconds).map_err(|_| invalid_numeric())?; + let request_profile_updates = update + .request_profile_updates + .iter() + .filter_map(|(provider, profile)| { + profile.as_ref().map(|profile| { + ( + provider.as_str().to_owned(), + serde_json::Value::Object(profile.expose_to_provider().clone()), + ) + }) + }) + .collect::>(); + let request_profile_deletions = update + .request_profile_updates + .iter() + .filter(|(_, profile)| profile.is_none()) + .map(|(provider, _)| provider.as_str().to_owned()) + .collect::>(); let next = sqlx::query_scalar::<_, i64>( "update runtime_settings set config_revision = config_revision + 1, @@ -374,9 +454,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( request_location_json = $23, request_location_enabled = $24, responses_max_decompressed_body_bytes = $25, - provider_request_profiles_json = provider_request_profiles_json - || case when $26::jsonb is null then '{}'::jsonb else jsonb_build_object('openai', $26::jsonb) end - || case when $27::jsonb is null then '{}'::jsonb else jsonb_build_object('xai', $27::jsonb) end, + provider_request_profiles_json = (provider_request_profiles_json - $26::text[]) || $27::jsonb, updated_at = now() where id = 1 returning config_revision", @@ -418,8 +496,8 @@ pub(crate) async fn update_runtime_settings_in_transaction( i64::try_from(update.responses_max_decompressed_body_bytes) .map_err(|_| invalid_numeric())?, ) - .bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) - .bind(update.xai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider()))) + .bind(request_profile_deletions) + .bind(sqlx::types::Json(request_profile_updates)) .fetch_optional(&mut **transaction) .await .map_err(|_| postgres_unavailable("update runtime settings in transaction"))? @@ -501,18 +579,22 @@ struct RuntimeSettingsRow { account_auto_freeze_adaptive_concurrency: bool, } -fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult { +fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult { + let request_profiles = row + .provider_request_profiles_json + .0 + .into_iter() + .map(|(provider, profile)| { + let provider = gateway_core::routing::ProviderKind::new(provider) + .map_err(|_| invalid_request_profile())?; + Ok(( + provider, + gateway_core::account::OpaqueProviderData::new(profile), + )) + }) + .collect::>>()?; Ok(RuntimeSettings { - openai_client_profile: row - .provider_request_profiles_json - .0 - .remove("openai") - .map(gateway_core::account::OpaqueProviderData::new), - xai_client_profile: row - .provider_request_profiles_json - .0 - .remove("xai") - .map(gateway_core::account::OpaqueProviderData::new), + request_profiles, config_revision: Revision::new(to_u64(row.config_revision)?)?, admin_api_key: row.admin_api_key, refresh_margin_seconds: to_u64(row.refresh_margin_seconds)?, @@ -562,6 +644,13 @@ fn invalid_location() -> StoreError { } } +fn invalid_request_profile() -> StoreError { + StoreError::InvalidData { + entity: "runtime settings", + message: "Provider request profile key is invalid".to_owned(), + } +} + fn invalid_numeric() -> StoreError { StoreError::InvalidData { entity: "runtime settings", @@ -577,6 +666,10 @@ fn provider_invalid(operation: &'static str) -> ProviderStoreError { ProviderStoreError::new(ProviderStoreErrorKind::InvalidData, operation) } +fn provider_conflict(operation: &'static str) -> ProviderStoreError { + ProviderStoreError::new(ProviderStoreErrorKind::Conflict, operation) +} + fn valid_model_mappings(mappings: &BTreeMap) -> bool { mappings.len() <= 512 && mappings.iter().all(|(requested, upstream)| { diff --git a/backend/crates/gateway-store/src/redis/coordination_buffer.rs b/backend/crates/gateway-store/src/redis/coordination_buffer.rs index 02521a2f3..ab0334969 100644 --- a/backend/crates/gateway-store/src/redis/coordination_buffer.rs +++ b/backend/crates/gateway-store/src/redis/coordination_buffer.rs @@ -3,8 +3,11 @@ //! Continuation affinity 决定下一轮 Provider 与账号,必须在响应 ID 可复用前 //! 获得 Redis 确认,因此不属于本模块的可丢失副作用。 -use std::num::NonZeroUsize; use std::sync::Arc; +use std::{ + num::NonZeroUsize, + time::{Duration, Instant}, +}; use futures::future::{BoxFuture, ready}; use gateway_core::engine::ModelRequestId; @@ -22,6 +25,7 @@ use gateway_core::task::{DaemonTask, WorkerTaskError}; use tokio::sync::{Mutex, mpsc}; const DEFAULT_QUEUE_CAPACITY: usize = 4_096; +const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(2); /// 准入读取保持强一致;终态释放只做有界入队,Redis 失败由 worker 吸收。 #[derive(Clone)] @@ -127,7 +131,10 @@ impl DaemonTask for ClientAdmissionReleaseWriter { let mut receiver = self.receiver.lock().await; loop { let release = tokio::select! { - () = cancellation.cancelled() => return Ok(()), + () = cancellation.cancelled() => { + drain_admission_releases(&mut receiver, self.inner.as_ref()).await; + return Ok(()); + }, release = receiver.recv() => release, }; let Some(release) = release else { @@ -237,7 +244,10 @@ impl DaemonTask for ProviderCircuitFeedbackWriter { let mut receiver = self.receiver.lock().await; loop { let feedback = tokio::select! { - () = cancellation.cancelled() => return Ok(()), + () = cancellation.cancelled() => { + drain_circuit_feedback(&mut receiver, self.inner.as_ref()).await; + return Ok(()); + }, feedback = receiver.recv() => feedback, }; let Some(feedback) = feedback else { @@ -260,3 +270,52 @@ impl DaemonTask for ProviderCircuitFeedbackWriter { }) } } + +async fn drain_admission_releases( + receiver: &mut mpsc::Receiver, + port: &dyn ClientAdmissionPort, +) { + receiver.close(); + let started_at = Instant::now(); + while let Some(release) = receiver.recv().await { + let remaining = SHUTDOWN_DRAIN_TIMEOUT.saturating_sub(started_at.elapsed()); + if remaining.is_zero() + || tokio::time::timeout( + remaining, + port.release(&release.client_api_key_id, &release.model_request_id), + ) + .await + .is_err() + { + tracing::warn!("Client admission 关闭排空超时,剩余租约依赖 TTL 收敛"); + break; + } + } +} + +async fn drain_circuit_feedback( + receiver: &mut mpsc::Receiver, + port: &dyn ProviderCircuitPort, +) { + receiver.close(); + let started_at = Instant::now(); + while let Some(feedback) = receiver.recv().await { + let remaining = SHUTDOWN_DRAIN_TIMEOUT.saturating_sub(started_at.elapsed()); + if remaining.is_zero() { + tracing::warn!("Provider circuit 关闭排空超时,剩余反馈已丢弃"); + break; + } + let result = match feedback { + CircuitFeedback::Failure(provider) => { + tokio::time::timeout(remaining, port.observe_failure(&provider)).await + } + CircuitFeedback::Success(provider) => { + tokio::time::timeout(remaining, port.observe_success(&provider)).await + } + }; + if result.is_err() { + tracing::warn!("Provider circuit 关闭排空超时,剩余反馈已丢弃"); + break; + } + } +} diff --git a/backend/crates/gateway-store/src/workers.rs b/backend/crates/gateway-store/src/workers.rs index 1bcf4d8be..29dce232b 100644 --- a/backend/crates/gateway-store/src/workers.rs +++ b/backend/crates/gateway-store/src/workers.rs @@ -1,6 +1,101 @@ //! Worker 贡献、调度定义与健康探针。 use super::*; +use gateway_core::task::DaemonTask; + +const COMMAND_DRAIN_TIMEOUT: Duration = Duration::from_secs(4); + +pub(crate) struct CommandStoreWriters { + pub(crate) execution: postgres::ExecutionObservationWriter, + pub(crate) client_key_usage: postgres::PgClientApiKeyUsageWriter, + pub(crate) admission_release: redis::ClientAdmissionReleaseWriter, + pub(crate) circuit_feedback: redis::ProviderCircuitFeedbackWriter, +} + +/// 短生命周期 CLI 只运行数据面必需的四个写泵,不注册恢复、保留或维护 Worker。 +pub struct CommandStoreDrain { + cancellation: gateway_core::lifecycle::CancellationToken, + tasks: Vec>>, + execution_idle: postgres::ExecutionBufferIdle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("command store writes could not be drained")] +pub struct CommandStoreDrainError; + +impl CommandStoreWriters { + pub(crate) fn start(self) -> CommandStoreDrain { + let cancellation = gateway_core::lifecycle::CancellationToken::new(); + let Self { + execution, + client_key_usage, + admission_release, + circuit_feedback, + } = self; + let execution_idle = execution.idle(); + let tasks = vec![ + spawn_command_writer(execution, cancellation.child_token()), + spawn_command_writer(client_key_usage, cancellation.child_token()), + spawn_command_writer(admission_release, cancellation.child_token()), + spawn_command_writer(circuit_feedback, cancellation.child_token()), + ]; + CommandStoreDrain { + cancellation, + tasks, + execution_idle, + } + } +} + +fn spawn_command_writer( + writer: T, + cancellation: gateway_core::lifecycle::CancellationToken, +) -> tokio::task::JoinHandle> +where + T: DaemonTask + Send + 'static, +{ + tokio::spawn(async move { writer.run(cancellation).await }) +} + +impl CommandStoreDrain { + pub(crate) async fn shutdown(mut self) -> Result<(), CommandStoreDrainError> { + let deadline = std::time::Instant::now() + COMMAND_DRAIN_TIMEOUT; + // CLI 调用方已结束数据面会话;先让已接收的 execution 写入在正常 writer + // 路径完成,避免立即取消后落入更短的常驻进程关闭丢弃窗口。 + let mut failed = !self.execution_idle.wait_until(deadline).await; + self.cancellation.cancel(); + for mut task in self.tasks.drain(..) { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + task.abort(); + failed = true; + continue; + } + match tokio::time::timeout(remaining, &mut task).await { + Ok(Ok(Ok(()))) => {} + Ok(Ok(Err(_))) | Ok(Err(_)) => failed = true, + Err(_) => { + task.abort(); + failed = true; + } + } + } + if failed { + Err(CommandStoreDrainError) + } else { + Ok(()) + } + } +} + +impl Drop for CommandStoreDrain { + fn drop(&mut self) { + self.cancellation.cancel(); + for task in &self.tasks { + task.abort(); + } + } +} pub(crate) fn store_worker_contributions( execution: Arc, diff --git a/backend/crates/gateway-store/tests/bundle.rs b/backend/crates/gateway-store/tests/bundle.rs new file mode 100644 index 000000000..f7b37ae68 --- /dev/null +++ b/backend/crates/gateway-store/tests/bundle.rs @@ -0,0 +1,109 @@ +use gateway_core::{ + account::{ + CredentialRevision, NewProviderAccount, PlaintextCredential, ProviderAccount, + ProviderAccountId, + }, + routing::ProviderKind, +}; +use gateway_store::StoreConfig; +use serde_json::json; +use sqlx::postgres::PgPoolOptions; + +#[tokio::test] +async fn inspection_bundle_does_not_migrate_and_rejects_business_writes() { + let (Some(database), Some(redis)) = ( + crate::support::test_env("CPR_TEST_DATABASE_URL"), + crate::support::test_env("CPR_TEST_REDIS_URL"), + ) else { + return; + }; + let schema = format!("cpr_inspection_{}", uuid::Uuid::new_v4().simple()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&database) + .await + .unwrap(); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("create schema {schema}"))) + .execute(&admin) + .await + .unwrap(); + let mut database = url::Url::parse(&database).unwrap(); + database + .query_pairs_mut() + .append_pair("options", &format!("-csearch_path={schema}")); + let config_connection = |mut url: url::Url| { + let password = url.password().unwrap().to_owned(); + url.set_password(None).unwrap(); + json!({"url":url.as_str(),"password":password}) + }; + let mut config: StoreConfig = serde_json::from_value(json!({ + "database":config_connection(database), "redis":config_connection(url::Url::parse(&redis).unwrap()), + })).unwrap(); + let directory = tempfile::tempdir().unwrap(); + config.resolve_and_validate(directory.path()).unwrap(); + let inspection = gateway_store::initialize_read_only(config.clone()) + .await + .unwrap(); + assert!( + inspection + .admin_ports() + .plugins() + .load_instances() + .await + .is_err() + ); + let tables: i64 = + sqlx::query_scalar("select count(*) from information_schema.tables where table_schema=$1") + .bind(&schema) + .fetch_one(&admin) + .await + .unwrap(); + assert_eq!(tables, 0, "只读帮助不能在空库创建迁移表或业务表"); + drop(inspection); + let writable = gateway_store::initialize(config.clone()).await.unwrap(); + let inspection = gateway_store::initialize_read_only(config).await.unwrap(); + let create = |id: &str| NewProviderAccount { + account: ProviderAccount::new( + ProviderAccountId::new(id.to_owned()).unwrap(), + ProviderKind::new("example").unwrap(), + "inspection test".into(), + None, + "api_key".into(), + CredentialRevision::new(1).unwrap(), + None, + ), + credential: PlaintextCredential::new( + json!({"key":"test-only"}).as_object().unwrap().clone(), + ), + model_access: None, + }; + writable + .provider_ports() + .accounts() + .create_account(create("acct_writable")) + .await + .unwrap(); + assert!( + inspection + .provider_ports() + .accounts() + .create_account(create("acct_readonly")) + .await + .is_err() + ); + let accounts = inspection + .provider_ports() + .accounts() + .list_accounts() + .await + .unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0].id().as_str(), "acct_writable"); + drop(inspection); + drop(writable); + sqlx::raw_sql(sqlx::AssertSqlSafe(format!("drop schema {schema} cascade"))) + .execute(&admin) + .await + .unwrap(); + admin.close().await; +} diff --git a/backend/crates/gateway-store/tests/main.rs b/backend/crates/gateway-store/tests/main.rs index f1acfc4ba..10559529c 100644 --- a/backend/crates/gateway-store/tests/main.rs +++ b/backend/crates/gateway-store/tests/main.rs @@ -1,4 +1,5 @@ mod backup; +mod bundle; mod config; mod postgres; mod redis; diff --git a/backend/crates/gateway-store/tests/postgres/account_groups.rs b/backend/crates/gateway-store/tests/postgres/account_groups.rs index 87cfaceba..d5daa06f6 100644 --- a/backend/crates/gateway-store/tests/postgres/account_groups.rs +++ b/backend/crates/gateway-store/tests/postgres/account_groups.rs @@ -173,8 +173,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip let (scope_revision, widened) = keys .update_client_key( UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, id: client_key_id("key_group_one"), @@ -200,8 +199,7 @@ async fn groups_aggregate_cross_provider_members_and_key_bindings_without_multip let (restricted_revision, restricted) = keys .update_client_key( UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, id: client_key_id("key_group_one"), @@ -325,8 +323,7 @@ async fn group_costs_should_include_statusless_websocket_but_reject_statusless_h fn new_key(id: &str, group_ids: Vec) -> NewClientKey { let marker = char::from(id.as_bytes().last().copied().unwrap_or(b'k')); NewClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), budget: Default::default(), id: client_key_id(id), name: id.to_owned(), diff --git a/backend/crates/gateway-store/tests/postgres/client_budgets.rs b/backend/crates/gateway-store/tests/postgres/client_budgets.rs index adbcb3151..af91b63ea 100644 --- a/backend/crates/gateway-store/tests/postgres/client_budgets.rs +++ b/backend/crates/gateway-store/tests/postgres/client_budgets.rs @@ -471,8 +471,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { .await .unwrap(); let update = UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), id: ClientApiKeyId::new("key").unwrap(), name: "key".to_owned(), label: None, @@ -491,8 +490,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { admin .update_client_key( UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), daily_limit_usd: None, weekly_limit_usd: None, ..update.clone() @@ -516,8 +514,7 @@ async fn budget_updates_preserve_omitted_limits_and_do_not_clear_usage() { admin .update_client_key( UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), daily_limit_usd: Some("0".parse().unwrap()), weekly_limit_usd: None, ..update diff --git a/backend/crates/gateway-store/tests/postgres/client_keys.rs b/backend/crates/gateway-store/tests/postgres/client_keys.rs index 7ccfa4b85..693a3c4ac 100644 --- a/backend/crates/gateway-store/tests/postgres/client_keys.rs +++ b/backend/crates/gateway-store/tests/postgres/client_keys.rs @@ -49,8 +49,7 @@ async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() { let (_, record) = store .create_client_key( NewClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), id: id.clone(), name: format!("Migrated {index}"), label: None, @@ -105,8 +104,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() { }; let key = "legacy-key-case-sensitive!"; let command = |id: &str| NewClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), id: ClientApiKeyId::new(id).unwrap(), name: id.to_owned(), label: None, @@ -142,8 +140,7 @@ async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() { #[test] fn generated_client_key_format_remains_valid() { let key = NewClientApiKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), budget: Default::default(), id: "key-1".to_owned(), name: "default".to_owned(), @@ -176,8 +173,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { request_id: "duplicate-name-test".to_owned(), }; let create = |id: &str, name: &str| NewClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), id: ClientApiKeyId::new(id).unwrap(), name: name.to_owned(), label: None, @@ -187,8 +183,7 @@ async fn client_key_names_are_checked_atomically_on_create_and_rename() { plaintext: format!("synthetic-credential-{id}"), }; let update = |id: ClientApiKeyId, name: &str| UpdateClientKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_override_updates: Default::default(), id, name: name.to_owned(), label: None, @@ -643,8 +638,7 @@ async fn dedicated_reveal_returns_plaintext_without_debug_exposure() { fn client_key_debug_redacts_plaintext() { let secret = format!("sk_{}", "s".repeat(43)); let key = NewClientApiKey { - openai_client_profile_override: None, - xai_client_profile_override: None, + request_profile_overrides: Default::default(), budget: Default::default(), id: "key-1".to_owned(), name: "default".to_owned(), @@ -712,6 +706,8 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance .clone(), ); let id = ClientApiKeyId::new("key_profile").unwrap(); + let openai = gateway_core::routing::ProviderKind::new("openai").unwrap(); + let dynamic = gateway_core::routing::ProviderKind::new("provider.example").unwrap(); let (_, record) = store .create_client_key( NewClientKey { @@ -722,16 +718,24 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance limits: RateLimits::unlimited(), budget: Default::default(), plaintext: "synthetic-profile-test-key".to_owned(), - openai_client_profile_override: Some(profile.clone()), - xai_client_profile_override: Some(profile.clone()), + request_profile_overrides: BTreeMap::from([ + (openai.clone(), profile.clone()), + (dynamic.clone(), profile.clone()), + ]), }, &context, ) .await .unwrap(); - assert_eq!(record.openai_client_profile_override, Some(profile.clone())); - assert_eq!(record.xai_client_profile_override, Some(profile.clone())); - let update = |override_value| UpdateClientKey { + assert_eq!( + record.request_profile_overrides.get(&openai), + Some(&profile) + ); + assert_eq!( + record.request_profile_overrides.get(&dynamic), + Some(&profile) + ); + let update = |override_value: Option>| UpdateClientKey { id: id.clone(), name: "profile".to_owned(), label: None, @@ -739,18 +743,22 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance limits: RateLimits::unlimited(), daily_limit_usd: None, weekly_limit_usd: None, - openai_client_profile_override: override_value, - xai_client_profile_override: None, + request_profile_override_updates: override_value + .map(|profile| BTreeMap::from([(openai.clone(), profile)])) + .unwrap_or_default(), }; let (_, unchanged) = store .update_client_key(update(None), &context) .await .unwrap(); assert_eq!( - unchanged.openai_client_profile_override, - Some(profile.clone()) + unchanged.request_profile_overrides.get(&openai), + Some(&profile) + ); + assert_eq!( + unchanged.request_profile_overrides.get(&dynamic), + Some(&profile) ); - assert_eq!(unchanged.xai_client_profile_override, Some(profile.clone())); let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) .load_runtime_snapshot() .await @@ -760,24 +768,31 @@ async fn key_profile_override_roundtrips_and_explicit_clear_restores_inheritance Some(&profile) ); assert_eq!( - snapshot.client_api_keys[0] - .request_profiles - .get(&gateway_core::routing::ProviderKind::new("xai").unwrap()), + snapshot.client_api_keys[0].request_profiles.get(&dynamic), Some(&profile) ); - let mut clear_xai = update(None); - clear_xai.xai_client_profile_override = Some(None); - let (_, cleared_xai) = store.update_client_key(clear_xai, &context).await.unwrap(); - assert_eq!(cleared_xai.xai_client_profile_override, None); + let mut clear_dynamic = update(None); + clear_dynamic + .request_profile_override_updates + .insert(dynamic.clone(), None); + let (_, cleared_dynamic) = store + .update_client_key(clear_dynamic, &context) + .await + .unwrap(); + assert!( + !cleared_dynamic + .request_profile_overrides + .contains_key(&dynamic) + ); assert_eq!( - cleared_xai.openai_client_profile_override, - Some(profile.clone()) + cleared_dynamic.request_profile_overrides.get(&openai), + Some(&profile) ); let (_, cleared) = store .update_client_key(update(Some(None)), &context) .await .unwrap(); - assert_eq!(cleared.openai_client_profile_override, None); + assert!(!cleared.request_profile_overrides.contains_key(&openai)); let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) .load_runtime_snapshot() .await diff --git a/backend/crates/gateway-store/tests/postgres/execution.rs b/backend/crates/gateway-store/tests/postgres/execution.rs index 1dfa7b40d..1cc352b5f 100644 --- a/backend/crates/gateway-store/tests/postgres/execution.rs +++ b/backend/crates/gateway-store/tests/postgres/execution.rs @@ -441,6 +441,14 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads() seed_running_request(&database.pool, "req_billing_snapshot") .await .unwrap(); + sqlx::query( + "update model_requests + set provider_kind = 'example-plugin', upstream_model_id = 'plugin-model' + where id = 'req_billing_snapshot'", + ) + .execute(&database.pool) + .await + .unwrap(); let money = |ticks| { Money::new( Decimal::from_scaled(ticks).unwrap(), @@ -489,7 +497,7 @@ async fn billing_snapshot_survives_later_price_changes_and_usage_detail_reads() assert_eq!(saved.custom_multiplier_bps, 12500); assert_eq!(saved.total_amount.amount, "0.00075".parse().unwrap()); sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1") - .bind(json!({"openai":{"gpt-5.4":{"multiplierBps":90000,"bands":{}}}})) + .bind(json!({"example-plugin":{"plugin-model":{"multiplierBps":90000,"bands":{}}}})) .execute(&database.pool) .await .unwrap(); diff --git a/backend/crates/gateway-store/tests/postgres/execution_buffer.rs b/backend/crates/gateway-store/tests/postgres/execution_buffer.rs index 5c014b290..76cdf0c84 100644 --- a/backend/crates/gateway-store/tests/postgres/execution_buffer.rs +++ b/backend/crates/gateway-store/tests/postgres/execution_buffer.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime}; +use std::time::{Duration, Instant, SystemTime}; use async_trait::async_trait; use gateway_core::account::ProviderAccountId; @@ -18,6 +19,7 @@ use gateway_core::upstream::UpstreamSendState; use gateway_store::postgres::{ BufferedExecutionStore, ObservabilityRepository as _, PgExecutionStore, }; +use tokio::sync::{Notify, Semaphore}; use super::{ TestDatabase, @@ -102,6 +104,139 @@ impl ExecutionStore for RecordingStore { } } +struct BlockingStore { + active: AtomicUsize, + maximum_active: AtomicUsize, + started: AtomicUsize, + started_changed: Notify, + releases: Semaphore, + operations: Mutex>, +} + +impl Default for BlockingStore { + fn default() -> Self { + Self { + active: AtomicUsize::new(0), + maximum_active: AtomicUsize::new(0), + started: AtomicUsize::new(0), + started_changed: Notify::new(), + releases: Semaphore::new(0), + operations: Mutex::new(Vec::new()), + } + } +} + +struct ActiveWrite<'a>(&'a AtomicUsize); + +impl Drop for ActiveWrite<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +impl BlockingStore { + async fn record(&self, request_id: &str, operation: &'static str) -> Result<(), StoreError> { + self.operations + .lock() + .expect("operations lock") + .push((request_id.to_owned(), operation)); + let active = self.active.fetch_add(1, Ordering::AcqRel) + 1; + self.maximum_active.fetch_max(active, Ordering::AcqRel); + let _active = ActiveWrite(&self.active); + self.started.fetch_add(1, Ordering::AcqRel); + self.started_changed.notify_waiters(); + self.releases + .acquire() + .await + .expect("release semaphore") + .forget(); + Ok(()) + } + + async fn wait_for_started(&self, expected: usize) { + tokio::time::timeout(Duration::from_secs(1), async { + loop { + let changed = self.started_changed.notified(); + if self.started.load(Ordering::Acquire) >= expected { + break; + } + changed.await; + } + }) + .await + .expect("expected writes should start"); + } + + fn operations_for(&self, request_id: &str) -> Vec<&'static str> { + self.operations + .lock() + .expect("operations lock") + .iter() + .filter_map(|(candidate, operation)| (candidate == request_id).then_some(*operation)) + .collect() + } +} + +#[async_trait] +impl ExecutionStore for BlockingStore { + async fn create_model_request(&self, request: NewModelRequest) -> Result<(), StoreError> { + self.record(request.id.as_str(), "create").await + } + + async fn record_attempt(&self, attempt: AttemptRecord) -> Result<(), StoreError> { + self.record(attempt.request_id.as_str(), "attempt").await + } + + async fn mark_send_state( + &self, + request_id: &ModelRequestId, + _: UpstreamSendState, + ) -> Result<(), StoreError> { + self.record(request_id.as_str(), "send").await + } + + async fn mark_downstream_committed( + &self, + request_id: &ModelRequestId, + _: SystemTime, + _: Option, + ) -> Result<(), StoreError> { + self.record(request_id.as_str(), "commit").await + } + + async fn record_client_status( + &self, + request_id: &ModelRequestId, + _: u16, + ) -> Result<(), StoreError> { + self.record(request_id.as_str(), "status").await + } + + async fn record_intermediate_failure( + &self, + failure: IntermediateFailure, + ) -> Result<(), StoreError> { + self.record(failure.request_id.as_str(), "intermediate_failure") + .await + } + + async fn record_probe_failure(&self, _: ProbeFailure) -> Result<(), StoreError> { + self.record("probe", "probe_failure").await + } + + async fn finalize_model_request( + &self, + finalization: ModelRequestFinalization, + ) -> Result<(), StoreError> { + self.record(finalization.request_id.as_str(), "finalize") + .await + } + + async fn recover_expired(&self, _: SystemTime) -> Result { + Ok(RecoveryReport::default()) + } +} + #[tokio::test] async fn full_observation_queue_never_waits_for_the_database() { let inner = Arc::new(RecordingStore::default()); @@ -178,6 +313,185 @@ async fn observation_writer_persists_commands_in_enqueue_order() { assert_eq!(store.stats().queued_items, 0); } +#[tokio::test] +async fn request_affine_lanes_run_independently_and_preserve_each_request_order() { + let inner = Arc::new(BlockingStore::default()); + let (store, writer) = BufferedExecutionStore::with_capacity( + Arc::clone(&inner), + NonZeroUsize::new(4).expect("capacity"), + ); + let first = ModelRequestId::new("req_lane_0").expect("first request id"); + let second = ModelRequestId::new("req_lane_1").expect("second request id"); + for request_id in [&first, &second] { + store + .mark_send_state(request_id, UpstreamSendState::Sent) + .await + .expect("enqueue send state"); + store + .record_client_status(request_id, 200) + .await + .expect("enqueue status"); + } + + let cancellation = CancellationToken::new(); + let writer = Arc::new(writer); + let task = tokio::spawn({ + let writer = Arc::clone(&writer); + let cancellation = cancellation.clone(); + async move { writer.run(cancellation).await } + }); + inner.wait_for_started(2).await; + assert_eq!(inner.maximum_active.load(Ordering::Acquire), 2); + assert_eq!(inner.operations_for(first.as_str()), ["send"]); + assert_eq!(inner.operations_for(second.as_str()), ["send"]); + + inner.releases.add_permits(2); + inner.wait_for_started(4).await; + inner.releases.add_permits(2); + tokio::time::timeout(Duration::from_secs(1), async { + while store.stats().persisted_total != 4 { + tokio::task::yield_now().await; + } + }) + .await + .expect("all writes should persist"); + cancellation.cancel(); + task.await + .expect("writer task") + .expect("writer cancellation"); + + assert_eq!(inner.operations_for(first.as_str()), ["send", "status"]); + assert_eq!(inner.operations_for(second.as_str()), ["send", "status"]); + assert_eq!(store.stats().dropped_total, 0); + assert_eq!(store.stats().queued_items, 0); +} + +#[tokio::test] +async fn idle_wait_includes_a_dequeued_write_until_persistence_finishes() { + let inner = Arc::new(BlockingStore::default()); + let (store, writer) = BufferedExecutionStore::with_capacity( + Arc::clone(&inner), + NonZeroUsize::new(1).expect("capacity"), + ); + let request_id = ModelRequestId::new("req_idle_inflight").expect("request id"); + store + .mark_send_state(&request_id, UpstreamSendState::Sent) + .await + .expect("enqueue write"); + let cancellation = CancellationToken::new(); + let writer = Arc::new(writer); + let task = tokio::spawn({ + let writer = Arc::clone(&writer); + let cancellation = cancellation.clone(); + async move { writer.run(cancellation).await } + }); + inner.wait_for_started(1).await; + + let idle = writer.wait_until_idle(Instant::now() + Duration::from_secs(1)); + tokio::pin!(idle); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut idle) + .await + .is_err(), + "从队列取出的写入仍应计入 drain" + ); + inner.releases.add_permits(1); + assert!(idle.await); + + cancellation.cancel(); + task.await + .expect("writer task") + .expect("writer cancellation"); + assert_eq!(store.stats().persisted_total, 1); + assert_eq!(store.stats().queued_items, 0); +} + +#[tokio::test] +async fn global_item_budget_includes_writes_running_in_every_lane() { + let inner = Arc::new(BlockingStore::default()); + let (store, writer) = BufferedExecutionStore::with_capacity( + Arc::clone(&inner), + NonZeroUsize::new(2).expect("capacity"), + ); + let first = ModelRequestId::new("req_lane_0").expect("first request id"); + let second = ModelRequestId::new("req_lane_1").expect("second request id"); + let rejected = ModelRequestId::new("req_lane_2").expect("rejected request id"); + store + .mark_send_state(&first, UpstreamSendState::Sent) + .await + .expect("enqueue first lane"); + store + .mark_send_state(&second, UpstreamSendState::Sent) + .await + .expect("enqueue second lane"); + + let cancellation = CancellationToken::new(); + let writer = Arc::new(writer); + let task = tokio::spawn({ + let writer = Arc::clone(&writer); + let cancellation = cancellation.clone(); + async move { writer.run(cancellation).await } + }); + inner.wait_for_started(2).await; + assert_eq!(store.stats().queued_items, 2); + store + .mark_send_state(&rejected, UpstreamSendState::Sent) + .await + .expect("full global budget remains fail open"); + assert_eq!(store.stats().dropped_total, 1); + assert_eq!(store.stats().queued_items, 2); + + inner.releases.add_permits(2); + tokio::time::timeout(Duration::from_secs(1), async { + while store.stats().persisted_total != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("accepted writes should persist"); + cancellation.cancel(); + task.await + .expect("writer task") + .expect("writer cancellation"); + assert_eq!(store.stats().queued_items, 0); +} + +#[tokio::test] +async fn all_lanes_share_one_shutdown_deadline_for_inflight_writes() { + let inner = Arc::new(BlockingStore::default()); + let (store, writer) = BufferedExecutionStore::with_capacity( + Arc::clone(&inner), + NonZeroUsize::new(4).expect("capacity"), + ); + for index in 0..4 { + let request_id = ModelRequestId::new(format!("req_lane_{index}")) + .expect("request id assigned to a distinct lane"); + store + .mark_send_state(&request_id, UpstreamSendState::Sent) + .await + .expect("enqueue lane write"); + } + + let cancellation = CancellationToken::new(); + let writer = Arc::new(writer); + let task = tokio::spawn({ + let writer = Arc::clone(&writer); + let cancellation = cancellation.clone(); + async move { writer.run(cancellation).await } + }); + inner.wait_for_started(4).await; + cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(3), task) + .await + .expect("four blocked lanes must share the two-second shutdown deadline") + .expect("writer task") + .expect("writer cancellation"); + + assert_eq!(store.stats().persisted_total, 0); + assert_eq!(store.stats().dropped_total, 4); + assert_eq!(store.stats().queued_items, 0); +} + #[tokio::test] async fn observation_writer_continues_after_a_database_write_failure() { let inner = Arc::new(RecordingStore::failing_once("send")); @@ -331,6 +645,66 @@ async fn zero_attempt_create_and_finalize_drain_in_order_with_trace() { database.close().await; } +#[tokio::test] +async fn request_affine_lanes_persist_a_postgres_lifecycle_burst_without_orphans() { + const REQUESTS: usize = 256; + const WRITES: u64 = (REQUESTS * 2) as u64; + + let Some(database) = TestDatabase::create("execution_lane_burst").await else { + return; + }; + let (store, writer) = + BufferedExecutionStore::new(Arc::new(PgExecutionStore::new(database.pool.clone()))); + for index in 0..REQUESTS { + let request = accepted_request(&format!("req_execution_lane_burst_{index}")); + store + .create_model_request(request.clone()) + .await + .expect("enqueue create"); + store + .finalize_model_request(early_failure(&request)) + .await + .expect("enqueue finalization"); + } + assert_eq!(store.stats().enqueued_total, WRITES); + assert_eq!(store.stats().dropped_total, 0); + + let cancellation = CancellationToken::new(); + let writer = Arc::new(writer); + let task = tokio::spawn({ + let writer = Arc::clone(&writer); + let cancellation = cancellation.clone(); + async move { writer.run(cancellation).await } + }); + tokio::time::timeout(Duration::from_secs(15), async { + while store.stats().persisted_total != WRITES { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("PostgreSQL burst should drain"); + cancellation.cancel(); + task.await + .expect("writer task") + .expect("writer cancellation"); + + let (total, failed): (i64, i64) = sqlx::query_as( + "select count(*), count(*) filter (where outcome = 'failed') + from model_requests where id like 'req_execution_lane_burst_%'", + ) + .fetch_one(&database.pool) + .await + .expect("load burst rows"); + assert_eq!((total, failed), (REQUESTS as i64, REQUESTS as i64)); + let stats = store.stats(); + assert_eq!(stats.persisted_total, WRITES); + assert_eq!(stats.write_failure_total, 0); + assert_eq!(stats.dropped_total, 0); + assert_eq!(stats.queued_items, 0); + assert_eq!(stats.queued_bytes, 0); + database.close().await; +} + #[tokio::test] async fn zero_attempt_dropped_finalize_is_fail_open_and_recoverable_at_deadline() { let Some(database) = TestDatabase::create("zero_attempt_queue_full").await else { diff --git a/backend/crates/gateway-store/tests/postgres/mod.rs b/backend/crates/gateway-store/tests/postgres/mod.rs index f9fca9e88..e49a9e2d5 100644 --- a/backend/crates/gateway-store/tests/postgres/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/mod.rs @@ -21,6 +21,7 @@ mod execution_buffer; mod health; mod observability; mod ops_events; +mod plugins; mod pricing; mod provider_accounts; mod proxies; @@ -58,6 +59,10 @@ pub(super) fn admin_account_store(pool: &PgPool) -> PgAdminAccountStore { impl TestDatabase { pub(super) async fn create(label: &str) -> Option { + Self::create_through(label, i64::MAX).await + } + + pub(super) async fn create_through(label: &str, migration_version: i64) -> Option { let database_url = crate::support::test_env("CPR_TEST_DATABASE_URL")?; let schema = format!("cpr_store_{label}_{}", Uuid::new_v4().simple()); let admin = PgPoolOptions::new() @@ -86,7 +91,7 @@ impl TestDatabase { .await .expect("connect isolated test schema"); TEST_MIGRATOR - .run(&pool) + .run_to(migration_version, &pool) .await .expect("apply test migrations"); Some(Self { @@ -229,6 +234,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly "account_groups", "admin_audit_events", "admin_users", + "authorization_receipts", "backup_records", "backup_settings", "client_api_key_groups", @@ -238,6 +244,15 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly "model_requests", "ops_events", "outbound_proxies", + "plugin_artifact_credentials", + "plugin_artifact_platforms", + "plugin_artifacts", + "plugin_instance_secrets", + "plugin_instances", + "plugin_source_credentials", + "plugin_state_generations", + "plugin_state_records", + "plugin_update_sources", "provider_accounts", "runtime_settings", ] diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 8e4acc71e..b2c1a5c3e 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -35,6 +35,54 @@ fn observability_range_rejects_empty_window() { assert!(ObservabilityRange::new(now, now).is_err()); } +#[tokio::test] +async fn usage_provider_filters_preserve_deleted_accounts_and_late_errors_within_range() { + let Some(database) = TestDatabase::create("usage_provider_filters").await else { + return; + }; + let now = Utc::now(); + seed_observability_facts(&database.pool, now).await.unwrap(); + sqlx::query("update model_requests set provider_kind = 'outside-range', started_at = $1 - interval '3 hours', completed_at = $1 - interval '2 hours'") + .bind(now).execute(&database.pool).await.unwrap(); + sqlx::query("update model_requests set provider_kind = 'retired-plugin', started_at = $1 - interval '5 minutes', completed_at = $1 - interval '4 minutes' where id = 'req_observe_success'") + .bind(now).execute(&database.pool).await.unwrap(); + sqlx::query("update model_requests set provider_kind = 'late-error', completed_at = $1 - interval '3 minutes' where id = 'req_observe_failed'") + .bind(now).execute(&database.pool).await.unwrap(); + sqlx::query("update model_requests set provider_kind = 'end-boundary', started_at = $1, completed_at = $1 where id = 'req_observe_uncommitted'") + .bind(now).execute(&database.pool).await.unwrap(); + sqlx::query( + "update ops_events set provider_kind = 'ops-only', created_at = $1 - interval '2 minutes'", + ) + .bind(now) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("delete from provider_accounts") + .execute(&database.pool) + .await + .unwrap(); + let store = admin_observability_store(&database.pool); + let providers = store + .usage_provider_kinds( + admin_observability::TimeRange::new(now - TimeDelta::hours(1), now).unwrap(), + ) + .await + .unwrap(); + assert_eq!(providers, ["late-error", "ops-only", "retired-plugin"]); + let empty = store + .usage_provider_kinds( + admin_observability::TimeRange::new( + now + TimeDelta::hours(1), + now + TimeDelta::hours(2), + ) + .unwrap(), + ) + .await + .unwrap(); + assert!(empty.is_empty()); + database.close().await; +} + #[test] fn observability_range_accepts_full_configured_retention_window() { let now = Utc::now(); diff --git a/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs new file mode 100644 index 000000000..c585b2422 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/artifacts.rs @@ -0,0 +1,456 @@ +use std::collections::BTreeMap; + +use gateway_admin::{ + model::{ + MutationActor, MutationContext, + plugins::{ + InspectedPluginArtifact, PluginArtifactMetadata, PluginContribution, PluginSource, + distribution::{PluginSourceBinding, PluginUpdateSource}, + }, + }, + ports::{plugins::PluginStore, store::AdminStoreErrorKind}, +}; +use gateway_store::postgres::PgPluginStore; + +use super::super::TestDatabase; + +pub(super) fn artifact(digest: char, platforms: &[&str]) -> InspectedPluginArtifact { + InspectedPluginArtifact { + metadata: PluginArtifactMetadata { + plugin_id: "test.example".into(), + version: "1.0.0".into(), + name: "example".into(), + display_name: "Example".into(), + publisher: "test".into(), + author: Some("Tests".into()), + description: "测试包".into(), + license: "MIT".into(), + sha256: digest.to_string().repeat(64), + platforms: platforms.iter().map(|value| (*value).into()).collect(), + icon: None, + contributes: BTreeMap::from([( + "middleware".into(), + PluginContribution { + id: "test.example.middleware".into(), + version: 1, + stages: vec!["attempt".into()], + input_formats: vec!["openai".into()], + output_formats: vec!["openai".into()], + }, + )]), + requested_permissions: vec![], + configuration_schema: serde_json::json!({}), + secret_fields: vec![], + state_namespaces: vec![], + }, + archive: b"verified package fixture".as_slice().into(), + } +} + +pub(super) fn context() -> MutationContext { + MutationContext { + actor: MutationActor::System, + request_id: "plugin-test".into(), + } +} + +pub(super) async fn initialize_revision(database: &TestDatabase) { + sqlx::query( + "insert into runtime_settings(id, config_revision, updated_at) values (1,1,now()) on conflict do nothing", + ) + .execute(&database.pool) + .await + .unwrap(); +} + +#[tokio::test] +async fn presentation_text_is_not_persisted_and_legacy_text_does_not_change_artifact_identity() { + let Some(database) = TestDatabase::create("plugin_artifact_presentation").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let fixture = artifact('a', &["linux-x86_64"]); + let installed = store + .install_artifact(fixture.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + let stored: serde_json::Value = + sqlx::query_scalar("select metadata_json from plugin_artifacts where sha256=$1") + .bind(&fixture.metadata.sha256) + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(stored.get("permissionDescriptions").is_none()); + sqlx::query("update plugin_artifacts set metadata_json=metadata_json || $2 where sha256=$1") + .bind(&fixture.metadata.sha256) + .bind(serde_json::json!({"permissionDescriptions": [{"permission": "models", "label": "旧文案", "description": "旧说明"}]})) + .execute(&database.pool).await.unwrap(); + let repeated = store + .install_artifact(fixture.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + assert_eq!(repeated.config_revision, installed.config_revision); + assert_eq!(repeated.artifact.metadata, fixture.metadata); + assert_eq!( + store + .load_artifact(&fixture.metadata.sha256) + .await + .unwrap() + .metadata, + fixture.metadata + ); + let mut changed = fixture; + changed.metadata.requested_permissions.push("models".into()); + assert_eq!( + store + .install_artifact(changed, PluginSource::Upload, &context()) + .await + .err() + .unwrap() + .kind(), + AdminStoreErrorKind::Conflict + ); + database.close().await; +} + +#[tokio::test] +async fn artifact_acceptance_is_exact_and_idempotent() { + let Some(database) = TestDatabase::create("plugin_artifact_acceptance").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + assert!(installed.artifact.accepted_at.is_none()); + + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let repeated = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + assert!(accepted.artifact.accepted_at.is_some()); + assert_eq!(repeated.artifact, accepted.artifact); + assert_eq!(repeated.config_revision, accepted.config_revision); + let accept_audits: i64 = sqlx::query_scalar( + "select count(*) from admin_audit_events where entity_kind='plugin_artifact' and action='accept'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(accept_audits, 1); + database.close().await; +} + +#[tokio::test] +async fn plugin_migration_preserves_main_settings_and_accepts_new_installations() { + let Some(database) = TestDatabase::create_through("plugin_upgrade_from_main", 17).await else { + return; + }; + initialize_revision(&database).await; + sqlx::query("update runtime_settings set max_concurrent_per_account=0 where id=1") + .execute(&database.pool) + .await + .unwrap(); + let before: serde_json::Value = + sqlx::query_scalar("select to_jsonb(s) from runtime_settings s where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + super::super::TEST_MIGRATOR + .run(&database.pool) + .await + .unwrap(); + let after: serde_json::Value = + sqlx::query_scalar("select to_jsonb(s) from runtime_settings s where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(after, before); + let store = PgPluginStore::new(database.pool.clone()); + store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + database.close().await; +} + +#[tokio::test] +async fn source_identity_requires_publisher_and_name() { + let Some(database) = TestDatabase::create("plugin_source_identity").await else { + return; + }; + for plugin_id in [ + "legacy-plugin", + "Publisher.name", + "publisher.-name", + "publisher.name.extra", + ] { + let error = sqlx::query( + "insert into plugin_update_sources(plugin_id,source_json) values ($1,'{}')", + ) + .bind(plugin_id) + .execute(&database.pool) + .await + .expect_err("invalid identity must be rejected by the owning table"); + assert_eq!( + error.as_database_error().unwrap().code().as_deref(), + Some("23514") + ); + } + database.close().await; +} + +#[tokio::test] +async fn artifact_bytes_and_audit_survive_repository_recreation() { + let Some(database) = TestDatabase::create("plugin_recovery").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let expected = artifact('a', &["linux-x86_64"]); + let saved = store + .install_artifact(expected.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + assert_eq!(saved.config_revision.get(), 2); + drop(store); + let reopened = PgPluginStore::new(database.pool.clone()); + let recovered = reopened + .load_artifact(&expected.metadata.sha256) + .await + .unwrap(); + assert_eq!(recovered.archive, expected.archive); + assert_eq!(recovered.metadata, expected.metadata); + let audit: (String, i64, Vec) = sqlx::query_as("select action, config_revision, changed_fields from admin_audit_events where entity_kind='plugin_artifact'").fetch_one(&database.pool).await.unwrap(); + assert_eq!( + audit, + ( + "install".into(), + 2, + vec!["artifact".into(), "source".into()] + ) + ); + database.close().await; +} + +#[tokio::test] +async fn overlapping_platform_content_cannot_replace_a_published_version() { + let Some(database) = TestDatabase::create("plugin_immutable").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let result = store + .install_artifact( + artifact('b', &["linux-x86_64", "windows-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await; + assert!(result.is_err_and(|error| error.kind() == AdminStoreErrorKind::Conflict)); + assert_eq!(store.list_artifacts().await.unwrap().len(), 1); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(revision, 2, "冲突必须回滚全局 revision"); + database.close().await; +} + +#[tokio::test] +async fn unchanged_official_package_survives_host_upgrade_and_rollback() { + let Some(database) = TestDatabase::create("plugin_builtin_reimport").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let package = artifact('a', &["linux-x86_64"]); + let saved = store + .install_artifact( + package.clone(), + PluginSource::Builtin { + release: "3.12.0".into(), + }, + &context(), + ) + .await + .unwrap(); + for release in ["3.13.0", "3.12.0", "3.11.0"] { + let imported = store + .install_artifact( + package.clone(), + PluginSource::Builtin { + release: release.into(), + }, + &context(), + ) + .await + .expect("同一个官方包可随不同宿主发行重复导入"); + assert_eq!(imported.config_revision, saved.config_revision); + assert_eq!(imported.artifact, saved.artifact, "保留首次安装来源与时间"); + } + let audit_count: i64 = sqlx::query_scalar( + "select count(*) from admin_audit_events where entity_kind='plugin_artifact'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audit_count, 1, "幂等导入不制造配置变更与重复安装审计"); + database.close().await; +} + +#[tokio::test] +async fn official_reimport_still_rejects_changed_metadata_and_custom_source() { + let Some(database) = TestDatabase::create("plugin_builtin_identity").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let source = PluginSource::Builtin { + release: "3.12.0".into(), + }; + let package = artifact('a', &["linux-x86_64"]); + let saved = store + .install_artifact(package.clone(), source.clone(), &context()) + .await + .unwrap(); + let mut changed = package.clone(); + changed.metadata.display_name = "Changed metadata".into(); + for (candidate, source) in [(changed, source), (package, PluginSource::Upload)] { + assert!( + store + .install_artifact(candidate, source, &context()) + .await + .is_err_and(|error| error.kind() == AdminStoreErrorKind::Conflict) + ); + } + assert_eq!(store.list_artifacts().await.unwrap(), vec![saved.artifact]); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(revision, 2); + database.close().await; +} + +#[tokio::test] +async fn changing_update_binding_does_not_relabel_custom_package_as_official() { + let Some(database) = TestDatabase::create("plugin_builtin_provenance").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let package = artifact('a', &["linux-x86_64"]); + let saved = store + .install_artifact(package.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + let changed_revision = store + .change_update_source( + PluginSourceBinding { + plugin_id: package.metadata.plugin_id.clone(), + source: PluginUpdateSource::Builtin, + policy: Default::default(), + outbound_proxy_id: None, + }, + &context(), + ) + .await + .unwrap(); + assert!( + store + .install_artifact( + package, + PluginSource::Builtin { + release: "3.13.0".into(), + }, + &context(), + ) + .await + .is_err_and(|error| error.kind() == AdminStoreErrorKind::Conflict) + ); + assert_eq!(store.list_artifacts().await.unwrap(), vec![saved.artifact]); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(u64::try_from(revision).unwrap(), changed_revision.get()); + database.close().await; +} + +#[tokio::test] +async fn authorized_source_change_reuses_identical_package_without_rewriting_provenance() { + let Some(database) = TestDatabase::create("plugin_reinstall_source").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let package = artifact('a', &["linux-x86_64"]); + let saved = store + .install_artifact(package.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + let source = PluginSource::Url { + url: "https://example.com/plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: None, + }; + assert!( + store + .install_artifact(package.clone(), source.clone(), &context()) + .await + .is_err_and(|error| error.kind() == AdminStoreErrorKind::Conflict), + "未经管理员确认的来源仍需拒绝" + ); + let revision = store + .change_update_source( + PluginSourceBinding { + plugin_id: package.metadata.plugin_id.clone(), + source: PluginUpdateSource::Url { + url: "https://example.com/plugin.tar.gz".into(), + }, + policy: Default::default(), + outbound_proxy_id: None, + }, + &context(), + ) + .await + .unwrap(); + let repeated = store + .install_artifact(package, source, &context()) + .await + .unwrap(); + assert_eq!(repeated.artifact, saved.artifact, "保留首次安装来源和时间"); + assert_eq!( + repeated.config_revision, revision, + "相同包不产生额外版本变更" + ); + assert_eq!(store.list_artifacts().await.unwrap().len(), 1); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/plugins/credentials.rs b/backend/crates/gateway-store/tests/postgres/plugins/credentials.rs new file mode 100644 index 000000000..b97129ba2 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/credentials.rs @@ -0,0 +1,256 @@ +use gateway_admin::{ + model::plugins::{ + PluginSource, + distribution::{ + DownloadPurpose, SourceAuthentication, SourceCredential, SourceCredentialInfo, + }, + }, + ports::{plugins::PluginStore, store::AdminStoreErrorKind}, +}; +use gateway_store::postgres::PgPluginStore; +use secrecy::ExposeSecret as _; + +use super::{ + super::TestDatabase, + artifacts::{artifact, context, initialize_revision}, +}; + +#[tokio::test] +async fn download_secrets_survive_recovery_without_entering_lists_or_audit_and_references_block_deletion() + { + let Some(database) = TestDatabase::create("plugin_credentials").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let id = uuid::Uuid::now_v7().to_string(); + let info = SourceCredentialInfo { + id: id.clone(), + name: "Test GitHub".into(), + origin: "https://api.github.com".into(), + path_prefix: "/repos/example/plugin".into(), + purposes: vec![DownloadPurpose::Metadata, DownloadPurpose::Artifact], + }; + store + .save_source_credential( + SourceCredential { + info: info.clone(), + authentication: SourceAuthentication::Github { + token: "fixture-sensitive-value".into(), + }, + }, + &context(), + ) + .await + .unwrap(); + let listed = store.list_source_credentials().await.unwrap(); + assert_eq!(listed, vec![info]); + assert!( + !serde_json::to_string(&listed) + .unwrap() + .contains("fixture-sensitive-value") + ); + drop(store); + let store = PgPluginStore::new(database.pool.clone()); + let recovered = store.load_source_credential(&id).await.unwrap(); + let SourceAuthentication::Github { token } = recovered.authentication else { + panic!("wrong authentication type") + }; + assert_eq!(token.expose_secret(), "fixture-sensitive-value"); + let saved = store + .install_artifact( + artifact('c', &["linux-x86_64"]), + PluginSource::Github { + repository: "example/plugin".into(), + tag: "v1.0.0".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![id.clone()], + outbound_proxy: None, + }, + &context(), + ) + .await + .unwrap(); + assert_eq!( + store + .delete_source_credential(&id, &context()) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + let audit: Vec = + sqlx::query_scalar("select row_to_json(events)::text from admin_audit_events events") + .fetch_all(&database.pool) + .await + .unwrap(); + assert!( + audit + .iter() + .all(|event| !event.contains("fixture-sensitive-value")) + ); + store + .delete_artifact(&saved.artifact.metadata.sha256, &context()) + .await + .unwrap(); + assert!(store.list_source_credentials().await.unwrap().is_empty()); + assert!(store.list_update_sources().await.unwrap().is_empty()); + assert!(matches!( + store.load_source_credential(&id).await, + Err(error) if error.kind() == AdminStoreErrorKind::NotFound + )); + database.close().await; +} + +fn credential(id: &str) -> SourceCredential { + SourceCredential { + info: SourceCredentialInfo { + id: id.into(), + name: "Cleanup fixture".into(), + origin: "https://downloads.example.com".into(), + path_prefix: "/".into(), + purposes: vec![DownloadPurpose::Artifact], + }, + authentication: SourceAuthentication::Bearer { + token: "cleanup-fixture-secret".into(), + }, + } +} + +fn source(ids: &[&str]) -> PluginSource { + PluginSource::Url { + url: "https://downloads.example.com/plugin.tar.gz".into(), + credential_ids: ids.iter().map(|id| (*id).into()).collect(), + outbound_proxy: None, + } +} + +#[tokio::test] +async fn deletion_cleans_exclusive_credentials_but_preserves_shared_and_unrelated_ones() { + let Some(database) = TestDatabase::create("plugin_credential_cleanup").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let exclusive = uuid::Uuid::now_v7().to_string(); + let shared = uuid::Uuid::now_v7().to_string(); + let unrelated = uuid::Uuid::now_v7().to_string(); + for id in [&exclusive, &shared, &unrelated] { + store + .save_source_credential(credential(id), &context()) + .await + .unwrap(); + } + let first = artifact('a', &["linux-x86_64"]); + store + .install_artifact(first.clone(), source(&[&exclusive, &shared]), &context()) + .await + .unwrap(); + let mut second = artifact('b', &["linux-x86_64"]); + second.metadata.plugin_id = "test.other".into(); + second.metadata.name = "other".into(); + store + .install_artifact(second.clone(), source(&[&shared]), &context()) + .await + .unwrap(); + + let revision = store + .delete_artifact(&first.metadata.sha256, &context()) + .await + .unwrap(); + let remaining = store.list_source_credentials().await.unwrap(); + assert_eq!( + remaining + .iter() + .map(|info| info.id.as_str()) + .collect::>(), + [&shared, &unrelated] + ); + assert_eq!( + store.list_update_sources().await.unwrap()[0].plugin_id, + "test.other" + ); + let audits: Vec<(String, i64)> = sqlx::query_as( + "select entity_kind,config_revision from admin_audit_events where action='delete' order by entity_kind", + ).fetch_all(&database.pool).await.unwrap(); + let revision = i64::try_from(revision.get()).unwrap(); + assert_eq!( + audits, + vec![ + ("plugin_artifact".into(), revision), + ("plugin_source".into(), revision), + ("plugin_source_credential".into(), revision), + ] + ); + + store + .delete_artifact(&second.metadata.sha256, &context()) + .await + .unwrap(); + assert_eq!( + store.list_source_credentials().await.unwrap(), + vec![credential(&unrelated).info] + ); + assert!(store.list_update_sources().await.unwrap().is_empty()); + database.close().await; +} + +#[tokio::test] +async fn cleanup_failure_rolls_back_artifact_source_credentials_revision_and_audit() { + let Some(database) = TestDatabase::create("plugin_cleanup_atomic").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let id = uuid::Uuid::now_v7().to_string(); + store + .save_source_credential(credential(&id), &context()) + .await + .unwrap(); + let installed = store + .install_artifact(artifact('c', &["linux-x86_64"]), source(&[&id]), &context()) + .await + .unwrap(); + let before_sources = store.list_update_sources().await.unwrap(); + sqlx::raw_sql( + "create function reject_credential_cleanup() returns trigger language plpgsql as $$ + begin raise exception 'cleanup failure fixture'; end $$; + create trigger reject_credential_cleanup before delete on plugin_source_credentials + for each row execute function reject_credential_cleanup();", + ) + .execute(&database.pool) + .await + .unwrap(); + + assert!( + store + .delete_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .is_err() + ); + assert_eq!( + store.list_artifacts().await.unwrap(), + vec![installed.artifact] + ); + assert_eq!(store.list_update_sources().await.unwrap(), before_sources); + assert_eq!( + store.list_source_credentials().await.unwrap(), + vec![credential(&id).info] + ); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!( + u64::try_from(revision).unwrap(), + installed.config_revision.get() + ); + let audits: i64 = + sqlx::query_scalar("select count(*) from admin_audit_events where action='delete'") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audits, 0); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/plugins/instances.rs b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs new file mode 100644 index 000000000..02d2394aa --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/instances.rs @@ -0,0 +1,835 @@ +use std::collections::BTreeMap; + +use gateway_admin::{ + model::{ + Revision, + client_keys::DeleteClientKey, + plugins::{ + PluginSource, + instances::{ + PluginCapabilityBinding, PluginFailurePolicy, PluginFrontendIdentityBinding, + PluginInstance, PluginInstanceReplacement, + }, + state::{PluginStateCommit, PluginStateConfiguration}, + }, + }, + ports::{ + plugins::PluginStore, + store::{AdminStoreErrorKind, ClientKeyStore}, + }, +}; +use gateway_core::policy::ClientApiKeyId; +use gateway_store::postgres::{PgAdminClientKeyStore, PgPluginStore}; +use secrecy::ExposeSecret as _; + +use super::{ + super::TestDatabase, + artifacts::{artifact, context, initialize_revision}, +}; + +fn instance(digest: String) -> PluginInstance { + PluginInstance { + id: uuid::Uuid::now_v7().to_string(), + name: "Example".into(), + artifact_sha256: digest, + enabled: false, + trusted_process: true, + configuration: serde_json::json!({"baseUrl":"https://example.com"}), + secrets: BTreeMap::from([("token".into(), "sensitive-fixture".into())]), + grants: vec![], + bindings: vec![], + revision: Revision::new(1).unwrap(), + } +} + +#[tokio::test] +async fn management_authorization_checks_exact_target_without_loading_secrets() { + use gateway_admin::model::plugins::management::PluginManagementTarget; + let Some(database) = TestDatabase::create("plugin_target_authorization").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let mut candidate = instance(installed.artifact.metadata.sha256); + candidate.enabled = true; + let saved = store + .save_instance(candidate, accepted.config_revision, &context()) + .await + .unwrap(); + let target = PluginManagementTarget { + instance_id: saved.instance.id.clone(), + artifact_sha256: saved.instance.artifact_sha256.clone(), + revision: saved.instance.revision.get(), + }; + // 故意留下不能解码为密钥映射的测试数据,授权查询不得读取或解释它。 + sqlx::query("update plugin_instance_secrets set secrets_json=$2 where instance_id=$1") + .bind(uuid::Uuid::parse_str(&target.instance_id).unwrap()) + .bind(serde_json::json!({"token": false})) + .execute(&database.pool) + .await + .unwrap(); + assert!(store.management_target_is_current(&target).await.unwrap()); + for changed in [ + PluginManagementTarget { + revision: target.revision + 1, + ..target.clone() + }, + PluginManagementTarget { + revision: u64::MAX, + ..target.clone() + }, + PluginManagementTarget { + artifact_sha256: "b".repeat(64), + ..target.clone() + }, + PluginManagementTarget { + instance_id: uuid::Uuid::now_v7().to_string(), + ..target.clone() + }, + PluginManagementTarget { + instance_id: "invalid".into(), + ..target.clone() + }, + ] { + assert!(!store.management_target_is_current(&changed).await.unwrap()); + } + sqlx::query("update plugin_instances set enabled=false where id=$1") + .bind(uuid::Uuid::parse_str(&target.instance_id).unwrap()) + .execute(&database.pool) + .await + .unwrap(); + assert!(!store.management_target_is_current(&target).await.unwrap()); + sqlx::query("update plugin_instances set enabled=true where id=$1") + .bind(uuid::Uuid::parse_str(&target.instance_id).unwrap()) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("update plugin_artifacts set accepted_at=null where sha256=$1") + .bind(&target.artifact_sha256) + .execute(&database.pool) + .await + .unwrap(); + assert!(!store.management_target_is_current(&target).await.unwrap()); + store + .delete_instance(&target.instance_id, saved.config_revision, &context()) + .await + .expect_err("启用中的实例不能删除"); + sqlx::query("update plugin_instances set enabled=false where id=$1") + .bind(uuid::Uuid::parse_str(&target.instance_id).unwrap()) + .execute(&database.pool) + .await + .unwrap(); + store + .delete_instance(&target.instance_id, saved.config_revision, &context()) + .await + .unwrap(); + assert!(!store.management_target_is_current(&target).await.unwrap()); + database.close().await; +} + +fn usage_binding(client_key_id: &str, account_group_id: &str) -> PluginCapabilityBinding { + PluginCapabilityBinding { + contribution: "test.example.usage".into(), + stage: "observation".into(), + order: 0, + failure_policy: PluginFailurePolicy::Observe, + client_key_ids: vec![client_key_id.into()], + account_group_ids: vec![account_group_id.into()], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![], + } +} + +fn frontend_authentication_binding( + principal: &str, + client_key_id: &str, +) -> PluginCapabilityBinding { + PluginCapabilityBinding { + contribution: "test.example.frontendAuthentication".into(), + stage: "authentication".into(), + order: 0, + failure_policy: PluginFailurePolicy::Reject, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + identity_bindings: vec![PluginFrontendIdentityBinding { + principal: principal.into(), + client_key_id: client_key_id.into(), + }], + } +} + +#[tokio::test] +async fn replacement_commits_enablement_together_and_preserves_old_configuration_and_secrets() { + let Some(database) = TestDatabase::create("plugin_replace").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = store + .install_artifact( + artifact('e', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let mut old = instance(installed.artifact.metadata.sha256.clone()); + old.enabled = true; + let saved = store + .save_instance(old.clone(), accepted.config_revision, &context()) + .await + .unwrap(); + let mut current = instance(installed.artifact.metadata.sha256); + current.enabled = true; + let switched = store + .save_instance_replacing( + current.clone(), + saved.config_revision, + PluginStateCommit { + configuration: PluginStateConfiguration { namespaces: vec![] }, + transition_id: None, + }, + &[PluginInstanceReplacement { + id: old.id.clone(), + expected_revision: saved.instance.revision.get(), + }], + &context(), + ) + .await + .unwrap(); + let snapshot = store.load_instances().await.unwrap(); + assert_eq!(snapshot.config_revision, switched.config_revision); + let previous = snapshot + .instances + .iter() + .find(|item| item.id == old.id) + .unwrap(); + let target = snapshot + .instances + .iter() + .find(|item| item.id == current.id) + .unwrap(); + assert!(!previous.enabled); + assert!(target.enabled); + assert_eq!(previous.revision, target.revision); + assert_eq!(previous.configuration, old.configuration); + assert_eq!( + previous.secrets["token"].expose_secret(), + old.secrets["token"].expose_secret() + ); + let audits: i64 = + sqlx::query_scalar("select count(*) from admin_audit_events where config_revision=$1") + .bind(i64::try_from(switched.config_revision.get()).unwrap()) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audits, 2); + database.close().await; +} + +#[tokio::test] +async fn replacement_rolls_back_all_writes_when_a_confirmed_configuration_has_changed() { + let Some(database) = TestDatabase::create("plugin_replace_stale").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = store + .install_artifact( + artifact('f', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let mut first = instance(installed.artifact.metadata.sha256.clone()); + first.enabled = true; + let first = store + .save_instance(first, accepted.config_revision, &context()) + .await + .unwrap(); + let mut second = instance(installed.artifact.metadata.sha256.clone()); + second.enabled = true; + let second = store + .save_instance(second, first.config_revision, &context()) + .await + .unwrap(); + let mut target = instance(installed.artifact.metadata.sha256); + target.enabled = true; + let result = store + .save_instance_replacing( + target, + second.config_revision, + PluginStateCommit { + configuration: PluginStateConfiguration { namespaces: vec![] }, + transition_id: None, + }, + &[ + PluginInstanceReplacement { + id: first.instance.id.clone(), + expected_revision: first.instance.revision.get(), + }, + PluginInstanceReplacement { + id: second.instance.id.clone(), + expected_revision: second.instance.revision.get() - 1, + }, + ], + &context(), + ) + .await; + assert_eq!(result.err().unwrap().kind(), AdminStoreErrorKind::Conflict); + let snapshot = store.load_instances().await.unwrap(); + assert_eq!(snapshot.config_revision, second.config_revision); + assert_eq!(snapshot.instances.len(), 2); + assert!(snapshot.instances.iter().all(|item| item.enabled)); + database.close().await; +} + +#[tokio::test] +async fn instance_save_rejects_unaccepted_enablement_and_forged_acceptance_facts() { + let Some(database) = TestDatabase::create("plugin_instance_acceptance_facts").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let mut package = artifact('d', &["linux-x86_64"]); + package.metadata.requested_permissions = vec!["network".into()]; + let installed = store + .install_artifact(package, PluginSource::Upload, &context()) + .await + .unwrap(); + + let mut enabled = instance(installed.artifact.metadata.sha256.clone()); + enabled.enabled = true; + enabled.trusted_process = false; + assert_eq!( + store + .save_instance(enabled, installed.config_revision, &context()) + .await + .err() + .expect("unaccepted artifact cannot be enabled") + .kind(), + AdminStoreErrorKind::Invalid + ); + let mut forged = instance(installed.artifact.metadata.sha256.clone()); + forged.trusted_process = true; + assert_eq!( + store + .save_instance(forged, installed.config_revision, &context()) + .await + .err() + .expect("unaccepted artifact cannot forge trust") + .kind(), + AdminStoreErrorKind::Invalid + ); + + let accepted = store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let missing_grant = instance(installed.artifact.metadata.sha256); + assert_eq!( + store + .save_instance(missing_grant, accepted.config_revision, &context()) + .await + .err() + .expect("accepted artifact grants cannot be forged") + .kind(), + AdminStoreErrorKind::Invalid + ); + database.close().await; +} + +#[tokio::test] +async fn instance_and_secret_restore_together_and_artifact_deletion_respects_references() { + let Some(database) = TestDatabase::create("plugin_instances").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let artifact = store + .install_artifact( + artifact('e', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let artifact = store + .accept_artifact(&artifact.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let input = instance(artifact.artifact.metadata.sha256.clone()); + let saved = store + .save_instance(input.clone(), artifact.config_revision, &context()) + .await + .unwrap(); + drop(store); + let store = PgPluginStore::new(database.pool.clone()); + let loaded = store.load_instances().await.unwrap(); + assert_eq!(loaded.config_revision, saved.config_revision); + assert_eq!(loaded.instances[0].configuration, input.configuration); + assert_eq!( + loaded.instances[0].secrets["token"].expose_secret(), + "sensitive-fixture" + ); + let configuration: String = + sqlx::query_scalar("select configuration_json::text from plugin_instances") + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(!configuration.contains("sensitive-fixture")); + assert_eq!( + store + .delete_artifact(&input.artifact_sha256, &context()) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + store + .delete_instance(&input.id, loaded.config_revision, &context()) + .await + .unwrap(); + store + .delete_artifact(&input.artifact_sha256, &context()) + .await + .unwrap(); + let count: i64 = sqlx::query_scalar("select count(*) from plugin_instance_secrets") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(count, 0); + database.close().await; +} + +#[tokio::test] +async fn a_prepared_instance_cannot_commit_over_a_later_configuration_change() { + let Some(database) = TestDatabase::create("plugin_instance_race").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let artifact = store + .install_artifact( + artifact('f', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let artifact = store + .accept_artifact(&artifact.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let first = instance(artifact.artifact.metadata.sha256); + let mut stale = first.clone(); + stale.name = "stale".into(); + let saved = store + .save_instance(first.clone(), artifact.config_revision, &context()) + .await + .unwrap(); + let error = store + .save_instance(stale, artifact.config_revision, &context()) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + let loaded = store.load_instances().await.unwrap(); + assert_eq!(loaded.instances[0].name, first.name); + assert_eq!(loaded.config_revision, saved.config_revision); + database.close().await; +} + +#[tokio::test] +async fn missing_binding_references_reject_without_bumping_revision() { + let Some(database) = TestDatabase::create("plugin_instance_missing_scope").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let artifact = store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let artifact = store + .accept_artifact(&artifact.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let mut input = instance(artifact.artifact.metadata.sha256); + input.enabled = true; + input.bindings = vec![usage_binding( + "missing-client-key", + "grp_11111111111111111111111111111111", + )]; + let error = match store + .save_instance(input, artifact.config_revision, &context()) + .await + { + Ok(_) => panic!("missing binding references must be rejected"), + Err(error) => error, + }; + assert_eq!(error.kind(), AdminStoreErrorKind::Invalid); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!( + revision, + i64::try_from(artifact.config_revision.get()).unwrap() + ); + let count: i64 = sqlx::query_scalar("select count(*) from plugin_instances") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(count, 0); + database.close().await; +} + +#[tokio::test] +async fn existing_binding_references_roundtrip_in_the_instance_snapshot() { + let Some(database) = TestDatabase::create("plugin_instance_existing_scope").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let artifact = store + .install_artifact( + artifact('b', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let artifact = store + .accept_artifact(&artifact.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let client_key_id = "client-key-observer"; + let account_group_id = "grp_22222222222222222222222222222222"; + let plaintext_key = format!("sk_{}", "a".repeat(43)); + sqlx::query( + "insert into client_api_keys( + id, name, key, enabled, max_concurrency, requests_per_minute, created_at, updated_at + ) values ($1, 'Observer Key', $2, true, 0, 0, now(), now())", + ) + .bind(client_key_id) + .bind(plaintext_key) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query( + "insert into account_groups( + id, name, description, enabled, created_at, updated_at, color + ) values ($1, 'Observer Group', null, false, now(), now(), '#112233FF')", + ) + .bind(account_group_id) + .execute(&database.pool) + .await + .unwrap(); + + let mut input = instance(artifact.artifact.metadata.sha256); + input.enabled = true; + input.bindings = vec![usage_binding(client_key_id, account_group_id)]; + let saved = store + .save_instance(input, artifact.config_revision, &context()) + .await + .unwrap(); + let loaded = store.load_instances().await.unwrap(); + assert_eq!(loaded.instances.len(), 1); + assert_eq!( + loaded.instances[0].bindings[0].client_key_ids, + [client_key_id] + ); + assert_eq!( + loaded.instances[0].bindings[0].account_group_ids, + [account_group_id] + ); + + sqlx::query("delete from client_api_keys where id = $1") + .bind(client_key_id) + .execute(&database.pool) + .await + .unwrap(); + sqlx::query("delete from account_groups where id = $1") + .bind(account_group_id) + .execute(&database.pool) + .await + .unwrap(); + let mut disabled = loaded.instances[0].clone(); + disabled.enabled = false; + store + .save_instance(disabled, saved.config_revision, &context()) + .await + .expect("disabling must preserve stale bindings as a recovery path"); + let disabled = store.load_instances().await.unwrap().instances.remove(0); + assert!(!disabled.enabled); + assert_eq!(disabled.bindings[0].client_key_ids, [client_key_id]); + assert_eq!(disabled.bindings[0].account_group_ids, [account_group_id]); + database.close().await; +} + +#[tokio::test] +async fn client_key_deletion_revision_prevents_a_stale_authentication_mapping_commit() { + let Some(database) = TestDatabase::create("plugin_instance_auth_key_race").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let artifact = store + .install_artifact( + artifact('c', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + let artifact = store + .accept_artifact(&artifact.artifact.metadata.sha256, &context()) + .await + .unwrap(); + let client_key_id = "client-key-authentication"; + sqlx::query( + "insert into client_api_keys( + id, name, key, enabled, max_concurrency, requests_per_minute, created_at, updated_at + ) values ($1, 'Authentication Key', $2, true, 0, 0, now(), now())", + ) + .bind(client_key_id) + .bind(format!("sk_{}", "b".repeat(43))) + .execute(&database.pool) + .await + .unwrap(); + + let mut input = instance(artifact.artifact.metadata.sha256); + input.enabled = true; + input.bindings = vec![frontend_authentication_binding( + "controlled-principal", + client_key_id, + )]; + let saved = store + .save_instance(input.clone(), artifact.config_revision, &context()) + .await + .expect("existing mapped key is accepted"); + let loaded = store.load_instances().await.unwrap(); + assert_eq!( + loaded.instances[0].bindings[0].identity_bindings[0].client_key_id, + client_key_id + ); + + let keys = PgAdminClientKeyStore::new(database.pool.clone()); + let deleted_revision = keys + .delete_client_key( + DeleteClientKey { + id: ClientApiKeyId::new(client_key_id).unwrap(), + }, + &context(), + ) + .await + .expect("deletion is an explicit revocation"); + assert!(deleted_revision.get() > saved.config_revision.get()); + + let stale_error = match store + .save_instance(input.clone(), saved.config_revision, &context()) + .await + { + Ok(_) => panic!("stale candidate cannot commit over key deletion"), + Err(error) => error, + }; + assert_eq!(stale_error.kind(), AdminStoreErrorKind::Conflict); + let missing_error = match store + .save_instance(input.clone(), deleted_revision, &context()) + .await + { + Ok(_) => panic!("an enabled binding cannot reference the deleted key"), + Err(error) => error, + }; + assert_eq!(missing_error.kind(), AdminStoreErrorKind::Invalid); + + input.enabled = false; + store + .save_instance(input, deleted_revision, &context()) + .await + .expect("disabling preserves the stale mapping as a recovery path"); + database.close().await; +} + +#[tokio::test] +async fn version_settings_survive_upgrade_and_disabled_edits_and_share_the_transaction() { + let Some(database) = TestDatabase::create("plugin_version_settings").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let mut revision = Revision::new(1).unwrap(); + for digest in ['a', 'b'] { + let mut package = artifact(digest, &["linux-x86_64"]); + package.metadata.version = if digest == 'a' { "1.0.0" } else { "2.0.0" }.into(); + store + .install_artifact(package, PluginSource::Upload, &context()) + .await + .unwrap(); + revision = store + .accept_artifact(&digest.to_string().repeat(64), &context()) + .await + .unwrap() + .config_revision; + } + let mut old = instance("a".repeat(64)); + old.enabled = true; + old.configuration = serde_json::json!({"v1":"original"}); + let saved = store + .save_instance(old, revision, &context()) + .await + .unwrap(); + let id = saved.instance.id.clone(); + let mut next = saved.instance; + next.artifact_sha256 = "b".repeat(64); + next.configuration = serde_json::json!({"v2":"changed"}); + next.secrets.insert("token".into(), "v2-secret".into()); + let saved = store + .save_instance(next, saved.config_revision, &context()) + .await + .unwrap(); + let old_settings = store + .load_version_configuration(&id, &"a".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!( + old_settings.configuration, + serde_json::json!({"v1":"original"}) + ); + assert_eq!( + old_settings.secrets["token"].expose_secret(), + "sensitive-fixture" + ); + let mut disabled = saved.instance; + disabled.enabled = false; + disabled.configuration = serde_json::json!({"incomplete":"draft"}); + disabled.secrets.clear(); + let saved = store + .save_instance(disabled, saved.config_revision, &context()) + .await + .unwrap(); + let v2_settings = store + .load_version_configuration(&id, &"b".repeat(64)) + .await + .unwrap() + .unwrap(); + assert_eq!( + v2_settings.configuration, + serde_json::json!({"v2":"changed"}) + ); + assert_eq!(v2_settings.secrets["token"].expose_secret(), "v2-secret"); + let mut conflict = saved.instance; + conflict.enabled = true; + conflict.configuration = serde_json::json!({"mustNotCommit":true}); + assert!( + store + .save_instance(conflict, revision, &context()) + .await + .is_err() + ); + assert_eq!( + store + .load_version_configuration(&id, &"b".repeat(64)) + .await + .unwrap() + .unwrap() + .configuration, + v2_settings.configuration + ); + assert_eq!(store.configuration_versions(&id).await.unwrap().len(), 2); + store + .delete_artifact(&"a".repeat(64), &context()) + .await + .unwrap(); + assert!( + store + .load_version_configuration(&id, &"a".repeat(64)) + .await + .unwrap() + .is_none() + ); + let current = store.load_instances().await.unwrap(); + store + .delete_instance(&id, current.config_revision, &context()) + .await + .unwrap(); + assert!(store.configuration_versions(&id).await.unwrap().is_empty()); + database.close().await; +} + +#[tokio::test] +async fn version_configuration_migration_preserves_existing_enabled_settings() { + let Some(database) = TestDatabase::create_through("plugin_version_upgrade", 18).await else { + return; + }; + initialize_revision(&database).await; + let id = uuid::Uuid::now_v7(); + let digest = "a".repeat(64); + let store = PgPluginStore::new(database.pool.clone()); + let package = store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + store + .accept_artifact(&package.artifact.metadata.sha256, &context()) + .await + .unwrap(); + sqlx::query("insert into plugin_instances (id,artifact_sha256,name,enabled,configuration_json,bindings_json,revision) values ($1,$2,'prior installation',true,$3,'[]'::jsonb,1)") + .bind(id).bind(&digest).bind(serde_json::json!({"preserved":true})).execute(&database.pool).await.unwrap(); + sqlx::query("insert into plugin_instance_secrets (instance_id,secrets_json) values ($1,$2)") + .bind(id) + .bind(serde_json::json!({"token":"migration-fixture-secret"})) + .execute(&database.pool) + .await + .unwrap(); + super::super::TEST_MIGRATOR + .run(&database.pool) + .await + .unwrap(); + let saved = store + .load_version_configuration(&id.to_string(), &digest) + .await + .unwrap() + .unwrap(); + assert_eq!(saved.configuration, serde_json::json!({"preserved":true})); + assert_eq!( + saved.secrets["token"].expose_secret(), + "migration-fixture-secret" + ); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/plugins/mod.rs b/backend/crates/gateway-store/tests/postgres/plugins/mod.rs new file mode 100644 index 000000000..d45179844 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/mod.rs @@ -0,0 +1,5 @@ +mod artifacts; +mod credentials; +mod instances; +mod sources; +mod state; diff --git a/backend/crates/gateway-store/tests/postgres/plugins/sources.rs b/backend/crates/gateway-store/tests/postgres/plugins/sources.rs new file mode 100644 index 000000000..fab0debb7 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/sources.rs @@ -0,0 +1,365 @@ +use gateway_admin::{ + model::{ + plugins::{ + PluginSource, PluginSourceEgress, + distribution::{PluginSourceBinding, PluginUpdatePolicy, PluginUpdateSource}, + }, + proxies::{NewProxy, UpdateProxy}, + }, + ports::{plugins::PluginStore, proxy::ProxyStore, store::AdminStoreErrorKind}, +}; +use gateway_core::account::OutboundProxy; +use gateway_store::postgres::{PgPluginStore, PgProxyRepository}; + +use super::{ + super::TestDatabase, + artifacts::{artifact, context, initialize_revision}, +}; + +#[tokio::test] +async fn upgrades_keep_old_artifacts_and_require_explicit_source_change() { + let Some(database) = TestDatabase::create("plugin_sources").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let old = artifact('a', &["linux-x86_64"]); + store + .install_artifact(old.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + let mut new = artifact('b', &["linux-x86_64"]); + new.metadata.version = "2.0.0".into(); + let remote = PluginSource::Github { + repository: "example/plugin".into(), + tag: "v2.0.0".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: None, + }; + let failure = store + .install_artifact(new.clone(), remote.clone(), &context()) + .await + .err() + .unwrap(); + assert_eq!(failure.kind(), AdminStoreErrorKind::Conflict); + assert_eq!(store.list_artifacts().await.unwrap().len(), 1); + store + .change_update_source( + PluginSourceBinding { + plugin_id: "test.example".into(), + source: PluginUpdateSource::Github { + repository: "example/plugin".into(), + }, + policy: Default::default(), + outbound_proxy_id: None, + }, + &context(), + ) + .await + .unwrap(); + store + .install_artifact(new, remote, &context()) + .await + .unwrap(); + assert_eq!(store.list_artifacts().await.unwrap().len(), 2); + assert_eq!( + store + .load_artifact(&old.metadata.sha256) + .await + .unwrap() + .archive, + old.archive + ); + database.close().await; +} + +#[tokio::test] +async fn concurrent_identical_installations_are_idempotent_without_duplicate_audit() { + let Some(database) = TestDatabase::create("plugin_install_race").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let context = context(); + let results = futures::future::join_all((0..8).map(|_| { + store.install_artifact( + artifact('d', &["linux-x86_64"]), + PluginSource::Upload, + &context, + ) + })) + .await; + assert!(results.iter().all(|result| { + result + .as_ref() + .is_ok_and(|mutation| mutation.config_revision.get() == 2) + })); + let count: i64 = sqlx::query_scalar( + "select count(*) from admin_audit_events where entity_kind='plugin_artifact'", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(count, 1); + database.close().await; +} + +#[tokio::test] +async fn last_version_deletion_clears_source_rules_and_allows_a_fresh_install() { + let Some(database) = TestDatabase::create("plugin_source_cleanup").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let first = artifact('a', &["linux-x86_64"]); + let mut second = artifact('b', &["linux-x86_64"]); + second.metadata.version = "2.0.0".into(); + for package in [&first, &second] { + store + .install_artifact(package.clone(), PluginSource::Upload, &context()) + .await + .unwrap(); + } + store + .delete_artifact(&first.metadata.sha256, &context()) + .await + .unwrap(); + assert_eq!( + store.list_update_sources().await.unwrap()[0].source, + PluginUpdateSource::Upload + ); + let remote = PluginSource::Github { + repository: "example/new-source".into(), + tag: "v1.0.0".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: None, + }; + assert_eq!( + store + .install_artifact(first.clone(), remote.clone(), &context()) + .await + .err() + .unwrap() + .kind(), + AdminStoreErrorKind::Conflict + ); + + store + .delete_artifact(&second.metadata.sha256, &context()) + .await + .unwrap(); + assert!(store.list_update_sources().await.unwrap().is_empty()); + store + .install_artifact(first, remote.clone(), &context()) + .await + .unwrap(); + assert_eq!( + store.list_update_sources().await.unwrap()[0].source, + (&remote).into() + ); + database.close().await; +} + +#[tokio::test] +async fn update_policy_is_persistent_audited_and_unchanged_by_install_or_read() { + let Some(database) = TestDatabase::create("plugin_update_policy").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + store + .install_artifact( + artifact('a', &["linux-x86_64"]), + PluginSource::Upload, + &context(), + ) + .await + .unwrap(); + assert_eq!( + store.list_update_sources().await.unwrap()[0].policy, + PluginUpdatePolicy::Manual {} + ); + let binding = PluginSourceBinding { + plugin_id: "test.example".into(), + source: PluginUpdateSource::Github { + repository: "example/plugin".into(), + }, + policy: PluginUpdatePolicy::Pinned { + tag: "v2.0.0-rc.1".into(), + allow_prerelease: true, + }, + outbound_proxy_id: None, + }; + assert_eq!( + store + .change_update_source(binding.clone(), &context()) + .await + .unwrap() + .get(), + 3 + ); + drop(store); + let reopened = PgPluginStore::new(database.pool.clone()); + for _ in 0..2 { + assert_eq!( + reopened.list_update_sources().await.unwrap(), + vec![binding.clone()] + ); + } + let mut next = artifact('b', &["linux-x86_64"]); + next.metadata.version = "2.0.0-rc.1".into(); + reopened + .install_artifact( + next, + PluginSource::Github { + repository: "example/plugin".into(), + tag: "v2.0.0-rc.1".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: None, + }, + &context(), + ) + .await + .unwrap(); + assert_eq!(reopened.list_update_sources().await.unwrap(), vec![binding]); + let audits: Vec<(String, i64, Vec)> = sqlx::query_as("select action,config_revision,changed_fields from admin_audit_events where entity_kind='plugin_source'") + .fetch_all(&database.pool).await.unwrap(); + assert_eq!( + audits, + vec![( + "change_source".into(), + 3, + vec!["source".into(), "policy".into(), "outbound_proxy".into()] + )] + ); + let revision: i64 = + sqlx::query_scalar("select config_revision from runtime_settings where id=1") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(revision, 4, "读取不能推进配置 revision"); + database.close().await; +} + +#[tokio::test] +async fn source_proxy_is_revision_fenced_and_referenced_until_the_artifact_is_deleted() { + let Some(database) = TestDatabase::create("plugin_source_proxy").await else { + return; + }; + initialize_revision(&database).await; + let plugins = PgPluginStore::new(database.pool.clone()); + let proxies = PgProxyRepository::new(database.pool.clone()); + let saved = proxies + .create( + NewProxy { + location: None, + name: "Plugin source".into(), + proxy: OutboundProxy::parse("http://127.0.0.1:18080").unwrap(), + }, + &context(), + ) + .await + .unwrap() + .record; + let source = PluginSource::Github { + repository: "example/plugin".into(), + tag: "v1.0.0".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: Some(PluginSourceEgress { + id: saved.id.clone(), + revision: saved.revision.get(), + }), + }; + let installed = plugins + .install_artifact(artifact('e', &["linux-x86_64"]), source.clone(), &context()) + .await + .unwrap(); + assert_eq!( + plugins.list_update_sources().await.unwrap()[0].outbound_proxy_id, + Some(saved.id.clone()) + ); + let updated = proxies + .update( + UpdateProxy { + location: None, + id: saved.id.clone(), + revision: saved.revision, + name: saved.name, + proxy: Some(OutboundProxy::parse("http://127.0.0.1:18081").unwrap()), + }, + &context(), + ) + .await + .unwrap() + .record; + let mut candidate = artifact('f', &["linux-x86_64"]); + candidate.metadata.version = "2.0.0".into(); + let Err(error) = plugins + .install_artifact(candidate, source, &context()) + .await + else { + panic!("stale proxy revision must reject the artifact") + }; + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + assert_eq!( + proxies + .delete(&updated.id, updated.revision, &context()) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict + ); + plugins + .change_update_source( + PluginSourceBinding { + plugin_id: "test.example".into(), + source: PluginUpdateSource::Upload, + policy: PluginUpdatePolicy::Manual {}, + outbound_proxy_id: None, + }, + &context(), + ) + .await + .unwrap(); + assert_eq!( + proxies + .delete(&updated.id, updated.revision, &context()) + .await + .unwrap_err() + .kind(), + AdminStoreErrorKind::Conflict, + "历史制品仍引用该代理时不得删除" + ); + plugins + .delete_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap(); + proxies + .delete(&updated.id, updated.revision, &context()) + .await + .unwrap(); + let removed_proxy_source = PluginSource::Github { + repository: "example/plugin".into(), + tag: "v3.0.0".into(), + asset: "plugin.tar.gz".into(), + credential_ids: vec![], + outbound_proxy: Some(PluginSourceEgress { + id: updated.id, + revision: updated.revision.get(), + }), + }; + let mut candidate = artifact('0', &["linux-x86_64"]); + candidate.metadata.version = "3.0.0".into(); + let Err(error) = plugins + .install_artifact(candidate, removed_proxy_source, &context()) + .await + else { + panic!("deleted frozen proxy identity must reject the artifact") + }; + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/plugins/state.rs b/backend/crates/gateway-store/tests/postgres/plugins/state.rs new file mode 100644 index 000000000..7177605c2 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/plugins/state.rs @@ -0,0 +1,855 @@ +use std::collections::BTreeMap; + +use gateway_admin::{ + model::{ + Revision, + plugins::{ + PluginSource, + instances::PluginInstance, + state::{ + ApplyPluginStateMigration, DeletePluginState, PluginStateCommit, + PluginStateConfiguration, PluginStateMigrationAction, PluginStateMigrationChange, + PluginStateOwnerRequest, PluginStateSchema, PutPluginState, + }, + }, + }, + ports::{ + plugins::{PluginStateStore, PluginStateStoreErrorKind, PluginStore}, + store::AdminStoreErrorKind, + }, +}; +use gateway_store::postgres::PgPluginStore; +use serde_json::json; + +use super::{ + super::TestDatabase, + artifacts::{artifact, context, initialize_revision}, +}; + +fn schema(version: u32, migrates_from: Vec, maximum_records: u32) -> PluginStateSchema { + PluginStateSchema { + namespace: "cache".into(), + schema_version: version, + schema_sha256: char::from_digit(version, 16) + .unwrap() + .to_string() + .repeat(64), + schema: json!({ + "type": "object", + "required": ["value"], + "properties": {"value": {"type": "integer"}}, + "additionalProperties": false, + }), + maximum_records, + maximum_bytes: 1024, + maximum_value_bytes: 128, + migrates_from, + } +} + +fn configuration(schema: PluginStateSchema) -> PluginStateConfiguration { + PluginStateConfiguration { + namespaces: vec![schema], + } +} + +fn instance(artifact_sha256: String) -> PluginInstance { + PluginInstance { + id: uuid::Uuid::now_v7().to_string(), + name: "State fixture".into(), + artifact_sha256, + enabled: false, + trusted_process: true, + configuration: json!({}), + secrets: BTreeMap::new(), + grants: vec![], + bindings: vec![], + revision: Revision::new(1).unwrap(), + } +} + +fn owner_request( + instance: &PluginInstance, + configuration: PluginStateConfiguration, +) -> PluginStateOwnerRequest { + PluginStateOwnerRequest { + instance_id: instance.id.clone(), + artifact_sha256: instance.artifact_sha256.clone(), + instance_revision: instance.revision, + configuration, + } +} + +async fn install_version( + store: &PgPluginStore, + digest: char, + version: &str, +) -> gateway_admin::model::plugins::PluginArtifactMutation { + let mut artifact = artifact(digest, &["linux-x86_64"]); + artifact.metadata.version = version.into(); + let installed = store + .install_artifact(artifact, PluginSource::Upload, &context()) + .await + .unwrap(); + store + .accept_artifact(&installed.artifact.metadata.sha256, &context()) + .await + .unwrap() +} + +async fn migrate_all( + store: &PgPluginStore, + transition_id: &str, + namespace: &str, + batch_size: u32, +) -> Vec { + let mut keys = Vec::new(); + loop { + let batch = store + .migration_batch(transition_id, namespace, batch_size) + .await + .unwrap(); + let complete = batch.records.is_empty(); + let expected_keys: Vec<_> = batch + .records + .iter() + .map(|record| record.key.clone()) + .collect(); + keys.extend(expected_keys.iter().cloned()); + store + .apply_migration_batch(ApplyPluginStateMigration { + transition_id: transition_id.to_owned(), + namespace: namespace.to_owned(), + cursor: batch.cursor, + changes: expected_keys + .iter() + .map(|key| PluginStateMigrationChange { + key: key.clone(), + action: PluginStateMigrationAction::Keep, + }) + .collect(), + expected_keys, + }) + .await + .unwrap(); + if complete { + return keys; + } + } +} + +#[tokio::test] +async fn migration_preserves_byte_order_for_mixed_case_and_unicode_keys() { + let Some(database) = TestDatabase::create("plugin_state_key_order").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let state = configuration(schema(1, vec![], 10)); + let saved = store + .save_instance_with_state( + instance(installed.artifact.metadata.sha256), + installed.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&saved.instance, state)) + .await + .unwrap() + .unwrap(); + for key in ["a", "B", "é", "Z", "中", "a10", "a2"] { + store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: key.into(), + value: json!({"value": 1}), + expected_version: None, + }, + ) + .await + .unwrap(); + } + let target = install_version(&store, 'b', "2.0.0").await; + let transition = store + .begin_transition( + &saved.instance.id, + saved.instance.revision, + &target.artifact.metadata.sha256, + configuration(schema(2, vec![1], 10)), + ) + .await + .unwrap(); + assert_eq!( + migrate_all(&store, &transition.id, "cache", 3).await, + ["B", "Z", "a", "a10", "a2", "é", "中"] + ); + database.close().await; +} + +#[tokio::test] +async fn completed_migration_releases_old_records_and_artifact() { + let Some(database) = TestDatabase::create("plugin_state_release").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let old_digest = installed.artifact.metadata.sha256; + let state = configuration(schema(1, vec![], 2)); + let saved = store + .save_instance_with_state( + instance(old_digest.clone()), + installed.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&saved.instance, state)) + .await + .unwrap() + .unwrap(); + store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "retained".into(), + value: json!({"value": 1}), + expected_version: None, + }, + ) + .await + .unwrap(); + let target = install_version(&store, 'b', "2.0.0").await; + let state = configuration(schema(2, vec![1], 2)); + let transition = store + .begin_transition( + &saved.instance.id, + saved.instance.revision, + &target.artifact.metadata.sha256, + state.clone(), + ) + .await + .unwrap(); + migrate_all(&store, &transition.id, "cache", 100).await; + let mut upgraded = saved.instance; + upgraded.artifact_sha256 = target.artifact.metadata.sha256; + let upgraded = store + .save_instance_with_state( + upgraded, + target.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: Some(transition.id), + }, + &context(), + ) + .await + .unwrap(); + store + .delete_artifact(&old_digest, &context()) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&upgraded.instance, state)) + .await + .unwrap() + .unwrap(); + assert_eq!( + store + .get(&owner, "cache", "retained") + .await + .unwrap() + .unwrap() + .value, + json!({"value": 1}) + ); + let counts: (i64, i64) = sqlx::query_as( + "select (select count(*) from plugin_state_generations), \ + (select count(*) from plugin_state_records)", + ) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(counts, (1, 1)); + database.close().await; +} + +#[tokio::test] +async fn schema_budget_uses_compact_json_not_database_formatting() { + let Some(database) = TestDatabase::create("plugin_state_schema_budget").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let mut namespace = schema(1, vec![], 2); + namespace.schema = json!({ + "type": "string", + "enum": (0..4000).map(|index| format!("v{index:04}")).collect::>(), + }); + assert!(serde_json::to_vec(&namespace.schema).unwrap().len() <= 32 * 1024); + let database_bytes: i32 = sqlx::query_scalar("select octet_length($1::jsonb::text)") + .bind(&namespace.schema) + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(database_bytes > 32 * 1024); + let state = configuration(namespace); + let saved = store + .save_instance_with_state( + instance(installed.artifact.metadata.sha256), + installed.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + assert!( + store + .load_owner(owner_request(&saved.instance, state)) + .await + .unwrap() + .is_some() + ); + database.close().await; +} + +#[tokio::test] +async fn failed_multi_namespace_promotion_restores_cleaned_source_generations() { + let Some(database) = TestDatabase::create("plugin_state_promotion_rollback").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let old_digest = installed.artifact.metadata.sha256; + let mut state = configuration(schema(1, vec![], 2)); + let mut second = state.namespaces[0].clone(); + second.namespace = "settings".into(); + state.namespaces.push(second); + let saved = store + .save_instance_with_state( + instance(old_digest.clone()), + installed.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&saved.instance, state)) + .await + .unwrap() + .unwrap(); + for namespace in ["cache", "settings"] { + store + .put( + &owner, + PutPluginState { + namespace: namespace.into(), + key: "retained".into(), + value: json!({"value": 1}), + expected_version: None, + }, + ) + .await + .unwrap(); + } + let target = install_version(&store, 'b', "2.0.0").await; + let mut state = configuration(schema(2, vec![1], 2)); + let mut second = state.namespaces[0].clone(); + second.namespace = "settings".into(); + state.namespaces.push(second); + let transition = store + .begin_transition( + &saved.instance.id, + saved.instance.revision, + &target.artifact.metadata.sha256, + state.clone(), + ) + .await + .unwrap(); + migrate_all(&store, &transition.id, "cache", 100).await; + let mut upgraded = saved.instance; + upgraded.artifact_sha256 = target.artifact.metadata.sha256; + let commit = PluginStateCommit { + configuration: state.clone(), + transition_id: Some(transition.id.clone()), + }; + let error = store + .save_instance_with_state( + upgraded.clone(), + target.config_revision, + commit.clone(), + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + // cache 已走过清理步骤,settings 未完成导致整笔事务回滚,旧身份和两份数据仍有效。 + for namespace in ["cache", "settings"] { + assert_eq!( + store + .get(&owner, namespace, "retained") + .await + .unwrap() + .unwrap() + .value, + json!({"value": 1}) + ); + } + migrate_all(&store, &transition.id, "settings", 100).await; + let upgraded = store + .save_instance_with_state(upgraded, target.config_revision, commit, &context()) + .await + .unwrap(); + store + .delete_artifact(&old_digest, &context()) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&upgraded.instance, state)) + .await + .unwrap() + .unwrap(); + for namespace in ["cache", "settings"] { + assert_eq!( + store + .get(&owner, namespace, "retained") + .await + .unwrap() + .unwrap() + .value, + json!({"value": 1}) + ); + } + database.close().await; +} + +#[tokio::test] +async fn removing_empty_namespace_releases_its_artifact_reference() { + let Some(database) = TestDatabase::create("plugin_state_remove_namespace").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let old_digest = installed.artifact.metadata.sha256; + let saved = store + .save_instance_with_state( + instance(old_digest.clone()), + installed.config_revision, + PluginStateCommit { + configuration: configuration(schema(1, vec![], 2)), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let target = install_version(&store, 'b', "2.0.0").await; + let mut upgraded = saved.instance; + upgraded.artifact_sha256 = target.artifact.metadata.sha256; + store + .save_instance_with_state( + upgraded, + target.config_revision, + PluginStateCommit { + configuration: PluginStateConfiguration { namespaces: vec![] }, + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + store + .delete_artifact(&old_digest, &context()) + .await + .unwrap(); + let remaining: i64 = sqlx::query_scalar("select count(*) from plugin_state_generations") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(remaining, 0); + database.close().await; +} + +#[tokio::test] +async fn state_cas_quota_and_fence_are_independent_from_global_revision() { + let Some(database) = TestDatabase::create("plugin_state_cas").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let installed = install_version(&store, 'a', "1.0.0").await; + let state = configuration(schema(1, vec![], 2)); + let saved = store + .save_instance_with_state( + instance(installed.artifact.metadata.sha256), + installed.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let owner = store + .load_owner(owner_request(&saved.instance, state.clone())) + .await + .unwrap() + .unwrap(); + + // 无关制品安装会推进全局配置 revision,但不能撤销实例自己的状态 fence。 + let unrelated = install_version(&store, 'b', "2.0.0").await; + let first = store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "a".into(), + value: json!({"value": 1}), + expected_version: None, + }, + ) + .await + .unwrap(); + let second = store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "b".into(), + value: json!({"value": 2}), + expected_version: None, + }, + ) + .await + .unwrap(); + assert_eq!((first.version, second.version), (1, 2)); + assert_eq!( + store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "c".into(), + value: json!({"value": 3}), + expected_version: None, + }, + ) + .await + .unwrap_err() + .kind(), + PluginStateStoreErrorKind::Quota + ); + let updated = store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "a".into(), + value: json!({"value": 4}), + expected_version: Some(first.version), + }, + ) + .await + .unwrap(); + assert_eq!(updated.version, 3); + assert_eq!( + store + .delete( + &owner, + DeletePluginState { + namespace: "cache".into(), + key: "a".into(), + expected_version: first.version, + }, + ) + .await + .unwrap_err() + .kind(), + PluginStateStoreErrorKind::Conflict + ); + assert!( + store + .delete( + &owner, + DeletePluginState { + namespace: "cache".into(), + key: "a".into(), + expected_version: updated.version, + }, + ) + .await + .unwrap() + ); + let recreated = store + .put( + &owner, + PutPluginState { + namespace: "cache".into(), + key: "a".into(), + value: json!({"value": 5}), + expected_version: None, + }, + ) + .await + .unwrap(); + assert_eq!(recreated.version, 4, "删除后不能产生可 ABA 的记录版本"); + assert_eq!( + store.load_instances().await.unwrap().config_revision, + unrelated.config_revision, + "普通状态写入不能推进全局配置 revision" + ); + let mut undersized = state.clone(); + undersized.namespaces[0].maximum_value_bytes = 4; + assert_eq!( + store + .transition_required(&saved.instance.id, &undersized) + .await + .unwrap_err() + .kind(), + PluginStateStoreErrorKind::Conflict, + "配额收紧不能让已有记录超过单值上限" + ); + + // 同一实例的配置提交会轮换 fence;旧进程即使仍持有 owner 也不能继续写。 + let rebound = store + .save_instance_with_state( + saved.instance.clone(), + unrelated.config_revision, + PluginStateCommit { + configuration: state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let error = store.get(&owner, "cache", "a").await.err().unwrap(); + assert_eq!(error.kind(), PluginStateStoreErrorKind::PermissionDenied); + let rebound_owner = store + .load_owner(owner_request(&rebound.instance, state)) + .await + .unwrap() + .unwrap(); + assert_eq!( + store + .get(&rebound_owner, "cache", "a") + .await + .unwrap() + .unwrap() + .value, + json!({"value": 5}) + ); + database.close().await; +} + +#[tokio::test] +async fn migration_promotes_atomically_and_incomplete_staging_cannot_replace_active_state() { + let Some(database) = TestDatabase::create("plugin_state_migration").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let first_artifact = install_version(&store, 'c', "1.0.0").await; + let second_artifact = install_version(&store, 'd', "2.0.0").await; + let third_artifact = install_version(&store, 'e', "3.0.0").await; + let first_state = configuration(schema(1, vec![], 4)); + let saved = store + .save_instance_with_state( + instance(first_artifact.artifact.metadata.sha256), + third_artifact.config_revision, + PluginStateCommit { + configuration: first_state.clone(), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let first_owner = store + .load_owner(owner_request(&saved.instance, first_state)) + .await + .unwrap() + .unwrap(); + for (key, value) in [("a", 1), ("b", 2)] { + store + .put( + &first_owner, + PutPluginState { + namespace: "cache".into(), + key: key.into(), + value: json!({"value": value}), + expected_version: None, + }, + ) + .await + .unwrap(); + } + + let second_state = configuration(schema(2, vec![1], 4)); + assert!( + store + .transition_required(&saved.instance.id, &second_state) + .await + .unwrap() + ); + let transition = store + .begin_transition( + &saved.instance.id, + saved.instance.revision, + &second_artifact.artifact.metadata.sha256, + second_state.clone(), + ) + .await + .unwrap(); + let batch = store + .migration_batch(&transition.id, "cache", 64) + .await + .unwrap(); + assert_eq!( + batch + .records + .iter() + .map(|record| record.key.as_str()) + .collect::>(), + ["a", "b"] + ); + store + .apply_migration_batch(ApplyPluginStateMigration { + transition_id: transition.id.clone(), + namespace: "cache".into(), + cursor: batch.cursor, + expected_keys: vec!["a".into(), "b".into()], + changes: vec![ + PluginStateMigrationChange { + key: "a".into(), + action: PluginStateMigrationAction::Keep, + }, + PluginStateMigrationChange { + key: "b".into(), + action: PluginStateMigrationAction::Replace(json!({"value": 20})), + }, + ], + }) + .await + .unwrap(); + let terminal = store + .migration_batch(&transition.id, "cache", 64) + .await + .unwrap(); + assert!(terminal.records.is_empty()); + store + .apply_migration_batch(ApplyPluginStateMigration { + transition_id: transition.id.clone(), + namespace: "cache".into(), + cursor: terminal.cursor, + expected_keys: vec![], + changes: vec![], + }) + .await + .unwrap(); + assert_eq!( + store.load_instances().await.unwrap().config_revision, + saved.config_revision, + "状态迁移批次不能推进全局配置 revision" + ); + + let mut upgraded = saved.instance.clone(); + upgraded.artifact_sha256 = second_artifact.artifact.metadata.sha256; + let upgraded = store + .save_instance_with_state( + upgraded, + saved.config_revision, + PluginStateCommit { + configuration: second_state.clone(), + transition_id: Some(transition.id), + }, + &context(), + ) + .await + .unwrap(); + let error = store.get(&first_owner, "cache", "a").await.err().unwrap(); + assert_eq!(error.kind(), PluginStateStoreErrorKind::PermissionDenied); + let second_owner = store + .load_owner(owner_request(&upgraded.instance, second_state)) + .await + .unwrap() + .unwrap(); + let migrated = store + .get(&second_owner, "cache", "b") + .await + .unwrap() + .unwrap(); + assert_eq!(migrated.schema_version, 2); + assert_eq!(migrated.value, json!({"value": 20})); + + let third_state = configuration(schema(3, vec![2], 4)); + let incomplete = store + .begin_transition( + &upgraded.instance.id, + upgraded.instance.revision, + &third_artifact.artifact.metadata.sha256, + third_state.clone(), + ) + .await + .unwrap(); + let mut rejected = upgraded.instance.clone(); + rejected.artifact_sha256 = third_artifact.artifact.metadata.sha256; + let error = store + .save_instance_with_state( + rejected, + upgraded.config_revision, + PluginStateCommit { + configuration: third_state, + transition_id: Some(incomplete.id.clone()), + }, + &context(), + ) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), AdminStoreErrorKind::Conflict); + let current = store.load_instances().await.unwrap(); + assert_eq!(current.config_revision, upgraded.config_revision); + assert_eq!( + current.instances[0].artifact_sha256, + upgraded.instance.artifact_sha256 + ); + store.abort_transition(&incomplete.id).await.unwrap(); + assert_eq!( + store + .get(&second_owner, "cache", "b") + .await + .unwrap() + .unwrap() + .value, + json!({"value": 20}) + ); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/pricing.rs b/backend/crates/gateway-store/tests/postgres/pricing.rs index 54e91665e..47e9269e5 100644 --- a/backend/crates/gateway-store/tests/postgres/pricing.rs +++ b/backend/crates/gateway-store/tests/postgres/pricing.rs @@ -245,7 +245,11 @@ async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selecte let (first, second) = tokio::join!( settings.sync_pricing(patch(json!({"openai": {"model-a": price}})), &context), settings.sync_pricing( - patch(json!({"openai": {"model-b": price}, "xai": {"model-a": price}})), + patch(json!({ + "openai": {"model-b": price}, + "xai": {"model-a": price}, + "example-plugin": {"model-c": price} + })), &context ), ); @@ -254,9 +258,16 @@ async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selecte assert_eq!( serde_json::to_value(&stored.synced).unwrap(), json!({ - "openai": {"model-a": price, "model-b": price}, "xai": {"model-a": price} + "example-plugin": {"model-c": price}, + "openai": {"model-a": price, "model-b": price}, + "xai": {"model-a": price} }) ); + let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone()) + .load_runtime_snapshot() + .await + .unwrap(); + assert!(snapshot.settings.pricing["example-plugin"].contains_key("model-c")); settings .sync_pricing(patch(json!({"openai": {"model-a": null}})), &context) .await @@ -265,7 +276,9 @@ async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selecte assert_eq!( serde_json::to_value(&stored.synced).unwrap(), json!({ - "openai": {"model-b": price}, "xai": {"model-a": price} + "example-plugin": {"model-c": price}, + "openai": {"model-b": price}, + "xai": {"model-a": price} }) ); let before = stored; @@ -273,7 +286,7 @@ async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selecte settings .sync_pricing( patch(json!({ - "openai": {"model-b": null}, "invalid": {"model-c": price} + "openai": {"model-b": null}, "__invalid": {"model-c": price} })), &context ) diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs new file mode 100644 index 000000000..a6fffc909 --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/admin_adapter.rs @@ -0,0 +1,277 @@ +use super::*; + +#[tokio::test] +async fn provider_filters_include_unregistered_accounts_even_on_an_empty_filtered_page() { + let Some(database) = TestDatabase::create("account_provider_filters").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + for (index, provider) in ["openai", "retired-plugin", "retired-plugin", "all"] + .into_iter() + .enumerate() + { + let mut record = account( + &format!("acct_filter_{index}"), + &format!("user-filter-{index}"), + ); + record.provider_kind = provider.into(); + repository.insert_provider_account(record).await.unwrap(); + } + let query = AccountListQuery { + page: 2, + page_size: PageSize::new(1).unwrap(), + provider_kind: Some(ProviderKind::new("openai").unwrap()), + group_filter: None, + search: None, + status: None, + sort: None, + }; + let store = admin_account_store(&database.pool); + let page = store + .list_accounts(query.clone(), Default::default()) + .await + .unwrap(); + assert!(page.items.is_empty()); + assert_eq!(page.providers, ["all", "openai", "retired-plugin"]); + let literal = store + .list_accounts( + AccountListQuery { + page: 1, + provider_kind: Some(ProviderKind::new("all").unwrap()), + ..query.clone() + }, + Default::default(), + ) + .await + .unwrap(); + assert_eq!(literal.items.len(), 1); + assert_eq!(literal.total, 1); + sqlx::query("delete from provider_accounts") + .execute(&database.pool) + .await + .unwrap(); + let empty = store + .list_accounts(query, Default::default()) + .await + .unwrap(); + assert!(empty.providers.is_empty()); + database.close().await; +} + +#[tokio::test] +async fn plugin_account_provider_filter_is_optional_and_applied_before_cursor_pagination() { + let Some(database) = TestDatabase::create("plugin_account_scope").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + for id in ["acct_plugin_a", "acct_plugin_b", "acct_plugin_c"] { + repository + .insert_provider_account(account(id, &format!("user-{id}"))) + .await + .unwrap(); + } + let mut other_provider = account("acct_plugin_other", "user-other"); + other_provider.provider_kind = "xai".to_owned(); + repository + .insert_provider_account(other_provider) + .await + .unwrap(); + let store = admin_account_store(&database.pool); + let first = store + .list_plugin_accounts(PluginAccountListQuery { + provider_kind: Some(ProviderKind::new("openai").unwrap()), + cursor: None, + limit: PageSize::new(1).unwrap(), + }) + .await + .unwrap(); + assert_eq!( + first + .accounts + .iter() + .map(|account| account.id.as_str()) + .collect::>(), + ["acct_plugin_a"] + ); + assert_eq!( + first.next_cursor.as_ref().map(ProviderAccountId::as_str), + Some("acct_plugin_a") + ); + + let second = store + .list_plugin_accounts(PluginAccountListQuery { + provider_kind: Some(ProviderKind::new("openai").unwrap()), + cursor: first.next_cursor, + limit: PageSize::new(1).unwrap(), + }) + .await + .unwrap(); + assert_eq!( + second + .accounts + .iter() + .map(|account| account.id.as_str()) + .collect::>(), + ["acct_plugin_b"] + ); + assert_eq!( + second.next_cursor.as_ref().map(ProviderAccountId::as_str), + Some("acct_plugin_b") + ); + + // 未指定 Provider 时按全局账号 ID 跨 Provider 分页,Provider 归属来自持久记录。 + let all = store + .list_plugin_accounts(PluginAccountListQuery { + provider_kind: None, + cursor: None, + limit: PageSize::new(16).unwrap(), + }) + .await + .unwrap(); + assert_eq!( + all.accounts + .iter() + .map(|account| account.id.as_str()) + .collect::>(), + [ + "acct_plugin_a", + "acct_plugin_b", + "acct_plugin_c", + "acct_plugin_other" + ] + ); + assert!(all.next_cursor.is_none()); + + database.close().await; +} + +pub(super) fn credential(id: &str, user: &str) -> PreparedCredentialCreate { + PreparedCredentialCreate { + account_id: ProviderAccountId::new(id).unwrap(), + provider_kind: ProviderKind::new("example").unwrap(), + name: "login account".into(), + email: None, + upstream_user_id: Some(user.into()), + upstream_account_id: None, + plan_type: None, + authentication_kind: "oauth".into(), + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"access_token":"test-only"}) + .as_object() + .unwrap() + .clone(), + )), + has_refresh_token: false, + access_token_expires_at: None, + next_refresh_at: None, + enabled: true, + credential_state: CredentialState::Ready, + credential_observed_at: Utc::now(), + outbound_proxy: None, + model_access: None, + } +} + +pub(super) fn command( + credentials: Vec, +) -> (AuthorizationCommit, MutationContext) { + let context = MutationContext { + actor: MutationActor::System, + request_id: "authorization-batch".into(), + }; + ( + AuthorizationCommit { + key: gateway_admin::model::provider_credentials::AuthorizationReceiptKey::new( + ProviderKind::new("example").unwrap(), + &uuid::Uuid::new_v4().to_string(), + &context, + ) + .unwrap(), + settings: None, + pending: PendingAuthorizationMutation::new( + ProviderKind::new("example").unwrap(), + AuthorizationMutationTarget::Create { + name: "login".into(), + }, + AuthorizationOwnerBinding::from_context(&context), + ), + credential: AuthorizationCredentialCommit::Create(credentials), + }, + context, + ) +} + +#[tokio::test] +async fn authorization_batch_returns_actual_ids_and_revisions_from_one_audited_transaction() { + let Some(database) = TestDatabase::create("authorization_batch").await else { + return; + }; + let store = admin_account_store(&database.pool); + let (first, context) = command(vec![credential("acct_existing", "user-one")]); + store.commit_authorization(first, &context).await.unwrap(); + let (batch, context) = command(vec![ + credential("acct_candidate", "user-one"), + credential("acct_second", "user-two"), + ]); + let result = store + .commit_authorization(batch, &context) + .await + .unwrap() + .result; + assert_eq!( + result + .accounts + .iter() + .map(|account| ( + account.account_id.as_str(), + account.credential_revision.unwrap().get() + )) + .collect::>(), + [("acct_existing", 2), ("acct_second", 1)] + ); + assert_eq!( + result.config_revision.get(), + current_revision(&database.pool).await as u64 + ); + let accounts: i64 = sqlx::query_scalar("select count(*) from provider_accounts") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(accounts, 2); + let audit: i64 = + sqlx::query_scalar("select count(*) from admin_audit_events where action = 'authorize'") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audit, 2, "每批一次审计,不能对每个账号分别提交"); + database.close().await; +} + +#[tokio::test] +async fn authorization_batch_conflict_rolls_back_earlier_accounts_revision_and_audit() { + let Some(database) = TestDatabase::create("authorization_batch_rollback").await else { + return; + }; + let store = admin_account_store(&database.pool); + let (seed, context) = command(vec![credential("acct_existing", "user-one")]); + store.commit_authorization(seed, &context).await.unwrap(); + let before = current_revision(&database.pool).await; + let (batch, context) = command(vec![ + credential("acct_new", "user-new"), + credential("acct_existing", "different-user"), + ]); + assert!(store.commit_authorization(batch, &context).await.is_err()); + let accounts: Vec = sqlx::query_scalar("select id from provider_accounts order by id") + .fetch_all(&database.pool) + .await + .unwrap(); + assert_eq!(accounts, ["acct_existing"]); + assert_eq!(current_revision(&database.pool).await, before); + let audit: i64 = + sqlx::query_scalar("select count(*) from admin_audit_events where action = 'authorize'") + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(audit, 1); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs new file mode 100644 index 000000000..5451427fa --- /dev/null +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/authorization.rs @@ -0,0 +1,222 @@ +use gateway_admin::model::provider_credentials::{ + AuthorizationReceiptKey, PreparedCredentialRotationFacts, +}; + +use super::{ + admin_adapter::{command, credential}, + *, +}; + +#[tokio::test] +async fn concurrent_authorization_retries_share_one_account_write_and_one_receipt() { + let Some(database) = TestDatabase::create("authorization_receipt_race").await else { + return; + }; + let store = admin_account_store(&database.pool); + let (authorization, context) = command(vec![credential("acct_receipt", "receipt-user")]); + let key = authorization.key.clone(); + let (first, second) = tokio::join!( + store.commit_authorization(authorization.clone(), &context), + store.commit_authorization(authorization, &context) + ); + let (first, second) = (first.unwrap(), second.unwrap()); + assert_eq!(first.result, second.result); + assert_ne!(first.newly_committed, second.newly_committed); + assert_eq!( + first.result.accounts[0].credential_revision.unwrap().get(), + 1 + ); + let counts: (i64, i64, i64) = sqlx::query_as("select (select count(*) from provider_accounts), (select count(*) from admin_audit_events where action='authorize'), (select count(*) from authorization_receipts)") + .fetch_one(&database.pool).await.unwrap(); + assert_eq!(counts, (1, 1, 1)); + let restarted = admin_account_store(&database.pool); + assert_eq!( + restarted.authorization_receipt(&key).await.unwrap(), + Some(first.result.clone()) + ); + let outsider = MutationContext { + actor: MutationActor::AdminApiKey, + request_id: "different-owner".into(), + }; + // 相同 flow 的另一身份不能读取账号结果,也不能另行提交同一个授权。 + let (mut foreign, _) = command(vec![credential("acct_foreign", "foreign-user")]); + foreign.key = key.clone(); + assert!( + restarted + .commit_authorization(foreign, &outsider) + .await + .is_err() + ); + let stored: String = + sqlx::query_scalar("select to_jsonb(r)::text from authorization_receipts r") + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(!stored.contains("access_token") && !stored.contains("test-only")); + database.close().await; +} + +#[tokio::test] +async fn a_receipt_failure_rolls_back_accounts_audit_and_config_revision() { + let Some(database) = TestDatabase::create("authorization_receipt_failure").await else { + return; + }; + let store = admin_account_store(&database.pool); + let before = current_revision(&database.pool).await; + sqlx::query("alter table authorization_receipts add constraint fail_receipt check (false)") + .execute(&database.pool) + .await + .unwrap(); + let (command, context) = command(vec![credential("acct_rollback", "rollback-user")]); + assert!( + store + .commit_authorization(command.clone(), &context) + .await + .is_err() + ); + assert_eq!(current_revision(&database.pool).await, before); + let counts: (i64, i64, i64) = sqlx::query_as("select (select count(*) from provider_accounts), (select count(*) from admin_audit_events), (select count(*) from authorization_receipts)").fetch_one(&database.pool).await.unwrap(); + assert_eq!(counts, (0, 0, 0)); + assert!( + store + .authorization_receipt(&command.key) + .await + .unwrap() + .is_none() + ); + sqlx::query("alter table authorization_receipts drop constraint fail_receipt") + .execute(&database.pool) + .await + .unwrap(); + assert!( + store + .commit_authorization(command, &context) + .await + .unwrap() + .newly_committed + ); + database.close().await; +} + +#[tokio::test] +async fn expired_receipts_are_unreadable_and_reclaimed_without_removing_accounts() { + let Some(database) = TestDatabase::create("authorization_receipt_expiry").await else { + return; + }; + let store = admin_account_store(&database.pool); + let (authorization, context) = command(vec![credential("acct_expired", "expired-user")]); + let key = authorization.key.clone(); + store + .commit_authorization(authorization, &context) + .await + .unwrap(); + sqlx::query("update authorization_receipts set created_at=now()-interval '25 hours', expires_at=now()-interval '1 hour'") + .execute(&database.pool).await.unwrap(); + assert!(store.authorization_receipt(&key).await.unwrap().is_none()); + let (next, context) = command(vec![credential("acct_next", "next-user")]); + let current = next.key.clone(); + let committed = store.commit_authorization(next, &context).await.unwrap(); + assert_eq!( + store.authorization_receipt(¤t).await.unwrap(), + Some(committed.result) + ); + let counts: (i64, i64) = sqlx::query_as("select (select count(*) from provider_accounts), (select count(*) from authorization_receipts)") + .fetch_one(&database.pool).await.unwrap(); + assert_eq!(counts, (2, 1)); + database.close().await; +} + +#[tokio::test] +async fn reauthorization_replay_keeps_the_committed_revision_and_later_profile_edits() { + let Some(database) = TestDatabase::create("reauthorization_receipt").await else { + return; + }; + let store = admin_account_store(&database.pool); + let (seed, context) = command(vec![credential("acct_reauthorize", "reauthorize-user")]); + let receipt = store.commit_authorization(seed, &context).await.unwrap(); + let id = receipt.result.accounts[0].account_id.clone(); + let command = AuthorizationCommit { + key: AuthorizationReceiptKey::new( + ProviderKind::new("example").unwrap(), + "reauthorize-flow", + &context, + ) + .unwrap(), + pending: PendingAuthorizationMutation::new( + ProviderKind::new("example").unwrap(), + AuthorizationMutationTarget::Reauthorize { + account_id: id.clone(), + }, + AuthorizationOwnerBinding::from_context(&context), + ), + settings: None, + credential: AuthorizationCredentialCommit::Reauthorize(Box::new( + PreparedCredentialRotationFacts { + account_id: id.clone(), + provider_kind: ProviderKind::new("example").unwrap(), + expected_credential_revision: gateway_admin::model::Revision::new(1).unwrap(), + replacement_identity: None, + name: "remote profile".into(), + email: None, + plan_type: None, + preserve_profile: true, + provider_material: ProviderDocument::new(OpaqueProviderData::new( + json!({"access_token":"rotated-test-only"}) + .as_object() + .unwrap() + .clone(), + )), + has_refresh_token: false, + access_token_expires_at: None, + next_refresh_at: None, + }, + )), + }; + let committed = store + .commit_authorization(command.clone(), &context) + .await + .unwrap(); + sqlx::query("update provider_accounts set name='later profile' where id=$1") + .bind(id.as_str()) + .execute(&database.pool) + .await + .unwrap(); + let replayed = store + .commit_authorization(command.clone(), &context) + .await + .unwrap(); + assert!(!replayed.newly_committed); + assert_eq!(replayed.result, committed.result); + let account: (String, i64) = + sqlx::query_as("select name, credential_revision from provider_accounts where id=$1") + .bind(id.as_str()) + .fetch_one(&database.pool) + .await + .unwrap(); + assert_eq!(account, ("later profile".into(), 2)); + let outsider = MutationContext { + actor: MutationActor::AdminApiKey, + request_id: "outsider".into(), + }; + let key = AuthorizationReceiptKey::new( + ProviderKind::new("example").unwrap(), + "reauthorize-flow", + &outsider, + ) + .unwrap(); + assert!(store.authorization_receipt(&key).await.is_err()); + let another = AuthorizationReceiptKey::new( + ProviderKind::new("other").unwrap(), + "reauthorize-flow", + &context, + ) + .unwrap(); + assert!( + store + .authorization_receipt(&another) + .await + .unwrap() + .is_none() + ); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs index ea19dba85..78b47ab9e 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/mod.rs @@ -4,6 +4,8 @@ use std::{ time::{Duration, SystemTime}, }; +mod admin_adapter; +mod authorization; mod quota_forecast; mod timestamps; @@ -19,8 +21,8 @@ use gateway_admin::{ observability::TimeRange, provider_credentials::{ AuthorizationCommit, AuthorizationCredentialCommit, AuthorizationMutationTarget, - AuthorizationOwnerBinding, PendingAuthorizationMutation, PreparedCredentialCreate, - ProviderDocument, + AuthorizationOwnerBinding, PendingAuthorizationMutation, PluginAccountListQuery, + PreparedCredentialCreate, ProviderDocument, }, }, ports::store::AccountStore, @@ -771,6 +773,7 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u .expect("sort accounts by retained usage"); assert_eq!(usage_page.config_revision.get(), 1); assert_eq!(usage_page.total, 6); + assert_eq!(usage_page.providers, ["openai", "xai"]); assert_eq!(usage_page.summary.total, 6); assert_eq!(usage_page.summary.normal, 1); assert_eq!(usage_page.summary.quota_exhausted, 2); @@ -838,6 +841,7 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u .expect("filter account directory"); assert_eq!(filtered.total, 1); assert_eq!(filtered.summary, usage_page.summary); + assert_eq!(filtered.providers, usage_page.providers); assert_eq!(filtered.items[0].account.id, "acct_alpha"); assert_eq!(filtered.items[0].account.provider_kind.as_str(), "openai"); @@ -1961,6 +1965,12 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser let result = admin_account_store(&database.pool) .commit_authorization( AuthorizationCommit { + key: gateway_admin::model::provider_credentials::AuthorizationReceiptKey::new( + provider_kind.clone(), + "authorization-upsert", + &context, + ) + .unwrap(), settings: Some(gateway_admin::model::accounts::AccountImportSettings { notes: Some(" OAuth 新建备注 ".to_owned()), model_access: Default::default(), @@ -1976,7 +1986,7 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser }, AuthorizationOwnerBinding::from_context(&context), ), - credential: AuthorizationCredentialCommit::Create(PreparedCredentialCreate { + credential: AuthorizationCredentialCommit::Create(vec![PreparedCredentialCreate { model_access: Default::default(), outbound_proxy: None, account_id: ProviderAccountId::new("acct_authorization_candidate") @@ -1997,17 +2007,21 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser enabled: true, credential_state: CredentialState::Ready, credential_observed_at: Utc::now(), - }), + }]), }, &context, ) .await .expect("authorize existing identity"); + let result = result.result; + assert_eq!( ( - result.account_id.as_str(), - result.credential_revision.map(|revision| revision.get()), + result.accounts[0].account_id.as_str(), + result.accounts[0] + .credential_revision + .map(|revision| revision.get()), ), ("acct_authorization_existing", Some(2)), ); diff --git a/backend/crates/gateway-store/tests/postgres/proxies.rs b/backend/crates/gateway-store/tests/postgres/proxies.rs index fc39be7c5..65e2c88c6 100644 --- a/backend/crates/gateway-store/tests/postgres/proxies.rs +++ b/backend/crates/gateway-store/tests/postgres/proxies.rs @@ -857,33 +857,26 @@ async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() { #[tokio::test] async fn migration_backfills_shared_proxies_without_changing_credentials() { - let Some(database) = TestDatabase::create("proxy_backfill").await else { + let Some(database) = TestDatabase::create_through("proxy_backfill", 4).await else { return; }; - let accounts = PgProviderAccountRepository::new(database.pool.clone()); + // 从真实旧 schema 正向升级,不拆卸最新结构,避免遗漏后续增加的引用约束。 for id in ["acct_one", "acct_two", "acct_direct"] { - accounts - .insert_provider_account(account(id, id)) - .await - .unwrap(); + let proxy_url = (id != "acct_direct").then_some("http://user:secret@127.0.0.1:8080/"); + sqlx::query( + "insert into provider_accounts ( + id,provider_kind,name,authentication_kind,provider_credentials_json, + has_refresh_token,credential_observed_at,created_at,updated_at,outbound_proxy_url + ) values ($1,'openai',$1,'oauth','{}',false,now(),now(),now(),$2)", + ) + .bind(id) + .bind(proxy_url) + .execute(&database.pool) + .await + .unwrap(); } - // 回退到迁移前的结构,随后完整重放同一份位置迁移。 - sqlx::raw_sql("alter table runtime_settings drop column request_location_json, drop column request_location_enabled; - alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies; - update provider_accounts set outbound_proxy_url = 'http://user:secret@127.0.0.1:8080/' where id <> 'acct_direct';") - .execute(&database.pool).await.unwrap(); - sqlx::raw_sql(include_str!( - "../../../../migrations/0005_managed_outbound_proxies.sql" - )) - .execute(&database.pool) - .await - .unwrap(); - sqlx::raw_sql(include_str!( - "../../../../migrations/0011_proxy_request_location.sql" - )) - .execute(&database.pool) - .await - .unwrap(); + super::TEST_MIGRATOR.run(&database.pool).await.unwrap(); + let accounts = PgProviderAccountRepository::new(database.pool.clone()); let inherited = gateway_core::account::ProviderAccountStore::list_accounts(&accounts) .await .unwrap(); diff --git a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs index e11693bfb..442e7058f 100644 --- a/backend/crates/gateway-store/tests/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/tests/postgres/runtime_settings.rs @@ -9,8 +9,7 @@ use super::TestDatabase; fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate { RuntimeSettingsUpdate { - openai_client_profile: None, - xai_client_profile: None, + request_profile_updates: BTreeMap::new(), request_location_enabled: false, request_location: Default::default(), admin_api_key: None, @@ -527,11 +526,14 @@ async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_i .load_runtime_settings() .await .unwrap() - .openai_client_profile, - Some(initial) + .request_profiles + .get(&provider), + Some(&initial) ); let mut update = settings_with_margin(3600); - update.openai_client_profile = Some(document("edited")); + update + .request_profile_updates + .insert(provider.clone(), Some(document("edited"))); repository.update_runtime_settings(update).await.unwrap(); assert_eq!( repository @@ -543,6 +545,50 @@ async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_i database.close().await; } +#[tokio::test] +async fn request_profile_deletion_is_explicit_and_preserves_other_profiles() { + use gateway_core::{ + account::OpaqueProviderData, provider_ports::ProviderRuntimePolicyPort, + routing::ProviderKind, + }; + let Some(database) = TestDatabase::create("request_profile_deletion").await else { + return; + }; + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let kept = ProviderKind::new("provider.kept").unwrap(); + let removed = ProviderKind::new("provider.removed").unwrap(); + let profile = |marker: &str| { + OpaqueProviderData::new( + serde_json::json!({"marker":marker}) + .as_object() + .unwrap() + .clone(), + ) + }; + repository + .initialize_request_profile(&kept, profile("kept")) + .await + .unwrap(); + repository + .initialize_request_profile(&removed, profile("removed")) + .await + .unwrap(); + + let mut update = settings_with_margin(3_600); + update.request_profile_updates.insert(removed.clone(), None); + repository.update_runtime_settings(update).await.unwrap(); + let settings = repository.load_runtime_settings().await.unwrap(); + + assert_eq!( + ( + settings.request_profiles.get(&kept), + settings.request_profiles.get(&removed), + ), + (Some(&profile("kept")), None), + ); + database.close().await; +} + #[tokio::test] async fn xai_profile_initialization_and_updates_preserve_other_providers() { use gateway_core::{ @@ -574,15 +620,30 @@ async fn xai_profile_initialization_and_updates_preserve_other_providers() { document("initial") ); let mut update = settings_with_margin(3600); - update.openai_client_profile = Some(document("openai")); - update.xai_client_profile = Some(document("xai")); + update.request_profile_updates.insert( + ProviderKind::new("openai").unwrap(), + Some(document("openai")), + ); + update + .request_profile_updates + .insert(provider.clone(), Some(document("xai"))); repository.update_runtime_settings(update).await.unwrap(); let mut update = settings_with_margin(3600); - update.xai_client_profile = Some(document("edited")); + update + .request_profile_updates + .insert(provider.clone(), Some(document("edited"))); repository.update_runtime_settings(update).await.unwrap(); let settings = repository.load_runtime_settings().await.unwrap(); - assert_eq!(settings.openai_client_profile, Some(document("openai"))); - assert_eq!(settings.xai_client_profile, Some(document("edited"))); + assert_eq!( + settings + .request_profiles + .get(&ProviderKind::new("openai").unwrap()), + Some(&document("openai")) + ); + assert_eq!( + settings.request_profiles.get(&provider), + Some(&document("edited")) + ); assert_eq!( repository .initialize_request_profile(&provider, document("old-yaml")) @@ -592,3 +653,69 @@ async fn xai_profile_initialization_and_updates_preserve_other_providers() { ); database.close().await; } + +#[tokio::test] +async fn request_profile_projection_is_revision_consistent_and_includes_key_overrides() { + use gateway_core::{ + account::OpaqueProviderData, + provider_ports::{ProviderRuntimePolicyPort, ProviderStoreErrorKind}, + routing::ProviderKind, + }; + let Some(database) = TestDatabase::create("request_profile_projection").await else { + return; + }; + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let provider = ProviderKind::new("provider.example").unwrap(); + let document = |marker: &str| { + OpaqueProviderData::new( + serde_json::json!({"marker":marker}) + .as_object() + .unwrap() + .clone(), + ) + }; + let mut update = settings_with_margin(3600); + update + .request_profile_updates + .insert(provider.clone(), Some(document("global"))); + repository.update_runtime_settings(update).await.unwrap(); + sqlx::query( + "insert into client_api_keys ( + id, name, key, enabled, max_concurrency, requests_per_minute, + provider_request_profiles_json, created_at, updated_at + ) values ($1, $2, $3, true, 0, 0, $4::jsonb, now(), now())", + ) + .bind("key_profile_projection") + .bind("profile projection") + .bind("synthetic-profile-projection-secret") + .bind(sqlx::types::Json(serde_json::json!({ + "provider.example":{"marker":"key"} + }))) + .execute(&database.pool) + .await + .unwrap(); + let revision = repository + .load_runtime_settings() + .await + .unwrap() + .config_revision; + let revision = gateway_core::routing::ConfigRevision::new(revision.get()).unwrap(); + let profiles = repository + .load_request_profile_configurations(revision, &provider) + .await + .unwrap(); + assert_eq!(profiles.len(), 2); + assert!(profiles.contains(&document("global"))); + assert!(profiles.contains(&document("key"))); + + repository + .update_runtime_settings(settings_with_margin(7200)) + .await + .unwrap(); + let error = repository + .load_request_profile_configurations(revision, &provider) + .await + .unwrap_err(); + assert_eq!(error.kind(), ProviderStoreErrorKind::Conflict); + database.close().await; +} diff --git a/backend/crates/gateway-store/tests/redis/coordination_buffer.rs b/backend/crates/gateway-store/tests/redis/coordination_buffer.rs index ff5a15e4f..2a12e308b 100644 --- a/backend/crates/gateway-store/tests/redis/coordination_buffer.rs +++ b/backend/crates/gateway-store/tests/redis/coordination_buffer.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::num::NonZeroUsize; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -6,13 +7,13 @@ use futures::future::BoxFuture; use gateway_core::engine::ModelRequestId; use gateway_core::engine::admission::{ ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery, - ClientAdmissionRequest, ClientAdmissionRestoreResult, + ClientAdmissionRejection, ClientAdmissionRequest, ClientAdmissionRestoreResult, }; use gateway_core::engine::execution::{ ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort, }; use gateway_core::lifecycle::CancellationToken; -use gateway_core::policy::ClientApiKeyId; +use gateway_core::policy::{ClientApiKeyId, RateLimits}; use gateway_core::routing::ProviderKind; use gateway_store::redis::{BufferedClientAdmissionPort, BufferedProviderCircuitPort}; @@ -101,6 +102,70 @@ impl ProviderCircuitPort for RecordingCoordination { } } +struct CapacityCoordination { + active: Mutex>, + maximum: usize, +} + +impl CapacityCoordination { + fn new(maximum: usize) -> Self { + Self { + active: Mutex::new(BTreeSet::new()), + maximum, + } + } + + fn active(&self) -> usize { + self.active.lock().expect("active admission lock").len() + } +} + +impl ClientAdmissionPort for CapacityCoordination { + fn abandon(&self, _: &ClientApiKeyId, request: &ModelRequestId) { + self.active + .lock() + .expect("active admission lock") + .remove(request.as_str()); + } + + fn admit( + &self, + request: ClientAdmissionRequest, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let mut active = self.active.lock().expect("active admission lock"); + if active.len() >= self.maximum { + return Ok(ClientAdmissionDecision::Rejected( + ClientAdmissionRejection::ConcurrencyLimited, + )); + } + active.insert(request.model_request_id.as_str().to_owned()); + Ok(ClientAdmissionDecision::Granted) + }) + } + + fn release<'a>( + &'a self, + _: &'a ClientApiKeyId, + request: &'a ModelRequestId, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(self + .active + .lock() + .expect("active admission lock") + .remove(request.as_str())) + }) + } + + fn restore( + &self, + _: ClientAdmissionRecovery, + ) -> BoxFuture<'_, Result> { + Box::pin(async { Ok(ClientAdmissionRestoreResult::default()) }) + } +} + #[tokio::test] async fn full_recoverable_coordination_queues_should_drop_writes_without_waiting() { let inner = Arc::new(RecordingCoordination::default()); @@ -189,6 +254,75 @@ async fn redis_coordination_writers_should_flush_each_side_effect() { assert_eq!(operations, ["admission", "circuit_success"]); } +#[tokio::test] +async fn awaited_buffered_release_should_not_publish_capacity_before_writer_runs() { + let inner = Arc::new(CapacityCoordination::new(8)); + let (admissions, admission_writer) = BufferedClientAdmissionPort::with_capacity( + inner.clone(), + NonZeroUsize::new(8).expect("capacity"), + ); + let client = ClientApiKeyId::new("key_capacity_handoff").expect("client key"); + let request = |index| ClientAdmissionRequest { + model_request_id: ModelRequestId::new(format!("req_capacity_handoff_{index}")) + .expect("request ID"), + client_api_key_id: client.clone(), + lease_ttl: Duration::from_secs(60), + allow_concurrency_acquire: true, + limits: RateLimits { + max_concurrency: 8, + requests_per_minute: 0, + }, + }; + + let mut granted = Vec::new(); + for index in 0..8 { + let request = request(index); + assert_eq!( + admissions.admit(request.clone()).await.expect("admit"), + ClientAdmissionDecision::Granted + ); + granted.push(request.model_request_id); + } + assert_eq!(inner.active(), 8); + + assert!( + admissions + .release(&client, &granted[0]) + .await + .expect("enqueue release") + ); + assert_eq!(inner.active(), 8, "await only confirms queue admission"); + let replacement = request(8); + assert_eq!( + admissions + .admit(replacement.clone()) + .await + .expect("admit before writer"), + ClientAdmissionDecision::Rejected(ClientAdmissionRejection::ConcurrencyLimited) + ); + + let cancellation = CancellationToken::new(); + let task = spawn_writer(Arc::new(admission_writer), cancellation.clone()); + tokio::time::timeout(Duration::from_secs(1), async { + while inner.active() == 8 { + tokio::task::yield_now().await; + } + }) + .await + .expect("writer should publish released capacity"); + assert_eq!( + admissions + .admit(replacement) + .await + .expect("admit after writer"), + ClientAdmissionDecision::Granted + ); + cancellation.cancel(); + task.await + .expect("admission writer task") + .expect("admission writer cancellation"); +} + fn spawn_writer( writer: Arc, cancellation: CancellationToken, diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 6142e3677..b7dcd2fd5 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -163,6 +163,23 @@ impl OpenAiAdminProvider { #[async_trait] impl ProviderAdmin for OpenAiAdminProvider { + fn account_capabilities( + &self, + _account_id: &ProviderAccountId, + authentication_kind: &str, + ) -> gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + let oauth = authentication_kind == crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH; + gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + quota: oauth, + quota_refresh: oauth, + profile: oauth, + subscription: oauth, + avatar: oauth, + reset_credits: oauth, + consume_reset_credit: oauth, + } + } + fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { crate::transport::usage::pricing_catalog() } @@ -184,6 +201,23 @@ impl ProviderAdmin for OpenAiAdminProvider { .map_err(map_client_profile_error) } + fn credential_capabilities( + &self, + ) -> gateway_admin::model::provider_capabilities::ProviderCredentialCapabilities { + use gateway_admin::model::provider_capabilities::{ + AuthorizationCompletion, ProviderCredentialCapabilities, ProviderLoginCapability, + }; + ProviderCredentialCapabilities { + import: Some(serde_json::json!({"type":"object"})), + login: Some(ProviderLoginCapability { + input_schema: serde_json::json!({"type":"object", "additionalProperties":false}), + completion: AuthorizationCompletion::Callback, + }), + refresh: true, + export: true, + } + } + fn provider_kind(&self) -> &ProviderKind { &self.provider_kind } @@ -230,7 +264,7 @@ impl ProviderAdmin for OpenAiAdminProvider { } } - fn connection_test_operation( + async fn connection_test_operation( &self, upstream_model: &UpstreamModelId, input_text: &str, @@ -429,8 +463,17 @@ impl ProviderAdmin for OpenAiAdminProvider { async fn start_authorization( &self, - pending: PendingAuthorizationMutation, + command: gateway_admin::model::provider_credentials::PrepareAuthorization, ) -> Result { + if !command + .input + .expose_to_provider() + .expose_to_provider() + .is_empty() + { + return Err(provider_admin_error(ProviderAdminErrorKind::Invalid)); + } + let pending = command.pending; if pending.provider_kind() != &self.provider_kind { return Err(provider_admin_error(ProviderAdminErrorKind::Invalid)); } @@ -474,20 +517,21 @@ impl ProviderAdmin for OpenAiAdminProvider { }) .map_err(map_oauth_error)?; let (mutation, completed_credential, authorization_guard) = completed.into_parts(); - let credential = match (mutation.target(), completed_credential) { - ( - AuthorizationMutationTarget::Create { .. }, - CompletedCodexOAuthCredential::Create(credential), - ) => { - prepared_create(credential, Utc::now()).map(PreparedAuthorizationCredential::Create) - } - ( - AuthorizationMutationTarget::Reauthorize { .. }, - CompletedCodexOAuthCredential::Reauthorize(credential), - ) => prepared_rotation(credential, mutation.provider_kind().clone()) - .map(PreparedAuthorizationCredential::Reauthorize), - _ => Err(provider_admin_error(ProviderAdminErrorKind::Internal)), - }; + let credential = + match (mutation.target(), completed_credential) { + ( + AuthorizationMutationTarget::Create { .. }, + CompletedCodexOAuthCredential::Create(credential), + ) => prepared_create(credential, Utc::now()) + .map(|credential| PreparedAuthorizationCredential::Create(vec![credential])), + ( + AuthorizationMutationTarget::Reauthorize { .. }, + CompletedCodexOAuthCredential::Reauthorize(credential), + ) => prepared_rotation(credential, mutation.provider_kind().clone()).map( + |credential| PreparedAuthorizationCredential::Reauthorize(Box::new(credential)), + ), + _ => Err(provider_admin_error(ProviderAdminErrorKind::Internal)), + }; let credential = match credential { Ok(credential) => credential, Err(error) => { diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index 0b11637dc..7c2f7b01a 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -12,7 +12,7 @@ use gateway_core::account::{ ProviderAccountId, QuotaEvidence, }; use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection}; -use gateway_core::engine::{AttemptContext, ContinuationAttempt}; +use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError}; use gateway_core::provider_ports::{ ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingLeaseRequest, ProviderSessionAffinityKey, ProviderSessionAffinityPort, @@ -141,6 +141,7 @@ enum AffinityEscapeReason { LeaseSaturated, HigherPriority, PinnedAccount, + SchedulingPolicy, SelectionInvariant, } @@ -153,6 +154,7 @@ impl AffinityEscapeReason { Self::LeaseSaturated => "lease_saturated", Self::HigherPriority => "higher_priority", Self::PinnedAccount => "pinned_account", + Self::SchedulingPolicy => "scheduling_policy", Self::SelectionInvariant => "selection_invariant", } } @@ -206,6 +208,9 @@ impl AffinitySelection { } match selection { PreferredAccountSelection::Hit => {} + PreferredAccountSelection::OverriddenByPolicy => { + self.escape(AffinityEscapeReason::SchedulingPolicy); + } PreferredAccountSelection::Blocked(AccountSchedulingBlocker::ConcurrencyLimit) => { self.escape(AffinityEscapeReason::LeaseSaturated); } @@ -321,6 +326,42 @@ impl CodexCredentialSelector { .await } + /// 中间件完成请求改写后,用真实 OpenAI 会话事实复验已持有的租约。 + /// + /// 此处只复用既有亲和与 cyber-policy 端口,不再次选号;冲突必须在发送前失败, + /// 避免同一 attempt 持有旧租约时重入账号选择。 + pub(crate) async fn validate_translated_selection( + &self, + lease: &mut CodexCredentialLease, + session_affinity: Option<&CodexSessionAffinity>, + cyber_policy_session_key: Option<&ProviderSessionAffinityKey>, + ) -> Result<(), CredentialSelectionError> { + let selected_account = lease.account.id().clone(); + if let Some(affinity) = session_affinity { + if self + .claim_initial_session_affinity(affinity.key(), &selected_account) + .await + .is_some_and(|effective| effective != selected_account) + { + return Err(CredentialSelectionError::NoEligibleCredential); + } + lease.affinity_expected_account_id = selected_account.clone(); + } + + let cyber_policy_scope = self + .prepare_cyber_policy_scope(cyber_policy_session_key) + .await; + if cyber_policy_scope + .as_ref() + .and_then(|scope| scope.state.as_ref()) + .is_some_and(|state| state.excluded_accounts().contains(&selected_account)) + { + return Err(CredentialSelectionError::NoEligibleCredential); + } + lease.cyber_policy_scope = cyber_policy_scope; + Ok(()) + } + /// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号。 /// /// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路; @@ -573,7 +614,20 @@ impl CodexCredentialSelector { .insert(candidate.account.id().clone()); } } - let selection = AccountSelector.select(&candidates, &context); + let selection = match request + .attempt + .select_account(&self.provider_kind, upstream_model, &candidates, &context) + .await + { + Ok(selection) => selection, + Err(AccountPolicyError::StaleCandidate) => continue 'capacity, + Err(AccountPolicyError::Rejected) => { + return Err(CredentialSelectionError::PolicyRejected); + } + Err(AccountPolicyError::Fault) => { + return Err(CredentialSelectionError::PolicyUnavailable); + } + }; request.attempt.trace().account_selection( &candidates, &context, @@ -1527,6 +1581,10 @@ pub enum CredentialSelectionError { Coordinator, #[error("Codex Cookie policy rejected the value")] CookiePolicy, + #[error("account scheduling policy rejected the request")] + PolicyRejected, + #[error("account scheduling policy is unavailable")] + PolicyUnavailable, } impl From for CredentialSelectionError { diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index f3d024205..4310f8c37 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -6,7 +6,7 @@ use super::*; impl CodexProvider { pub(super) async fn execute_image( - &self, + self: Arc, image: &ImageRequest, candidate: &ProviderCandidate, context: AttemptContext, @@ -38,6 +38,7 @@ impl CodexProvider { self.execute_raw_json_endpoint( context, RawJsonEndpointRequest { + operation: Operation::GenerateImage(image.clone()), response_origin, endpoint_path, body: image.payload().body().clone(), @@ -50,7 +51,7 @@ impl CodexProvider { } pub(super) async fn execute_search( - &self, + self: Arc, search: &StandaloneSearchRequest, candidate: &ProviderCandidate, context: AttemptContext, @@ -72,10 +73,12 @@ impl CodexProvider { context.client_api_key_ref(), "id", ); + let response_origin = self.search_url.clone(); self.execute_raw_json_endpoint( context, RawJsonEndpointRequest { - response_origin: self.search_url.clone(), + operation: Operation::Search(search.clone()), + response_origin, endpoint_path: CODEX_ALPHA_SEARCH_PATH, body: search.payload().body().clone(), image_turn_id: None, @@ -87,7 +90,7 @@ impl CodexProvider { } async fn execute_raw_json_endpoint( - &self, + self: Arc, context: AttemptContext, request: RawJsonEndpointRequest, ) -> Result { @@ -104,6 +107,91 @@ impl CodexProvider { let account_selection_wait_ms = u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); let lease = Arc::new(lease); + let operation = request.operation.clone(); + let provider_kind = ProviderKind::new(PROVIDER_NAME) + .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; + let account_id = lease.account_id().clone(); + let provider = Arc::clone(&self); + let terminal_context = context.clone(); + context + .execute_middleware( + operation, + provider_kind, + None, + account_id, + Box::new(move |operation, middleware_headers| { + Box::pin(async move { + provider + .execute_selected_raw_json_endpoint( + terminal_context, + operation, + middleware_headers, + request, + lease, + account_selection_wait_ms, + ) + .await + }) + }), + ) + .await + } + + async fn execute_selected_raw_json_endpoint( + self: Arc, + context: AttemptContext, + operation: Operation, + middleware_headers: Vec, + mut request: RawJsonEndpointRequest, + lease: Arc, + account_selection_wait_ms: u64, + ) -> Result { + match operation { + Operation::GenerateImage(image) => { + let Operation::GenerateImage(original) = &request.operation else { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + }; + if image.kind() != original.kind() || image.payload().protocol() != PROVIDER_NAME { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + request.body = image.payload().body().clone(); + request.image_turn_id = image + .payload() + .context() + .get("image_turn_id") + .and_then(Value::as_str) + .map(str::to_owned); + } + Operation::Search(search) => { + if !matches!(&request.operation, Operation::Search(_)) + || search.payload().protocol() != PROVIDER_NAME + { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + request.body = search.payload().body().clone(); + request.turn_metadata = search + .payload() + .context() + .get("turn_metadata") + .and_then(Value::as_str) + .map(str::to_owned); + } + _ => { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + } let allows_account_state_mutation = lease.allows_account_state_mutation(); let provider_kind = ProviderKind::new(PROVIDER_NAME) .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; @@ -130,7 +218,8 @@ impl CodexProvider { .map_err(|_| { provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) })? - .with_authentication(lease.authentication()), + .with_authentication(lease.authentication()) + .with_middleware_headers(middleware_headers), response_origin: request.response_origin, endpoint_path: request.endpoint_path, body: request.body, @@ -156,6 +245,7 @@ impl CodexProvider { } struct RawJsonEndpointRequest { + operation: Operation, response_origin: Url, endpoint_path: &'static str, body: Bytes, diff --git a/backend/crates/providers/openai/src/provider/failure.rs b/backend/crates/providers/openai/src/provider/failure.rs index a6672803e..2bf4a32a6 100644 --- a/backend/crates/providers/openai/src/provider/failure.rs +++ b/backend/crates/providers/openai/src/provider/failure.rs @@ -855,6 +855,7 @@ pub(super) fn map_client_error( )), CodexClientError::InvalidHeaderName(_) | CodexClientError::InvalidHeaderValue(_) + | CodexClientError::MiddlewareHeaderConflict | CodexClientError::WebSocketEncode(_) | CodexClientError::RequestBodyEncode(_) | CodexClientError::RequestCompression(_) @@ -1040,6 +1041,11 @@ fn client_diagnostic(error: &CodexClientError) -> Option { "invalid_header_value", "OpenAI request header value is invalid".to_owned(), ), + CodexClientError::MiddlewareHeaderConflict => ( + "prepare", + "middleware_header_conflict", + "OpenAI middleware header conflicts with a provider-managed header".to_owned(), + ), CodexClientError::WebSocketEncode(_) | CodexClientError::RequestBodyEncode(_) => ( "prepare", "request_encode_failed", diff --git a/backend/crates/providers/openai/src/provider/mod.rs b/backend/crates/providers/openai/src/provider/mod.rs index 77121f2e1..59efd3d2f 100644 --- a/backend/crates/providers/openai/src/provider/mod.rs +++ b/backend/crates/providers/openai/src/provider/mod.rs @@ -11,6 +11,7 @@ use bytes::Bytes; use futures::{StreamExt, future::BoxFuture}; use gateway_core::account::{AccountFeedbackStats, ProviderAccount}; use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope}; +use gateway_core::engine::middleware::MiddlewareHeader; use gateway_core::engine::provider::{ ContinuationRequestObservation, EventStream, Provider, ProviderCallMetadata, ProviderRequest, ProviderRequestObservation, ProviderSelectionObservation, ProviderStream, @@ -28,8 +29,8 @@ use gateway_core::event::{ }; use gateway_core::lifecycle::CancellationToken; use gateway_core::operation::{ - GenerateRequest, ImageRequest, ImageRequestKind, Operation, OperationKind, - ProviderSessionState, StandaloneSearchRequest, + CapabilityRequirements, GenerateRequest, ImageRequest, ImageRequestKind, Operation, + OperationKind, ProviderSessionState, StandaloneSearchRequest, }; use gateway_core::provider_ports::ProviderSessionAffinityKey; use gateway_core::routing::{ @@ -45,7 +46,7 @@ use gateway_core::upstream::{UpstreamSendState, UpstreamTransport}; use gateway_protocol::openai::events::{ ParsedRateLimits, parse_rate_limit_headers, rate_limits_to_header_pairs, }; -use reqwest::Client; +use reqwest::{Client, header::HeaderValue}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; @@ -137,6 +138,7 @@ pub enum CodexProviderConfigError { InvalidBaseUrl, } +#[derive(Clone)] pub struct CodexProvider { selector: Arc, catalog: Arc, @@ -152,7 +154,70 @@ pub struct CodexProvider { stream_max_retries: u32, } +struct PreparedGenerateRequest { + upstream: CodexResponsesRequest, + previous_session: Option, + continuation_requested: bool, + session_affinity: Option, + cyber_policy_session_key: Option, +} + +struct SelectedGenerate { + lease: CodexCredentialLease, + session_affinity: Option, + cyber_policy_key: Option, + account_selection_wait_ms: u64, + frozen_requirements: CapabilityRequirements, +} + impl CodexProvider { + fn prepare_generate_request( + &self, + generate: &GenerateRequest, + upstream_model: &UpstreamModelId, + context: &AttemptContext, + ) -> Result { + let previous_session = decode_openai_session_state(generate); + let continuation_requested = generate.native_continuation_requested(); + let mut upstream = encode_generate_request(generate, upstream_model.as_str(), None) + .map_err(map_request_error)?; + // 插件加工后仍重新应用宿主强制策略与本地会话身份。 + upstream.apply_fast_policy(context.disable_fast()); + if let Some(conversation_id) = previous_session + .as_ref() + .and_then(|state| state.conversation_id.as_ref()) + { + upstream.local_conversation_id = Some(conversation_id.clone()); + } + if let Some(identity) = &self.session_identity { + identity.prepare_local_conversation(&mut upstream); + } + if let Some(previous_session) = previous_session.as_ref() { + upstream.turn_state = if same_client_turn( + previous_session.client_turn_id.as_deref(), + upstream.client_turn_id.as_deref(), + ) { + upstream + .turn_state + .take() + .or_else(|| previous_session.turn_state.clone()) + } else { + None + }; + } + let session_affinity = + derive_codex_session_affinity(&upstream, context.client_api_key_ref()); + let cyber_policy_session_key = + derive_codex_cyber_policy_session_key(&upstream, context.client_api_key_ref()); + Ok(PreparedGenerateRequest { + upstream, + previous_session, + continuation_requested, + session_affinity, + cyber_policy_session_key, + }) + } + fn client_for_request( &self, context: &AttemptContext, @@ -352,7 +417,7 @@ impl Provider for CodexProvider { } async fn execute( - &self, + self: Arc, request: ProviderRequest, context: AttemptContext, ) -> Result { @@ -393,45 +458,27 @@ impl Provider for CodexProvider { UpstreamSendState::NotSent, )); }; - let previous_session = decode_openai_session_state(generate); - let continuation_requested = generate.native_continuation_requested(); - let mut upstream_request = encode_generate_request(generate, upstream_model.as_str(), None) - .map_err(map_request_error)?; - // 编码已生成独立请求;HTTP、WS 与重试在头部和计量之前共用此策略。 - upstream_request.apply_fast_policy(context.disable_fast()); - if let Some(conversation_id) = previous_session - .as_ref() - .and_then(|state| state.conversation_id.as_ref()) - { - upstream_request.local_conversation_id = Some(conversation_id.clone()); - } - if let Some(identity) = &self.session_identity { - identity.prepare_local_conversation(&mut upstream_request); - } - if let Some(previous_session) = previous_session.as_ref() { - upstream_request.turn_state = if same_client_turn( - previous_session.client_turn_id.as_deref(), - upstream_request.client_turn_id.as_deref(), - ) { - upstream_request - .turn_state - .take() - .or_else(|| previous_session.turn_state.clone()) - } else { - None - }; - } - let session_affinity = - derive_codex_session_affinity(&upstream_request, context.client_api_key_ref()); - let cyber_policy_session_key = - derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref()); - - let requires_websocket = transport_requirement(&upstream_request).requires_websocket() - || (context.continuation_attempt() == ContinuationAttempt::Native - && (previous_session.as_ref().is_some_and(|state| { - state.continuation_scope == OpenAiContinuationScope::ConnectionLocal - }) || matches!(context.continuation(), Some(ContinuationBinding::Pinned(binding)) - if binding.scope() == NativeContinuationScope::ConnectionLocal))); + // 其他协议必须先取得真实账号,再按固定 attempt 阶段调用转换器;选号前不能 + // 把未知正文当成 OpenAI wire 解释会话、亲和或传输字段。 + let preselection = (generate.protocol_payload().protocol() == PROVIDER_NAME) + .then(|| self.prepare_generate_request(generate, upstream_model, &context)) + .transpose()?; + let (selection_session_affinity, selection_cyber_policy_key, requires_websocket) = + preselection.map_or((None, None, false), |prepared| { + let requires_websocket = + transport_requirement(&prepared.upstream).requires_websocket() + || (context.continuation_attempt() == ContinuationAttempt::Native + && (prepared.previous_session.as_ref().is_some_and(|state| { + state.continuation_scope + == OpenAiContinuationScope::ConnectionLocal + }) || matches!(context.continuation(), Some(ContinuationBinding::Pinned(binding)) + if binding.scope() == NativeContinuationScope::ConnectionLocal))); + ( + prepared.session_affinity, + prepared.cyber_policy_session_key, + requires_websocket, + ) + }); let selection_started_at = Instant::now(); let lease = self .selector @@ -440,18 +487,108 @@ impl Provider for CodexProvider { upstream_model: upstream_model.as_str(), request_url: &self.responses_url, attempt: &context, - session_affinity_key: session_affinity.as_ref().map(|affinity| affinity.key()), + session_affinity_key: selection_session_affinity + .as_ref() + .map(|affinity| affinity.key()), }, - cyber_policy_session_key.as_ref(), - session_affinity.as_ref(), + selection_cyber_policy_key.as_ref(), + selection_session_affinity.as_ref(), requires_websocket, ) .await .map_err(map_selection_error)?; let account_selection_wait_ms = u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let provider_kind = ProviderKind::new(PROVIDER_NAME) + .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; + let account_id = lease.account_id().clone(); + let frozen_requirements = Operation::Generate(generate.clone()).capability_requirements(); + let provider = Arc::clone(&self); + let terminal_context = context.clone(); + let terminal_model = upstream_model.clone(); + context + .execute_middleware( + Operation::Generate(generate.clone()), + provider_kind, + Some(upstream_model.as_str().to_owned()), + account_id, + Box::new(move |operation, middleware_headers| { + Box::pin(async move { + provider + .execute_selected_generate( + operation, + middleware_headers, + terminal_model, + terminal_context, + SelectedGenerate { + lease, + session_affinity: selection_session_affinity, + cyber_policy_key: selection_cyber_policy_key, + account_selection_wait_ms, + frozen_requirements, + }, + ) + .await + }) + }), + ) + .await + } +} + +impl CodexProvider { + async fn execute_selected_generate( + self: Arc, + operation: Operation, + middleware_headers: Vec, + upstream_model: UpstreamModelId, + context: AttemptContext, + selected: SelectedGenerate, + ) -> Result { + let SelectedGenerate { + mut lease, + session_affinity: selection_session_affinity, + cyber_policy_key: selection_cyber_policy_key, + account_selection_wait_ms, + frozen_requirements, + } = selected; + let Operation::Generate(generate) = operation else { + return Err(provider_error( + ProviderErrorKind::Protocol, + UpstreamSendState::NotSent, + )); + }; + if generate.protocol_payload().protocol() != PROVIDER_NAME + || native_request_requirements(&generate) != frozen_requirements + { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + validate_openai_reasoning(generate.protocol_payload().body())?; + let processed = self.prepare_generate_request(&generate, &upstream_model, &context)?; + let mut upstream_request = processed.upstream; + let previous_session = processed.previous_session; + let continuation_requested = processed.continuation_requested; + let session_affinity = processed.session_affinity; + let cyber_policy_session_key = processed.cyber_policy_session_key; + if selection_session_affinity + .as_ref() + .map(|affinity| affinity.key()) + != session_affinity.as_ref().map(|affinity| affinity.key()) + || selection_cyber_policy_key.as_ref() != cyber_policy_session_key.as_ref() + { + self.selector + .validate_translated_selection( + &mut lease, + session_affinity.as_ref(), + cyber_policy_session_key.as_ref(), + ) + .await + .map_err(map_selection_error)?; + } let lease = Arc::new(lease); - // 首字计时的起点:账号选择完成之后、上游建立之前。 if previous_session.as_ref().is_some_and(|state| { state .credential_revision @@ -555,7 +692,6 @@ impl Provider for CodexProvider { ) { normalize_non_codex_request_body(upstream_request.body_mut()); } - // 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。 if let Some(location) = lease .account() .request_location() @@ -592,6 +728,19 @@ impl Provider for CodexProvider { } else { requested_transport }; + if transport == CodexProviderTransport::PreferWebSocket + && middleware_headers.iter().any(|header| { + match HeaderValue::from_bytes(header.value()) { + Ok(value) => value.to_str().is_err(), + Err(_) => true, + } + }) + { + return Err(provider_error( + ProviderErrorKind::Protocol, + UpstreamSendState::NotSent, + )); + } apply_transport(&mut upstream_request, transport); let metadata = ProviderCallMetadata::new( provider_kind, @@ -636,10 +785,11 @@ impl Provider for CodexProvider { .map_err(|_| { provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) })? - .with_authentication(lease.authentication()), + .with_authentication(lease.authentication()) + .with_middleware_headers(middleware_headers), response_origin: self.responses_url.clone(), request: upstream_request, - upstream_model: upstream_model.clone(), + upstream_model, transport_policy: transport, context, selector: Arc::clone(&self.selector), @@ -665,3 +815,34 @@ impl Provider for CodexProvider { }) } } + +fn native_request_requirements(request: &GenerateRequest) -> CapabilityRequirements { + // 此处只解释已知 OpenAI wire;不在 Core 的通用转换路径推断任意目标协议。 + Operation::Generate(GenerateRequest::from_protocol_payload( + request.protocol_payload().clone(), + )) + .capability_requirements() +} + +fn validate_openai_reasoning(body: &Map) -> Result<(), ProviderError> { + let Some(effort) = body + .get("reasoning") + .and_then(Value::as_object) + .and_then(|reasoning| reasoning.get("effort")) + else { + return Ok(()); + }; + let Some(effort) = effort.as_str() else { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + }; + if effort.is_empty() || effort.len() > 64 || effort.chars().any(char::is_control) { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + Ok(()) +} diff --git a/backend/crates/providers/openai/src/provider/observation.rs b/backend/crates/providers/openai/src/provider/observation.rs index 7040ab50c..8f7235611 100644 --- a/backend/crates/providers/openai/src/provider/observation.rs +++ b/backend/crates/providers/openai/src/provider/observation.rs @@ -812,5 +812,13 @@ pub(super) fn map_selection_error(error: CredentialSelectionError) -> ProviderEr ProviderErrorKind::ProviderInfrastructureUnavailable, UpstreamSendState::NotSent, ), + CredentialSelectionError::PolicyRejected => provider_error( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::NotSent, + ), + CredentialSelectionError::PolicyUnavailable => provider_error( + ProviderErrorKind::Unavailable, + UpstreamSendState::NotSent, + ), } } diff --git a/backend/crates/providers/openai/src/transport/client.rs b/backend/crates/providers/openai/src/transport/client.rs index 14d171343..ba646f8e3 100644 --- a/backend/crates/providers/openai/src/transport/client.rs +++ b/backend/crates/providers/openai/src/transport/client.rs @@ -12,6 +12,7 @@ use crate::transport::profile::CodexWireProfileState; use bytes::Bytes; use chrono::{DateTime, NaiveDateTime, Utc}; use futures::{Stream, StreamExt}; +use gateway_core::engine::middleware::MiddlewareHeader; use gateway_protocol::openai::{ WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, events::retry_after_seconds_from_body, sse::SseError, @@ -212,6 +213,9 @@ pub enum CodexClientError { /// 请求头值无效。 #[error("invalid request header value: {0}")] InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), + /// 中间件业务头试图覆盖 Provider 已构造的受管头。 + #[error("middleware request header conflicts with a provider-managed header")] + MiddlewareHeaderConflict, /// SSE 响应解析失败。 #[error("invalid upstream SSE response: {0}")] InvalidSse(#[from] SseError), @@ -281,6 +285,9 @@ impl fmt::Debug for CodexClientError { Self::InvalidHeaderValue(_) => { formatter.write_str("CodexClientError::InvalidHeaderValue([REDACTED])") } + Self::MiddlewareHeaderConflict => { + formatter.write_str("CodexClientError::MiddlewareHeaderConflict") + } Self::InvalidSse(_) => formatter.write_str("CodexClientError::InvalidSse([REDACTED])"), Self::ModelCatalog(error) => formatter .debug_tuple("CodexClientError::ModelCatalog") @@ -334,6 +341,7 @@ impl CodexClientError { Self::CustomCa(_) | Self::InvalidHeaderName(_) | Self::InvalidHeaderValue(_) + | Self::MiddlewareHeaderConflict | Self::WebSocketEncode(_) | Self::RequestBodyEncode(_) | Self::RequestCompression(_) => None, @@ -671,6 +679,7 @@ pub struct CodexBackendClient { pub(super) websocket_origin_key: String, pub(super) outbound_proxy: Option, pub(super) egress_key: String, + pub(super) middleware_headers: Vec, } impl CodexBackendClient { diff --git a/backend/crates/providers/openai/src/transport/client_json.rs b/backend/crates/providers/openai/src/transport/client_json.rs index 3b861ab5c..909f49944 100644 --- a/backend/crates/providers/openai/src/transport/client_json.rs +++ b/backend/crates/providers/openai/src/transport/client_json.rs @@ -45,6 +45,7 @@ impl CodexBackendClient { "x-codex-turn-metadata", context.turn_metadata, ); + self.append_middleware_headers(&mut headers)?; let trace = context .trace diff --git a/backend/crates/providers/openai/src/transport/client_sse.rs b/backend/crates/providers/openai/src/transport/client_sse.rs index f353549f6..437da2348 100644 --- a/backend/crates/providers/openai/src/transport/client_sse.rs +++ b/backend/crates/providers/openai/src/transport/client_sse.rs @@ -69,6 +69,7 @@ impl CodexBackendClient { websocket_origin_key: websocket_origin_key(&base_url), outbound_proxy: None, egress_key: String::new(), + middleware_headers: Vec::new(), base_url, official_base_url: crate::OFFICIAL_CODEX_BASE_URL.to_owned(), protocol: OpenAiUpstreamProtocol::Codex, @@ -84,6 +85,16 @@ impl CodexBackendClient { self } + /// 附加当前 attempt 经 Core 复核的业务请求头。 + #[must_use] + pub(crate) fn with_middleware_headers( + mut self, + middleware_headers: Vec, + ) -> Self { + self.middleware_headers = middleware_headers; + self + } + /// 驱逐指定账号的 Responses WebSocket 池连接。 pub async fn evict_websocket_account(&self, account_id: &str) { if let Some(pool) = &self.websocket_pool { @@ -292,7 +303,7 @@ impl CodexBackendClient { tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact"); } } - let connection_profile = websocket_connection_profile(&headers); + let connection_profile = websocket_connection_profile(&headers, &self.middleware_headers); let pool_key = self.websocket_pool_key(request, context, pool_account_id, &connection_profile); let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key); @@ -634,15 +645,32 @@ async fn read_model_catalog_body(response: ReqwestResponse) -> CodexClientResult Ok(body) } -fn websocket_connection_profile(headers: &HeaderMap) -> String { - ["originator", "user-agent", X_OPENAI_MEMGEN_REQUEST_HEADER] +fn websocket_connection_profile( + headers: &HeaderMap, + middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader], +) -> String { + let mut profile = ["originator", "user-agent", X_OPENAI_MEMGEN_REQUEST_HEADER] .map(|name| { headers .get(name) .and_then(|value| value.to_str().ok()) .unwrap_or_default() }) - .join("\0") + .join("\0"); + if !middleware_headers.is_empty() { + use sha2::{Digest, Sha256}; + + let mut digest = Sha256::new(); + for header in middleware_headers { + digest.update(header.name().len().to_le_bytes()); + digest.update(header.name().as_bytes()); + digest.update(header.value().len().to_le_bytes()); + digest.update(header.value()); + } + profile.push('\0'); + profile.push_str(&hex::encode(digest.finalize())); + } + profile } fn http_sse_stream( diff --git a/backend/crates/providers/openai/src/transport/headers.rs b/backend/crates/providers/openai/src/transport/headers.rs index 23ad8cafe..b8ed31c40 100644 --- a/backend/crates/providers/openai/src/transport/headers.rs +++ b/backend/crates/providers/openai/src/transport/headers.rs @@ -206,8 +206,24 @@ impl CodexBackendClient { }; insert_optional_protocol_header(&mut headers, "x-codex-routing-hint", Some(&routing_hint)); append_passthrough_headers(&mut headers, request); + self.append_middleware_headers(&mut headers)?; Ok(headers) } + + pub(super) fn append_middleware_headers( + &self, + headers: &mut HeaderMap, + ) -> CodexClientResult<()> { + let provider_headers = headers.keys().cloned().collect::>(); + for header in &self.middleware_headers { + let name = HeaderName::from_bytes(header.name().as_bytes())?; + if provider_headers.contains(&name) { + return Err(super::client::CodexClientError::MiddlewareHeaderConflict); + } + headers.append(name, HeaderValue::from_bytes(header.value())?); + } + Ok(()) + } } fn append_passthrough_headers(headers: &mut HeaderMap, request: &CodexResponsesRequest) { diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 1b2b7468b..8c384063d 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -64,6 +64,29 @@ const COMPLETED_SESSION_SSE: &str = concat!( "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n" ); +#[tokio::test] +async fn account_capabilities_distinguish_oauth_from_api_key_and_unknown_credentials() { + let config = valid_config(); + let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) + .await + .unwrap(); + let provider = bundle.admin_provider(); + let id = ProviderAccountId::new("acct_capabilities").unwrap(); + let oauth = provider.account_capabilities(&id, "oauth"); + assert!( + oauth.quota + && oauth.quota_refresh + && oauth.profile + && oauth.subscription + && oauth.avatar + && oauth.reset_credits + && oauth.consume_reset_credit + ); + for kind in ["api_key", "unknown"] { + assert_eq!(provider.account_capabilities(&id, kind), Default::default()); + } +} + #[tokio::test] async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() { let config = valid_config(); @@ -265,7 +288,7 @@ async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinc .expect("OpenAI payload") .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])); let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload)); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( initialized_provider_request(operation, account_id), initialized_attempt_context(request_id, account_id), @@ -525,13 +548,20 @@ async fn openai_admin_provider_persists_the_full_pending_envelope_and_binds_owne }; let started = bundle .admin_provider() - .start_authorization(PendingAuthorizationMutation::new( - ProviderKind::new("openai").expect("provider"), - AuthorizationMutationTarget::Create { - name: "OAuth account".to_owned(), + .start_authorization( + gateway_admin::model::provider_credentials::PrepareAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), + pending: PendingAuthorizationMutation::new( + ProviderKind::new("openai").expect("provider"), + AuthorizationMutationTarget::Create { + name: "OAuth account".to_owned(), + }, + AuthorizationOwnerBinding::from_context(&start_context), + ), }, - AuthorizationOwnerBinding::from_context(&start_context), - )) + ) .await .expect("start authorization"); { @@ -627,11 +657,18 @@ async fn openai_reauthorization_pending_payload_reuses_the_account_installation_ bundle .admin_provider() - .start_authorization(PendingAuthorizationMutation::new( - ProviderKind::new("openai").expect("provider"), - AuthorizationMutationTarget::Reauthorize { account_id }, - AuthorizationOwnerBinding::from_context(&context), - )) + .start_authorization( + gateway_admin::model::provider_credentials::PrepareAuthorization { + input: gateway_admin::model::provider_credentials::ProviderDocument::new( + gateway_core::account::OpaqueProviderData::new(serde_json::Map::new()), + ), + pending: PendingAuthorizationMutation::new( + ProviderKind::new("openai").expect("provider"), + AuthorizationMutationTarget::Reauthorize { account_id }, + AuthorizationOwnerBinding::from_context(&context), + ), + }, + ) .await .expect("start reauthorization"); @@ -715,6 +752,7 @@ async fn openai_admin_provider_projects_cached_quota_models_and_canonical_export &UpstreamModelId::new("gpt-5.4").expect("upstream model"), "Reply with exactly OK.", ) + .await .expect("connection test operation"); let Operation::Generate(request) = operation else { panic!("connection test must be a generate operation"); @@ -2323,3 +2361,48 @@ async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotatin ProviderAdminErrorKind::Unsupported ); } + +#[tokio::test] +async fn browser_authorization_declares_callback_and_rejects_extra_login_inputs() { + use gateway_admin::model::{ + provider_capabilities::AuthorizationCompletion, + provider_credentials::{ + AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation, + PrepareAuthorization, + }, + }; + let config = valid_config(); + let bundle = provider_openai::initialize(config.config.clone(), provider_ports()) + .await + .unwrap(); + let provider = bundle.admin_provider(); + let login = provider.credential_capabilities().login.unwrap(); + assert_eq!(login.completion, AuthorizationCompletion::Callback); + assert_eq!( + login.input_schema, + json!({"type":"object", "additionalProperties":false}) + ); + let error = provider + .start_authorization(PrepareAuthorization { + pending: PendingAuthorizationMutation::new( + ProviderKind::new("openai").unwrap(), + AuthorizationMutationTarget::Create { + name: "form account".into(), + }, + AuthorizationOwnerBinding::from_context(&MutationContext { + actor: MutationActor::System, + request_id: "login-input-test".into(), + }), + ), + input: ProviderDocument::new(OpaqueProviderData::new( + json!({"token":"unexpected-fixture-secret"}) + .as_object() + .unwrap() + .clone(), + )), + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), ProviderAdminErrorKind::Invalid); + assert!(!format!("{error:?}").contains("unexpected-fixture-secret")); +} diff --git a/backend/crates/providers/openai/tests/provider/contract.rs b/backend/crates/providers/openai/tests/provider/contract.rs index aadd84661..e9d997ce2 100644 --- a/backend/crates/providers/openai/tests/provider/contract.rs +++ b/backend/crates/providers/openai/tests/provider/contract.rs @@ -7,7 +7,7 @@ use std::time::{Duration, SystemTime}; use base64::{Engine as _, engine::general_purpose::STANDARD}; use bytes::Bytes; use chrono::Utc; -use futures::{SinkExt, StreamExt}; +use futures::{SinkExt, StreamExt, future::BoxFuture}; use gateway_core::account::{ AccountFeedbackStats, AccountWeight, CredentialState, OpaqueProviderData, ProviderAccountId, ProviderAccountStore as _, QuotaAccessChange, QuotaAccessState, QuotaEvidence, @@ -16,6 +16,11 @@ use gateway_core::account::{ use gateway_core::engine::continuation::{ ContinuationBinding, NativeContinuationPin, PreviousResponseId, }; +use gateway_core::engine::execution::ClientTransport; +use gateway_core::engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareHeader, MiddlewareMount, + MiddlewareNext, MiddlewarePlan, MiddlewareRequest, MiddlewareResponse, +}; use gateway_core::engine::provider::{Provider as _, ProviderRequest}; use gateway_core::engine::{ AccountAttemptContext, AttemptContext, AttemptTransport, ContinuationAttempt, ModelRequestId, @@ -38,6 +43,7 @@ use gateway_core::routing::{ ProviderKind, ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId, }; +use gateway_core::runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; use gateway_core::upstream::UpstreamSendState; use provider_openai::config::DEFAULT_STREAM_MAX_RETRIES; use provider_openai::credential::{ @@ -66,6 +72,432 @@ use crate::support::{ }; use crate::transport::accept_codex_test_websocket; +#[tokio::test] +async fn native_openai_translates_a_non_native_source_before_encoding() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + let translations = Arc::new(Mutex::new(0_usize)); + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", source), + context_with_middleware( + "req_native_translate", + Arc::new(TranslationMiddleware { + translations: Arc::clone(&translations), + target: Map::from_iter([ + ("model".to_owned(), json!("ignored-by-forced-model")), + ("input".to_owned(), json!("translated")), + ("stream".to_owned(), json!(true)), + ]), + }), + false, + ), + ) + .await + .expect("registered translation should run before native encoding"); + assert!(server.received_requests().await.unwrap().is_empty()); + while let Some(event) = stream.next().await { + event.expect("translated request response"); + } + assert_eq!(*translations.lock().unwrap(), 1); + let requests = server.received_requests().await.unwrap(); + let sent = captured_request_body(&requests[0]); + assert_eq!(sent["input"][0]["content"][0]["text"], "translated"); + assert_eq!(sent["model"], "gpt-5.4"); +} + +#[tokio::test] +async fn native_openai_rejects_missing_translation_before_send() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let error = match provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", source), + context_with_middleware( + "req_native_translate_missing", + Arc::new(PassThroughMiddleware), + false, + ), + ) + .await + { + Ok(_) => panic!("missing translation pair must be explicit"), + Err(error) => error, + }; + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn native_openai_rejects_capability_expanding_translation_before_send() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let translations = Arc::new(Mutex::new(0)); + let error = match provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", source), + context_with_middleware( + "req_native_translate_expansion", + Arc::new(TranslationMiddleware { + translations: Arc::clone(&translations), + target: json!({ + "model":"ignored-by-forced-model", + "input":"translated", + "tools":[{"type":"function", "name":"new_capability"}] + }) + .as_object() + .unwrap() + .clone(), + }), + false, + ), + ) + .await + { + Ok(_) => panic!("translation cannot expand routed capabilities"), + Err(error) => error, + }; + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert_eq!(*translations.lock().unwrap(), 1); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn native_openai_revalidates_translated_transport_without_reselecting() { + let store = Arc::new(MemoryAccountStore::default()); + let upstream = MockServer::start().await; + store + .seed_api_key( + "acct_provider_contract", + upstream.uri(), + provider_openai::credential::ApiKeyTransport::Http, + ) + .await; + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(true))])), + )); + let error = match provider(&store) + .execute( + planned_request("openai", source), + context_with_middleware( + "req_native_translate_transport", + Arc::new(TranslationMiddleware { + translations: Arc::new(Mutex::new(0)), + target: json!({ + "model": "ignored-by-forced-model", + "input": [], + "store": false, + "generate": false + }) + .as_object() + .unwrap() + .clone(), + }), + false, + ), + ) + .await + { + Ok(_) => panic!("selected HTTP account cannot satisfy translated WebSocket request"), + Err(error) => error, + }; + assert_eq!(error.kind(), ProviderErrorKind::Unsupported); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(upstream.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn native_openai_claims_translated_session_affinity_before_send() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let affinity = Arc::new(MemorySessionAffinity::default()); + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap() + .with_context(Map::from_iter([ + ("conversation_id".to_owned(), json!("translated-session")), + ("use_websocket".to_owned(), json!(false)), + ])), + )); + let stream = provider_with_affinity_and_base_url(&store, Arc::clone(&affinity), server.uri()) + .execute( + planned_request("openai", source), + context_with_middleware( + "req_native_translate_affinity", + Arc::new(TranslationMiddleware { + translations: Arc::new(Mutex::new(0)), + target: json!({ + "model": "ignored-by-forced-model", + "input": "translated", + "stream": true + }) + .as_object() + .unwrap() + .clone(), + }), + false, + ), + ) + .await + .expect("translated session should keep the selected account"); + assert_eq!( + stream.metadata().provider_account_id().as_str(), + "acct_provider_contract" + ); + assert_eq!(affinity.binding_count(), 1); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn attempt_middleware_runs_once_before_native_encoding_and_fast_policy() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + let observed = Arc::new(Mutex::new(Vec::new())); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"gpt-5.6-sol", "input":"hello", "unknown":{"preserved":true}}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", operation), + context_with_middleware( + "req_native_normalize", + Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: ("service_tier".to_owned(), json!("priority")), + request_headers: vec![ + MiddlewareHeader::new( + "x-business-context", + Bytes::from_static(b"tenant-public"), + ), + MiddlewareHeader::new( + "x-business-context", + Bytes::from_static(b"trace-public"), + ), + ], + }), + true, + ), + ) + .await + .unwrap(); + assert!(server.received_requests().await.unwrap().is_empty()); + while let Some(event) = stream.next().await { + event.unwrap(); + } + { + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0]["input"], "hello"); + } + let requests = server.received_requests().await.unwrap(); + let sent = captured_request_body(&requests[0]); + assert_eq!(sent["service_tier"], "default"); + assert_eq!(sent["unknown"]["preserved"], true); + assert_eq!( + captured_header_values(&requests[0], "x-business-context"), + vec![b"tenant-public".to_vec(), b"trace-public".to_vec()], + ); + + let mut conflict = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", http_generate_operation()), + context_with_middleware( + "req_native_header_conflict", + Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: ("service_tier".to_owned(), json!("priority")), + request_headers: vec![MiddlewareHeader::new( + "x-codex-routing-hint", + Bytes::from_static(b"plugin-value"), + )], + }), + false, + ), + ) + .await + .expect("header validation remains on the cold Provider stream"); + let Some(Err(error)) = conflict.next().await else { + panic!("a Provider-managed header collision must fail before send") + }; + assert_eq!(error.kind(), ProviderErrorKind::Protocol); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert_eq!(server.received_requests().await.unwrap().len(), 1); +} + +#[tokio::test] +async fn attempt_middleware_rejects_non_text_websocket_headers_before_opening() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({"model":"gpt-5.4", "input":"hello"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(true))])), + )); + + let error = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", operation), + context_with_middleware( + "req_native_websocket_obs_text", + Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: ("service_tier".to_owned(), json!("priority")), + request_headers: vec![MiddlewareHeader::new( + "x-business-context", + Bytes::from_static(&[0x80]), + )], + }), + false, + ), + ) + .await + .err() + .expect("WebSocket headers must be representable before opening"); + + assert_eq!(error.kind(), ProviderErrorKind::Protocol); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn attempt_middleware_can_change_reasoning_before_native_encoding() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_provider_contract").await; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(CAPTURE_COMPLETED_SSE), + ) + .expect(1) + .mount(&server) + .await; + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + json!({ + "model":"gpt-5.6-sol", + "input":"hello", + "reasoning":{"effort":"future-level","summary":"detailed"}, + "unknown":{"preserved":true} + }) + .as_object() + .unwrap() + .clone(), + ) + .unwrap() + .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), + )); + let observed = Arc::new(Mutex::new(Vec::new())); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + planned_request("openai", operation), + context_with_middleware( + "req_native_reasoning", + Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: ("reasoning".to_owned(), json!({"effort":"high"})), + request_headers: Vec::new(), + }), + false, + ), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + + let requests = server.received_requests().await.unwrap(); + let body = captured_request_body(&requests[0]); + assert_eq!(body["reasoning"], json!({"effort":"high"})); + assert_eq!( + body.pointer("/input/0/content/0/text"), + Some(&json!("hello")) + ); + assert_eq!(body["unknown"]["preserved"], true); + assert_eq!( + observed.lock().unwrap()[0]["reasoning"]["effort"], + "future-level" + ); +} + #[tokio::test] async fn responses_bill_sent_model_and_observe_unpriced_response_without_rewriting_it() { let store = Arc::new(MemoryAccountStore::default()); @@ -251,7 +683,7 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c ].into_iter().enumerate() { let location = location.map(|value| serde_json::from_value::(value).unwrap()); store.set_egress(account_id, Some(OutboundProxy::parse(&proxy.uri()).unwrap()), location); - let mut stream = provider.execute(planned_request("openai", operation.clone()), context_with_state_owner_and_location(&format!("req_proxy_location_{index}"), account_id, global_enabled.then(global_request_location))).await.expect("prepare"); + let mut stream = Arc::clone(&provider).execute(planned_request("openai", operation.clone()), context_with_state_owner_and_location(&format!("req_proxy_location_{index}"), account_id, global_enabled.then(global_request_location))).await.expect("prepare"); while let Some(event) = stream.next().await { event.expect("proxy response"); } let requests = proxy.received_requests().await.unwrap(); let body = captured_request_body(requests.last().expect("request reached selected proxy")); @@ -557,18 +989,18 @@ fn wire_profile() -> CodexWireProfileState { }) } -fn provider(store: &Arc) -> CodexProvider { +fn provider(store: &Arc) -> Arc { provider_with_affinity(store, Arc::new(MemorySessionAffinity::default())) } fn provider_with_affinity( store: &Arc, session_affinity: Arc, -) -> CodexProvider { +) -> Arc { provider_with_affinity_and_base_url(store, session_affinity, OFFICIAL_CODEX_BASE_URL.to_owned()) } -fn provider_with_base_url(store: &Arc, base_url: String) -> CodexProvider { +fn provider_with_base_url(store: &Arc, base_url: String) -> Arc { provider_with_base_url_and_retry_budget( store, base_url, @@ -580,7 +1012,7 @@ fn provider_with_base_url_and_retry_budget( store: &Arc, base_url: String, stream_max_retries: u32, -) -> CodexProvider { +) -> Arc { provider_and_quota_with_affinity_and_base_url_and_leases( store, Arc::new(MemorySessionAffinity::default()), @@ -594,7 +1026,7 @@ fn provider_with_base_url_and_retry_budget( fn provider_with_leases( store: &Arc, leases: Arc, -) -> CodexProvider { +) -> Arc { provider_with_affinity_and_base_url_and_leases( store, Arc::new(MemorySessionAffinity::default()), @@ -607,7 +1039,7 @@ fn provider_with_affinity_and_base_url( store: &Arc, session_affinity: Arc, base_url: String, -) -> CodexProvider { +) -> Arc { provider_with_affinity_and_base_url_and_leases( store, session_affinity, @@ -621,7 +1053,7 @@ fn provider_with_affinity_and_base_url_and_leases( session_affinity: Arc, base_url: String, leases: Arc, -) -> CodexProvider { +) -> Arc { provider_and_quota_with_affinity_and_base_url_and_leases( store, session_affinity, @@ -638,7 +1070,7 @@ fn provider_and_quota_with_affinity_and_base_url_and_leases( base_url: String, leases: Arc, stream_max_retries: u32, -) -> (CodexProvider, Arc) { +) -> (Arc, Arc) { let (provider, quota, _) = provider_and_quota_with_runtime_ports( store, session_affinity, @@ -660,7 +1092,7 @@ fn provider_and_quota_with_runtime_ports( cooldowns: Arc, policy: Arc, ) -> ( - CodexProvider, + Arc, Arc, Arc, ) { @@ -710,7 +1142,7 @@ fn provider_and_quota_with_runtime_ports( stream_max_retries, ) .expect("official OpenAI provider"); - (provider, quota, websocket_pool) + (Arc::new(provider), quota, websocket_pool) } async fn create_account(store: &Arc, id: &str) { @@ -852,6 +1284,7 @@ fn planned_provider_endpoint_request(provider_name: &str, operation: Operation) let plan = snapshot .plan_provider_endpoint( &provider, + None, &operation, account_scope, &RoutingContext::default(), @@ -933,6 +1366,118 @@ fn global_request_location() -> gateway_core::account::RequestLocation { } } +#[derive(Debug)] +struct TranslationMiddleware { + translations: Arc>, + target: Map, +} + +impl MiddlewarePlan for TranslationMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Attempt); + assert!(context.account_id().is_some()); + *self.translations.lock().unwrap() += 1; + let (_, headers, _) = request.into_parts(); + next.run(MiddlewareRequest::new( + "openai", + headers, + Bytes::from(serde_json::to_vec(&self.target).unwrap()), + )) + } +} + +#[derive(Debug)] +struct PassThroughMiddleware; + +impl MiddlewarePlan for PassThroughMiddleware { + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + next.run(request) + } +} + +#[derive(Debug)] +struct RecordingMiddleware { + observed: Arc>>>, + replacement: (String, Value), + request_headers: Vec, +} + +impl MiddlewarePlan for RecordingMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Attempt); + assert_eq!(request.protocol(), "openai"); + let (protocol, mut headers, bytes) = request.into_parts(); + let mut body: Map = serde_json::from_slice(&bytes).unwrap(); + self.observed.lock().unwrap().push(body.clone()); + body.insert(self.replacement.0.clone(), self.replacement.1.clone()); + headers.extend(self.request_headers.clone()); + next.run(MiddlewareRequest::new( + protocol, + headers, + Bytes::from(serde_json::to_vec(&body).unwrap()), + )) + } +} + +#[derive(Debug)] +struct TestExtensionLease; + +impl ExtensionSetLease for TestExtensionLease { + fn is_ready(&self) -> bool { + true + } +} + +fn context_with_middleware( + request_id: &str, + plan: Arc, + disable_fast: bool, +) -> AttemptContext { + let plan = FrozenMiddlewarePlan::new( + plan, + ExtensionSetReference::new( + ExtensionSetId::new(format!("thinking-{request_id}")).unwrap(), + Arc::new(TestExtensionLease), + ), + ); + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new(request_id).expect("request id"), + ClientApiKeyId::new("key_openai_contract").expect("client key id"), + ) + .with_disable_fast(disable_fast) + .with_middleware( + Some(plan), + Arc::from([]), + "/v1/responses".to_owned(), + ClientTransport::HttpSse, + ) + .with_request_location(Some(global_request_location())), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(30), + account_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(contract_account_scope()), + None, + CancellationToken::new(), + ) +} + fn context(request_id: &str, cancellation: CancellationToken) -> AttemptContext { context_with_fast_policy(request_id, cancellation, false) } @@ -1862,7 +2407,7 @@ async fn exhausted_account_pool_returns_usage_limit_before_http_or_websocket_net let server = MockServer::start().await; let provider = provider_with_base_url(&store, server.uri()); for operation in [http_generate_operation(), generate_operation()] { - let Err(error) = provider + let Err(error) = Arc::clone(&provider) .execute( planned_request("openai", operation), context("req_exhausted_pool", CancellationToken::new()), @@ -2044,7 +2589,7 @@ async fn image_endpoints_bypass_only_the_text_catalog_and_preserve_the_current_c )])); let operation = Operation::GenerateImage(ImageRequest::from_raw_json(*kind, payload)); let request = planned_provider_endpoint_request("openai", operation); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( request, context( @@ -2518,7 +3063,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { Arc::clone(&leases), ); let root = Operation::Generate(generate_with_session_context("shared-root", None, None)); - let root_stream = provider + let root_stream = Arc::clone(&provider) .execute( planned_request("openai", root.clone()), context("req_cross_endpoint_root", CancellationToken::new()), @@ -2532,7 +3077,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { thread_id, None, )); - let first = provider + let first = Arc::clone(&provider) .execute( planned_request("openai", generation.clone()), context("req_cross_endpoint_first", CancellationToken::new()), @@ -2581,7 +3126,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { .continuation .affinity_hash ); - let mut same = provider + let mut same = Arc::clone(&provider) .execute( planned_provider_endpoint_request("openai", search.clone()), context("req_cross_endpoint_search", CancellationToken::new()), @@ -2604,7 +3149,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { .lock() .expect("busy accounts") .insert(first_account.clone()); - let mut fallback = provider + let mut fallback = Arc::clone(&provider) .execute( planned_provider_endpoint_request("openai", search), context("req_cross_endpoint_busy", CancellationToken::new()), @@ -2629,7 +3174,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { ); store.set_scheduling(other_account, None, AccountWeight::new(1).expect("weight")); - let resumed = provider + let resumed = Arc::clone(&provider) .execute( planned_request("openai", generation.clone()), context("req_cross_endpoint_resumed", CancellationToken::new()), @@ -2666,7 +3211,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { .continuation .affinity_hash ); - let selected_image = provider + let selected_image = Arc::clone(&provider) .execute( planned_provider_endpoint_request("openai", image), context("req_cross_endpoint_image", CancellationToken::new()), @@ -2680,7 +3225,7 @@ async fn assert_cross_endpoint_affinity(thread_id: Option<&str>) { drop(selected_image); } if thread_id.is_some() { - let root_stream = provider + let root_stream = Arc::clone(&provider) .execute( planned_request("openai", root), context( @@ -3029,7 +3574,7 @@ async fn websocket_close_after_delivery_preserves_details_and_reconnects_through SESSION_ID, "thread-first", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", first_operation), context("req_websocket_close", CancellationToken::new()), @@ -3184,7 +3729,7 @@ async fn websocket_fast_path_miss_uses_http_and_keeps_background_preconnect() { )) }; - let mut first = provider + let mut first = Arc::clone(&provider) .execute( planned_request("openai", operation("thread-http-fallback")), context("req_fast_path_http", CancellationToken::new()), @@ -3704,7 +4249,7 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation() SESSION_ID, "thread-first", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", operation), context("req_websocket_normal_close", CancellationToken::new()), @@ -3777,7 +4322,7 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation() SESSION_ID, "thread-second", )); - let mut fresh_stream = provider + let mut fresh_stream = Arc::clone(&provider) .execute( planned_request("openai", second_operation), context("req_websocket_fresh_retry", CancellationToken::new()), @@ -3814,7 +4359,7 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation() ) .with_provider_session_state(recovered_session), ); - let mut continuation_stream = provider + let mut continuation_stream = Arc::clone(&provider) .execute( planned_request("openai", continuation_operation), pinned_continuation_context( @@ -3842,7 +4387,7 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation() Some("thread-first"), None, )); - let mut other_stream = provider + let mut other_stream = Arc::clone(&provider) .execute( planned_request("openai", other_operation), context("req_other_session_websocket", CancellationToken::new()), @@ -3933,7 +4478,7 @@ async fn repeated_websocket_failures_exhaust_budget_then_use_http_and_report_htt "repeated-close", "turn", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", operation), context( @@ -3962,7 +4507,7 @@ async fn repeated_websocket_failures_exhaust_budget_then_use_http_and_report_htt "repeated-close", "turn", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", operation), context( @@ -4046,7 +4591,7 @@ async fn websocket_upgrade_required_immediately_enables_session_http_fallback() None, )) }; - let mut first = provider + let mut first = Arc::clone(&provider) .execute( planned_request("openai", operation()), context("req_upgrade_required", CancellationToken::new()), @@ -4067,7 +4612,7 @@ async fn websocket_upgrade_required_immediately_enables_session_http_fallback() ); drop(first); - let mut second = provider + let mut second = Arc::clone(&provider) .execute( planned_request("openai", operation()), context("req_upgrade_required_next_turn", CancellationToken::new()), @@ -4081,7 +4626,7 @@ async fn websocket_upgrade_required_immediately_enables_session_http_fallback() tokio::time::pause(); for index in 0..3 { tokio::time::advance(Duration::from_secs(5 * 60 * 60)).await; - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request("openai", operation()), context( @@ -4093,7 +4638,7 @@ async fn websocket_upgrade_required_immediately_enables_session_http_fallback() .expect("prepare active session"); assert_eq!(stream.metadata().transport().as_str(), "http_sse"); } - let unrelated = provider + let unrelated = Arc::clone(&provider) .execute( planned_request( "openai", @@ -4194,7 +4739,7 @@ async fn downstream_websocket_new_chain_should_override_session_and_attempt_http } }); let provider = provider_with_base_url(&store, base_url); - let mut seed = provider + let mut seed = Arc::clone(&provider) .execute( planned_request( "openai", @@ -4246,7 +4791,7 @@ async fn downstream_websocket_new_chain_should_override_session_and_attempt_http ) .unwrap(), ); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", Operation::Generate(request)), attempt, @@ -5431,7 +5976,7 @@ async fn account_selection_log_should_include_affinity_observation_fields() { )])); } let generation = GenerateRequest::from_protocol_payload(payload); - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request("openai", Operation::Generate(generation)), context(request_id, CancellationToken::new()), @@ -5547,7 +6092,7 @@ async fn subagent_requests_should_share_the_root_session_account_affinity_key() .expect("OpenAI payload") .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])), ); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", Operation::Generate(generation)), context(request_id, CancellationToken::new()), @@ -5611,7 +6156,7 @@ async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for )])), ); - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request("openai", Operation::Generate(generation)), context(request_id, CancellationToken::new()), @@ -5662,7 +6207,7 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st ) .expect("OpenAI payload") .with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])); - let first = provider + let first = Arc::clone(&provider) .execute( planned_request( "openai", @@ -5801,7 +6346,7 @@ async fn thread_spawn_children_should_inherit_the_root_with_independent_scoped_b .affinity_hash .expect("affinity hash"), ); - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request("openai", operation), context(request_id, CancellationToken::new()), @@ -5928,7 +6473,7 @@ async fn child_busy_failover_should_leave_the_running_parent_and_siblings_on_the server.uri(), Arc::clone(&leases), ); - let child = provider + let child = Arc::clone(&provider) .execute( planned_request("openai", operation("child-one")), context("req_child_inherit", CancellationToken::new()), @@ -5949,7 +6494,7 @@ async fn child_busy_failover_should_leave_the_running_parent_and_siblings_on_the .expect("busy accounts") .insert(root_account.clone()); for thread in ["child-one", "cold-child"] { - let mut child = provider + let mut child = Arc::clone(&provider) .execute( planned_request("openai", operation(thread)), context("req_child_busy", CancellationToken::new()), @@ -5990,7 +6535,7 @@ async fn child_busy_failover_should_leave_the_running_parent_and_siblings_on_the ("cold-child", "acct_subagent_b"), ("new-sibling", "acct_subagent_a"), ] { - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request("openai", operation(thread)), context("req_after_parent_completion", CancellationToken::new()), @@ -6046,7 +6591,7 @@ async fn failed_child_failover_should_preserve_both_existing_bindings() { Arc::clone(&leases), ); for thread in [None, Some("child")] { - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request( "openai", @@ -6075,7 +6620,7 @@ async fn failed_child_failover_should_preserve_both_existing_bindings() { ) .expect("search payload"), )); - let mut child = provider + let mut child = Arc::clone(&provider) .execute( planned_provider_endpoint_request("openai", search), context("req_failed_child", CancellationToken::new()), @@ -6097,7 +6642,7 @@ async fn failed_child_failover_should_preserve_both_existing_bindings() { drop(child); leases.busy_accounts.lock().expect("busy accounts").clear(); for thread in [None, Some("child")] { - let stream = provider + let stream = Arc::clone(&provider) .execute( planned_request( "openai", @@ -6813,7 +7358,7 @@ fn provider_with_capacity_tracking( store: &Arc, base_url: String, cooldowns: Arc, -) -> (CodexProvider, Arc) { +) -> (Arc, Arc) { let (provider, _, pool) = provider_and_quota_with_runtime_ports( store, Arc::new(MemorySessionAffinity::default()), @@ -6888,7 +7433,7 @@ async fn capacity_feedback_only_counts_overload_rejections_and_excludes_diagnost } else { http_generate_operation() }; - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute(planned_request("openai", operation), attempt) .await .expect("prepare upstream attempt"); @@ -7670,7 +8215,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se ) .expect("initial provider session state"); - let mut seed = provider + let mut seed = Arc::clone(&provider) .execute( planned_request("openai", operation(None, initial_session_state)), context("req_ws_busy_seed", CancellationToken::new()), @@ -7694,7 +8239,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se ); let continuation = operation(Some(CLIENT_PREVIOUS_RESPONSE_ID), session_state); - let mut busy = provider + let mut busy = Arc::clone(&provider) .execute( planned_request("openai", continuation.clone()), pinned_continuation_context( @@ -7719,7 +8264,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se } } - let mut exact = provider + let mut exact = Arc::clone(&provider) .execute( planned_request("openai", continuation.clone()), pinned_continuation_context( @@ -7761,7 +8306,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se ), ("req_ws_busy_replay_any", 3, ContinuationAttempt::ReplayAny), ] { - let result = provider + let result = Arc::clone(&provider) .execute( planned_request("openai", continuation.clone()), pinned_continuation_context( @@ -8556,7 +9101,7 @@ async fn completed_websocket_response_resets_consecutive_failure_budget() { "budget-reset", "turn", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", operation), context( @@ -8624,7 +9169,7 @@ async fn connection_limit_rejection_requests_one_provider_retry_and_reconnects_i "turn", )) }; - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", operation()), context("req_rejected", CancellationToken::new()), @@ -8890,7 +9435,7 @@ async fn assert_websocket_failure_headers(headers: Value, request_id: Option<&st )) }; if reuse { - let mut first = provider + let mut first = Arc::clone(&provider) .execute( planned_request("openai", operation()), context("req_ws_initial", CancellationToken::new()), @@ -9101,7 +9646,7 @@ async fn api_key_native_endpoints_preserve_bodies_headers_and_own_base_url() { )), _ => Operation::Search(StandaloneSearchRequest::from_raw_json(payload)), }; - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_provider_endpoint_request("openai", operation), context( @@ -9424,7 +9969,7 @@ async fn disabled_api_key_diagnostic_preserves_authentication_and_transport_cons .mount(&upstream) .await; let provider = provider_with_base_url(&store, oauth.uri()); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", generate_operation()), diagnostic_context("req_disabled_api_http", account_id), @@ -9458,7 +10003,7 @@ async fn disabled_api_key_diagnostic_preserves_authentication_and_transport_cons ) .expect("search payload"), )); - let result = provider + let result = Arc::clone(&provider) .execute( planned_request("openai", warmup), diagnostic_context("req_disabled_api_restricted", account_id), @@ -9514,7 +10059,7 @@ async fn api_key_http_account_is_rejected_before_websocket_warmup_or_old_revisio .unwrap(), )); assert!( - provider + Arc::clone(&provider) .execute( planned_request("openai", warmup), diagnostic_context("req_api_warmup", "acct_provider_contract") @@ -9968,7 +10513,7 @@ async fn quota_continuation_full_client_replay_selects_another_account() { "quota-replay", "turn", )); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", first), context("req_quota_first", CancellationToken::new()), @@ -9983,7 +10528,7 @@ async fn quota_continuation_full_client_replay_selects_another_account() { event.unwrap(); } drop(stream); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( planned_request("openai", quota_continuation_operation(true)), context("req_quota_delta", CancellationToken::new()) @@ -10165,7 +10710,7 @@ async fn fast_policy_changes_preserve_the_websocket_continuation_and_meter_each_ } else { ContinuationAttempt::None }); - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute(planned_request("openai", operation), context) .await .unwrap(); diff --git a/backend/crates/providers/xai/src/admin.rs b/backend/crates/providers/xai/src/admin.rs index eb4fa6d91..268a29b4d 100644 --- a/backend/crates/providers/xai/src/admin.rs +++ b/backend/crates/providers/xai/src/admin.rs @@ -321,6 +321,19 @@ impl XaiAdminProvider { #[async_trait] impl ProviderAdmin for XaiAdminProvider { + fn account_capabilities( + &self, + _account_id: &ProviderAccountId, + authentication_kind: &str, + ) -> gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + let oauth = authentication_kind == crate::credential::XAI_AUTHENTICATION_KIND_OAUTH; + gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + quota: oauth, + quota_refresh: oauth, + ..Default::default() + } + } + fn client_profile_options( &self, ) -> Result { @@ -378,6 +391,23 @@ impl ProviderAdmin for XaiAdminProvider { fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog { crate::transport::canonical::pricing_catalog() } + fn credential_capabilities( + &self, + ) -> gateway_admin::model::provider_capabilities::ProviderCredentialCapabilities { + use gateway_admin::model::provider_capabilities::{ + AuthorizationCompletion, ProviderCredentialCapabilities, ProviderLoginCapability, + }; + ProviderCredentialCapabilities { + import: Some(serde_json::json!({"type":"object"})), + login: Some(ProviderLoginCapability { + input_schema: serde_json::json!({"type":"object", "additionalProperties":false}), + completion: AuthorizationCompletion::Callback, + }), + refresh: true, + export: true, + } + } + fn provider_kind(&self) -> &ProviderKind { &self.provider_kind } @@ -391,7 +421,7 @@ impl ProviderAdmin for XaiAdminProvider { self.quota.invalidate_scheduling(account_ids); } - fn connection_test_operation( + async fn connection_test_operation( &self, upstream_model: &UpstreamModelId, input_text: &str, @@ -586,8 +616,17 @@ impl ProviderAdmin for XaiAdminProvider { async fn start_authorization( &self, - pending: PendingAuthorizationMutation, + command: gateway_admin::model::provider_credentials::PrepareAuthorization, ) -> Result { + if !command + .input + .expose_to_provider() + .expose_to_provider() + .is_empty() + { + return Err(provider_error(ProviderAdminErrorKind::Invalid)); + } + let pending = command.pending; if pending.provider_kind() != &self.provider_kind { return Err(provider_error(ProviderAdminErrorKind::Invalid)); } @@ -901,16 +940,19 @@ impl XaiAdminProvider { .with_outbound_proxy(stored.mutation.outbound_proxy().cloned()), credential: prepared.credential, }; - PreparedAuthorizationCredential::Create(prepared_create(prepared, Utc::now())?) + PreparedAuthorizationCredential::Create(vec![prepared_create( + prepared, + Utc::now(), + )?]) } AuthorizationMutationTarget::Reauthorize { .. } => { let current = current.ok_or_else(|| provider_error(ProviderAdminErrorKind::Internal))?; let prepared = verified_rotation(current, tokens)?; - PreparedAuthorizationCredential::Reauthorize(prepared_rotation( + PreparedAuthorizationCredential::Reauthorize(Box::new(prepared_rotation( prepared, self.provider_kind.clone(), - )?) + )?)) } }; Ok(PreparedAuthorizationCommit::new( diff --git a/backend/crates/providers/xai/src/credential/selector.rs b/backend/crates/providers/xai/src/credential/selector.rs index bd6cd65af..7f8f7689f 100644 --- a/backend/crates/providers/xai/src/credential/selector.rs +++ b/backend/crates/providers/xai/src/credential/selector.rs @@ -11,6 +11,7 @@ use gateway_core::account::{ QuotaAccessState, QuotaEvidence, QuotaState, }; use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue}; +use gateway_core::engine::policy::AccountPolicyError; use gateway_core::provider_ports::{ ProviderCooldown, ProviderCooldownPort, ProviderCooldownScope, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest, ProviderSchedulingLeaseRequest, @@ -98,7 +99,7 @@ impl GrokAccountSessionSelector { request.deadline(), request.concurrency_wait_budget(), ); - loop { + 'refresh: loop { let diagnostic = request.eligibility() == AccountEligibilityPolicy::BypassForDiagnostic; // store 侧常规调度列表不包含停用账号;管理端诊断要对固定账号执行真实上游 // 验证,这里把不在列表里的 required 账号显式补回候选。 @@ -243,7 +244,30 @@ impl GrokAccountSessionSelector { let mut retry_after = None; loop { let capacity = AccountSelector.capacity_snapshot(&candidates, &capacity_context); - let Some(selection) = AccountSelector.select(&candidates, &context) else { + let selection = match request.request_policy() { + Some(policy) => { + policy + .select_account( + request.attempt_index(), + &self.provider_kind, + Some(request.upstream_model().as_str()), + &candidates, + &context, + ) + .await + } + None => Ok(AccountSelector.select(&candidates, &context)), + }; + let Some(selection) = (match selection { + Ok(selection) => selection, + Err(AccountPolicyError::StaleCandidate) => continue 'refresh, + Err(AccountPolicyError::Rejected) => { + return Err(GrokSessionSelectorError::PolicyRejected); + } + Err(AccountPolicyError::Fault) => { + return Err(GrokSessionSelectorError::PolicyUnavailable); + } + }) else { break; }; let selected = selection.candidate(); diff --git a/backend/crates/providers/xai/src/provider/failure.rs b/backend/crates/providers/xai/src/provider/failure.rs index 04ddb823c..86434adc5 100644 --- a/backend/crates/providers/xai/src/provider/failure.rs +++ b/backend/crates/providers/xai/src/provider/failure.rs @@ -415,6 +415,15 @@ pub(super) fn map_selection_error(error: GrokSessionSelectorError) -> ProviderEr "account scheduling state is temporarily unreadable".to_owned(), "account_selector_unavailable", ), + GrokSessionSelectorError::PolicyRejected => { + return provider_error( + ProviderErrorKind::RequestPolicyDenied, + UpstreamSendState::NotSent, + ); + } + GrokSessionSelectorError::PolicyUnavailable => { + return provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent); + } GrokSessionSelectorError::InvalidSession => { return provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent); } diff --git a/backend/crates/providers/xai/src/provider/mod.rs b/backend/crates/providers/xai/src/provider/mod.rs index 147152c51..09b4200d2 100644 --- a/backend/crates/providers/xai/src/provider/mod.rs +++ b/backend/crates/providers/xai/src/provider/mod.rs @@ -11,6 +11,7 @@ use gateway_core::account::{ ProviderAccountStore, }; use gateway_core::engine::continuation::ContinuationBinding; +use gateway_core::engine::middleware::MiddlewareHeader; use gateway_core::engine::provider::{ EventStream, Provider, ProviderCallMetadata, ProviderRequest, ProviderRequestObservation, ProviderSelectionObservation, ProviderStream, @@ -25,7 +26,8 @@ use gateway_core::event::{ ProviderResponseTimings, ResponseMeta, }; use gateway_core::operation::{ - Feature, GenerateRequest, Operation, OperationKind, ProviderSessionState, + CapabilityRequirements, Feature, GenerateRequest, Operation, OperationKind, + ProviderSessionState, }; use gateway_core::routing::{ ModelCapabilities, ModelPresentation, ProviderCandidate, ProviderCatalogGeneration, @@ -52,7 +54,7 @@ use crate::reasoning_replay::{ GrokReasoningReplay, GrokReasoningReplayCapture, GrokReasoningReplayKey, valid_reasoning_ciphertext, }; -use crate::transport::canonical::GrokCanonicalDecoder; +use crate::transport::canonical::{GrokCanonicalDecoder, GrokNativeResponseTranslator}; use crate::transport::config::XAI_PROVIDER_NAME; use crate::transport::headers::{GrokClientIdentity, build_grok_headers}; use crate::transport::profile::{GROK_CLI_RELEASE_POLL_INTERVAL, GrokCliReleaseService}; @@ -89,6 +91,7 @@ const RESPONSE_NOT_FOUND_CODE: &str = "not_found"; /// 每次调用只选择一个 OAuth 会话。仅当 xAI 明确拒绝历史 reasoning 密文时, /// 允许在同账号、同凭据、同会话绑定上剥离密文后有界重试一次。凭据轮换、 /// endpoint fallback 以及公开 xAI API key 推理都不在该 adapter 内。 +#[derive(Clone)] pub struct GrokBuildProvider { selector: Arc, transport: Arc, @@ -101,6 +104,24 @@ pub struct GrokBuildProvider { responses_url: Url, } +struct PreparedGrokAttempt { + generate: GenerateRequest, + middleware_headers: Vec, + previous_session: Option, + upstream_model: UpstreamModelId, + selected: SelectedGrokSession, + account_selection_wait_ms: u64, + context: AttemptContext, +} + +struct SelectedGrokAttempt { + upstream_model: UpstreamModelId, + selected: SelectedGrokSession, + account_selection_wait_ms: u64, + context: AttemptContext, + frozen_requirements: CapabilityRequirements, +} + impl GrokBuildProvider { /// 在显式的会话与 transport 边界上创建 Provider。 pub fn new( @@ -194,7 +215,7 @@ impl Provider for GrokBuildProvider { } async fn execute( - &self, + self: Arc, request: ProviderRequest, context: AttemptContext, ) -> Result { @@ -240,19 +261,181 @@ impl GrokBuildProvider { } async fn execute_generate( - &self, + self: Arc, generate: &GenerateRequest, candidate: &ProviderCandidate, context: AttemptContext, ) -> Result { - if crate::transport::compaction::has_terminal_compaction_trigger(generate) { - return self.execute_compaction(generate, candidate, context).await; - } + let selected = self + .select_grok_attempt(generate, candidate, context) + .await?; + let provider_kind = + ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?; + let account_id = selected.selected.account_id().clone(); + let model = selected.upstream_model.as_str().to_owned(); + let middleware_context = selected.context.clone(); + let provider = Arc::clone(&self); + middleware_context + .execute_middleware( + Operation::Generate(generate.clone()), + provider_kind, + Some(model), + account_id, + Box::new(move |operation, middleware_headers| { + Box::pin(async move { + provider + .execute_selected_grok(operation, middleware_headers, selected) + .await + }) + }), + ) + .await + } + + async fn select_grok_attempt( + &self, + generate: &GenerateRequest, + candidate: &ProviderCandidate, + context: AttemptContext, + ) -> Result { let upstream_model = candidate_upstream_model(candidate)?; let previous_session = decode_xai_session_state(generate)?; - let continuation_account = continuation_account(&context, previous_session.as_ref())?; - let mut upstream_request = GrokResponsesRequest::encode( + let native_source = generate.protocol_payload().protocol() == "openai"; + let native_compaction = native_source + && crate::transport::compaction::has_terminal_compaction_trigger(generate); + let (selection_model, operation_account, affinity) = if native_compaction { + validate_compaction_context(&context)?; + let operation_account = previous_session_account(previous_session.as_ref())?; + let request = GrokCompactionRequest::encode( + generate, + upstream_model.as_str(), + context.client_api_key_ref(), + ) + .map_err(map_request_error)?; + let selection_model = request + .upstream_model() + .ok_or_else(protocol_not_sent)? + .to_owned(); + ( + selection_model, + merge_frozen_account_owner(&context, operation_account)?, + request.affinity().cloned(), + ) + } else if native_source { + let operation_account = continuation_account(&context, previous_session.as_ref())?; + let request = GrokResponsesRequest::encode( + generate, + upstream_model.as_str(), + context.client_api_key_ref(), + ) + .map_err(map_request_error)?; + let selection_model = request + .upstream_model() + .ok_or_else(protocol_not_sent)? + .to_owned(); + ( + selection_model, + merge_frozen_account_owner(&context, operation_account)?, + request.affinity().cloned(), + ) + } else { + // 源协议由转换插件拥有;这里只使用冻结的候选模型与 Core 账号 owner, + // 不把未知 JSON 中的同名字段解释为 xAI 会话或亲和事实。 + let operation_account = continuation_account(&context, previous_session.as_ref())?; + ( + upstream_model.as_str().to_owned(), + merge_frozen_account_owner(&context, operation_account)?, + None, + ) + }; + let selection_model = + UpstreamModelId::new(selection_model).map_err(|_| protocol_not_sent())?; + let selection_started_at = Instant::now(); + let selected = select_grok_session( + self.selector.as_ref(), + candidate, + &selection_model, + &context, + operation_account, + affinity, + ) + .await?; + let account_selection_wait_ms = + u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + Ok(SelectedGrokAttempt { + upstream_model: upstream_model.clone(), + selected, + account_selection_wait_ms, + context, + frozen_requirements: Operation::Generate(generate.clone()).capability_requirements(), + }) + } + + async fn execute_selected_grok( + &self, + operation: Operation, + middleware_headers: Vec, + selected: SelectedGrokAttempt, + ) -> Result { + let SelectedGrokAttempt { + upstream_model, + selected, + account_selection_wait_ms, + context, + frozen_requirements, + } = selected; + let Operation::Generate(generate) = operation else { + return Err(protocol_not_sent()); + }; + if generate.protocol_payload().protocol() != "openai" + || native_request_requirements(&generate) != frozen_requirements + { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + let previous_session = decode_xai_session_state(&generate)?; + let operation_account = + if crate::transport::compaction::has_terminal_compaction_trigger(&generate) { + validate_compaction_context(&context)?; + previous_session_account(previous_session.as_ref())? + } else { + continuation_account(&context, previous_session.as_ref())? + }; + let operation_account = merge_frozen_account_owner(&context, operation_account)?; + validate_selected_grok_account(&selected, &context, operation_account.as_ref())?; + let prepared = PreparedGrokAttempt { generate, + middleware_headers, + previous_session, + upstream_model, + selected, + account_selection_wait_ms, + context, + }; + if crate::transport::compaction::has_terminal_compaction_trigger(&prepared.generate) { + self.execute_prepared_compaction(prepared).await + } else { + self.execute_prepared_generate(prepared).await + } + } + + async fn execute_prepared_generate( + &self, + prepared: PreparedGrokAttempt, + ) -> Result { + let PreparedGrokAttempt { + generate, + middleware_headers, + previous_session, + upstream_model, + selected, + account_selection_wait_ms, + context, + } = prepared; + let mut upstream_request = GrokResponsesRequest::encode( + &generate, upstream_model.as_str(), context.client_api_key_ref(), ) @@ -269,18 +452,6 @@ impl GrokBuildProvider { if let Some(previous) = previous_session.as_ref() { upstream_request.inherit_session(previous.session_id.as_deref()); } - let selection_started_at = Instant::now(); - let selected = select_grok_session( - self.selector.as_ref(), - candidate, - &wire_upstream_model, - &context, - continuation_account, - upstream_request.affinity().cloned(), - ) - .await?; - let account_selection_wait_ms = - u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); // 首字计时的起点:账号选择完成之后、上游建立之前。 let output_started_at = Instant::now(); apply_continuation( @@ -320,6 +491,8 @@ impl GrokBuildProvider { .set_replay_input(input) .map_err(map_request_error)?; } + let native_response_translator = + GrokNativeResponseTranslator::for_request(&upstream_request); let reasoning_replay_capture = reasoning_replay_key.map(|key| self.reasoning_replay.capture(key)); let session_capture = (!matches!( @@ -341,7 +514,8 @@ impl GrokBuildProvider { }); let selected = Arc::new(selected); let allows_account_state_mutation = selected.allows_account_state_mutation(); - let metadata = provider_call_metadata(candidate, &selected, account_selection_wait_ms)?; + let metadata = + provider_call_metadata(&upstream_model, &selected, account_selection_wait_ms)?; let events = cold_http_sse_stream( Arc::clone(&self.selector), Arc::clone(&self.transport), @@ -351,48 +525,44 @@ impl GrokBuildProvider { credential_recovery: Arc::clone(&self.credential_recovery), responses_url: self.responses_url.clone(), request: upstream_request, + middleware_headers, upstream_model: wire_upstream_model, context, session: Arc::clone(&selected), output_started_at, + native_response_boundary: true, session_capture, reasoning_replay_capture, }, ); let stream = ProviderStream::new(metadata, events, selected); - Ok(if allows_account_state_mutation { + let stream = if allows_account_state_mutation { stream.with_account_feedback(Arc::clone(&self.account_feedback)) } else { stream - }) + }; + Ok(stream.with_native_response_translator(native_response_translator)) } - async fn execute_compaction( + async fn execute_prepared_compaction( &self, - generate: &GenerateRequest, - candidate: &ProviderCandidate, - context: AttemptContext, + prepared: PreparedGrokAttempt, ) -> Result { - if context.continuation().is_some() - || context.continuation_attempt() != ContinuationAttempt::None - { - return Err(provider_error( - ProviderErrorKind::InvalidRequest, - UpstreamSendState::NotSent, - )); - } - let previous_session = decode_xai_session_state(generate)?; - let operation_account = previous_session - .as_ref() - .map(|previous| ProviderAccountId::new(previous.account_id.clone())) - .transpose() - .map_err(|_| protocol_not_sent())?; + let PreparedGrokAttempt { + generate, + middleware_headers, + previous_session, + upstream_model, + selected, + account_selection_wait_ms, + context, + } = prepared; + validate_compaction_context(&context)?; let inherited_session_id = previous_session .as_ref() .and_then(|previous| previous.session_id.clone()); - let upstream_model = candidate_upstream_model(candidate)?; let upstream_request = GrokCompactionRequest::encode( - generate, + &generate, upstream_model.as_str(), context.client_api_key_ref(), ) @@ -410,20 +580,7 @@ impl GrokBuildProvider { let upstream_session_id = inherited_session_id .clone() .or_else(|| explicit_replay_session_id.clone()); - let selection_started_at = Instant::now(); - let selected = Arc::new( - select_grok_session( - self.selector.as_ref(), - candidate, - &wire_upstream_model, - &context, - operation_account, - upstream_request.affinity().cloned(), - ) - .await?, - ); - let account_selection_wait_ms = - u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX); + let selected = Arc::new(selected); let reasoning_replay_key = explicit_replay_session_id .as_deref() .or(inherited_session_id.as_deref()) @@ -435,7 +592,8 @@ impl GrokBuildProvider { ) }); let allows_account_state_mutation = selected.allows_account_state_mutation(); - let metadata = provider_call_metadata(candidate, &selected, account_selection_wait_ms)?; + let metadata = + provider_call_metadata(&upstream_model, &selected, account_selection_wait_ms)?; let events = cold_compaction_http_sse_stream( Arc::clone(&self.selector), Arc::clone(&self.transport), @@ -445,6 +603,7 @@ impl GrokBuildProvider { credential_recovery: Arc::clone(&self.credential_recovery), responses_url: self.responses_url.clone(), request: upstream_request, + middleware_headers, upstream_model: wire_upstream_model, upstream_session_id, context, @@ -462,6 +621,14 @@ impl GrokBuildProvider { } } +fn native_request_requirements(request: &GenerateRequest) -> CapabilityRequirements { + // 这里只读取本 Provider 已知的 Responses 字段;工具别名等原生保护字段另行校验。 + Operation::Generate(GenerateRequest::from_protocol_payload( + request.protocol_payload().clone(), + )) + .capability_requirements() +} + async fn select_grok_session( selector: &dyn GrokSessionSelector, candidate: &ProviderCandidate, @@ -481,6 +648,10 @@ async fn select_grok_session( context.client_api_key_ref().clone(), ) .with_concurrency_wait_budget(context.concurrency_wait_budget().clone()) + .with_request_policy( + context.request_policy_context().cloned(), + context.attempt_index(), + ) .with_eligibility_policy(if context.is_diagnostic_required_account() { AccountEligibilityPolicy::BypassForDiagnostic } else { @@ -516,13 +687,13 @@ async fn select_grok_session( } fn provider_call_metadata( - candidate: &ProviderCandidate, + upstream_model: &UpstreamModelId, selected: &SelectedGrokSession, account_selection_wait_ms: u64, ) -> Result { Ok(ProviderCallMetadata::new( ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?, - candidate_upstream_model(candidate)?.clone(), + upstream_model.clone(), selected.account_id().clone(), UpstreamTransport::new(HTTP_SSE_TRANSPORT).map_err(|_| protocol_not_sent())?, ) @@ -532,6 +703,66 @@ fn provider_call_metadata( ))) } +fn validate_compaction_context(context: &AttemptContext) -> Result<(), ProviderError> { + if context.continuation().is_some() + || context.continuation_attempt() != ContinuationAttempt::None + { + return Err(provider_error( + ProviderErrorKind::InvalidRequest, + UpstreamSendState::NotSent, + )); + } + Ok(()) +} + +fn previous_session_account( + previous_session: Option<&XaiSessionState>, +) -> Result, ProviderError> { + previous_session + .map(|previous| ProviderAccountId::new(previous.account_id.clone())) + .transpose() + .map_err(|_| protocol_not_sent()) +} + +fn merge_frozen_account_owner( + context: &AttemptContext, + operation_account: Option, +) -> Result, ProviderError> { + if context.continuation_attempt() == ContinuationAttempt::ReplayAny { + return Ok(operation_account); + } + let provider = ProviderKind::new(XAI_PROVIDER_NAME).map_err(|_| protocol_not_sent())?; + let Some(owner) = context + .account_state_owner() + .filter(|owner| owner.provider() == &provider) + else { + return Ok(operation_account); + }; + if operation_account + .as_ref() + .is_some_and(|account| account != owner.account()) + { + return Err(invalid_continuation()); + } + Ok(Some(owner.account().clone())) +} + +fn validate_selected_grok_account( + selected: &SelectedGrokSession, + context: &AttemptContext, + operation_account: Option<&ProviderAccountId>, +) -> Result<(), ProviderError> { + if context.excluded_accounts().contains(selected.account_id()) + || context + .required_account() + .is_some_and(|required| required != selected.account_id()) + || operation_account.is_some_and(|required| required != selected.account_id()) + { + return Err(protocol_not_sent()); + } + Ok(()) +} + fn candidate_upstream_model( candidate: &ProviderCandidate, ) -> Result<&UpstreamModelId, ProviderError> { diff --git a/backend/crates/providers/xai/src/provider/stream.rs b/backend/crates/providers/xai/src/provider/stream.rs index 5c4ea544f..a4062ed97 100644 --- a/backend/crates/providers/xai/src/provider/stream.rs +++ b/backend/crates/providers/xai/src/provider/stream.rs @@ -8,10 +8,12 @@ pub(super) struct GrokStreamAttempt { pub(super) credential_recovery: Arc, pub(super) responses_url: Url, pub(super) request: GrokResponsesRequest, + pub(super) middleware_headers: Vec, pub(super) upstream_model: UpstreamModelId, pub(super) context: AttemptContext, pub(super) session: Arc, pub(super) output_started_at: Instant, + pub(super) native_response_boundary: bool, pub(super) session_capture: Option, pub(super) reasoning_replay_capture: Option, } @@ -22,6 +24,7 @@ pub(super) struct GrokCompactionStreamAttempt { pub(super) credential_recovery: Arc, pub(super) responses_url: Url, pub(super) request: GrokCompactionRequest, + pub(super) middleware_headers: Vec, pub(super) upstream_model: UpstreamModelId, pub(super) upstream_session_id: Option, pub(super) context: AttemptContext, @@ -30,6 +33,31 @@ pub(super) struct GrokCompactionStreamAttempt { pub(super) reasoning_replay_key: Option, } +fn append_middleware_grok_headers( + target: &mut Vec, + headers: &[MiddlewareHeader], +) -> Result<(), ProviderError> { + let provider_header_count = target.len(); + for header in headers { + if target[..provider_header_count] + .iter() + .any(|existing| existing.name().eq_ignore_ascii_case(header.name())) + { + return Err(provider_error( + ProviderErrorKind::Protocol, + UpstreamSendState::NotSent, + )); + } + let value = std::str::from_utf8(header.value()) + .map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?; + target.push(crate::transport::GrokHeader::public( + header.name().to_owned(), + value.to_owned(), + )); + } + Ok(()) +} + pub(super) struct AcceptedGrokInference { response: GrokInferenceResponse, observation: ProviderResponseObservation, @@ -83,6 +111,7 @@ pub(super) fn cold_compaction_http_sse_stream( credential_recovery, responses_url, mut request, + middleware_headers, upstream_model, upstream_session_id, context, @@ -91,7 +120,7 @@ pub(super) fn cold_compaction_http_sse_stream( reasoning_replay_key, } = attempt; Box::pin(async_stream::try_stream! { - let headers = build_grok_headers( + let mut headers = build_grok_headers( &wire_profile, &session, &client_identity, @@ -100,6 +129,7 @@ pub(super) fn cold_compaction_http_sse_stream( None, &upstream_model, ); + append_middleware_grok_headers(&mut headers, &middleware_headers)?; let mut invalid_encrypted_content_retried = false; let accepted = loop { if context.cancellation().is_cancelled() { @@ -381,10 +411,12 @@ pub(super) fn cold_http_sse_stream( credential_recovery, responses_url, mut request, + middleware_headers, upstream_model, context, session, output_started_at, + native_response_boundary, mut session_capture, mut reasoning_replay_capture, } = attempt; @@ -395,7 +427,7 @@ pub(super) fn cold_http_sse_stream( UpstreamSendState::NotSent, ))?; } - let headers = build_grok_headers( + let mut headers = build_grok_headers( &wire_profile, &session, &client_identity, @@ -404,6 +436,7 @@ pub(super) fn cold_http_sse_stream( None, &upstream_model, ); + append_middleware_grok_headers(&mut headers, &middleware_headers)?; let cancellation = context.cancellation().clone(); let mut invalid_encrypted_content_retried = false; let response = loop { @@ -510,7 +543,13 @@ pub(super) fn cold_http_sse_stream( let Some(chunk) = next else { break; }; - let mut events = match decoder.push(&chunk) { + let (mut events, mut projected_events) = match if native_response_boundary { + decoder + .push_before_translation(&chunk) + .map(|batch| (batch.source_events, Some(batch.projected_events))) + } else { + decoder.push(&chunk).map(|events| (events, None)) + } { Ok(events) => events, Err(error) => { let error = map_continuation_failure(&context, error); @@ -560,10 +599,12 @@ pub(super) fn cold_http_sse_stream( .iter() .flat_map(ProviderEvent::canonical_facts) .any(|event| matches!(event, GatewayEvent::Completed(_))); - attach_xai_session_update(&mut events, &mut session_capture)?; - if let Some(capture) = reasoning_replay_capture.as_mut() { - capture.observe(&events); - } + attach_grok_response_state( + &mut events, + projected_events.as_deref_mut(), + &mut session_capture, + &mut reasoning_replay_capture, + )?; if completed && session.allows_account_state_mutation() { selector.record_success(&session).await; } @@ -575,7 +616,13 @@ pub(super) fn cold_http_sse_stream( return; } } - let mut final_events = match decoder.finish() { + let (mut final_events, mut projected_events) = match if native_response_boundary { + decoder + .finish_before_translation() + .map(|batch| (batch.source_events, Some(batch.projected_events))) + } else { + decoder.finish().map(|events| (events, None)) + } { Ok(events) => events, Err(error) => { let error = map_continuation_failure(&context, error); @@ -624,10 +671,12 @@ pub(super) fn cold_http_sse_stream( ProviderResponseTimings { first_token_ms, ..base_timings }, )); } - attach_xai_session_update(&mut final_events, &mut session_capture)?; - if let Some(capture) = reasoning_replay_capture.as_mut() { - capture.observe(&final_events); - } + attach_grok_response_state( + &mut final_events, + projected_events.as_deref_mut(), + &mut session_capture, + &mut reasoning_replay_capture, + )?; if completed && session.allows_account_state_mutation() { selector.record_success(&session).await; } @@ -637,3 +686,40 @@ pub(super) fn cold_http_sse_stream( } }) } + +fn attach_grok_response_state( + delivery_events: &mut [ProviderEvent], + projected_events: Option<&mut [ProviderEvent]>, + session_capture: &mut Option, + reasoning_replay_capture: &mut Option, +) -> Result<(), ProviderError> { + let Some(projected_events) = projected_events else { + attach_xai_session_update(delivery_events, session_capture)?; + if let Some(capture) = reasoning_replay_capture.as_mut() { + capture.observe(delivery_events); + } + return Ok(()); + }; + + attach_xai_session_update(projected_events, session_capture)?; + if let Some(capture) = reasoning_replay_capture.as_mut() { + capture.observe(projected_events); + } + let Some(state) = projected_events + .iter_mut() + .find_map(ProviderEvent::take_session_update) + else { + return Ok(()); + }; + let terminal = delivery_events + .iter_mut() + .find(|event| { + event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))) + }) + .ok_or_else(protocol_sent)?; + terminal.attach_session_update(state); + Ok(()) +} diff --git a/backend/crates/providers/xai/src/transport/canonical.rs b/backend/crates/providers/xai/src/transport/canonical.rs index cbbb69cea..8d10c74a7 100644 --- a/backend/crates/providers/xai/src/transport/canonical.rs +++ b/backend/crates/providers/xai/src/transport/canonical.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use gateway_core::engine::provider::NativeResponseTranslator; use gateway_core::error::{ ClientVisibleUpstreamError, OpaqueUpstreamValue, ProviderError, ProviderErrorKind, }; @@ -26,6 +27,23 @@ use super::{classify_grok_quota_failure, scrub_account_fingerprints}; const CONTENTS_PER_OUTPUT: u32 = 1_024; const LONG_CONTEXT_THRESHOLD: u64 = 200_000; const GROK_PING_SSE_COMMENT: &[u8] = b": ping\n\n"; +const XAI_RESPONSE_PROTOCOL: &str = "xai"; +const OPENAI_RESPONSE_PROTOCOL: &str = "openai"; + +pub(crate) struct GrokDecodedResponseBatch { + pub(crate) source_events: Vec, + pub(crate) projected_events: Vec, +} + +struct ProjectedWireEvent { + event_type: String, + wire: ProtocolWireEvent, +} + +/// 单次 xAI stream 独占的原生响应交付转换状态。 +pub(crate) struct GrokNativeResponseTranslator { + response_transform: GrokResponseTransform, +} #[derive(Clone, Copy)] struct TokenRates { @@ -278,6 +296,64 @@ pub struct GrokCanonicalDecoder { requires_provider_cost: bool, } +impl GrokNativeResponseTranslator { + #[must_use] + pub(crate) fn for_request(request: &GrokResponsesRequest) -> Self { + Self { + response_transform: request.response_transform(), + } + } +} + +impl NativeResponseTranslator for GrokNativeResponseTranslator { + fn source_protocol(&self) -> &str { + XAI_RESPONSE_PROTOCOL + } + + fn target_protocol(&self) -> &str { + OPENAI_RESPONSE_PROTOCOL + } + + fn translate( + &mut self, + event: &ProtocolWireEvent, + ) -> Result, ProviderError> { + if event.protocol() != XAI_RESPONSE_PROTOCOL { + return Err(protocol_error_marker()); + } + if event.has_json_data() { + let value = event.data().clone(); + let event_type = value + .get("type") + .and_then(Value::as_str) + .or_else(|| event.event_type()) + .unwrap_or_default() + .to_owned(); + return project_response_event( + &mut self.response_transform, + &event_type, + value, + event.event_type(), + event.sse_id(), + event.sse_retry(), + ) + .map(|events| events.into_iter().map(|event| event.wire).collect()); + } + if event + .raw_sse_frame() + .is_some_and(|frame| frame.as_ref() == GROK_PING_SSE_COMMENT) + { + return ProtocolWireEvent::raw_sse( + OPENAI_RESPONSE_PROTOCOL, + bytes::Bytes::from_static(GROK_PING_SSE_COMMENT), + ) + .map(|wire| vec![wire]) + .map_err(protocol_error); + } + Err(protocol_error_marker()) + } +} + impl GrokCanonicalDecoder { /// 使用路由后最终发往上游的请求模型计价,并在响应缺少模型时用于 canonical 兜底。 pub fn new(upstream_model: impl Into) -> Self { @@ -345,11 +421,31 @@ impl GrokCanonicalDecoder { } pub fn push(&mut self, chunk: &[u8]) -> Result, ProviderError> { + let events = self.decoder.push(chunk).map_err(protocol_error)?; + self.decode(events).map(|batch| batch.projected_events) + } + + /// 解码同一批上游事件,同时返回转换前 xAI wire 与独立生成的 OpenAI 投影。 + pub(crate) fn push_before_translation( + &mut self, + chunk: &[u8], + ) -> Result { let events = self.decoder.push(chunk).map_err(protocol_error)?; self.decode(events) } pub fn finish(&mut self) -> Result, ProviderError> { + let events = self.decoder.finish().map_err(protocol_error)?; + let output = self.decode(events)?.projected_events; + if !self.completed { + return Err(protocol_error_marker()); + } + Ok(output) + } + + pub(crate) fn finish_before_translation( + &mut self, + ) -> Result { let events = self.decoder.finish().map_err(protocol_error)?; let output = self.decode(events)?; if !self.completed { @@ -360,7 +456,7 @@ impl GrokCanonicalDecoder { pub(crate) fn finish_without_terminal(&mut self) -> Result, ProviderError> { let events = self.decoder.finish().map_err(protocol_error)?; - self.decode(events) + self.decode(events).map(|batch| batch.projected_events) } /// 取走本批解码帧里是否已出现首个非前导输出事件(结构帧也算),用于首字计时。 @@ -368,8 +464,9 @@ impl GrokCanonicalDecoder { std::mem::take(&mut self.output_start_seen) } - fn decode(&mut self, events: Vec) -> Result, ProviderError> { - let mut output = Vec::new(); + fn decode(&mut self, events: Vec) -> Result { + let mut source_events = Vec::new(); + let mut projected_events = Vec::new(); for event in events { if event .event @@ -395,12 +492,18 @@ impl GrokCanonicalDecoder { .and_then(|value| value.get("type").and_then(Value::as_str)) .is_none_or(|event_type| event_type == "ping") { - let wire = ProtocolWireEvent::raw_sse( - "openai", + let source_wire = ProtocolWireEvent::raw_sse( + XAI_RESPONSE_PROTOCOL, bytes::Bytes::from_static(GROK_PING_SSE_COMMENT), ) .map_err(|_| protocol_error_marker())?; - output.push(ProviderEvent::wire(wire)); + let projected_wire = ProtocolWireEvent::raw_sse( + OPENAI_RESPONSE_PROTOCOL, + bytes::Bytes::from_static(GROK_PING_SSE_COMMENT), + ) + .map_err(|_| protocol_error_marker())?; + source_events.push(ProviderEvent::wire(source_wire)); + projected_events.push(ProviderEvent::wire(projected_wire)); continue; } let Ok(value) = parsed else { @@ -410,28 +513,21 @@ impl GrokCanonicalDecoder { if body_type == Some("response.doom_loop_check") { continue; } - let Some(event_type) = body_type.or(event.event.as_deref()) else { - let transformed = self - .response_transform - .rewrite_stream_event("", value) - .map_err(|_| protocol_error_marker())?; - for (index, transformed) in transformed.into_iter().enumerate() { - let mut value = transformed.into_value(); - self.response_transform.resequence_stream_value(&mut value); - let wire = ProtocolWireEvent::json_with_sse_metadata( - "openai", - None, - value, - (index == 0).then(|| event.id.clone()).flatten(), - (index == 0).then_some(event.retry).flatten(), - ) - .map_err(|_| protocol_error_marker())?; - output.push(ProviderEvent::wire(wire)); - } - continue; - }; - let event_type = event_type.to_owned(); - self.response_model.observe(Some(&event_type), &value); + let event_type = body_type + .or(event.event.as_deref()) + .unwrap_or_default() + .to_owned(); + let source_wire = ProtocolWireEvent::json_with_sse_metadata( + XAI_RESPONSE_PROTOCOL, + event.event.clone(), + value.clone(), + event.id.clone(), + event.retry, + ) + .map_err(|_| protocol_error_marker())?; + if !event_type.is_empty() { + self.response_model.observe(Some(&event_type), &value); + } // 工具转换可能隐藏注入的调用,计费事实必须从转换前的上游事件读取。 if let Some(response) = value.get("response") { if let Some(tier) = response.get("service_tier").and_then(Value::as_str) { @@ -443,22 +539,23 @@ impl GrokCanonicalDecoder { self.requires_provider_cost |= !token_only_output(item); } // 转换失败必须终止,不能丢弃工具参数后仍向客户端报告成功。 - let transformed = self - .response_transform - .rewrite_stream_event(&event_type, value) - .map_err(|_| protocol_error_marker())?; - for (index, transformed) in transformed.into_iter().enumerate() { - let transformed_type = transformed.event_type().to_owned(); - if !client_visible_event(&transformed_type) { - continue; - } + let projected = project_response_event( + &mut self.response_transform, + &event_type, + value, + event.event.as_deref(), + event.id.as_deref(), + event.retry, + )?; + let mut source_canonical = Vec::new(); + for projected in projected { + let transformed_type = projected.event_type; // 首个非前导、非失败事件(结构帧也算)开启首字计时。 self.output_start_seen |= !matches!( transformed_type.as_str(), "response.created" | "response.in_progress" | "response.failed" | "error" ); - let mut value = transformed.into_value(); - self.response_transform.resequence_stream_value(&mut value); + let value = projected.wire.data(); let mut canonical = Vec::new(); // 终态事件(completed/incomplete)fail-closed:用量/计费校验失败即断流。 // 其余内容事件容忍字段校验失败——正常上游变体(空 delta、重复 index、 @@ -468,7 +565,7 @@ impl GrokCanonicalDecoder { transformed_type.as_str(), "response.completed" | "response.incomplete" ); - match self.decode_event(&transformed_type, &value, &mut canonical) { + match self.decode_event(&transformed_type, value, &mut canonical) { Ok(()) => {} Err(error) if !terminal_event && error.kind() == ProviderErrorKind::Protocol => @@ -477,27 +574,23 @@ impl GrokCanonicalDecoder { } Err(error) => return Err(error), } - let wire_event = if transformed_type == event_type { - event.event.clone() + source_canonical.extend(canonical.iter().cloned()); + projected_events.push(if canonical.is_empty() { + ProviderEvent::wire(projected.wire) } else { - Some(transformed_type) - }; - let wire = ProtocolWireEvent::json_with_sse_metadata( - "openai", - wire_event, - value, - (index == 0).then(|| event.id.clone()).flatten(), - (index == 0).then_some(event.retry).flatten(), - ) - .map_err(|_| protocol_error_marker())?; - output.push(if canonical.is_empty() { - ProviderEvent::wire(wire) - } else { - ProviderEvent::canonical_with_wire(canonical, wire) + ProviderEvent::canonical_with_wire(canonical, projected.wire) }); } + source_events.push(if source_canonical.is_empty() { + ProviderEvent::wire(source_wire) + } else { + ProviderEvent::canonical_with_wire(source_canonical, source_wire) + }); } - Ok(output) + Ok(GrokDecodedResponseBatch { + source_events, + projected_events, + }) } fn decode_event( @@ -840,6 +933,46 @@ impl GrokCanonicalDecoder { } } +fn project_response_event( + response_transform: &mut GrokResponseTransform, + event_type: &str, + value: Value, + source_event_type: Option<&str>, + sse_id: Option<&str>, + sse_retry: Option, +) -> Result, ProviderError> { + let transformed = response_transform + .rewrite_stream_event(event_type, value) + .map_err(protocol_error)?; + let mut projected = Vec::with_capacity(transformed.len()); + for (index, transformed) in transformed.into_iter().enumerate() { + let transformed_type = transformed.event_type().to_owned(); + if !event_type.is_empty() && !client_visible_event(&transformed_type) { + continue; + } + let mut value = transformed.into_value(); + response_transform.resequence_stream_value(&mut value); + let wire_event = if transformed_type == event_type { + source_event_type.map(str::to_owned) + } else { + Some(transformed_type.clone()) + }; + let wire = ProtocolWireEvent::json_with_sse_metadata( + OPENAI_RESPONSE_PROTOCOL, + wire_event, + value, + (index == 0).then(|| sse_id.map(str::to_owned)).flatten(), + (index == 0).then_some(sse_retry).flatten(), + ) + .map_err(protocol_error)?; + projected.push(ProjectedWireEvent { + event_type: transformed_type, + wire, + }); + } + Ok(projected) +} + fn client_visible_event(event_type: &str) -> bool { event_type == "error" || (event_type.starts_with("response.") && event_type != "response.doom_loop_check") diff --git a/backend/crates/providers/xai/src/transport/headers.rs b/backend/crates/providers/xai/src/transport/headers.rs index fbb06286b..360a0aae3 100644 --- a/backend/crates/providers/xai/src/transport/headers.rs +++ b/backend/crates/providers/xai/src/transport/headers.rs @@ -67,15 +67,15 @@ impl fmt::Debug for GrokHeaderValue { /// 一个官方 Grok CLI 代理请求头。 #[derive(Debug, Clone)] pub struct GrokHeader { - name: &'static str, + name: String, value: GrokHeaderValue, } impl GrokHeader { - /// 返回静态 header 名。 + /// 返回 header 名。 #[must_use] - pub const fn name(&self) -> &'static str { - self.name + pub fn name(&self) -> &str { + &self.name } /// 返回类型化的 header 值。 @@ -84,16 +84,16 @@ impl GrokHeader { &self.value } - pub(crate) fn public(name: &'static str, value: impl Into) -> Self { + pub(crate) fn public(name: impl Into, value: impl Into) -> Self { Self { - name, + name: name.into(), value: GrokHeaderValue::Public(value.into()), } } - pub(crate) fn sensitive(name: &'static str, value: SecretValue) -> Self { + pub(crate) fn sensitive(name: impl Into, value: SecretValue) -> Self { Self { - name, + name: name.into(), value: GrokHeaderValue::Sensitive(value), } } diff --git a/backend/crates/providers/xai/src/transport/session.rs b/backend/crates/providers/xai/src/transport/session.rs index 0ccc8b852..d11334145 100644 --- a/backend/crates/providers/xai/src/transport/session.rs +++ b/backend/crates/providers/xai/src/transport/session.rs @@ -9,6 +9,7 @@ use gateway_core::account::{ AccountCapacitySnapshot, AccountEligibilityPolicy, AccountSelectionPolicy, CredentialRevision, ProviderAccountId, }; +use gateway_core::engine::policy::RequestPolicyContext; use gateway_core::policy::ClientApiKeyId; use gateway_core::routing::{FrozenAccountScope, UpstreamModelId}; use sha2::{Digest as _, Sha256}; @@ -238,6 +239,8 @@ pub struct GrokSessionSelection { account_scope: Arc, client_api_key_id: ClientApiKeyId, concurrency_wait_budget: gateway_core::concurrency::ConcurrencyWaitBudget, + request_policy: Option, + attempt_index: std::num::NonZeroU32, } impl GrokSessionSelection { @@ -263,9 +266,23 @@ impl GrokSessionSelection { account_scope, client_api_key_id, concurrency_wait_budget: gateway_core::concurrency::ConcurrencyWaitBudget::default(), + request_policy: None, + attempt_index: std::num::NonZeroU32::MIN, } } + /// 附着普通数据面请求冻结的插件策略;管理诊断保持空值。 + #[must_use] + pub(crate) fn with_request_policy( + mut self, + policy: Option, + attempt_index: std::num::NonZeroU32, + ) -> Self { + self.request_policy = policy; + self.attempt_index = attempt_index; + self + } + /// 附着仅由显式客户端会话派生的账号亲和键。 #[must_use] pub fn with_affinity(mut self, affinity: Option) -> Self { @@ -349,6 +366,16 @@ impl GrokSessionSelection { pub const fn client_api_key_id(&self) -> &ClientApiKeyId { &self.client_api_key_id } + + #[must_use] + pub(crate) const fn request_policy(&self) -> Option<&RequestPolicyContext> { + self.request_policy.as_ref() + } + + #[must_use] + pub(crate) const fn attempt_index(&self) -> std::num::NonZeroU32 { + self.attempt_index + } } /// Grok session selector 返回的 future。 @@ -456,6 +483,12 @@ pub enum GrokSessionSelectorError { /// 选择器依赖的后端服务不可用。 #[error("Grok Build session selector is unavailable")] Unavailable, + /// 插件调度策略明确拒绝本次请求。 + #[error("account scheduling policy rejected the request")] + PolicyRejected, + /// 插件调度策略调用失败且配置为拒绝。 + #[error("account scheduling policy is unavailable")] + PolicyUnavailable, } /// 构造选中会话时的失败。 diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 343a95cf9..237cc0343 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -51,6 +51,25 @@ use crate::support::{ TestSessionExclusions, account_id, create_input, seed_input, }; +#[tokio::test] +async fn account_capabilities_advertise_only_xai_oauth_quota_operations() { + let bundle = provider_xai::initialize(provider_ports()).await.unwrap(); + let provider = bundle.admin_provider(); + let id = ProviderAccountId::new("acct_capabilities").unwrap(); + let oauth = provider.account_capabilities(&id, "oauth"); + assert_eq!( + oauth, + gateway_admin::model::provider_capabilities::ProviderAccountCapabilities { + quota: true, + quota_refresh: true, + ..Default::default() + } + ); + for kind in ["api_key", "unknown"] { + assert_eq!(provider.account_capabilities(&id, kind), Default::default()); + } +} + #[tokio::test] async fn account_unavailable_clears_real_selector_cooldowns_only_for_deleted_account() { let store = MemoryProviderAccountStore::shared(); @@ -424,6 +443,7 @@ async fn xai_admin_provider_projects_cached_quota_models_and_canonical_export() &UpstreamModelId::new("grok-4.5").expect("upstream model"), "Reply with exactly OK.", ) + .await .expect("connection test operation"); let Operation::Generate(request) = operation else { panic!("connection test must be a generate operation"); @@ -1263,3 +1283,45 @@ async fn client_profile_preview_and_dashboard_use_saved_configuration() { assert!(dashboard.release.is_none()); assert!(dashboard.verified_at.is_none()); } + +#[tokio::test] +async fn browser_authorization_declares_callback_and_rejects_extra_login_inputs() { + use gateway_admin::model::{ + provider_capabilities::AuthorizationCompletion, + provider_credentials::{ + AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation, + PrepareAuthorization, + }, + }; + let bundle = provider_xai::initialize(provider_ports()).await.unwrap(); + let provider = bundle.admin_provider(); + let login = provider.credential_capabilities().login.unwrap(); + assert_eq!(login.completion, AuthorizationCompletion::Callback); + assert_eq!( + login.input_schema, + json!({"type":"object", "additionalProperties":false}) + ); + let error = provider + .start_authorization(PrepareAuthorization { + pending: PendingAuthorizationMutation::new( + ProviderKind::new("xai").unwrap(), + AuthorizationMutationTarget::Create { + name: "form account".into(), + }, + AuthorizationOwnerBinding::from_context(&MutationContext { + actor: MutationActor::System, + request_id: "login-input-test".into(), + }), + ), + input: ProviderDocument::new(OpaqueProviderData::new( + json!({"token":"unexpected-fixture-secret"}) + .as_object() + .unwrap() + .clone(), + )), + }) + .await + .unwrap_err(); + assert_eq!(error.kind(), ProviderAdminErrorKind::Invalid); + assert!(!format!("{error:?}").contains("unexpected-fixture-secret")); +} diff --git a/backend/crates/providers/xai/tests/provider/contract.rs b/backend/crates/providers/xai/tests/provider/contract.rs index a62553caf..62a5c54a2 100644 --- a/backend/crates/providers/xai/tests/provider/contract.rs +++ b/backend/crates/providers/xai/tests/provider/contract.rs @@ -4,7 +4,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; -use futures::{StreamExt, stream}; +use bytes::Bytes; +use futures::{StreamExt, future::BoxFuture, stream}; use gateway_core::account::{ AccountFeedbackStats, AccountRuntimeSignals, AccountSelectionPolicy, CredentialRevision, CredentialState, ProviderAccountStore, RotationStrategy, @@ -12,6 +13,11 @@ use gateway_core::account::{ use gateway_core::engine::continuation::{ ContinuationBinding, NativeContinuationPin, PreviousResponseId, }; +use gateway_core::engine::execution::ClientTransport; +use gateway_core::engine::middleware::{ + FrozenMiddlewarePlan, MiddlewareContext, MiddlewareError, MiddlewareHeader, MiddlewareMount, + MiddlewareNext, MiddlewarePlan, MiddlewareRequest, MiddlewareResponse, +}; use gateway_core::engine::provider::{Provider, ProviderRequest}; use gateway_core::engine::{ AccountAttemptContext, AttemptContext, ContinuationAttempt, ModelRequestId, @@ -35,6 +41,7 @@ use gateway_core::routing::{ ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, SupportLevel, UpstreamModelId, }; +use gateway_core::runtime::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference}; use gateway_core::upstream::UpstreamSendState; use provider_xai::{ GrokAccountSessionSelector, GrokBillingRequest, GrokBillingTransport, @@ -61,6 +68,501 @@ const MODEL: &str = "grok-4.5"; const CATALOG_FIXTURE: &[u8] = include_bytes!("../transport/catalog/fixtures/official_grok_models_snapshot.json"); +#[tokio::test] +async fn native_xai_translates_a_non_native_source_before_encoding() { + let transport = StubInferenceTransport::success(); + let selector = StubSelector::success(); + let provider = provider(Arc::clone(&selector), transport.clone()).await; + let translations = Arc::new(Mutex::new(0_usize)); + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([ + ("opaque".to_owned(), json!("source-only")), + ("input".to_owned(), json!([{"type":"compaction_trigger"}])), + ("prompt_cache_key".to_owned(), json!("must-not-be-read")), + ]), + ) + .unwrap(), + )); + let mut stream = provider + .execute( + provider_request_with_operation("xai", source), + context_with_middleware(Arc::new(TranslationMiddleware { + translations: Arc::clone(&translations), + target: Map::from_iter([ + ("model".to_owned(), json!("ignored-by-forced-model")), + ("input".to_owned(), json!("translated")), + ("stream".to_owned(), json!(true)), + ]), + })), + ) + .await + .expect("registered translation should run before native encoding"); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + while let Some(event) = stream.next().await { + event.expect("translated request response"); + } + assert_eq!(*translations.lock().unwrap(), 1); + let requests = transport.requests.lock().unwrap(); + let sent: Value = serde_json::from_slice(requests[0].body()).unwrap(); + assert_eq!(sent["input"], "translated"); + assert_eq!(sent["model"], MODEL); +} + +#[tokio::test] +async fn native_xai_translated_compaction_uses_one_selection_and_one_translation() { + let selector = StubSelector::success(); + let transport = StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse( + &valid_compaction_summary("translated compaction"), + None, + ))]); + let provider = provider(Arc::clone(&selector), transport.clone()).await; + let translations = Arc::new(Mutex::new(0_usize)); + let source = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap(), + )); + let target = match compaction_operation() { + Operation::Generate(request) => request.protocol_payload().body().clone(), + _ => unreachable!("compaction fixture is a generation request"), + }; + let mut stream = provider + .execute( + provider_request_with_operation("xai", source), + context_with_middleware(Arc::new(TranslationMiddleware { + translations: Arc::clone(&translations), + target, + })), + ) + .await + .expect("translated compaction should prepare one cold stream"); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + while let Some(event) = stream.next().await { + event.expect("translated compaction response"); + } + assert_eq!(*translations.lock().unwrap(), 1); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); + let requests = transport.requests.lock().unwrap(); + let sent: Value = serde_json::from_slice(requests[0].body()).unwrap(); + assert_eq!(sent["store"], false); + assert!( + sent["input"] + .as_array() + .unwrap() + .iter() + .all(|item| { item.get("type").and_then(Value::as_str) != Some("compaction_trigger") }) + ); +} + +#[tokio::test] +async fn native_xai_rejects_missing_or_capability_expanding_translation_before_send() { + let source = || { + Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "example-source", + Map::from_iter([("opaque".to_owned(), json!("source-only"))]), + ) + .unwrap(), + )) + }; + + let selector = StubSelector::success(); + let transport = StubInferenceTransport::success(); + let missing_provider = provider(Arc::clone(&selector), transport.clone()).await; + let missing = match missing_provider + .execute( + provider_request_with_operation("xai", source()), + context_with_middleware(Arc::new(PassThroughMiddleware)), + ) + .await + { + Ok(_) => panic!("missing translation pair must be explicit"), + Err(error) => error, + }; + assert_eq!(missing.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(missing.send_state(), UpstreamSendState::NotSent); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + + let selector = StubSelector::success(); + let transport = StubInferenceTransport::success(); + let expanded_provider = provider(Arc::clone(&selector), transport.clone()).await; + let expanded = match expanded_provider + .execute( + provider_request_with_operation("xai", source()), + context_with_middleware(Arc::new(TranslationMiddleware { + translations: Arc::new(Mutex::new(0)), + target: object(json!({ + "model": "ignored-by-forced-model", + "input": "translated", + "tools": [{"type":"function", "name":"new_capability"}] + })), + })), + ) + .await + { + Ok(_) => panic!("translation cannot expand routed capabilities"), + Err(error) => error, + }; + assert_eq!(expanded.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(expanded.send_state(), UpstreamSendState::NotSent); + assert_eq!(selector.calls.load(Ordering::SeqCst), 1); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn native_response_translation_keeps_raw_xai_and_delivery_state_independent() { + let arguments = + serde_json::to_string(&json!({"operation":{"type":"delete_file","path":"note.txt"}})) + .unwrap(); + let arguments_json = serde_json::to_string(&arguments).unwrap(); + let body = format!( + concat!( + "event: response.created\n", + "data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_native_boundary\",\"model\":\"grok-4.5\"}}}}\n\n", + "event: response.output_item.added\n", + "data: {{\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{{\"id\":\"item_patch\",\"type\":\"function_call\",\"call_id\":\"call_patch\",\"name\":\"xai_proxy_apply_patch\"}}}}\n\n", + "event: response.output_item.done\n", + "data: {{\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{{\"id\":\"item_patch\",\"type\":\"function_call\",\"call_id\":\"call_patch\",\"name\":\"xai_proxy_apply_patch\",\"arguments\":{arguments_json}}}}}\n\n", + "event: response.completed\n", + "data: {{\"type\":\"response.completed\",\"response\":{{\"id\":\"resp_native_boundary\",\"model\":\"grok-4.5\",\"status\":\"completed\"}}}}\n\n", + ), + arguments_json = arguments_json, + ); + let transport = + StubInferenceTransport::sequence([InferenceMode::SuccessBody(body.into_bytes())]); + let provider = provider(StubSelector::success(), transport).await; + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object( + "openai", + Map::from_iter([ + ("model".to_owned(), json!("client-model")), + ("input".to_owned(), json!("edit")), + ("tools".to_owned(), json!([{"type":"apply_patch"}])), + ]), + ) + .unwrap(), + )); + let mut stream = provider + .execute( + provider_request_with_operation("xai", operation), + context(CancellationToken::new(), None), + ) + .await + .expect("native response stream"); + assert!(stream.has_native_response_translator()); + + let mut added_was_deferred = false; + let mut done_expanded = false; + let mut terminal_state_preserved = false; + while let Some(event) = stream.next().await { + let event = event.expect("source event"); + if !event.has_client_event() { + continue; + } + let source_type = event + .wire_event() + .and_then(|wire| { + assert_eq!(wire.protocol(), "xai"); + wire.data() + .get("type") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .expect("xAI source event type"); + let terminal = event + .canonical_facts() + .iter() + .any(|fact| matches!(fact, GatewayEvent::Completed(_))); + let had_session_update = event.session_update().is_some(); + let translated = stream + .translate_native_response(event, terminal) + .expect("native response translation"); + if source_type == "response.output_item.added" { + assert!(translated.is_empty()); + added_was_deferred = true; + continue; + } + assert!(translated.iter().all(|event| { + event + .wire_event() + .is_some_and(|wire| wire.protocol() == "openai") + })); + if source_type == "response.output_item.done" { + assert_eq!(translated.len(), 2); + assert_eq!( + translated[0] + .wire_event() + .and_then(|wire| wire.event_type()), + Some("response.output_item.added") + ); + assert!(translated.iter().all(|event| { + event.wire_event().is_some_and(|wire| { + wire.data().pointer("/item/type") == Some(&json!("apply_patch_call")) + }) + })); + done_expanded = true; + } + if terminal { + assert!(had_session_update); + terminal_state_preserved = translated + .first() + .is_some_and(|event| event.session_update().is_some()); + } + } + assert!(added_was_deferred); + assert!(done_expanded); + assert!(terminal_state_preserved); +} + +#[tokio::test] +async fn pass_through_middleware_projects_native_response_once() { + let provider = provider(StubSelector::success(), StubInferenceTransport::success()).await; + let mut stream = provider + .execute( + provider_request("xai"), + context_with_middleware(Arc::new(PassThroughMiddleware)), + ) + .await + .expect("native response stream"); + assert!(!stream.has_native_response_translator()); + + let mut client_events = 0; + while let Some(event) = stream.next().await { + let event = event.expect("projected response event"); + if let Some(wire) = event.wire_event() { + client_events += 1; + assert_eq!(wire.protocol(), "openai"); + } + } + assert!(client_events > 0); +} + +#[tokio::test] +async fn attempt_middleware_runs_once_before_native_encoding() { + let transport = StubInferenceTransport::success(); + let native_provider = provider(StubSelector::success(), transport.clone()).await; + let observed = Arc::new(Mutex::new(Vec::new())); + let body = json!({ + "model": "client-model", + "input": "hello", + "service_tier": "priority", + }); + let operation = Operation::Generate(GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(), + )); + let mut stream = native_provider + .execute( + provider_request_with_operation("xai", operation), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: None, + request_headers: vec![ + MiddlewareHeader::new( + "x-business-context", + Bytes::from_static(b"tenant-public"), + ), + MiddlewareHeader::new( + "x-business-context", + Bytes::from_static(b"trace-public"), + ), + ], + })), + ) + .await + .expect("native conversion should prepare the request"); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + while let Some(event) = stream.next().await { + event.expect("successful native response"); + } + { + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].0, "openai"); + assert_eq!(observed[0].1["service_tier"], "priority"); + let requests = transport.requests.lock().unwrap(); + let sent: Map = serde_json::from_slice(requests[0].body()).unwrap(); + assert!(!sent.contains_key("service_tier")); + assert_eq!( + requests[0] + .headers() + .iter() + .filter(|header| header.name() == "x-business-context") + .map(|header| header.value().expose()) + .collect::>(), + vec!["tenant-public", "trace-public"], + ); + } + + let conflict_transport = StubInferenceTransport::success(); + let conflict_provider = provider(StubSelector::success(), conflict_transport.clone()).await; + let mut conflict = conflict_provider + .execute( + provider_request("xai"), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: None, + request_headers: vec![MiddlewareHeader::new( + "x-grok-client-version", + Bytes::from_static(b"plugin-value"), + )], + })), + ) + .await + .expect("header validation remains on the cold Provider stream"); + let error = next_provider_error(&mut conflict).await; + assert_eq!(error.kind(), ProviderErrorKind::Protocol); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert_eq!(conflict_transport.calls.load(Ordering::SeqCst), 0); + assert!(conflict_transport.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn attempt_middleware_preserves_native_cache_tools() { + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let observed = Arc::new(Mutex::new(Vec::new())); + let mut stream = provider + .execute( + provider_request_with_operation( + "xai", + reasoning_replay_operation( + "cache-policy-session", + json!([{"type":"message","role":"user","content":"hello"}]), + ), + ), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: None, + request_headers: Vec::new(), + })), + ) + .await + .expect("native cache tools must not look like policy capability expansion"); + while let Some(event) = stream.next().await { + event.expect("successful native response"); + } + + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert!(observed[0].1.get("tools").is_none()); + drop(observed); + + let requests = transport.requests.lock().unwrap(); + let sent: Value = serde_json::from_slice(requests[0].body()).unwrap(); + assert_eq!( + sent.get("tools"), + Some(&json!([{"type":"web_search"}, {"type":"x_search"}])) + ); + assert_eq!(sent.get("tool_choice"), Some(&json!("none"))); +} + +#[tokio::test] +async fn attempt_middleware_updates_generation_and_compaction_input() { + for compact in [false, true] { + let (operation, transport) = if compact { + ( + compaction_operation(), + StubInferenceTransport::sequence([InferenceMode::SuccessBody(compaction_sse( + &valid_compaction_summary("normalized compaction"), + None, + ))]), + ) + } else { + (operation(), StubInferenceTransport::success()) + }; + let provider = provider(StubSelector::success(), transport.clone()).await; + let observed = Arc::new(Mutex::new(Vec::new())); + let mut stream = provider + .execute( + provider_request_with_operation("xai", operation), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: Some(("instructions".to_owned(), json!("normalized instruction"))), + request_headers: Vec::new(), + })), + ) + .await + .expect("processed native request"); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + while let Some(event) = stream.next().await { + event.expect("normalization preserves a successful native response"); + } + let observed = observed.lock().unwrap(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].0, "openai"); + let requests = transport.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let sent: Value = serde_json::from_slice(requests[0].body()).unwrap(); + assert_eq!(sent["instructions"], "normalized instruction"); + assert_eq!(sent["model"], MODEL); + } +} + +#[tokio::test] +async fn attempt_middleware_can_change_reasoning_before_native_validation() { + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let observed = Arc::new(Mutex::new(Vec::new())); + let mut stream = provider + .execute( + provider_request_with_reasoning_operation( + "xai", + operation_with_reasoning_effort("minimal"), + ), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::clone(&observed), + replacement: Some(("reasoning".to_owned(), json!({"effort":"xhigh"}))), + request_headers: Vec::new(), + })), + ) + .await + .expect("thinking policy should prepare the xAI stream"); + while let Some(event) = stream.next().await { + event.expect("successful xAI response"); + } + + let requests = transport.requests.lock().unwrap(); + let body: Value = serde_json::from_slice(requests[0].body()).unwrap(); + assert_eq!(body["reasoning"]["effort"], "high"); + assert_eq!(body["model"], MODEL); + assert_eq!( + observed.lock().unwrap()[0].1["reasoning"]["effort"], + "minimal" + ); +} + +#[tokio::test] +async fn middleware_output_still_passes_native_validation_before_send() { + let transport = StubInferenceTransport::success(); + let provider = provider(StubSelector::success(), transport.clone()).await; + let error = provider + .execute( + provider_request_with_reasoning_operation( + "xai", + operation_with_reasoning_effort("medium"), + ), + context_with_middleware(Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: Some(("input".to_owned(), json!(42))), + request_headers: Vec::new(), + })), + ) + .await + .err() + .expect("unsupported reasoning remains invalid after middleware"); + assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); +} + fn observed_transport_metrics() -> GrokInferenceTransportMetrics { GrokInferenceTransportMetrics::default() .with_headers_ms(42) @@ -620,7 +1122,7 @@ impl GrokCredentialRecovery for StubRecovery { async fn provider( selector: Arc, transport: Arc, -) -> GrokBuildProvider { +) -> Arc { provider_with_recovery( selector, transport, @@ -633,7 +1135,7 @@ async fn provider_with_recovery( selector: Arc, transport: Arc, recovery: Arc, -) -> GrokBuildProvider { +) -> Arc { provider_with_catalog_transport( selector, transport, @@ -648,7 +1150,7 @@ async fn provider_with_catalog_transport( transport: Arc, recovery: Arc, catalog_transport: Arc, -) -> GrokBuildProvider { +) -> Arc { let store = MemoryProviderAccountStore::shared(); let account_store: Arc = store.clone(); let repository = GrokCredentialRepository::new(account_store); @@ -664,15 +1166,17 @@ async fn provider_with_catalog_transport( catalog_transport, cache, )); - GrokBuildProvider::new( - selector, - transport, - catalog, - recovery, - Arc::new(AccountFeedbackStats::default()), - crate::support::xai_wire_profile(), + Arc::new( + GrokBuildProvider::new( + selector, + transport, + catalog, + recovery, + Arc::new(AccountFeedbackStats::default()), + crate::support::xai_wire_profile(), + ) + .expect("official xAI provider configuration"), ) - .expect("official xAI provider configuration") } async fn mapped_transport_error( @@ -933,21 +1437,42 @@ fn provider_request(provider_kind: &str) -> ProviderRequest { } fn provider_request_with_operation(provider_kind: &str, operation: Operation) -> ProviderRequest { - provider_request_with_upstream_model(provider_kind, MODEL, operation) + provider_request_with_model_capabilities(provider_kind, MODEL, operation, false) +} + +fn provider_request_with_reasoning_operation( + provider_kind: &str, + operation: Operation, +) -> ProviderRequest { + provider_request_with_model_capabilities(provider_kind, MODEL, operation, true) } fn provider_request_with_upstream_model( provider_kind: &str, upstream_model: &str, operation: Operation, +) -> ProviderRequest { + provider_request_with_model_capabilities(provider_kind, upstream_model, operation, false) +} + +fn provider_request_with_model_capabilities( + provider_kind: &str, + upstream_model: &str, + operation: Operation, + supports_reasoning: bool, ) -> ProviderRequest { let provider = ProviderKind::new(provider_kind).expect("provider"); + let mut capabilities = + ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(131_072)) + .with_feature(Feature::Tools, SupportLevel::Native) + .with_feature(Feature::NativeContinuation, SupportLevel::Native); + if supports_reasoning { + capabilities = capabilities.with_feature(Feature::Reasoning, SupportLevel::Native); + } let provider_model = ProviderModel::new( provider.clone(), UpstreamModelId::new(upstream_model).expect("model"), - ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(131_072)) - .with_feature(Feature::Tools, SupportLevel::Native) - .with_feature(Feature::NativeContinuation, SupportLevel::Native), + capabilities, ); let account_scope = Arc::new(FrozenAccountScope::new( Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([( @@ -983,6 +1508,124 @@ fn selection_policy() -> AccountSelectionPolicy { ) } +#[derive(Debug)] +struct TranslationMiddleware { + translations: Arc>, + target: Map, +} + +impl MiddlewarePlan for TranslationMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Attempt); + assert!(context.account_id().is_some()); + *self.translations.lock().unwrap() += 1; + let (_, headers, _) = request.into_parts(); + next.run(MiddlewareRequest::new( + "openai", + headers, + Bytes::from(serde_json::to_vec(&self.target).unwrap()), + )) + } +} + +#[derive(Debug)] +struct PassThroughMiddleware; + +impl MiddlewarePlan for PassThroughMiddleware { + fn handle( + &self, + _: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + next.run(request) + } +} + +type ObservedRequest = (String, Map); + +#[derive(Debug)] +struct RecordingMiddleware { + observed: Arc>>, + replacement: Option<(String, Value)>, + request_headers: Vec, +} + +impl MiddlewarePlan for RecordingMiddleware { + fn handle( + &self, + context: MiddlewareContext, + request: MiddlewareRequest, + next: Box, + ) -> BoxFuture<'static, Result> { + assert_eq!(context.mount(), MiddlewareMount::Attempt); + let (protocol, mut headers, bytes) = request.into_parts(); + let mut body: Map = serde_json::from_slice(&bytes).unwrap(); + self.observed + .lock() + .unwrap() + .push((protocol.clone(), body.clone())); + if let Some((field, value)) = &self.replacement { + body.insert(field.clone(), value.clone()); + } + headers.extend(self.request_headers.clone()); + next.run(MiddlewareRequest::new( + protocol, + headers, + Bytes::from(serde_json::to_vec(&body).unwrap()), + )) + } +} + +#[derive(Debug)] +struct TestExtensionLease; + +impl ExtensionSetLease for TestExtensionLease { + fn is_ready(&self) -> bool { + true + } +} + +fn context_with_middleware(plan: Arc) -> AttemptContext { + context_with_middleware_and_cancellation(plan, CancellationToken::new()) +} + +fn context_with_middleware_and_cancellation( + plan: Arc, + cancellation: CancellationToken, +) -> AttemptContext { + let plan = FrozenMiddlewarePlan::new( + plan, + ExtensionSetReference::new( + ExtensionSetId::new("native-xai-middleware".to_owned()).unwrap(), + Arc::new(TestExtensionLease), + ), + ); + AttemptContext::new( + gateway_core::engine::RequestAttemptContext::new( + ModelRequestId::new("req_xai_middleware").unwrap(), + ClientApiKeyId::new("key_xai_contract").unwrap(), + ) + .with_middleware( + Some(plan), + Arc::from([]), + "/v1/responses".to_owned(), + ClientTransport::HttpSse, + ), + NonZeroU32::MIN, + SystemTime::now() + Duration::from_secs(30), + selection_policy(), + AccountAttemptContext::new(BTreeSet::new(), None, None), + None, + cancellation, + ) +} + fn context( cancellation: CancellationToken, continuation: Option, @@ -1030,8 +1673,8 @@ fn context_with_continuation_attempt( context(CancellationToken::new(), Some(continuation)).with_continuation_attempt(attempt) } -async fn execute_successfully(provider: &GrokBuildProvider, operation: Operation) { - let mut stream = provider +async fn execute_successfully(provider: &Arc, operation: Operation) { + let mut stream = Arc::clone(provider) .execute( provider_request_with_operation("xai", operation), context(CancellationToken::new(), None), @@ -1139,19 +1782,21 @@ async fn disabled_account_diagnostic_selects_the_pinned_account_without_state_wr Arc::new(MemoryCooldownPort::default()), Arc::new(AccountFeedbackStats::default()), ); - let provider = GrokBuildProvider::new( - Arc::new(selector), - StubInferenceTransport::success(), - Arc::new(crate::support::grok_catalog_service( - repository, - Arc::new(StaticCatalogTransport), - MemoryGrokCatalogCache::shared(), - )), - StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable), - Arc::new(AccountFeedbackStats::default()), - crate::support::xai_wire_profile(), - ) - .expect("official xAI provider configuration"); + let provider = Arc::new( + GrokBuildProvider::new( + Arc::new(selector), + StubInferenceTransport::success(), + Arc::new(crate::support::grok_catalog_service( + repository, + Arc::new(StaticCatalogTransport), + MemoryGrokCatalogCache::shared(), + )), + StubRecovery::new(GrokCredentialRecoveryOutcome::Unavailable), + Arc::new(AccountFeedbackStats::default()), + crate::support::xai_wire_profile(), + ) + .expect("official xAI provider configuration"), + ); let mut stream = provider .execute( @@ -1884,7 +2529,7 @@ async fn compaction_protocol_stream_failure_should_allow_pre_delivery_recovery() async fn inference_request_uses_oauth_headers_and_no_api_key() { let transport = StubInferenceTransport::success(); let provider = provider(StubSelector::success(), transport.clone()).await; - let mut stream = provider + let mut stream = Arc::clone(&provider) .execute( provider_request("xai"), context(CancellationToken::new(), None), @@ -2606,7 +3251,7 @@ async fn continuation_should_inherit_unchanged_instructions_and_replay_changed_i ProtocolPayload::json_object("openai", first_body.as_object().expect("body").clone()) .expect("payload"), )); - let mut first = provider + let mut first = Arc::clone(&provider) .execute( provider_request_with_operation("xai", operation), context(CancellationToken::new(), None), @@ -2999,7 +3644,7 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco ) .expect("OpenAI payload"), )); - let mut first = provider + let mut first = Arc::clone(&provider) .execute( provider_request_with_operation("xai", first_operation), context(CancellationToken::new(), None), @@ -3025,7 +3670,7 @@ async fn connection_state_inherits_session_and_recovers_reasoning_on_pinned_acco }), state.clone(), ); - let mut continued = provider + let mut continued = Arc::clone(&provider) .execute( provider_request_with_operation("xai", continued_operation), context( @@ -3125,7 +3770,7 @@ async fn replay_owner_should_reencode_custom_apply_patch_call_for_grok() { ) .expect("OpenAI payload"), )); - let mut first = provider + let mut first = Arc::clone(&provider) .execute( provider_request_with_operation("xai", first_operation), context(CancellationToken::new(), None), @@ -3253,9 +3898,16 @@ async fn cancellation_before_poll_never_calls_transport() { let provider = provider(StubSelector::success(), transport.clone()).await; let cancellation = CancellationToken::new(); let mut stream = provider - .execute(provider_request("xai"), context(cancellation.clone(), None)) + .execute( + provider_request("xai"), + context_with_middleware_and_cancellation( + Arc::new(PassThroughMiddleware), + cancellation.clone(), + ), + ) .await .expect("prepared stream"); + assert!(!stream.has_native_response_translator()); cancellation.cancel(); let error = stream .next() diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 8d360df6e..1e8e3f5c9 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -15,3 +15,5 @@ b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_ 9bd37bd314b6864816c43cb4fd1869fb415920d4f10f766976e344334a1f60fc 0015_proxy_dual_stack_exit.sql a1e23b8535ed41de27861833cd2d5ee7498a9bb0e523156ba3f011f9c6b3966b 0016_client_profiles_and_model_pricing.sql fcfae912ddefd54551ebf8e7e733442519d2862d2977134d548ebc1e75c1dc34 0017_unlimited_default_account_concurrency.sql +deb4ef30b3a8a1c7a76055dbd09e5d232d9bb85b712205f5fb9c38c28417900d 0018_plugins.sql +667fa2969ab9fc714a74e1a53470f738298d78ed6a248e07172c828bd702452c 0019_plugin_version_configurations.sql diff --git a/backend/migrations/0018_plugins.sql b/backend/migrations/0018_plugins.sql new file mode 100644 index 000000000..b58403231 --- /dev/null +++ b/backend/migrations/0018_plugins.sql @@ -0,0 +1,145 @@ +-- 更新规则可变;制品保存安装时的来源事实,包体随 PostgreSQL 备份恢复。 +create table plugin_update_sources ( + plugin_id text primary key check ( + octet_length(plugin_id) <= 64 + and plugin_id ~ '^[a-z0-9]([a-z0-9-]*[a-z0-9])?[.][a-z0-9]([a-z0-9-]*[a-z0-9])?$' + ), + source_json jsonb not null check (jsonb_typeof(source_json) = 'object'), + policy_json jsonb not null default '{"kind":"manual"}'::jsonb + check (jsonb_typeof(policy_json) = 'object'), + outbound_proxy_id text references outbound_proxies(id) on delete restrict +); + +create index plugin_update_sources_outbound_proxy_id_idx on plugin_update_sources(outbound_proxy_id); + +create table plugin_artifacts ( + sha256 text primary key check (sha256 ~ '^[0-9a-f]{64}$'), + plugin_id text not null references plugin_update_sources(plugin_id), + version text not null check (length(version) between 1 and 128), + metadata_json jsonb not null check (jsonb_typeof(metadata_json) = 'object'), + source_json jsonb not null check (jsonb_typeof(source_json) = 'object'), + -- 历史下载引用阻止代理被误删;地址与认证仍只存在受管代理记录中。 + outbound_proxy_id text references outbound_proxies(id) on delete restrict, + archive bytea not null check (octet_length(archive) between 1 and 33554432), + installed_at timestamptz not null default now(), + -- 接受事实绑定不可变包;实例运行权限从包声明派生。 + accepted_at timestamptz +); + +create index plugin_artifacts_plugin_id_idx on plugin_artifacts(plugin_id); +create index plugin_artifacts_outbound_proxy_id_idx on plugin_artifacts(outbound_proxy_id); + +-- 平台逐项约束,避免通过增加另一平台条目绕过同版本不可变规则。 +create table plugin_artifact_platforms ( + plugin_id text not null, + version text not null, + platform text not null, + artifact_sha256 text not null references plugin_artifacts(sha256) on delete cascade, + primary key (plugin_id, version, platform) +); + +create index plugin_artifact_platforms_artifact_sha256_idx on plugin_artifact_platforms(artifact_sha256); + +-- 下载鉴权单独持久化;普通列表和审计只返回 info_json,不读取 secret_json。 +create table plugin_source_credentials ( + id uuid primary key, + info_json jsonb not null check (jsonb_typeof(info_json) = 'object'), + secret_json jsonb not null check (jsonb_typeof(secret_json) = 'object') +); + +create table plugin_artifact_credentials ( + artifact_sha256 text not null references plugin_artifacts(sha256) on delete cascade, + credential_id uuid not null references plugin_source_credentials(id), + primary key (artifact_sha256, credential_id) +); + +create index plugin_artifact_credentials_credential_id_idx on plugin_artifact_credentials(credential_id); + +create table plugin_instances ( + id uuid primary key, + artifact_sha256 text not null references plugin_artifacts(sha256), + name text not null check (length(name) between 1 and 128), + enabled boolean not null, + configuration_json jsonb not null check (jsonb_typeof(configuration_json) = 'object' and octet_length(configuration_json::text) <= 65536), + bindings_json jsonb not null check (jsonb_typeof(bindings_json) = 'array'), + revision bigint not null check (revision > 0) +); + +create index plugin_instances_artifact_sha256_idx on plugin_instances(artifact_sha256); + +create table plugin_instance_secrets ( + instance_id uuid primary key references plugin_instances(id) on delete cascade, + secrets_json jsonb not null check (jsonb_typeof(secrets_json) = 'object' and octet_length(secrets_json::text) <= 65536) +); + +-- 私有状态与配置 revision 分离;普通状态写入不得推进全局 revision。 +create table plugin_state_generations ( + id uuid primary key, + transition_id uuid, + instance_id uuid not null references plugin_instances(id) on delete cascade, + namespace text not null check (namespace ~ '^[a-z][a-z0-9_.-]{0,63}$'), + artifact_sha256 text not null references plugin_artifacts(sha256), + schema_version bigint not null check (schema_version between 1 and 4294967295), + schema_sha256 text not null check (schema_sha256 ~ '^[0-9a-f]{64}$'), + maximum_records bigint not null check (maximum_records between 1 and 10000), + maximum_bytes bigint not null check (maximum_bytes between 1 and 16777216), + maximum_value_bytes bigint not null check ( + maximum_value_bytes between 1 and 262144 + and maximum_value_bytes <= maximum_bytes + ), + instance_revision bigint not null check (instance_revision > 0), + fence uuid not null, + status text not null check (status in ('staging', 'active')), + source_generation_id uuid references plugin_state_generations(id), + next_record_version bigint not null default 1 check (next_record_version > 0), + record_count bigint not null default 0 check (record_count between 0 and maximum_records), + total_bytes bigint not null default 0 check (total_bytes between 0 and maximum_bytes), + migration_cursor text, + migration_complete boolean not null default false, + created_at timestamptz not null default now(), + promoted_at timestamptz, + unique (instance_id, namespace, status), + unique (transition_id, namespace), + check (source_generation_id is null or source_generation_id <> id), + check ( + (status = 'staging' and transition_id is not null) + or (status = 'active' and transition_id is null and source_generation_id is null + and migration_cursor is null and migration_complete and promoted_at is not null) + ) +); + +create index plugin_state_generations_artifact_sha256_idx on plugin_state_generations(artifact_sha256); +create index plugin_state_generations_source_generation_id_idx on plugin_state_generations(source_generation_id); + +create table plugin_state_records ( + generation_id uuid not null references plugin_state_generations(id) on delete cascade, + -- 游标分页与 Rust 字符串比较都按 UTF-8 字节序,不受数据库 locale 影响。 + state_key text collate "C" not null check ( + length(state_key) between 1 and 256 + and octet_length(state_key) <= 512 + and state_key !~ '[[:cntrl:]]' + ), + value_json jsonb not null, + value_bytes bigint not null check (value_bytes between 1 and 262144), + record_version bigint not null check (record_version > 0), + primary key (generation_id, state_key) +); + +-- 授权回执与账号写入同事务提交;不保存 flow、回调地址或凭据文档。 +create table authorization_receipts ( + provider_kind text not null, + flow_digest text not null check (flow_digest ~ '^[0-9a-f]{64}$'), + owner_digest text not null check (owner_digest ~ '^[0-9a-f]{64}$'), + config_revision bigint not null check (config_revision > 0), + accounts_json jsonb not null check ( + jsonb_typeof(accounts_json) = 'array' + and jsonb_array_length(accounts_json) between 1 and 200 + and octet_length(accounts_json::text) <= 65536 + ), + created_at timestamptz not null default now(), + expires_at timestamptz not null default (now() + interval '24 hours'), + primary key (provider_kind, flow_digest), + check (expires_at > created_at) +); + +create index authorization_receipts_expiry_idx on authorization_receipts (expires_at); diff --git a/backend/migrations/0019_plugin_version_configurations.sql b/backend/migrations/0019_plugin_version_configurations.sql new file mode 100644 index 000000000..c260cc5dc --- /dev/null +++ b/backend/migrations/0019_plugin_version_configurations.sql @@ -0,0 +1,18 @@ +-- 配置恢复与插件私有状态迁移分离,删除实例或制品时同步清理对应快照。 +create table plugin_version_configurations ( + instance_id uuid not null references plugin_instances(id) on delete cascade, + artifact_sha256 text not null references plugin_artifacts(sha256) on delete cascade, + configuration_json jsonb not null check (jsonb_typeof(configuration_json) = 'object'), + secrets_json jsonb not null check (jsonb_typeof(secrets_json) = 'object'), + bindings_json jsonb not null check (jsonb_typeof(bindings_json) = 'array'), + primary key (instance_id, artifact_sha256) +); + +-- 只为已有启用配置建立恢复点,未启用草稿不能冒充可用版本。 +insert into plugin_version_configurations + (instance_id, artifact_sha256, configuration_json, secrets_json, bindings_json) +select i.id, i.artifact_sha256, i.configuration_json, + coalesce(s.secrets_json, '{}'::jsonb), i.bindings_json +from plugin_instances i +left join plugin_instance_secrets s on s.instance_id = i.id +where i.enabled; diff --git a/backend/migrations/README.md b/backend/migrations/README.md index e71510665..bf2005e18 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -3,7 +3,7 @@ sqlx 在服务启动、监听请求之前按编号执行迁移,并把文件 checksum 记入 `_sqlx_migrations`。 已应用迁移的字节与记录不一致时,服务会拒绝启动。 -`0001_initial.sql` 定义数据库初始化基线,后续 schema 变更按编号新增迁移。 +初始化基线与后续 schema 变更按递增编号组织。 完整清单以本目录 SQL 文件和 `.frozen-sha256` 为准。 ## 冻结规则 @@ -12,7 +12,7 @@ sqlx 在服务启动、监听请求之前按编号执行迁移,并把文件 ch 空白。任何改动都会让该大版本的已部署实例在下次重启时因 checksum 失配而无法启动, 且没有自动恢复路径。 - 同一大版本内的 schema 变更一律新增编号迁移,永远不回改 - 旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的 `0001` 基线,因为跨大版本 + 旧文件。切换大版本时允许把已完成升级的 schema 折叠成新的初始化基线,因为跨大版本 不支持在线升级,目标版本必须使用全新数据目录部署。 - 支持连续升级的预发行线从首次发布起同样冻结迁移。alpha、beta、rc 到对应正式版必须保留 已发布迁移的编号与字节;同一轮 exp 的后续发行也只能追加迁移。发布前以该发行线已发布版本 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 867ce105b..56d49a9c6 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -8,7 +8,7 @@ FROM node:26-bookworm-slim@sha256:2d49d876e96237d76de412761cf05dbfe5aee325cc4406 WORKDIR /app/frontend COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ -RUN npm install --global pnpm@11.7.0 \ +RUN npm install --global "$(node -p 'require("./package.json").packageManager')" \ && pnpm install --frozen-lockfile COPY frontend ./ RUN pnpm build @@ -88,5 +88,9 @@ ARG CPR_PREBUILT_DIR=dist/docker COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/linux-${TARGETARCH}/codex-proxy-rs /app/bin/codex-proxy-rs COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/web/dist ./web/dist -RUN chmod +x /app/bin/codex-proxy-rs +COPY --chown=cpr:cpr ${CPR_PREBUILT_DIR}/linux-${TARGETARCH}/plugins/official /app/bin/plugins/official +RUN chmod +x /app/bin/codex-proxy-rs \ + && find /app/bin/plugins/official -type f -exec chmod 0444 '{}' + \ + && chmod 0555 /app/bin/plugins/official \ + && chmod 0755 /app/bin/plugins USER cpr diff --git a/deploy/Dockerfile.dockerignore b/deploy/Dockerfile.dockerignore index a130a34a4..8fa95e8d4 100644 --- a/deploy/Dockerfile.dockerignore +++ b/deploy/Dockerfile.dockerignore @@ -1,6 +1,8 @@ # Build outputs and dependency caches. **/target/ -frontend/node_modules/ +**/node_modules/ +**/.pnpm-store/ +**/.pnpm-cache/ frontend/dist/ frontend/.vite/ dist/* @@ -19,6 +21,7 @@ deploy/logs/ local.yaml local.yml .env +.env.* config.yaml deploy/config.yaml deploy/.env diff --git a/deploy/README.md b/deploy/README.md index 53a90885e..e4560b9d2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -4,16 +4,33 @@ 本文补充客户端配置、权限、备份和升级;部署命令从安装目录 `codex-proxy-rs/` 执行, 其中 `deploy/` 存放 Compose 文件和配置,`.runtime/` 存放持久化数据。 -需要维护的配置文件: +配置按职责保存,不都在 YAML 中: -- `config.yaml`:应用行为与真实凭据,由 `config.example.yaml` 复制得到并被 Git 忽略。 -- `compose.yaml`:镜像、容器网络、端口、目录映射、健康检查和资源限制。 +| 位置 | 内容 | 修改方式 | +| --- | --- | --- | +| `deploy/config.yaml` | 监听、数据库/Redis 连接、部署凭据与日志等启动配置 | 从模板创建,已有部署合并修改,不覆盖原文件 | +| `deploy/compose.yaml` | 镜像、容器网络、端口、挂载与资源限制 | 调整 Compose 并重建受影响容器 | +| PostgreSQL | 账号、Key、运行设置、上游身份、插件配置等业务数据 | 管理端或管理 API | +| 浏览器本地 | 主题和界面偏好 | 当前浏览器的主题设置 | -项目不使用 `.env` 配置文件。Compose 环境变量用于容器地址、镜像选择和构建发布; -应用设置与凭据保存在 `config.yaml` 中。已有部署不要重新复制模板覆盖配置。 +项目不使用 `.env` 配置文件。Compose 环境变量用于容器地址、镜像选择和构建发布。 配置加载会忽略未知或已移除的字段,并在启动控制台提示字段名;不输出对应值。缺少必填字段时会指出缺项并停止启动, 已知字段的类型和取值仍需合法;可选字段省略时使用默认值。 +## 部署结构 + +```mermaid +flowchart LR + Client[客户端 / 管理浏览器] -->|HTTPS| Proxy[反向代理] + Proxy -->|HTTP / SSE / WebSocket| App[网关:单副本] + App --> PG[(PostgreSQL)] + App --> Redis[(Redis)] + App --> Upstream[上游服务] + App -. 数据库备份 .-> Backup[S3 / R2] +``` + +网关端口默认只绑定本机;数据库和 Redis 不对公网开放。插件以网关同一系统身份运行,不能作为不可信代码沙箱。 + ## 手动安装 本节适用于 Linux amd64/arm64,需准备 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL, @@ -64,24 +81,6 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和 配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。 `config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。 -OpenAI 与 xAI 上游身份在管理端「系统设置 → 上游配置 → 客户端身份」配置,Key 可按 Provider 选择独立身份覆盖通用值。 -OpenAI 首次默认 macOS Desktop 自动最新,支持范围与字段见 [客户端身份合同](../docs/api.md#openai-上游客户端身份)。 -客户端身份只保存于数据库;首次使用内置默认值,重启不会覆盖已有选择。 -`openai.residency` 为独立的数据驻留部署约束,省略或 `null` 时不指定;后台账号与 Desktop 专属请求使用内置官方画像。 -OpenAI 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 默认值。 -xAI 无需 YAML 配置,首次默认 Grok CLI 的 Linux x86_64 身份并采用自动更新。 -管理端支持固定版本与自动更新;字段及生效范围见 [xAI 客户端身份](../docs/api.md#xai-上游客户端身份)。 -xAI 的后台 OAuth、目录和额度查询使用内置官方画像。 -后台发布检查只更新可重建的版本资料,不回写用户选择或 `config.yaml`;失败继续使用同组合的有效版本。 - -全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。 -开启后使用自定义位置,关闭时保留已保存的自定义值。「代理管理」设置的自定义位置优先于全局设置, -全局关闭时代理自定义仍生效。保存后新请求生效,无需重启;关闭代理自定义后恢复全局继承。 -该设置统一 OpenAI Responses 的 Web Search 位置与带来源标记的环境日期、时区,不修改 epoch 时间戳、 -真实出口 IP 或服务系统时区。字段约束见 [运行设置](../docs/api.md#8-运行设置)。 - -请求位置由管理端设置,数据库初始化不从 `config.yaml` 导入位置值。 - ## 启动 ```bash @@ -102,6 +101,45 @@ curl -i http://127.0.0.1:8080/healthz 不要把未脱敏的 `docker compose config` 或 `docker inspect` 输出上传到工单;它们会包含 PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`。 +### 启动后的运行设置 + +在管理端修改以下设置,保存后对新请求生效,重启不会覆盖已保存的选择: + +| 设置 | 入口与边界 | +| --- | --- | +| 上游客户端身份 | “系统设置 → 上游配置 → 客户端身份”;Key 可按 Provider 覆盖,支持自动或固定版本 | +| 全局请求位置 | “系统设置 → 上游配置 → 请求位置覆盖”;默认关闭,不从 YAML 导入 | +| 代理位置 | “代理管理”;优先于全局位置,全局关闭时仍可独立生效 | + +位置覆盖只影响 OpenAI 请求中受支持的位置、日期与时区信息,不改变真实出口 IP、epoch 时间戳或系统时区。 +`openai.residency` 是独立的部署约束。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)。 +后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml`。 + +### 插件命令行 + +使用与网关相同的工作目录和 `deploy/config.yaml`,读取制品已接受且当前已启用的插件实例: + +```bash +codex-proxy-rs plugin --help +codex-proxy-rs plugin <实例ID> <命令> --help +codex-proxy-rs plugin <实例ID> <命令> --参数 值 +``` + +不带子命令或使用 `serve` 时启动网关;插件命令不监听 HTTP、不启动后台任务,也不恢复或清理正在运行的 +网关准入状态。顶层 `--help/--version` 不读取配置。插件帮助只读数据库,不执行迁移、登录或账号保存, +因此需要先由正常部署完成数据库初始化。宿主诊断保留在配置的文件日志中,stdout/stderr 专用于命令结果。 + +参数支持 `--名称=值` 或 `--名称 值`;布尔参数可直接写 `--名称`,关闭时写 `--名称=false`。 +duration 使用 `500ms`、`1m30s` 等带单位形式。参数可能进入 shell 历史和系统进程列表,不能因为插件声明 +了敏感参数就认为命令行是安全的密钥输入通道;真实凭据优先使用对应插件提供的受控登录或导入入口。 + +成功命令原样返回插件退出码与输出。命令受总期限约束,`Ctrl-C` 或终止信号会取消调用并回收插件进程。 +若命令产生账号,只有成功结果才按顺序通过 Admin 保存;调用受制品访问域、`command_line` 阶段和当前资源事实约束, +模型执行 Key 由具体调用选择,不在实例中预绑定。 +部分保存后失败或取消不会自动重放,先核对账号和审计记录,再决定是否重新执行。 + +独立仓库 `codex-proxy-plugins` 的 `examples/request-workbench` 提供不访问真实上游的综合示例。 + ## 公网访问 Compose 默认只绑定 `127.0.0.1`。从其他设备访问时,在应用前配置反向代理, @@ -259,6 +297,12 @@ cargo run -p codex-proxy-rs `CPR_TEST_DATABASE_URL` / `CPR_TEST_REDIS_URL` 约定见 [迁移文档](../backend/migrations/README.md)。 +管理端的依赖准备与 UI 源码联调见 [贡献与验证](../CONTRIBUTING.md#验证)。另开终端从仓库根目录启动前端: + +```bash +pnpm --dir frontend dev +``` + ## 持久化与备份 Compose 使用以下绑定目录: @@ -343,6 +387,24 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr 请求问题反馈使用 [接口问题反馈表单](../.github/ISSUE_TEMPLATE/api-bug-report.yml);错误诊断与查询合同见 [API 文档](../docs/api.md#10-dashboard用量与错误)。 +### 插件取文与 Fake-IP DNS + +插件受管 HTTP 会检查域名解析出的全部地址,再使用通过校验的地址连接,默认拒绝内网、回环、 +链路本地和保留地址。配置了代理也不会跳过目标地址校验,宿主不自动改用公共 DNS。 + +若取文提示“目标地址被安全策略拦截”,先在**实际运行网关的主机或容器内**检查域名解析。 +Clash / Mihomo 的 Fake-IP 模式可能把公网域名解析为 `198.18.0.0/15` 中的地址,此时应调整代理 DNS, +让需要访问的域名返回真实公网地址,不要关闭宿主检查或放行整个保留地址段。 + +例如 Mihomo 使用 `fake-ip-filter-mode: blacklist` 时,将 `github.com` 追加到现有 +`dns.fake-ip-filter` 列表,保留原条目及其他 DNS、代理规则。Clash Verge Rev 开启 DNS 覆写时, +应在 DNS 设置中修改持久配置,而不是只编辑会被重新生成的运行配置。规则模式和白名单模式含义不同, +按所用版本的 [Mihomo DNS 文档](https://wiki.metacubex.one/config/dns/)配置。 +重新加载后确认解析结果已改变,再验证实际取文;浏览器或普通 `curl` 可访问不能代替宿主校验。 + +宿主日志中的“插件受管 HTTP 请求失败”只记录固定原因、错误类别和发送状态,不包含请求网址、 +请求头、正文或凭据。域名解析失败、网络超时、连接失败和地址拒绝应分别处理,不需要开启请求转储。 + ## 密码语义 - `admin.default_password` 只在首次创建管理员时使用。 @@ -379,7 +441,7 @@ docker compose -f deploy/compose.yaml pull codex-proxy-rs docker compose -f deploy/compose.yaml up -d --no-build --wait codex-proxy-rs ``` -源码构建需要克隆源码仓库并准备配置与数据目录,以下命令从仓库根目录执行: +源码构建需要克隆源码仓库并准备配置与数据目录。UI 组件库从锁定的 GitHub 提交下载,并在前端依赖安装阶段自动构建,无需准备同级源码仓库或额外 build context。以下命令从仓库根目录执行: ```bash docker compose -f deploy/compose.yaml build codex-proxy-rs @@ -451,10 +513,34 @@ Release 必须提供当前 OS/架构的 `codex-proxy-rs___.ta .runtime/data/update-tmp/ ``` +### 插件兼容与发行目录 + +教学示例在 `codex-proxy-plugins` 独立构建和发布,通过[插件管理](../docs/plugins.md)安装。 +宿主发行物当前不内置插件,但仍包含用于兼容检查的封口清单。 + +| 文件或目录 | 用途 | +| --- | --- | +| `plugin-release-manifest.json` | 固定宿主版本、提交、平台与支持的插件合同;插件列表当前为空 | +| 可执行文件同目录的 `plugins/official` | 随受信宿主发行物部署的只读目录,与二进制、Web 资源成套更新或恢复 | + +- 更新或回滚前,逐一检查启用实例的精确包与目标宿主是否兼容。制品缺失、不兼容或并发配置变更会阻止切换;失败或取消时恢复整组文件,不自动停用插件或接受其他制品 +- 非空官方清单中的包经身份、平台和摘要校验后幂等导入;导入不接受访问域、不创建配置、不启用,也不删除旧包。管理员确认访问域后才执行默认配置流程;重复摘要保留首次安装出处 +- `sealed` 是构建封口标记,不是密码学签名。普通上传、URL 或 GitHub 安装不能获得 `builtin` 身份,独立教学示例也不例外 + +首次从不支持官方目录的旧更新器升级时,须完整解压平台发行包或更新正式容器镜像;旧更新器不会复制新增目录。 +后续在线更新会成套处理该目录。macOS arm64 提供构建产物;部署前仍需验证目标平台的插件运行与上游调用,不能只以打包成功判断可用。 + ## 备份与恢复 ### 备份内容与限制 +- 插件包体、固定来源、下载凭据、制品接受事实、实例配置、版本配置快照、敏感配置、能力/Provider 绑定和私有状态都保存在 + PostgreSQL,随数据库一起备份;插件 Provider 账号仍使用同库的通用账号表,不另建第二份账号权威。 + 来源使用的出站代理及其认证也须随库恢复,不能只备份 `plugin_*` 表;来源代理与账号代理独立选择, + 不依赖进程代理环境,故障时不会自动回退直连。 + 包体单个上限为 32 MiB,需要计入数据库及备份容量;解压运行目录和 Redis 协调状态是可重建数据, + 不作为插件安装或访问域事实。恢复时保留制品接受状态、实例配置、账号引用和状态代次的一致性;插件降级仍须 + 通过状态兼容校验,不通过手工替换缓存目录回滚。 - 官方运行镜像内置与 Compose 数据库服务版本一致的 `pg_dump` / `pg_restore`,无需额外安装。 自行替换 PostgreSQL 版本时,应同步核对备份工具版本。 - 备份暂存目录为 `host.runtime_data_dir/backup-staging`;Compose 默认对应 @@ -491,7 +577,10 @@ pg_restore --no-owner --no-privileges --password \ 1. 先备份现有数据库,并停止应用;在独立的空数据库中恢复归档,检查数据和迁移版本。 2. 应用保持停止,离线核对备份设置与记录。禁用快照中的旧计划,处理非终态记录 (`queued/dumping/uploading/deleting`)、旧调度游标和到期清理条件,再决定哪些记录保留。 -3. 确认不会误执行旧任务或删除恢复前的远端对象后,再启动应用;重新测试 S3 连接并设置计划。 +3. 使用空 Redis 和空插件解包缓存做一次冷启动;逐一核对已启用实例的包摘要/平台、制品接受状态、配置与绑定、账号引用、 + 私有状态 schema/代次,并执行实际插件操作。缺少或损坏必需包体、引用不完整或状态不兼容时,恢复不能 + 记为成功,也不能用相同插件 ID 的其他包替代。 +4. 确认不会误执行旧任务或删除恢复前的远端对象后,再接入业务流量;重新测试 S3 连接并设置计划。 只关闭计划备份不会停止 Worker 的任务恢复和到期删除。无法确认这些记录的影响时, 不要把恢复后的数据库直接接入运行中的应用。具体处理应根据目标库数据制定,不提供清空生产记录的通用命令。 diff --git a/docs/api.md b/docs/api.md index 50293a7c0..1a082b124 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,11 +4,19 @@ `backend/crates/gateway-api/src` 中的 router 为准。配置 Codex 请先看 [客户端配置](../deploy/README.md#客户端配置); 运行实例是否包含这些功能,应结合其版本和 revision 确认。 +| 查找内容 | 入口 | +| --- | --- | +| 鉴权与响应 | [公共约定](#1-鉴权与公共约定) · [浏览器会话与 Key 自助页](#4-浏览器认证) | +| 客户端协议 | [Responses、Images、模型与 Provider 操作](#3-openai-数据面与模型目录) · [额度查询](#api-key-额度查询) | +| 账号与授权范围 | [账号](#5-账号) · [代理](#独立代理管理--managed-proxies) · [分组](#6-账号分组) · [Client Key](#7-client-key) | +| 系统与运维 | [健康检查](#2-健康检查) · [运行设置](#8-运行设置) · [备份](#9-备份) · [统计与诊断](#10-dashboard用量与错误) · [版本与更新](#11-版本更新与重启) | +| 插件 | [安装包与来源](#安装包与来源) · [运行实例](#121-运行实例) · [管理扩展](#122-管理扩展页面与原始-api) | + ## 1. 鉴权与公共约定 ### OpenAI 数据面客户端接口 -所有 `/v1/*` 请求都使用管理端创建的 Client Key: +默认情况下,`/v1/*` 使用管理端创建的 Client Key: ```http Authorization: Bearer sk_... @@ -17,6 +25,10 @@ Authorization: Bearer sk_... 自动生成的 Key 保持 `sk_` 格式;迁入的自定义 Key 使用保存时的原值,不限制前缀或固定长度。 无论格式如何,只有已保存且启用的 Client Key 能通过鉴权。 +启用[入口认证插件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#数据面入口认证)时, +`Authorization` 可按插件协议校验,并由宿主将外部身份映射到明确绑定的 Client Key。 +账号范围、模型权限、并发和预算仍由该 Key 决定;插件认证不接管管理员或 Key 自助页登录。 + Codex 原生生图配置还会携带 `X-OpenAI-Actor-Authorization: proxy-managed`。 它仅用于客户端识别服务端托管认证,不能代替 Client Key。网关和 OpenAI Provider 都会过滤该请求头, 上游账号身份只由服务端选中的账号提供;不要把真实账号 token 放进该标记。 @@ -68,7 +80,7 @@ HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、 请求无需自带 `x-request-id`;缺失时服务端自动生成 UUID 并在响应头回传同一 request ID。 `api.request_id_header` 可改变注入与回传的 header 名,管理端鉴权不依赖该名字。 -管理端响应统一带 `Cache-Control: no-store`。 +管理端 JSON 与动态响应统一带 `Cache-Control: no-store`;插件静态资源按下述资源接口的私有缓存策略返回。 配置了 CORS 白名单 origin 时,跨域请求以凭据模式放行,仅允许 `GET`/`POST` 方法和 `authorization`、`content-type`、`x-api-key` 与 request ID 四个请求头,不使用通配符。 @@ -106,6 +118,7 @@ HTTPS 来源以及缺失、`null` 或非法来源保留 `Secure`。`HttpOnly`、 | 404 | `40401` | 资源或管理接口不存在 | | 409 | `40901` | 资源状态冲突 | | 429 | `42901` | 登录尝试过多 | +| 429 | `42902` | 管理操作请求过于频繁;安全文案可说明具体受限操作 | | 500 | `50001` | 服务内部错误 | | 502 | `50201` | 上游服务请求失败 | | 502 | `50202` | 不可逆上游操作的执行结果未知;刷新状态后再决定是否重试 | @@ -135,6 +148,7 @@ Codex PAT 验证服务不可用和身份响应无效分别返回 `50301`、`5020 管理写入不要求客户端提供全局配置版本。会改变路由快照或安全配置的写入由后端在事务内推进 内部 `config_revision`,并用于快照发布与审计。账号更新和分组查询/写入的部分响应会返回 `configRevision` 作为已提交事实,但它不是客户端 mutation 的前置条件。 +个别资源另有自己的并发检查,如代理的 `revision` 和插件实例的 `expectedRevision`;按对应接口提交,不与全局版本混用。 ## 2. 健康检查 @@ -156,11 +170,35 @@ WebSocket message 和 frame 不设置网关私有长度上限;协议可接受 | `POST` | `/v1/images/edits` | 通过 OpenAI Provider 发起图像编辑;JSON 请求与响应正文原样转发 | | `GET` | `/v1/models` | 返回当前 Client Key 账号范围内各 Provider 的可用公开模型并集;有两种响应形态,见下 | | `GET` | `/v1/models/{model_id}` | 返回 OpenAI 兼容的单模型详情 | +| `POST` | `/v1/providers/{provider}/models/{model}/count_tokens` | 调用指定 Provider/model 声明的精确 Token 计数;请求和响应均为 JSON | +| `GET` / `POST` | `/v1/providers/{provider}/http/{endpoint}` | 调用指定 Provider 预先声明的固定 HTTP 操作;不是任意 URL 代理 | | `GET` | `/v1/usage` | 查询当前 Client Key 的日与周额度,仅使用网关已结算的 USD 账本 | +### Provider 自有操作 + +这两类 `/v1/providers/*` 路由使用同一 Client Key 鉴权、账号组范围、Provider 账号资格、租约、 +并发/频率准入、请求记录和发送事实。`provider`、`model` 与 `endpoint` 都是已发布目录中的稳定 ID; +宿主不会接受客户端提供的上游 URL,也不会把下游 Authorization、Cookie、API Key、Host、转发头或 +`Connection` 声明的逐跳头交给插件。上游账号认证只来自宿主选中的账号。 + +`POST /v1/providers/{provider}/models/{model}/count_tokens` 接受最多 8 MiB 的合法 JSON,且只路由到 +为该模型明确声明 `count_tokens` 的 Provider。Core 不解释或重写请求/响应 JSON,响应必须是插件按其 +声明的本地精确 tokenizer 或上游精确计数合同返回的原始 JSON;没有准确实现时返回不支持,不从 usage +或字符数推算。响应固定使用 `application/json` 并重算 `Content-Length`。 + +`GET|POST /v1/providers/{provider}/http/{endpoint}` 只允许 Provider descriptor 中该 endpoint 声明的 +方法;`HEAD`、其他方法、未声明 endpoint 和未声明的方法在出站前拒绝。GET 不能携带正文,POST 正文 +最多 8 MiB;query 作为不透明值交给目标 Provider 再按其固定合同校验,不能改变上游 origin 或路径。 +成功响应由最多 64 KiB 的有序原始片段组成,聚合后最多 8 MiB,字节不经 JSON 转码。宿主保留允许的 +`Content-Type` 和安全业务响应头,移除认证、`Set-Cookie`、`Location`、逐跳、压缩及 framing 头, +并按最终正文重算 `Content-Length`;缺少媒体类型时使用 `application/octet-stream`。Provider 的结构化 +失败仍沿用数据面错误映射和已确认的上游状态/可见正文边界,不把非 2xx 成功封套当作成功。 + Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型; 网关不提供独立的子代理请求路径。 +### Responses 请求与传输 + `POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、 `deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd 多帧连续解码,整体展开结果受运行设置 `responsesMaxDecompressedBodyBytes` 约束(默认 64 MiB), @@ -230,6 +268,8 @@ HTML 或截断正文当作 message。 提供网关关联 ID,并用 `x-gateway-request-id` 独立标识网关请求。除下述客户端错误兼容与原生续写额度恢复外, 已经取得的原始上游错误帧不重写。 +### 模型目录 + `GET /v1/models` 默认返回 OpenAI 兼容列表 `{"object": "list", "data": [...]}`;请求携带非空 `client_version` query 参数(Codex 客户端)时改为返回 Codex 专用目录合同 `{"models": [...]}`。 @@ -263,6 +303,8 @@ Codex 专用目录中的 `context_window` 与 `max_context_window` 分别表示 覆盖这些值。Codex 客户端配置 `model_context_window` 后,按该值与非空 `max_context_window` 的较小值 使用窗口;上限为空时保留客户端本地值。xAI 目录只声明一个窗口,其 Provider 继续以该值作为客户端覆盖上限。 +### 透传与错误恢复 + OpenAI 路径保留客户端 Responses wire 语义:请求 body 的未知字段和字段顺序保持不变(受控模型 映射除外),HTTP SSE 与 WebSocket 的上游业务事件除下述客户端错误兼容外按原始字节转发, response ID 按 opaque 值处理而不假设 UUID 或固定长度;除客户端错误兼容与原生续写额度恢复外, @@ -308,9 +350,10 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 ` ### API Key 额度查询 -`GET /v1/usage` 使用 `Authorization: Bearer `,不接受会话 Cookie、管理 API Key 或查询参数。 -只返回该 Key 的日与周额度,不包含明文 Key、账号资料或其他 Key 的数据。查询不会调用上游、扣费、占用推理并发/RPM, -也不会更新最近使用时间或开启预算窗口;额度耗尽后仍可查询。 +`GET /v1/usage` 使用同一数据面入口认证,默认传入 `Authorization: Bearer `; +启用入口认证插件时可查询其映射 Key 的额度。不接受会话 Cookie、管理 API Key 或查询参数。 +只返回当前 Key 的日与周额度,不包含明文 Key、账号资料或其他 Key 的数据。查询本身不执行模型推理、扣费或占用推理并发/RPM, +不更新最近使用时间或开启预算窗口;额度耗尽后仍可查询。已配置的入口认证和 request 中间件仍按授权执行。 成功响应直接返回以下 JSON,不使用管理接口信封,所有响应带 `Cache-Control: no-store`: @@ -412,12 +455,13 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 ## 5. 账号 -账号 API 使用统一路由,不存在 Provider Instance 或 Provider 专属账号路由。需要 Provider 的请求只接受 -`provider: "openai" | "xai"`。 +账号 API 使用统一路由,不存在 Provider Instance 或 Provider 专属账号路由。`provider` 使用 Provider ID, +包括内置 `openai`、`xai` 和插件平台;需要调用 Provider 的操作须由当前已发布的能力支持。 | 方法 | 路由 | 主要 query/body | 说明 | | --- | --- | --- | --- | | `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`groupId`、`search`、`status`、排序字段 | 分页查询账号与汇总 | +| `GET` | `/api/admin/accounts/providers` | 无 | 查询已发布 Provider 的凭据能力、输入 schema 与登录完成方式 | | `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 | | `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 | | `POST` | `/api/admin/accounts/import` | `{ provider, data, settings?, outboundProxyId? }` | 导入或按上游身份更新账号,可同时应用调度、分组设置与默认代理 | @@ -427,30 +471,35 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `POST` | `/api/admin/accounts/import-tasks/stop` | `{ taskId }` | 跳过未开始的条目,已开始的条目继续完成 | | `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 | | `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 停用账号只启用调度;已启用账号强制清除本地错误/额度/cooldown 事实,不访问上游 | -| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 更新指定 OpenAI 账号的 OAuth token 或 API Key 上游设置 | -| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 | +| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl?, connection? }` | 一次更新账号设置;`connection` 仅用于编辑 OpenAI API Key 账号的连接地址、传输方式和密钥,见下文 | | `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 | | `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 | -| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 | +| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游;Provider 未提供额度能力时返回空额度投影 | | `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | -| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 OpenAI/Codex 官方个人资料、累计活动与订阅信息,不更新额度或 credential | -| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 | -| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 | +| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 Provider 上游个人资料、累计活动与订阅信息,不更新额度或 credential | +| `GET` | `/api/admin/accounts/profile-avatar` | `accountId`、可选 `version` | 返回支持头像能力的 Provider 字节流,需要管理员会话;`version` 仅区分浏览器缓存 | +| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询支持该能力的 Provider 重置卡,不读取本地库存 | +| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 Provider 重置卡 | | `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 | | `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache | | `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 | -| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 | +| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, input?, accountId?, outboundProxyId?, outboundProxyUrl? }` | 为支持登录的 Provider 创建 flow;`accountId` 表示重新授权 | | `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 | +| `POST` | `/api/admin/accounts/oauth/poll` | `{ provider, flowId, callbackUrl?, settings? }` | 轮询支持该能力的 Provider 登录,返回待确认或批量完成 | 账号列表支持以下稳定值: -- `provider`: `all`、`openai`、`xai`; +- `provider`: Provider ID(如 `openai`、`xai` 或插件平台 ID),省略或空值表示不过滤; - `groupId`: 分组 ID、`ungrouped`,或省略以不过滤; - `status`: `normal`、`quota_exhausted`、`rate_limited`、`disabled`、`error`; - `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`; - `sortDirection`: `asc`、`desc`。 +账号列表的 `data.providers` 为全部已保存账号的 Provider ID 去重排序列表,不受当前筛选、分页或插件启停影响。 +没有账号时返回空数组;创建账号时可用的平台及操作另由 `/api/admin/accounts/providers` 的能力描述确定。 +显式 `provider` 按 ID 匹配,包括名为 `all` 的平台。既有调用方若使用 `provider=all` 表示全部平台,须改为省略该参数。 + 账号限流详情在 `quota` 中返回:`rateLimitReason` 为 `upstream_rate_limit`(上游临时限流)、 `capacity_freeze`(容量错误触发自动冻结)或 `null`。`recoveryProbeRequired` 表示解除冻结是否需要成功探测; 此时 `rateLimitedUntil` 是最早探测时间,到期后仍保持 `rate_limited`,直到探测成功或手动恢复。 @@ -600,7 +649,23 @@ JSON 文件按 Provider 文档分项,不拆解内部代理引用或改变 Prov 终态结果保留 1 小时后自动清理。服务重启会丢失任务与未执行输入,已提交的账号不受影响; 任务记录仍在保留期内时,可通过列表接口查询当前管理员的任务及进度。 -### 账号导入与 OAuth +### 账号能力、导入与 OAuth + +`GET /api/admin/accounts/providers` 返回 `{ provider, credentials }` 数组,读取同一已发布代次, +需要管理员身份。`credentials.import` 为输入对象的 JSON Schema,`null` +表示不支持;`refresh`、`export` 为支持标记。`login` 为 `null` 或 +`{ inputSchema, completion: "callback" | "poll" }`,分别使用授权完成或轮询入口。 +描述只表示 Provider 实现了哪些操作;账号类型、账号范围和权限仍由实际操作校验。停用的插件从该目录 +撤下,但既有账号与分组关系保留。响应不包含实例配置、凭据或登录输入。 + +敏感账号导出要求所选全部 Provider 当前已注册且声明 `credentials.export`。混合选择中只要有一个 +不支持导出的 Provider,整个请求都会在读取或解密任何凭据前拒绝,不返回部分导出结果。 + +账号列表及额度详情中的 `capabilities` 则针对当前账号,包含布尔字段 `quota`、`quotaRefresh`、`profile`、 +`subscription`、`avatar`、`resetCredits` 和 `consumeResetCredit`,分别表示额度读取、额度刷新、资料、订阅、 +头像、重置卡查询及消费能力。服务端结合 Provider 声明、账号认证类型和插件账号授权生成,客户端据此显示 +操作;只读与写入能力分别判断,不按 Provider 名称推测。插件撤下后保留账号,但这些能力为 `false`。 +能力是查询时的投影,不能代替实际操作的权限校验,也不保证上游可用。 导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制, 当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释: @@ -638,7 +703,8 @@ API Key 账号使用以下独立凭据形态: `transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON, 不携带 OAuth Cookie 或 ChatGPT 身份。上游模型列表使用标准 `/models` 格式,按账号和凭据版本隔离; 目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。 -API Key 每次导入创建独立账号;更新已有账号使用 `rotate`。导出会显式包含密钥,沿用敏感导出的确认合同。 +API Key 每次导入创建独立账号;更新已有账号使用 `/api/admin/accounts/update` 的 `connection` 字段。 +导出会显式包含密钥,沿用敏感导出的确认合同。 sub2api 的 `platform=openai`、`type=apikey` 使用 `credentials.base_url` / `credentials.api_key`; 导入时按其端点规则将服务根、版本前缀或完整 `/responses` 地址转换为 API 前缀,缺省地址为官方 `/v1`。 @@ -672,7 +738,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token `weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号, 与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组, 已有账号保留原有分组、权重与并发设置。可选 `notes` 与编辑备注使用相同的校验和清空语义,省略或 `null` 保留已有备注; -管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,credential refresh 和未携带 `settings` 的 rotation 保留账号设置。 +管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,凭据刷新保留账号设置。 账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。 @@ -681,23 +747,31 @@ OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。 旧文档不带此字段时,新账号默认 `all`,已有账号保留原值;凭据刷新、重新授权和目录刷新均保留政策。 sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限制。 -OpenAI rotation 请求字段为: +编辑 OpenAI API Key 账号时,在 `POST /api/admin/accounts/update` 的账号设置中附带 `connection`: ```json { - "provider": "openai", "accountId": "acct_...", - "idToken": "...", - "accessToken": "...", - "refreshToken": "..." + "enabled": true, + "concurrencyLimit": null, + "weight": 1, + "groupIds": [], + "connection": { + "baseUrl": "https://api.example.com/v1", + "transport": "http", + "apiKey": "..." + } } ``` -API Key rotation 使用 `{ provider: "openai", accountId, baseUrl, transport, apiKey?, settings? }`。 -省略 `apiKey` 保留当前密钥,空字符串无效;账号 ID 和认证类型不能通过轮换转换。 -rotation 可选携带 `settings`,字段与 `POST /api/admin/accounts/update` 相同,其中 `accountId` 必须与外层一致。 -凭据与设置在同一事务中保存,任一校验或持久化失败均不落库;省略 `settings` 保留现有分组、调度等设置。 -`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`,不回显密钥。 +`connection.transport` 支持 `http`、`prefer_websocket`。省略 `connection` 时只更新账号设置; +省略 `connection.apiKey` 保留当前密钥,空字符串无效。连接设置只适用于现有 OpenAI API Key 账号, +不能修改账号 ID、Provider 或认证类型,也不接受 OAuth token 或通用凭据文档。 +凭据与设置在同一事务中保存,任一校验或持久化失败均不落库。 +`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`; +声明连接设置能力的插件 Provider 可返回其 rotate schema 中明确公开的标量字段。字段投影 +不超过 64 KiB,不接受引用、组合或条件 schema,不回显密钥、`writeOnly` 字段、 +原始插件配置或 secret。不适用的账号省略该字段。 更新会推进凭据 revision 并失效目录与连接;旧版本会话不可静默续接到新上游。 OAuth start 使用: @@ -710,10 +784,26 @@ OAuth start 使用: } ``` +可选 `input` 为登录表单对象,省略时使用 `{}`,编码后最多 64 KiB。插件按其声明的登录 schema +校验,失败时不会发起插件登录调用,也不返回包含输入值的校验错误。OpenAI/xAI 的浏览器授权流程不接收 +额外输入。该对象只传给当前登录准备调用;宿主持久化的授权目标不保存它,插件需要跨步骤保留的状态由 +插件显式返回,再进入既有加密临时存储。 + 重新授权已有账号时,start 请求仍携带 `provider` 和展示用 `name`,只额外提供目标 `accountId`; 客户端不得提交 `credentialRevision`、旧 token 身份或其他并发控制字段。complete 请求也不重复提交 `accountId`,后端通过 `flowId` 中保存的目标绑定完成授权。 +声明登录轮询能力的插件使用 `POST /api/admin/accounts/oauth/poll` 接续 start 返回的 `flowId`。 +待确认时返回 `{ status: "pending", retryAfterMs }`,完成时返回 `{ status: "complete", accountIds }`, +两种结果均为 HTTP 200;一次登录可以创建多个账号,全部账号在同一事务提交。`callbackUrl` 可省略, +需要浏览器回调的插件由该字段接收返回值;`settings` 只允许用于首次创建。 +流程只允许发起它的管理员身份继续。尚未完成的流程过期后需重新发起;插件配置、制品或权限发生变化后, +也需重新发起登录,宿主重启但上述内容不变时可继续。插件私有登录状态不返回客户端。 + +账号提交成功后,`complete` 和 `poll` 保留 24 小时的完成结果。同一管理员使用相同 `provider`、`flowId` +重试时返回原账号结果,不再执行授权或更新凭据;新提交的 `callbackUrl` 和 `settings` 不会再次应用。 +恢复完成结果不要求插件仍处于启用状态。`complete` 只返回单账号结果,多账号授权必须使用 `poll`。 + ### OpenAI 身份、额度与状态 - OAuth 文件导入接受 camelCase 与 snake_case 的三个 token 字段,内部统一保存为 @@ -820,11 +910,12 @@ OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai 回退后的响应或错误作为最终结果,不以原来的 404 覆盖。重置卡消费回退复用原始请求体和幂等键, 传输失败仍按消费结果不明确处理。API Key 账号不会因此获得 OAuth 账号能力,也不会改变推理请求的目标地址。 -### OpenAI 个人信息 +### 账号个人信息 -`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,当前由 OpenAI/Codex OAuth -账号提供。后端并发读取资料统计与订阅,一次返回;每次请求均重新查询,除上述 404 路由回退外不自动重试, -不刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。 +`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,由支持对应能力的 Provider +提供,包括 OpenAI/Codex OAuth 账号与声明资料/订阅操作的插件 Provider。后端并发读取资料统计与订阅, +一次返回;每次请求均重新查询,不刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。 +OpenAI 的查询沿用上述 404 路由回退;插件查询需要 `profile` 用途的账号与出站授权。 响应 `data` 包含: @@ -852,7 +943,7 @@ OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai #### 订阅信息 -后端使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`, +OpenAI 使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`, 不枚举其他账号,不返回上游订阅 ID 或原始响应。 `subscription` 为 `null`(未获得可用订阅周期),或包含以下字段: @@ -866,14 +957,15 @@ OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai | `billingCurrency` | string 或 null | 上游计费币种 | | `observedAt` | RFC 3339 字符串 | 本次查询时间 | -订阅不写入额度快照或数据库,不参与账号状态或调度;查询含 404 回退共用最多 5 秒预算,响应最多 64 KiB。 +订阅不写入额度快照或数据库,不参与账号状态或调度;OpenAI 查询含 404 回退共用最多 5 秒预算,响应最多 64 KiB。 上游失败或未提供有效周期时返回未知,不据此标记免费、过期或禁用;请求期间账号身份或 credential revision 变化时丢弃结果。 -### OpenAI 主动额度重置卡 +### 主动额度重置卡 -`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询 OpenAI 上游;后端不把卡片列表写入 -PostgreSQL 或 Redis。 +`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询对应 Provider;后端不把卡片列表写入 +PostgreSQL 或 Redis。OpenAI OAuth 与声明该能力的插件可使用,账号视图分别以 `resetCredits` 和 +`consumeResetCredit` 表示查询和消费能力。 查询响应: @@ -907,7 +999,11 @@ PostgreSQL 或 Redis。 ``` 消费响应只返回上游结果 `code` 和可选 `credit`。消费端确认成功后应重新 GET 卡片列表,并显式调用 -`POST /api/admin/accounts/quota/refresh` 回读官方额度;不得直接改写本地 `resetAt`。xAI 不支持该能力。 +`POST /api/admin/accounts/quota/refresh` 回读上游额度;未提供 `quotaRefresh` 能力时不发起该查询。 +不得因为消费成功直接改写本地 `resetAt` 或解除冻结。xAI 不支持该能力。 + +插件的已确认拒绝与凭据需刷新是完整业务结果;消费调用超时、进程退出、协议损坏或结果无法通过校验时, +按结果未知处理。消费期间账号身份或凭据版本变化也返回结果未知,不将旧结果套用到新绑定账号。 ## 6. 账号分组 @@ -951,16 +1047,19 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造 | `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 | 创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选 -`dailyLimitUsd`、`weeklyLimitUsd`、`customKey`、`openaiClientProfileOverride` 和 `xaiClientProfileOverride`。更新请求携带 `id`,不接受 `customKey`。 +`dailyLimitUsd`、`weeklyLimitUsd`、`customKey` 和 `providerRequestProfileOverrides`。更新请求携带 `id`,不接受 `customKey`。 `groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生 `routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的 `providerKinds`。创建和 reveal 响应会返回完整明文 Key,调用方 必须立即安全保存。 -`openaiClientProfileOverride` 为完整的 [OpenAI 客户端身份](#openai-上游客户端身份)对象或 `null`,列表也返回该字段。 -`xaiClientProfileOverride` 对应完整的 [xAI 客户端身份](#xai-上游客户端身份),两者分别覆盖所属 Provider,列表同时返回。 -创建时省略或 `null` 表示跟随通用设置;更新时省略保留现值,显式 `null` 才清除覆盖。 -独立配置整体覆盖通用设置,不逐字段继承;切换全局配置不会影响独立 Key。 +`providerRequestProfileOverrides` 按 Provider ID 保存身份选择,列表返回同名对象。创建时各项为完整配置对象, +未提供的 Provider 跟随通用设置;更新时省略某项保留原值,设为 `null` 清除该项覆盖。最多 256 项,每项最多 64 KiB。 +独立配置整体覆盖通用设置,不逐字段继承;切换全局配置不会影响独立 Key。目录与选择规则见 [Provider 客户端身份](#provider-客户端身份)。 + +兼容字段 `openaiClientProfileOverride` 和 `xaiClientProfileOverride` 分别对应上述映射中的 `openai`、`xai`。 +创建时省略或 `null` 表示无覆盖;更新时省略保留、`null` 清除。与通用字段同时提交时必须一致,否则拒绝整个请求; +响应中的兼容字段从同一映射派生,不是第二份配置。 密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量前缀匹配。 创建和更新时去除名称首尾空白,并按忽略大小写、首尾空格的名称查重,重复返回 `409`。 @@ -1025,12 +1124,11 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | 方法 | 路由 | 说明 | | --- | --- | --- | | `GET` | `/api/admin/settings` | 读取运行设置 | -| `POST` | `/api/admin/settings/update` | 原子替换全部运行设置 | +| `POST` | `/api/admin/settings/update` | 原子保存运行设置;身份配置按显式提供的 Provider 项更新 | | `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 | -| `GET` | `/api/admin/settings/client-profiles/openai` | 读取六个预设、自动更新可用状态和 `globalConfiguration` | -| `POST` | `/api/admin/settings/client-profiles/openai/preview` | body 为 `{ configuration }`,值为完整身份对象或 `null`(解析当前通用设置);只预览,不保存 | -| `GET` | `/api/admin/settings/client-profiles/xai` | 读取 Grok CLI 默认字段 `defaults` 和 `globalConfiguration` | -| `POST` | `/api/admin/settings/client-profiles/xai/preview` | body 为 `{ configuration }`,值为完整 xAI 身份对象或 `null`(解析当前通用设置);只预览,不保存 | +| `GET` | `/api/admin/settings/client-profiles` | 返回 `{ providers: [Provider ID] }`,列出当前支持客户端身份配置的 Provider | +| `GET` | `/api/admin/settings/client-profiles/{provider}` | 读取该 Provider 的可选配置和 `globalConfiguration` | +| `POST` | `/api/admin/settings/client-profiles/{provider}/preview` | `{ configuration }`,完整配置对象或 `null`(解析当前通用设置);只预览,不保存 | | `GET` | `/api/admin/settings/admin-api-key` | 只返回管理 API Key 是否存在 | | `POST` | `/api/admin/settings/admin-api-key/delete` | 删除管理 API Key | | `POST` | `/api/admin/settings/admin-api-key/regenerate` | 重新生成并一次性返回完整管理 API Key | @@ -1038,7 +1136,9 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly 设置更新字段包括: ```text +providerRequestProfiles openaiClientProfile +xaiClientProfile requestLocationEnabled requestLocation modelMappings @@ -1117,8 +1217,9 @@ OpenAI 长上下文为输入超过 272000 Token,xAI 为输入达到 200000 Tok `multiplierBps` 为 0~1000000 的整数,10000 表示 1 倍、0 表示本地估算为零,最大 100 倍。 它作用于本地费用及对应明细,独立于服务档位;缺少计价依据的请求即使倍率为零仍然是费用未知。 -`provider` 为 `openai` 或 `xai`;`models` 为 1~500 个模型 ID,每个 ID 为 1~128 字节且不含空白、 -控制字符。`change` 为以下形式之一: +`provider` 必须是 `GET` 返回的 `defaults` 中当前已注册的 Provider;除内置 `openai`、`xai` 外, +当前注册的插件 Provider 也会出现在该目录中。`models` 为 1~500 个模型 ID,每个 ID 为 +1~128 字节且不含空白、控制字符。`change` 为以下形式之一: - `{ "action": "replace", "pricing": { "multiplierBps": 12500, "bands": { ... } } }`:替换选中模型的人工 配置,未提供档位重新继承来源;内置和同步均未登记的模型必须包含 `standard`。 @@ -1136,10 +1237,26 @@ models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 及所有人工单价与倍率保持不变。选中的已同步模型若不再出现在来源价目中,则移除其来源层,恢复内置价格; 没有内置价目的模型变为未配置。 +### Provider 客户端身份 + +`providerRequestProfiles` 是按 Provider ID 索引的通用配置对象,最多 256 项、每项最多 64 KiB。 +更新时省略某项保留原值,对象替换该项,`null` 删除显式选择并使用 Provider 默认值;不清除其他 Provider 的配置。 +配置内容由对应 Provider 校验,不包含账号凭据。Key 可通过 `providerRequestProfileOverrides` 整体覆盖所属 Provider 的选择。 + +`openaiClientProfile` 与 `xaiClientProfile` 是同一映射的兼容字段:省略保留,不能显式提交 `null`; +与通用字段同时提供时必须一致,否则整次更新拒绝。读取响应从映射派生这两个字段,不维护平行状态。 + +先查询 Provider 目录,再读取选项与预览。OpenAI 选项返回六个 `presets`,xAI 返回 `defaults`; +插件 Provider 返回 `presets: [{ id, label, description, configuration }]` 和 `defaultConfiguration`, +宿主均补充 `globalConfiguration`。插件配置只能选用声明的完整 `configuration`,不能自行拼接未知字段。 +插件预览包含 `configuration`、`source`、`product`、`version`、`build`、`target`、`userAgent`、`attributes`、 +`verifiedAt` 和 `release`;未知资料保持缺失,不代表身份已经核验。插件开发合同见 +[请求画像目录](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#请求画像目录)。 + ### OpenAI 上游客户端身份 `openaiClientProfile` 保存通用选择,首次默认 `MacOS · Desktop · 自动最新`。 -设置更新省略该字段保留现值,不能提交 `null`。初始化不读取 YAML 身份字段;内置默认只用于初始化,不形成第三层运行时回退。 +兼容字段的更新语义见上节。初始化不读取 YAML 身份字段。 该配置作用于 Client Key 的 OpenAI 模型请求与原生模型目录,适用于 HTTP/SSE、WebSocket、Images 和 Search。 不改变 xAI、入站客户端版本门禁、账号认证或后台 Desktop 专属操作。 @@ -1212,6 +1329,8 @@ User-Agent 使用 `grok-shell/<版本> (<系统>; <架构>)`,其中 `arm64` 观测在途峰值的 80%(下限 2,只降不升)。这会持久修改账号并发设置;跟随全局默认的账号也会设为独立上限, 解冻后不自动恢复,管理员可手动改回。 +### Windows 客户端下载 + Windows 离线包接口固定解析 Microsoft Store Product ID `9PLM9XGG6VKS` 的 Retail 包,不接受调用方提供 产品 ID、上游地址、ring 或文件名。后端只返回通过包名、架构、Microsoft CDN host/path、scheme 和失效 时间校验的 `x64` / `arm64` MSIX 直链,不代理安装包字节。Store 内容通道返回 HTTP/80 临时地址时保留 @@ -1324,6 +1443,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt | --- | --- | --- | | `GET` | `/api/admin/dashboard/summary` | Dashboard 汇总;支持 `kind`、`startTime`、`endTime` | | `GET` | `/api/admin/dashboard/trend` | Dashboard 趋势;`kind=usage|latency|errors` | +| `GET` | `/api/admin/usage/providers` | 时间范围内请求及错误记录的 Provider ID 去重排序列表 | | `GET` | `/api/admin/usage/records` | 请求记录分页列表 | | `GET` | `/api/admin/usage/records/detail` | 按 `id` 查询请求详情 | | `GET` | `/api/admin/usage/records/summary` | 当前筛选条件的请求汇总 | @@ -1339,6 +1459,11 @@ Dashboard 的 `capacityInfo.maxConcurrentPerAccount` 为默认账号并发上限 request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。 +`/api/admin/usage/providers` 仅接受可选的 `startTime`、`endTime`,返回 `data: string[]`,没有记录时为空数组。 +时间使用 RFC 3339 格式,包含起点、不包含终点;默认终点为当前时间、起点为终点前 7 天,范围须为正且不超过 366 天。 +平台集合包含按开始时间命中的请求、按完成时间命中的请求错误,以及按发生时间命中的运维错误,忽略尚未确定的平台。 +该集合来自历史记录,不受当前平台选择、分页、账号删除或插件停用影响,不表示平台当前可执行或可创建账号。 + 管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。 备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属。 @@ -1446,9 +1571,343 @@ Key 已删除或未关联时为 `null`,不影响记录返回,不包含密钥 执行,结果通过 `/update/status` 的 `operation` 查询:`status` 为 `idle`、`running`、`succeeded` 或 `failed`, 终态包含 `finishedAt`,失败原因在 `error` 中。SSE 的 `operationId` 用于关联进度;终态事件发出前状态已落盘。 连接中断不取消已受理任务;响应丢失时先查询状态,不自动重复提交。打开更新页面时也会恢复最近一次任务。 +下载并解包后,Admin 会以目标发行清单声明的宿主插件合同检查每个启用实例的精确制品;回滚则对备份发行清单 +执行同一检查。两条路径都会在文件交换前后复核同一全局插件配置版本。任一实例不兼容、制品缺失、请求取消或 +配置并发变化都会失败;已交换的二进制、Web 资源和官方插件目录会成组恢复。预检不会自动停用实例、切换插件 +版本或增加权限。 状态响应的 `currentVersion` 表示已安装文件的版本,运行中的版本仍以 `/version` 为准。 `needRestart=true` 表示成功安装的版本尚未在当前进程生效,此时应调用重启接口,不能重复发起更新。 Host 关闭或任务取消会记录失败终态;状态查询会收敛无执行锁的遗留 `running`。 异常退出留下的锁仍遵循 30 分钟过期规则,未过期前不会抢占其他进程的操作。 实例升级和仓库发版见 [部署文档](../deploy/README.md#镜像升级与源码构建)。 + +## 12. 插件管理 + +除明确标注的公开入口外,以下接口需要管理身份。图标读取及 12.2 中标注的原始响应接口直接返回资源、插件响应 +或模型响应,其余接口使用统一管理响应信封。制品按摘要接受其不可变访问域;首次安装某个插件时会尝试创建默认配置, +配置完整则启用,缺少必填项则保留为待配置。操作流程见 [插件使用](plugins.md),开发合同见 +[SDK](../backend/crates/gateway-plugin/sdk/README.md)。不提供官方商店或第三方市场订阅接口。 + +### 安装包与来源 + +| 方法 | 路径 | 行为 | +| --- | --- | --- | +| GET | `/api/admin/plugins/artifacts` | 列出制品的 `metadata`、固定 `source`、`installedAt` 与可为 `null` 的 `acceptedAt` | +| GET | `/api/admin/plugins/artifacts/{sha256}/icon?theme=light\|dark` | 管理身份;按不可变制品摘要读取已校验图标;无图标返回 404 | +| POST | `/api/admin/plugins/artifacts/upload` | 上传原始 tar.gz 包体,最多 32 MiB;查询参数 `sha256` 必须固定已解析的包;接受访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/upload/verify` | 同样接收原始包体,只读解析并校验,返回 `metadata` 与 `source`,不安装 | +| POST | `/api/admin/plugins/artifacts/install` | 从 URL 或固定 GitHub Release 下载、校验,接受访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/verify` | 按远程来源只读解析并校验完整包,返回 `metadata` 与固定 `source`,不持久化或启动插件 | +| POST | `/api/admin/plugins/artifacts/accept` | 严格请求 `{ "sha256": "<小写 SHA-256>" }`,接受已导入制品的访问域并完成安装 | +| POST | `/api/admin/plugins/artifacts/delete` | 请求 `{ "sha256": "<小写 SHA-256>" }`,删除未被引用的制品 | +| POST | `/api/admin/plugins/releases/query` | 查询指定 GitHub 仓库的最新稳定版或明确 tag | +| POST | `/api/admin/plugins/updates/check` | 按插件已保存的来源和策略查询 Release,只读,不下载或切换实例 | +| GET / POST | `/api/admin/plugins/source-credentials` | 列出公开授权范围,或创建独立下载凭据 | +| POST | `/api/admin/plugins/source-credentials/delete` | 请求 `{ "id": "<凭据 ID>" }`,删除未被来源引用的下载凭据 | +| GET / POST | `/api/admin/plugins/update-sources` | 查询或显式改变插件后续安装的允许来源及更新检查策略 | + +删除制品时,同一事务会清理该制品使用过且已无任何制品引用的下载凭据;删除插件的最后一个版本时, +同时清理其来源规则、更新检查策略及出站代理引用。其他版本共享的凭据、代理本身和审计记录保留, +独立创建但未被该制品使用的下载凭据不会被顺带删除。完整卸载后重新安装不受旧来源规则限制。 + +#### 包信息 + +安装包使用的版本与字段规则见 [SDK 清单](../backend/crates/gateway-plugin/sdk/docs/manifest.md)。 +插件 ID 由 `publisher + "." + name` 派生;`displayName` 和可选 `author` 仅用于展示。 + +制品 `metadata` 返回 `pluginId`、`name`、`displayName`、`publisher`、可为 `null` 的 `author`、`version`、 +`description`、`license`、`sha256`、`platforms`、可为 `null` 的 `icon`、`contributes`、`requestedPermissions`、 +`permissionDescriptions`、`configurationSchema`、`secretFields` 和 `stateNamespaces`。`permissionDescriptions` 的每项为 +`{ permission, label, description }`,供安装确认直接展示。`contributes` 是以稳定 snake_case capability 为 key 的对象;每个值包含 +`id`、`version`、`stages`、`inputFormats` 和 `outputFormats`。`id` 是实例绑定引用的完整贡献项 ID,客户端必须通过 +当前制品的 `contributes` 映射确定其 capability,不能解析 ID 文本推测能力。 + +#### 图标读取 + +`metadata.icon` 为清单中的包内路径或 `{ "light": "...", "dark": "..." }` 对象;未声明时为 `null`。 +配置示例、支持格式与文件限制见 [SDK 插件图标](../backend/crates/gateway-plugin/sdk/docs/manifest.md#插件图标)。 + +读取浅色主题图标使用 `GET /api/admin/plugins/artifacts/{sha256}/icon?theme=light`: + +- `sha256` 为制品的 64 位小写十六进制摘要;`theme` 必填,值为 `light` 或 `dark`。单路径图标在两种主题下返回相同内容 +- 成功返回图片原始字节,`Content-Type` 为清单声明的 MIME,不使用 JSON 信封、不转换图片格式。制品不存在或未提供图标时返回 `404` +- 响应包含 `X-Content-Type-Options: nosniff` 和 `Cache-Control: private, max-age=31536000, immutable`;缓存按制品摘要与主题区分 +- SVG 使用 sandbox CSP 隔离,允许内联样式和 data 图片,禁止脚本执行与外部资源请求 + +客户端应将接口地址用于 ``,不内联 SVG,也不把清单路径拼成文件系统路径或公开 URL。 +没有图标时由客户端显示通用图标。 + +#### 解析与确认安装 + +本地包先调用 `artifacts/upload/verify`,远程包先调用 `artifacts/verify`。校验只返回包信息与来源, +不保存、不接受访问域、不启动插件,也不代表实例配置或私有状态已经兼容。 +修改文件、地址、Release、摘要、凭据或代理选择后必须重新校验;确认安装时固定校验返回的身份、版本与摘要。 + +远程解析请求包含 `location`、可选 `credentialIds` 和 `outboundProxyId`,不需要预先提供 ID 或版本。 +更新已有插件时可传 `expectedPluginId` 锁定预期身份;首次安装从包内清单读取身份,并按该身份复核已保存的来源绑定。 +URL 与 GitHub 的 `location.sha256` 在解析时均可省略,由服务端计算实际包摘要;提供摘要时必须通过匹配检查。 + +确认安装时,将解析返回的 `metadata.pluginId`、`metadata.version` 和 `metadata.sha256` 分别填入 +`pluginId`、`version`、`location.sha256`,与原来的来源、凭据和代理选择一起提交 `artifacts/install`。 +这三个字段在远程安装时必填,确保实际安装内容与确认的包相同。清单 ID、版本、宿主兼容范围与平台 +必须通过校验;来源类型、地址或代理选择变化返回冲突,需要先显式修改更新来源。相同包及来源重复安装为幂等操作; +同一插件版本的平台产物不允许替换成不同内容。安装新版本保留已安装旧版本。 + +```json +{ + "pluginId": "acme.example", + "version": "1.0.0", + "credentialIds": [], + "outboundProxyId": null, + "location": { + "kind": "url", + "url": "https://downloads.example.org/plugin.tar.gz", + "sha256": "<64 位小写十六进制摘要>" + } +} +``` + +上传、远程安装和 `artifacts/accept` 成功响应均为平铺对象: + +```json +{ + "artifact": { + "metadata": {}, + "source": { "kind": "upload" }, + "installedAt": "2026-09-22T00:00:00Z", + "acceptedAt": "2026-09-22T00:00:01Z" + }, + "configRevision": 2, + "defaultInstanceId": "<实例 UUID 或 null>", + "configurationRequired": false +} +``` + +接受制品时,若该插件没有任何实例,服务端使用稳定的 creation ID 创建一份默认实例:普通配置取 schema 默认值, +敏感字段不复制默认值;除 `management`、`command_line` 和 `frontend_authentication` 外,为声明的贡献项及阶段建立 +空范围默认 binding。仅缺少必填项时 `configurationRequired=true` 且实例保持停用;配置完整时实例直接启用。 +非法 schema、类型错误或把敏感字段放入普通配置会拒绝安装。已有该插件实例时不额外创建或切换实例, +`defaultInstanceId=null`。相同摘要的接受和默认实例创建可重试,不产生副本。 + +宿主发行目录导入的制品只写入不可变包和来源,`acceptedAt=null`;在严格复核 +`permissionDescriptions` 后调用 `artifacts/accept`。未接受制品不能用于创建、更新或启用实例。 + +GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`tag`、`asset`、 +`allow_prerelease` 和 `sha256`。解析时优先使用显式摘要或 GitHub 的 SHA-256,缺失时读取同一 Release 的 +`checksums.txt`;都未提供时计算下载内容的摘要供确认安装使用。显式摘要与 GitHub 摘要冲突时拒绝下载。 +摘要用于内容一致性检查,不证明发布者身份,只应安装可信来源的插件。 + +#### GitHub Release 查询 + +查询请求为 `{ "query": { "repository": "owner/repo", "tag": null, "allowPrerelease": false }, "credentialIds": [], "outboundProxyId": null }`。 +`tag: null` 查询最新稳定版;预发行版须指定 tag 并允许预发行。响应包含固定 tag、产物列表、`queriedAt` +和 `expiresAt`。成功缓存 1 小时,失败缓存 30 秒,同一查询合并并发;限流返回错误,不转换成空列表。 + +#### 下载认证与代理 + +下载凭据创建字段为 `name`、`origin`、`pathPrefix`、`purposes`、`authentication`。 +`purposes` 可选 `metadata`、`artifact`;`authentication.kind` 可选 `github`、`bearer`、`basic`、`header`, +分别携带 `token`、`token`、`username/password`、`name/value`。列表与创建响应只返回 ID 和授权范围。 +每次重定向都按 origin、路径段边界和用途重新匹配凭据,重叠授权拒绝执行。 +来源 URL 使用 HTTPS,不包含用户信息、查询参数或片段;回环地址允许 HTTP,供本机来源使用。 + +查询、校验和远程安装的 `outboundProxyId` 指向已保存的出站代理,与下载凭据独立,公开来源也可选择代理。 +省略或 `null` 表示直连,不继承进程环境代理;代理失败不会回退直连。查询与下载期间代理配置变化时返回 `409`,需重新操作。 +查询缓存与限流按凭据及代理身份隔离。制品历史 `source.outbound_proxy` 只保存下载时的 `{ id, revision }`,不返回代理地址或认证信息。 +仍被更新来源或制品引用的代理不能删除;修改后续来源不会清除已安装制品的历史引用。 + +#### 更新来源与检查 + +修改更新来源请求为 `{ "pluginId": "acme.example", "source": { "kind": "github", "repository": "owner/repo" }, "policy": { "kind": "stable" }, "outboundProxyId": null }`; +其他可选来源为 `upload` 或带 `url` 的 `url`。`policy` 省略时为 `manual`;GitHub 还支持 `stable`(最新稳定版)和 +`{ "kind": "pinned", "tag": "v2.0.0-rc.1", "allow_prerelease": true }`。其他来源只支持 `manual`。 +来源、策略和代理选择一同持久化与审计;代理字段省略或 `null` 明确保存为直连。`upload` 和 `builtin` 不接受代理。 +变更不改变已安装包的历史来源,不自动下载、接受制品或切换实例版本。 + +检查更新请求为 `{ "pluginId": "acme.example", "credentialIds": [] }`,返回 `binding` 和 `release`。 +服务端使用已保存的来源、策略和代理,不接受临时替换仓库或代理;查询期间来源变更返回 `409`,不可访问或限流仍返回错误。 +`release` 仅是来源元数据,不表示插件包已通过身份、版本、平台、兼容范围或摘要校验;选择产物后仍须显式调用安装接口, +安装成功也不会自动修改现有实例的固定制品。手动策略和不支持 Release 查询的来源返回明确错误,不伪装为没有更新。 + +### 12.1 运行实例 + +| 方法 | 路径 | 行为 | +| --- | --- | --- | +| GET | `/api/admin/plugins/instances` | 列出实例配置、功能绑定、配置完整性及发布状态,不返回 secret 值 | +| POST | `/api/admin/plugins/instances` | 创建实例,返回 `id` 和 `configRevision` | +| POST | `/api/admin/plugins/instances/update` | 请求 `{ "id": "<实例 ID>", "instance": { ...配置字段 } }`,整体更新实例 | +| GET | `/api/admin/plugins/instances/version-plan?id=<实例 ID>&artifactSha256=<目标摘要>` | 只读生成版本设置草稿,不启动插件、不返回密钥值 | +| POST | `/api/admin/plugins/instances/switch-version` | 请求 `{ "id": "<实例 ID>", "target": { "artifactSha256": "<已安装目标摘要>", "expectedRevision": 1 } }`,准备后切换版本 | +| GET | `/api/admin/plugins/instances/rollback-plan?id=<实例 ID>` | 只读列出当前版本、实例 revision 与按语义版本降序排列的已安装、已接受且有配置快照的旧版候选,不启动插件 | +| POST | `/api/admin/plugins/instances/rollback` | 请求 `{ "id": "<实例 ID>", "target": { "artifactSha256": "<已安装且已接受旧版摘要>", "expectedRevision": 1 } }`,校验后回滚 | +| POST | `/api/admin/plugins/instances/disable` | 请求 `{ "id": "<实例 ID>" }`,停用实例并发布新集合 | +| POST | `/api/admin/plugins/instances/delete` | 请求 `{ "id": "<实例 ID>" }`,删除已停用实例 | + +#### 创建与更新 + +创建与更新的配置字段为 `name`、`artifactSha256`、`enabled`、`configuration` 和必填的 `bindings`, +`secrets` 可选。输入严格拒绝未知字段;`trustedProcess` 和 `grants` 不是输入字段。`artifactSha256` 必须指向 +已接受的制品,其访问域由制品声明精确派生,实例不能增加或删减。`configuration` 是匹配插件 schema 的 JSON 对象;敏感字段必须 +放入 `secrets`,空对象清除全部值。省略时同版本编辑保留当前值;跨版本编辑优先保留目标版本快照的密钥,没有快照则保留当前值。 + +创建可传 `creationId`(标准小写 UUID),同一草稿重试复用该 ID,已保存且内容不同则返回 409,不创建副本或覆盖旧配置。 +相同内容的重试仍会重新准备并发布,不保证配置 revision 不变。更新可传 `expectedRevision`,与实例列表的 `revision` +不一致时返回 409,管理员应重新加载后确认。创建不能传 `expectedRevision`,更新不能传 `creationId`。 +删除实例会一并删除其 secret、私有状态与版本配置快照,停用则保留这些数据。 + +保存启用配置时,可传 `replaceInstances: [{ "id": "<旧配置 ID>", "expectedRevision": 1 }]`, +明确确认同时停用的同插件配置(最多 256 项,不得包含当前配置或重复 ID)。同一插件最多启用一个实例,省略且已有其他启用配置时返回 409。 +不能将已有实例切换成另一个插件。 +目标配置准备通过后,停用旧配置与保存目标在同一事务提交,旧配置及其密钥、私有状态保留。 +待停用配置必须仍启用且 revision 与确认时一致,否则返回 409,重新读取并确认后再提交。 + +列表项包含 `id`、`name`、`artifactSha256`、`enabled`、`configurationRequired`、`configuration`、 +`secretFields`、`bindings`、`revision`、`running`、`publishedRevision` 和 `runtime`。`configurationRequired` +由当前制品 schema 与已保存的普通/敏感配置实时派生,不写入数据库;它只表示仍缺少必填值。停用实例也不能保存 +类型错误、非法 schema 或把敏感字段混入普通配置。 + +#### 版本切换与私有状态 + +私有状态通过 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#私有状态)访问,不提供任意读写的管理 HTTP 接口。 +升级若需要迁移,实例可能暂时停用;迁移失败且发生并发配置变更时保持停用,不覆盖管理员的新配置。 +版本切换只接受同一插件已安装且已接受的制品,`expectedRevision` 必须等于实例列表中的 `revision`,过期请求返回 409。 +`version-plan` 返回 `instanceRevision`、`artifactSha256`、`configuration`、`secretFields`、`bindings` 和 `restored`, +最后一项表示使用了目标版本的配置快照。没有快照时只补充缺失的 schema 默认值,保留当前显式值;功能范围按能力与阶段映射到目标声明, +新能力使用默认绑定,已关闭的既有能力继续关闭,客户端认证仍须显式身份映射。草稿不代表已通过校验。 + +`switch-version` 重新生成草稿并执行准备与提交,名称和启停状态保持不变,权限从目标制品派生。不兼容设置返回 400,当前设置不变; +可读取草稿并通过 `instances/update` 携带原 `expectedRevision` 提交修正。不会推断字段改名或丢弃未知参数与密钥。 + +每个实例、制品摘要保存一份最近启用时提交的配置快照,包含普通配置、secret 与 binding,停用草稿不覆盖快照。 +`rollback` 只接受有快照的较早语义版本,恢复该版本设置并重新检查平台、配置与私有状态,保留当前名称与启停状态。 +快照与实例修改共享事务,不从审计日志重建,也不恢复私有业务数据;删除实例或制品时删除对应快照。 + +#### 访问域与受管资源 + +清单的 `requestedPermissions` 只接受五个稳定访问域,确认结果以制品摘要为边界;实例输入没有单独授权字段。 + +| 标识 | 含义 | +| --- | --- | +| `network` | 访问网络 | +| `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | +| `accounts` | 读取和修改账号,包括访问原始凭据 | +| `requests` | 查看和处理请求、响应、路由及账号选择 | +| `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | + +访问域不是 operation、Provider、账号、origin 或命名空间白名单。Runtime 仍按当前贡献项、调用阶段、父调用和宿主业务 +端口限制可用方法,不能把一个域借给无关调用。账号资源以每次请求的 `accountId` 查找权威 Provider,不接受插件提供的 +Provider 事实;Client Key 列表只投影 `id`、`name`、`enabled`,不返回前缀、明文、账号绑定或额度策略。模型调用须显式 +选择 Key ID,并继续经过该 Key 的当前准入、范围、预算、用量与计费合同。私有状态只能访问清单声明的命名空间。 + +受管 HTTP 还会执行宿主网络策略。目标默认只允许常规公网地址;每次请求重新校验全部解析地址,连接使用通过校验的 +固定地址并保留原始 Host 与 TLS 域名校验。托管回调由宿主解析目标域名,HTTP/HTTPS/SOCKS 代理接收固定 IP; +无法由宿主安全解析的目标会被拒绝。代理沿用所选账号配置,不自动回退直连;重定向不会自动跟随,后续地址必须重新校验。 + +#### 能力绑定与范围 + +`bindings` 通过 `contribution` 指定清单已声明的贡献项,并包含该贡献项声明支持的 `stage`、顺序和失败策略; +尚未声明、尚未注册或阶段不匹配的贡献项不能启用。 +首次安装生成的默认实例会为适用贡献项的每个声明阶段创建 binding,`order=0`,观察阶段使用 `observe`, +其他阶段使用 `reject`,所有范围数组为空。`management`、`command_line` 按清单声明注册,不接受 binding; +`frontend_authentication` 必须由管理员显式配置身份映射,也不自动创建。 + +请求终态观察的绑定格式如下;同一实例同时订阅 `request_lifecycle` 和 `usage` 时使用相同 `order`, +宿主合并为一次调用,仅附带命中订阅的事实。 + +```json +{ + "contribution": "acme.usage-observer.usage", + "stage": "observation", + "order": 10, + "failurePolicy": "observe", + "clientKeyIds": [], + "accountGroupIds": [], + "providerIds": [], + "models": [] +} +``` + +范围数组缺省或为空表示不限制该维度;同一数组中的条件取并集,不同维度取交集,全部为空表示全局。 +`clientKeyIds` 和 `accountGroupIds` 引用已有资源 ID,保存启用实例时校验存在性;引用被删除后仍可停用实例, +保留原绑定供管理员修订,重新启用前必须修正引用。账号组范围使用请求开始时 +Client Key 已冻结的绑定组(含绑定但禁用的组),不按最终选中账号重新解释;全账号 Key 没有绑定组。 +Provider 范围匹配最终实际尝试的 Provider;未进入 Provider 的拒绝不会命中有限 Provider 范围。 +模型范围匹配客户端请求的公开模型 ID。配置发布不改变在途请求的匹配范围,同一实例不会因命中多个组而重复调用。 + +`frontend_authentication` binding 只能使用 `authentication` 阶段、`reject` 或 `delegate` 失败策略,并通过 +`identityBindings: [{ "principal": "...", "clientKeyId": "..." }]` 明确映射外部身份;其他范围数组必须为空。 +普通 binding 不能携带 `identityBindings`。已停用、删除或不再授权的 Key 不会因映射恢复访问。 + +实例不为管理页面或命令行预绑定模型执行 Key。管理页面经 12.2 的模型桥在每次调用时显式提交 `clientKeyId`; +CLI 插件也须在具体调用中选择当前 Key。管理员身份本身不提升为推理身份,Key 明文不会发送给插件。 + +观察失败只记录诊断,不改变响应、路由、标准 Usage 或账单。当前仅派发一次终态,过载直接丢弃, +不承诺进程崩溃后的持久投递;这不是请求前拦截、流逐帧观察或账单重算接口。 +尚未通过重新鉴权、未建立冻结请求上下文的失败不触发当前终态观察。 + +#### 发布状态 + +启用或修改时先准备候选进程及目录,再提交和发布;失败不替换正在运行的集合。 +切换后在途请求保留原版本,其引用排空后回收旧进程。 + +| 字段 | 含义 | +| --- | --- | +| `enabled` | 期望启用状态 | +| `publishedRevision` | 当前已发布的全局配置版本 | +| `running` | 实例已启用且发布版本与持久化配置一致;不代表进程存活或外部服务健康 | + +### 12.2 管理扩展页面与原始 API + +`GET /api/admin/plugins/extensions` 需要管理身份,返回统一信封,`data` 为当前已发布的扩展数组。 +每项包含 `target: { instanceId, artifactSha256, revision }`、`name`、`configurationSchema`、 +`pages`、`routes`、`resources` 和 `callbacks`。schema 不是实例配置,不包含 secret 值。 +页面目录的 `pages[].title` 与 `pages[].description` 分别用于宿主页面标题、副标题;未声明副标题时为 `null`。 +目录来自冻结的插件注册结果,并与当前持久化实例复验;停用、换包或修改实例后旧目录项立即不再可用。 + +以下用 `{target}` 表示 `{instanceId}/{artifactSha256}/{revision}`,`{path}` 表示包内已声明的相对路径: + +| 方法 | 路径 | 身份与响应 | +| --- | --- | --- | +| 已注册方法 | `/api/admin/plugins/extensions/{target}/api/{path}` | 管理身份;插件原始响应,不套管理信封 | +| GET | `/api/admin/plugins/extensions/{target}/resources/{path}` | 管理身份;已校验的静态字节 | +| POST | `/api/admin/plugins/extensions/{target}/models/responses?clientKeyId=` | 管理身份;原生 Responses JSON 或 SSE,不套管理信封 | +| POST | `/api/admin/plugins/extensions/{target}/callback-tickets` | 管理身份;统一信封中的一次性回调票据 | +| GET | `/plugins/resources/{target}/{path}` | 无须登录,但资源须声明公开且制品已接受 `public_endpoints` 访问域 | +| GET | `/plugins/callbacks/{target}/{path}` | 无须登录,但必须携带有效且未消费的 `state` 票据 | + +实例目标失效返回 409,调用方应刷新扩展目录,不重放原操作。尚无可用发布视图时返回 503。 +所有入口都要求当前实例仍启用、制品已接受且版本匹配;公开资源与回调还要求制品声明 `public_endpoints`。 +公开入口不是读取任意包文件的路径。 +单项静态资源最多 1 MiB,总量最多 8 MiB;资源必须同时出现在制品清单和注册结果中。 + +管理 API 只转交方法、相对路径、查询字符串、Content-Type、宿主请求关联与原始正文,不转交管理 Cookie +或任意认证头。仅接受注册的方法、路径和内容类型;查询最多 8 KiB,正文最多 1 MiB 且受运行帧预算约束, +GET/HEAD 不接受正文。响应状态码为 200–599,内容类型须在注册列表内,204/304 不允许正文;插件不能 +自选 Set-Cookie、Location 或其他响应头。失败可能发生在副作用之后,不提供自动重试。 + +动态 API 与登录回调设置 `Cache-Control: no-store`。静态资源成功响应设置 `private, no-cache` 与内容 ETag, +浏览器可保存正文,但每次复用前必须重新校验管理员身份(公开资源除外)、实例版本和资源授权; +`If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。 +两类响应均设置 nosniff、no-referrer 与限制性 CSP。管理端页面使用隔离 iframe, +仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。 +页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见 +[SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理-api-与页面)。 + +模型桥只接受严格的 `clientKeyId` 查询参数和 Responses JSON 正文;传输正文与解压后的正文均最多 8 MiB。 +服务端先复核固定目标与 `models` 访问域,再按 ID 查询当前 Client Key,并使用普通 Responses HTTP/SSE 执行链; +因此仍执行当前 Key 的准入、账号范围、预算、用量、计费与请求插件链,包括发起页面所属插件自己的适用 hook。 +这不是嵌套的宿主模型回调,管理身份本身也不提供推理身份,Key 明文不会离开宿主。 + +等待首帧、非流式生成和流式正文交付期间约每秒复核一次固定目标与 `models` 访问域。实例停用、制品或 revision +变化以及访问资格失效会取消底层执行并停止响应。成功或协议错误沿用 Responses 内容类型,并返回可检索的 +`x-request-id` 与 `x-gateway-request-id`;响应移除 Cookie 和认证头并设置 nosniff、no-referrer 与限制性 CSP。 +内置页面桥最多并发 4 个模型请求,每次拉取最多 64 KiB,总期限 10 分钟;页面在已收到响应后 30 秒不继续拉取会取消, +等待首个响应本身不受该空闲计时误杀。 + +票据请求为 `{ "path": "oauth", "ttlSeconds": 60 }`,有效期只允许 1–600 秒;响应数据为 +`{ "state": "<一次性不透明票据>", "expiresAtMs": 0 }`。state 绑定签发管理身份、实例、制品、版本与路径, +不得记录或截图。公开回调只接受无正文的 GET,查询参数必须恰好包含一个 `state`;过期、错路径、跨实例、 +并发重复或已消费票据均拒绝。宿主在执行前原子消费票据,因此超时或插件失败后不能重放。 +公开调用使用无宿主回调权限的独立阶段,不能读取或保存账号、读写状态、发送受管 HTTP 或日志。 +票据用于受控接收第三方返回结果,不代替敏感管理操作的身份校验。 diff --git a/docs/architecture.md b/docs/architecture.md index 628468fdb..2fb9eb939 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,14 +4,21 @@ 具体 HTTP 字段见 [接口文档](api.md),部署参数见 [部署文档](../deploy/README.md);上游 URL、超时、 重试间隔和 UI 布局属于源码或配置,不在架构文档重复维护。 +| 阅读目标 | 章节 | +| --- | --- | +| 了解模块与插件边界 | [运行拓扑](#2-运行拓扑)、[Workspace](#3-workspace-边界)、[插件扩展](#31-插件扩展) | +| 跟踪一次请求 | [请求生命周期](#4-数据面请求生命周期)、[协议边界](#5-provider-与协议边界)、[路由与结算](#6-路由账号范围与-continuation) | +| 修改配置与持久化 | [控制面](#7-控制面与-revision)、[状态所有权](#8-状态所有权) | +| 维护运行行为 | [凭据与额度](#9-credential额度与主动重置)、[观测与任务](#10-观测与后台任务)、[生命周期](#11-生命周期安全与恢复) | + ## 1. 系统定位 Codex Proxy RS 是单进程、单副本运行的多 Provider AI 网关,同时提供: - 面向客户端的 OpenAI Responses、Images、standalone Search 和模型目录协议; - 面向管理员的 `/api/admin/*` 控制面和 Vue 管理端; -- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口、独立 `/key-usage` 页面,以及 Bearer 鉴权的 `/v1/usage` 额度查询; -- OpenAI 与 xAI 两个编译期 Provider; +- 面向 Key 持有者的 `/api/key-usage/*` 用量与客户端配置接口、独立 `/key-usage` 页面,以及复用数据面认证的 `/v1/usage` 额度查询; +- OpenAI 与 xAI 两个编译期 Provider,以及插件提供的 Provider、认证、中间件和管理扩展; - PostgreSQL 持久化、Redis 协调状态以及 S3/R2 数据库备份。 系统不提供 `/v1/chat/completions`,不存在 Provider Instance 层,也不支持通过复制应用容器进行多副本 @@ -21,34 +28,27 @@ Codex Proxy RS 是单进程、单副本运行的多 Provider AI 网关,同时 ## 2. 运行拓扑 ```mermaid -flowchart LR - Client[API Client] --> API[gateway-api] - Browser[Vue Admin] --> API - - API --> Core[gateway-core] - API --> Admin[gateway-admin] - +flowchart TB + Client[API 客户端 / 管理端] --> API[gateway-api] + Host[gateway-host] -. 生命周期与后台任务 .-> API + API --> Core[gateway-core
请求执行] + API --> Admin[gateway-admin
管理用例] Core --> Registry[Provider Registry] Admin --> Registry - Registry --> OpenAI[provider-openai] - Registry --> XAI[provider-xai] - OpenAI --> OpenAIUpstream[OpenAI upstream] - XAI --> XAIUpstream[xAI upstream] - + Registry --> Builtin[OpenAI / xAI] + Registry --> Plugins[Plugin Runtime / 插件进程] + Builtin --> Upstream[上游服务] + Plugins --> Upstream Core --> Store[gateway-store] Admin --> Store - OpenAI --> Store - XAI --> Store + Builtin --> Store Store --> PG[(PostgreSQL)] Store --> Redis[(Redis)] Store --> Object[(S3 / R2)] - - Host[gateway-host] -. lifecycle / workers / update .-> API - Host -. lazy client distribution resolver .-> Admin - Host --> StoreLinks[RG-Adguard / Microsoft CDN] ``` -`backend/apps/gateway` 是唯一组合根,按 Host → Store → Provider → Core → Admin → API → Worker 的顺序 +图中表示运行时协作,不是 crate 的直接依赖。`backend/apps/gateway` 是网关的唯一组合根, +按 Host → Store → Provider → Core → Admin → API → Worker 的顺序 初始化具体实现。其余 crate 只暴露自己的配置、端口和 Bundle,不自行定位别的实现。 ## 3. Workspace 边界 @@ -56,15 +56,20 @@ flowchart LR | 路径 | 责任 | | --- | --- | | `backend/apps/gateway` | 读取顶层配置、连接 Bundle、注册 Provider 与 Worker | +| `backend/apps/plugin-cli`(包名 `codex-proxy-plugin-cli`) | `cpr-plugin package` 校验作者清单并生成平台元数据、资源摘要与归档;内部仅依赖 SDK,不参与网关运行时加载 | | `gateway-protocol` | 跨层共享的 OpenAI wire contract、SSE 编解码与无业务 owner 的解析事实,不依赖其他 workspace crate | | `gateway-core` | operation、canonical event、请求快照、路由、admission、attempt 协调、交付边界和计量 | | `gateway-admin` | 管理领域、Key 用量查询、Provider/Store 端口、审计语义和备份策略 | | `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider | | `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 | | `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督、系统更新及外部价格源适配 | +| `gateway-plugin/sdk`(包名 `gateway-plugin-sdk`) | 公开插件清单与线协议,独立于网关领域;异步收发通过可选 `io` feature 提供 | +| `gateway-plugin/runtime`(包名 `gateway-plugin-runtime`) | 插件包校验、能力适配、双向 RPC 与发布集合;通过 Host 管理子进程和受管 HTTP | | `providers/openai` | OpenAI OAuth、账号选择、目录、额度、Responses/Images/Search transport | | `providers/xai` | xAI OAuth session、账号选择、目录、额度和 Grok/Responses 转换 | | `frontend` | Vue 管理端与 Key 用量页,仅通过各自身份允许的控制面 API 访问状态 | +| 独立仓库 `codex-proxy-ui` | 管理端与插件页面共用的 Vue 基础组件、纯主题算法和样式,不依赖宿主业务状态、路由或 API | +| 独立仓库 `codex-proxy-plugins` | 官方维护综合示例,依赖公开 SDK,Rust 处理器和已构建页面合成一个可安装包,不随宿主构建发版 | 依赖方向遵守四条规则: @@ -73,10 +78,87 @@ flowchart LR 3. API 只调用 Core/Admin 抽象;Store 只实现端口。 4. 具体实现只在组合根相遇。 +`frontend/` 是独立的 Node 项目,自行管理依赖、pnpm 配置、锁文件和 ESLint;仓库根目录不建立前端 workspace。 +管理端与独立示例仓库分别依赖 `@codex-proxy/ui` 的固定 GitHub 标签,通过锁文件固定实际提交并校验源码归档完整性,不要求同级源码目录。安装与构建许可见 [贡献与审查](../CONTRIBUTING.md#验证)。 +UI 包只公开组件、主题与样式入口;Pinia 持久化、登录、路由和管理请求仍由各自应用持有。 +插件页面把所需 UI、Vue 和 Tailwind CSS 4 样式编译进包内静态资源,通过隔离页面与受限消息桥使用自己的管理接口, +不在运行时借用宿主 Vue 实例或内部模块。组件与主题扩展方式见 [管理端主题](theme.md)。 + +### 3.1 插件扩展 + +`gateway-plugin/` 只是目录分组,包含独立 SDK 和宿主 Runtime,不存在聚合 crate。 +SDK 定义双方通信合同;Runtime 把插件能力接入既有 Core / Admin 端口,不另建请求引擎或持久化体系。 +当前合同为 SDK `0.4.0`、清单 `manifestVersion: 3`、进程协议 `4`;宿主只准备通过当前合同与兼容范围校验的制品。 + +下图表示运行时协作,不表示 crate 直接依赖;具体实现由组合根注入: + +```mermaid +flowchart LR + API["管理端 / API"] --> Admin["Admin
制品接受、配置、绑定"] + Admin -->|持久化| Store["Store
包体、接受事实、配置与私有状态"] + Admin -->|校验与准备| Runtime["Plugin Runtime
包校验、能力适配、RPC"] + Core["Core
请求执行与发布快照"] -->|调用扩展| Runtime + Runtime <-->|SDK 协议| Plugin["插件进程"] + Runtime -->|受管资源| Host["Host
进程、网络、Worker"] + Runtime -->|账号事务端口| Admin + Runtime -->|模型执行端口| Core +``` + +#### 职责与源码入口 + +| 模块 | 负责什么 | 不负责什么 | +| --- | --- | --- | +| [SDK](../backend/crates/gateway-plugin/sdk/README.md) | 清单、调用数据、消息与帧;可选 `io` 会话辅助 | 不依赖其他 workspace crate,不启动宿主 | +| [Runtime](../backend/crates/gateway-plugin/runtime/src/lib.rs) | 校验包与声明、准备能力集合、RPC、访问域执行与资源回收 | 不持久化安装选择,不重新决定账号资格或费用 | +| Core | 发布不可变扩展快照;准入、路由、租约、请求交付与计费 | 不解释插件私有协议与存储格式 | +| Admin / Store | 制品接受与配置用例 / 事务、版本校验、加密存储与审计 | 缓存目录和进程内状态不能替代数据库事实 | +| Host | 下载、受管 HTTP、子进程容量与生命周期、维护任务监督 | 不解释插件业务协议 | +| [Plugin CLI](../backend/apps/plugin-cli/README.md) | 校验作者清单,生成平台包与摘要 | workspace 内仅依赖 SDK,不构建源码、不安装或启用插件 | + +#### 发布与执行边界 + +- **制品接受固定访问域。** 只读校验不持久化;上传、URL 和 GitHub 的确认安装接受精确摘要声明的五个访问域, + 官方发行导入仍须管理员确认。若插件尚无实例,Admin 以稳定 ID 合并 schema 默认值与适用贡献项 binding: + 配置完整则启用,缺少必填项则停用待补;已有实例时不复制配置或切换版本。实例不能增删制品访问域。 +- **一份发布快照。** 数据面、管理页面和维护任务使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 + 新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验。 +- **插件故障按实例隔离。** 恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; + 用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。 + 故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求。 +- **宿主持有业务事实。** 插件返回能力结果,Core 继续决定账号资格、发送状态、标准用量与费用;账号变更由 Admin / Store + 执行版本校验、事务和审计。撤下 Provider 不删除账号或历史记录,失败的观察回调不改变响应和账单。 +- **数据加工走中间件。** `request` 包裹逻辑请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。 + 正文按需读取,透传无需额外读取权限;转换和改写不能绕过 Core 的交付、取消与结算边界。 + 数据面插件通过受管 HTTP 已发送或无法证明未发送时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 + Provider 的 `not_sent` 结果透明重放。 +- **访问域不跨调用。** `network`、`models`、`accounts`、`requests`、`public_endpoints` 只开放对应资源域; + 账号、HTTP、模型、私有状态和日志回调仍绑定有效父调用与阶段。管理页和 CLI 不预绑定 Client Key,模型调用按次 + 选择当前 Key;页面 Responses 桥还复核精确实例目标和 `models` 域,并在等待与交付期间持续撤销检查。 + `frontend_authentication` 必须显式配置 principal 到 Key 的映射;公开登录回调使用一次性票据且不继承宿主回调权限。 +- **版本切换保持数据一致。** 同一插件最多启用一个实例;每个实例与制品摘要保存最近启用时提交的配置、密钥和绑定, + 快照与实例修改共享事务,停用草稿不覆盖恢复点。版本切换优先恢复目标快照,否则合并缺失默认值并保留显式设置。 + 私有状态按实例、schema 与配置版本隔离,写入使用精确记录版本。 + 不兼容升级须停用、排空并完成迁移,再原子提交配置与状态;失败不能覆盖并发修改。回滚也须通过兼容检查, + 续写状态不能静默丢弃或改用其他账号。 + +#### 信任与资源边界 + +插件以与宿主相同的 OS 身份运行;接受制品访问域是管理员确认,不是操作系统沙箱。 +进程、RPC、下载、缓存与回调均有容量和期限限制;Host 提供受管资源,Runtime 负责协议、访问域与调用上下文校验。 +下载来源、凭据与代理按目标匹配,网络操作不占用数据库事务,提交前重新校验来源与代理版本。 + +管理页面使用无同源权限的 sandbox iframe,通过固定目标的宿主桥访问已声明路由; +UI、Vue 与样式随插件打包,不读取宿主内部模块。 +官方插件身份仅来自受信宿主发行物内的封口清单,普通安装不能指定 `builtin`;封口标记不是密码学签名。 +宿主更新与回滚必须通过已启用插件的兼容检查,不能自动接受制品或停用实例来绕过检查。 + +安装到使用见 [插件使用](plugins.md),清单与能力合同见 [SDK](../backend/crates/gateway-plugin/sdk/README.md), +HTTP 字段见 [插件 API](api.md#12-插件管理),更新与数据恢复见 [部署说明](../deploy/README.md#插件兼容与发行目录)。 + 组合根的 workspace architecture tests 冻结成员清单、依赖 DAG、公开模块面、源码纪律以及生产/测试模块 镜像关系;具体行为边界由各 crate 的集成测试维护。 -### 3.1 Rust 模块组织约定 +### 3.2 Rust 模块组织约定 后端采用目录模块的 `mod.rs` 风格;以下规则由组合根的 workspace architecture tests 扫描全部生产源码与 测试模块树: @@ -91,14 +173,14 @@ flowchart LR 不能依靠同一 crate 内可见性掩盖循环边界。 5. 每个 crate 的 `src/` 与 `tests/` 平级,生产源码不承载测试。`tests/` 镜像 `src/` 的模块目录形态:例如 `src/foo/mod.rs` 对应 `tests/foo/mod.rs`,`src/foo/bar.rs` 对应 `tests/foo/bar.rs`。一个生产模块可以没有 - 测试;额外场景测试必须放在最近的生产 owner 目录下。根级 `support` 和冻结的 crate/workspace 架构场景 + 测试;额外场景测试必须放在最近的生产 owner 目录下。根级 `support` 及其辅助模块和冻结的 crate/workspace 架构场景 是明确例外。 较大的模块门面只负责组合和 re-export:账号 Admin HTTP 边界按 `wire`、`credentials`、`handlers`、 `presenter` 划分;Provider 执行按 continuation、stream、failure、observation 和 worker 等职责拆分;xAI 请求转换按 response、tools 与 history 拆分。各子模块之间只使用 owner 内最小可见性。 -### 3.2 `gateway-core` 内部 owner +### 3.3 `gateway-core` 内部 owner `gateway-core` 内共享事实按语义 owner 划分: @@ -149,24 +231,23 @@ Client Key 费用账本独立累计各次 attempt 的实际费用,不能因请 ```mermaid sequenceDiagram - participant C as Client - participant A as API Adapter - participant E as Core Engine + participant C as 客户端 + participant A as API + participant E as Core participant P as Provider - participant S as Store / Ops queue + participant S as Store - C->>A: authenticated request - A->>E: Operation + client context - E->>E: freeze snapshot and compile routing plan - E->>S: enqueue request / attempt observations - E->>S: check Key budget against recorded usage - E->>P: one candidate, one credential, one attempt - P-->>E: cold canonical stream + raw wire - E->>E: enforce send and downstream commit barriers - E-->>A: committed response stream + C->>A: 请求与认证 + A->>E: Operation 与客户端上下文 + E->>E: 冻结策略,生成路由计划 + E->>S: 检查 Key 预算 + E->>P: 一个凭据的一次尝试 + P-->>E: 冷流与原始协议数据 + E->>E: 检查发送与交付边界 + E-->>A: 提交响应流 A-->>C: JSON / SSE / WebSocket - E->>S: settle charge idempotently - E->>S: enqueue terminal observation and metering + E->>S: 幂等费用结算 + E->>S: 异步投递观测与计量 ``` 请求开始时冻结 `RuntimeSnapshot`、Client Key 的账号范围及账号模型政策、模型映射、Codex 客户端最低版本、Provider @@ -214,6 +295,9 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配 - OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone Search 的业务正文按原始字节转发,原生续写额度恢复遵循下述 continuation 例外。 canonical facts 从同一数据旁路提取,用于路由、恢复判断、观测和计费。 + 非流式 Responses 由 API 聚合 wire:终态省略或清空 `output` 时,使用同一响应的 `output_item.done` + 按 `output_index` 还原完整输出;已有非空终态输出不改写。完成项缺失或冲突时在下游提交前拒绝, + 不凭 canonical 增量补造内容,也不让 SSE/WS 转发额外保存整份输出。 - Responses 的业务扩展头保留原始多值字节。API 负责剥离鉴权、账号身份和 HTTP 传输字段, 并提取会话语义;`gateway-protocol` 共享 HTTP 传输与网关链路字段分类。客户端兼容规则集中在 `providers/openai/src/transport/downstream/`:`headers.rs` 管理下游环境头和已提取语义的头部别名, @@ -324,12 +408,13 @@ Vue 普通管理请求的错误提示由 `api/request.ts` 响应拦截器统一 规范化异常保留 `status`、`code`、`requestId` 与 `kind`。页面和 `useAsyncAction` 不重复弹出接口错误; 查询可以保留失败状态与重试入口,本地校验、文件操作、SSE 诊断和成功响应中的业务结果仍归各自 owner。 -API 模块按 `url`、`method`、`data`(POST)或 `params: data`(GET)排列请求配置; -仅在实际调用方需要时提供 `RequestOptions`(`signal`、`timeout`、`silent`),并放在请求配置末尾。取消或已被新查询取代的请求 -不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示,不吞异常,也不跳过会话失效处理。 +取消或已被新查询取代的请求不弹提示;后台轮询、重启探测等显式使用 `silent`,它只关闭提示, +不吞异常,也不跳过会话失效处理。 批量操作的部分成功汇总、不可逆操作的结果未知等必要业务处理先将对应请求静默,再由业务 owner 提供 一次有上下文的反馈;不得为普通失败重新维护一套消息或业务码映射。 +### 登录与查询身份 + 管理员和密钥登录共用 `/api/auth/*`、AuthService、Redis 会话结构和 `cpr_session` Cookie。 登录类型只选择凭据校验方式,权限来自服务端保存的身份。管理入口只接受管理员身份或部署级管理 API Key; AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;Key 会话不能访问管理员页面和管理接口。 @@ -338,8 +423,8 @@ AuthService 每次恢复 Key 会话时重新检查 Key 是否存在且启用;K 管理员会话保存由已加盐密码哈希派生的指纹,每次恢复时与 PostgreSQL 当前密码核对;普通设置变更不影响该绑定。 改密在 AuthService 验证当前密码和新密码策略,Store 以旧哈希条件更新密码并在同一 PostgreSQL 事务记录审计。 事务提交后旧管理员会话的指纹失配,不依赖 Redis 批量删除完成撤销;原始密码及密码哈希不进入 Redis。 -KeyUsageService 从 AuthService 的服务端身份或 Core 的 ClientKeyVerifier 只读校验确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和 -ObservabilityStore 的范围查询;Bearer 查询仅提供当前额度,不执行推理准入或开启窗口。 +KeyUsageService 从 AuthService 的服务端身份或 Core 的入口认证结果确定唯一查询范围,复用 ClientKeyStore 的额度账本投影和 +ObservabilityStore 的范围查询;数据面额度查询不执行推理准入或开启窗口,入口认证和适用的请求中间件仍会执行。 API 只输出各入口所需的字段白名单,不复用管理员的宽响应。 客户端配置通过 ClientKeyStore 显式读取当前会话绑定 Key 的明文,不进入用量响应。 前端 `/key-usage` 独立于管理布局,不挂载管理员菜单或请求管理接口;配置弹窗和 Codex / CCSwitch @@ -450,8 +535,9 @@ HTTP validation ``` 会改变路由快照或安全配置的 mutation 在同一 PostgreSQL 事务中提交业务事实、推进内部 -`config_revision` 并写入脱敏审计。Admin mutation 不要求客户端提交 revision;少数账号/分组响应 -返回已提交的 `configRevision`,不将它当作乐观并发前置条件。 +`config_revision` 并写入脱敏审计。`configRevision` 不作为客户端写入的乐观并发前置条件; +少数账号/分组响应返回它用于标识已提交的配置。插件配置等资源另有 `revision` / `expectedRevision` 检查, +不能与全局配置版本混用。 额度、cooldown、目录 generation、请求统计和自动 credential refresh 属于运行时观测,不推进全局 revision;credential 轮换只推进账号自己的 `credential_revision`。Redis 通知用于缩短收敛延迟, @@ -467,10 +553,11 @@ PostgreSQL 周期对账才是正确性基础。 | --- | --- | --- | | 账号、credential、分组、Client Key、设置、审计、请求与备份记录 | PostgreSQL | 业务持久化事实 | | Client Key 金额窗口与费用事件 | PostgreSQL | 准入与幂等结算的权威账本,独立于请求观测与日志保留策略 | +| 插件包体、安装来源、制品接受事实、实例配置与私有状态 | PostgreSQL | 制品访问域由已接受摘要派生;进程内发布集合由持久化事实构建 | | admission、lease、cooldown、circuit、会话亲和、continuation、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 | | 控制面统一登录会话与登录限流桶 | Redis | AuthService 唯一拥有;保存 Admin / Key 身份、绑定 ID、绝对有效期和计数,不保存原始凭据 | | 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 | -| 重置卡库存与消费结果 | OpenAI upstream | 后端不建立本地卡库存;前端按账号在浏览器会话期间保留最近查询、未决消费幂等键与发送锁 | +| 重置卡库存与消费结果 | 对应 Provider 的上游 | 后端不建立本地卡库存;前端按账号在浏览器会话期间保留最近查询、未决消费幂等键与发送锁 | | Provider 公开模型与官方发布资料 | Provider/runtime cache | 由官方目录或发布源刷新,与 PostgreSQL 中的用户身份选择分别管理 | | Windows 安装包临时直链 | Host 进程内短缓存 | 按需解析、严格校验、到期前丢弃;不写 PostgreSQL/Redis,也不代理包字节 | @@ -490,9 +577,9 @@ PostgreSQL schema 由迁移目录按编号管理。已应用迁移按字节冻 credential 与 quota 是两组独立事实:credential refresh 不等于 quota refresh,额度接口的 401/403 也不能单独证明 refresh token 永久失效。 -- OpenAI 支持 OAuth、AT/RT 与 OAuth JSON,导入识别 camelCase 和官方 `auth.json` 的 snake_case token 字段; - RT-only 导入先换取 AT,AT-only 导入没有 - 自动续期能力。OAuth 身份只从官方 JWT claims 投影,不信任导入文档顶层身份字段。 +- OpenAI 支持 OAuth、AT/RT、PAT 和上游 API Key。OAuth 身份来自官方 JWT claims,PAT 经官方身份接口验证, + 不信任导入文档顶层身份字段。RT-only 导入先换取 AT;AT-only、PAT 与 API Key 不参加 OAuth 自动续期。 + 输入形态和适用操作见 [账号能力与导入](api.md#账号能力导入与-oauth)。 - xAI 使用 OAuth session;API Key 不是受支持的账号 credential。刷新额度时同步查询官方实时订阅, 只把套餐事实写入现有 quota JSON。明确无付费订阅的个人账号显示 Free;查询失败、缺失字段或 团队身份不推断为 Free,订阅查询失败不影响额度观测。 @@ -517,8 +604,10 @@ OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号 ## 10. 观测与后台任务 账号容量预测属于 Admin 的只读派生规则,不参与 quota 权威状态、调度或金额结算。Store 通过专用采样端口 -在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;文档仅由具体 Provider 复用协议 -解析器解释,Admin 按中立的额度事实选择近期进度段预测剩余量,再加本周期已记录用量形成周期总量。 +在同一 SQL 快照内返回截至观测时间的累计数值及有界历史 Provider 文档;原生 Provider 复用协议解析器解释 +文档,插件由 Runtime 本地解释 SDK 声明的版本化额度观测,不在历史查询中调用插件进程。观测通过 Core 原有 +请求结算持久化,账号关联由实际执行上下文确定,窗口身份与归属必须匹配当前额度窗口。Admin 按中立的额度 +事实选择近期进度段预测剩余量,再加本周期已记录用量形成周期总量。 周期以额度重置为边界,重置后累计与样本重新开始。该采样不改变全站完整交付用量口径,不创建第二份 持久化额度状态。部分 Token/美元费用缺失仅提示精度限制,继续按已记录数值估算;对应数值完全不可用时 才不返回该项预测,不按请求数量补齐未知消耗。历史请求完成时间只是额度时间的近似,不承诺严格扣额归因 @@ -579,10 +668,8 @@ worker 复用连接测试探针执行真实上游调用,并按冻结代次处 启动只有在配置、PostgreSQL、Redis、Provider、Core、Admin、API 和 Worker 全部初始化成功后才进入服务。 健康检查综合 Core、Store 与 Worker 状态,但不会把单个 Provider 的业务降级等同于整个进程失活。 -组合根在启动时只把 Host 的 `ClientDistributionResolver` 能力注入 Admin;此时不访问 RG-Adguard,下载 -HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证管理员首次打开客户端下载弹窗时惰性执行, -失败时按架构使用 OpenAI 官方稳定地址。Microsoft Store 内容通道返回的 HTTP/80 临时链接保留原始 scheme, -不会被错误改写到该 host 不保证支持的 HTTPS 虚拟主机。 +客户端下载解析由 Host 的 `ClientDistributionResolver` 实现,组合根注入 Admin;只在管理员请求时访问外部来源, +不阻塞启动。失败时使用官方稳定地址,临时直链不持久化或代理下载,字段与来源规则见 [客户端下载 API](api.md#windows-客户端下载)。 关闭分为两段:先停止接收新连接并 drain HTTP/WS,再取消并等待 Worker;两段各有独立预算,Compose 的 `stop_grace_period` 必须覆盖二者之和。超时后只丢弃仍未落盘的可恢复观测,不执行隐式业务重放。 @@ -606,7 +693,9 @@ HTTP Client 构造失败也不会阻断网关启动。外部解析在已认证 - 真实 secret 不进入普通日志、Debug、fixture 或 audit details;明文只能通过账号导出、Key reveal、 备份设置等明确的敏感 Admin 合同返回。 - OAuth pending flow 使用有期限、带 owner 的一次性 claim;事务成功后才消费,失败释放 claim。 -- 在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新。 +- 在线更新校验 Release host、大小、SHA-256 和归档路径,并只允许同一大版本内更新;更新与回滚的目标发行清单 + 还要通过全部启用插件的静态兼容检查,文件交换前后复核同一插件配置版本,失败或取消时恢复二进制、Web 与 + 官方插件目录。 Host 在受理时持久化任务并转交后台执行,任务持有操作锁与终态写入责任,不依赖 HTTP 请求的生命周期。 状态文件是更新结果的权威来源,SSE 终态在状态落盘后发送;Host 关闭与任务析构都必须收敛状态。 @@ -632,5 +721,14 @@ RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml -- [迁移文档](../backend/migrations/README.md#本地测试库) 配置专用测试库;未设置环境变量时,本地相关测试会跳过。 其他检查与界面验证按 [贡献与审查](../CONTRIBUTING.md#验证) 执行。 +插件 Runtime 的真实子进程与持久化测试使用 `CPR_PLUGIN_TEST_DATABASE_URL` 和 +`CPR_PLUGIN_TEST_REDIS_URL` 指向专用实例,密码及隔离要求与上述 Store 测试一致。 +设置 `CPR_PLUGIN_TEST_LIVE_HTTP=1` 会额外请求 GitHub 公共 HTTPS API,验证受管出站链路;这不代表模型推理验收。 +若测试环境使用 Fake-IP 或私网 DNS,需通过 `CPR_PLUGIN_TEST_LIVE_NETWORK_RANGES` 显式提供逗号分隔的 +CIDR 授权。该选项只用于真实网络测试,默认为空,不改变生产网络策略或其他测试的授权。 + +独立插件包的构建、安装与功能验证说明位于 `codex-proxy-plugins` 仓库的 `examples/request-workbench/README.md`。 +功能测试与性能、隔离和平台实测分别记录,不相互替代。 + 改动使现有说明失真或缺少必要信息时,修订所属文档:用户入口写入根 README,HTTP 合同写入 `docs/api.md`, 部署操作写入 `deploy/README.md`,架构不变量保留在本文。 diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..27824b343 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,121 @@ +# 插件使用 + +插件“制品”是按 SHA-256 固定的代码、版本和访问域;“配置”是运行实例,保存普通设置、敏感设置和功能范围。 +日常使用只维护一个当前配置,同一插件最多启用一个实例。访问域随制品接受,不能在设置中另行增删。 + +```mermaid +flowchart LR + A[选择来源] --> B[校验包并查看访问域] + B --> C[确认安装并接受制品] + C --> D[生成默认配置] + D -->|配置完整| E[启用并发布] + D -->|缺少必填项| F[补充配置后启用] +``` + +宿主支持 SDK `0.4.0`、`manifestVersion: 3` 和进程协议 `4`。包必须同时满足当前平台和宿主兼容范围。 + +## 安装 + +在“插件管理”点击安装图标,选择来源: + +- **上传包**:选择发布者提供的 `tar.gz` 或 `tgz` 包 +- **URL**:填写固定下载地址;支持 HTTPS,本机回环地址可使用 HTTP +- **GitHub**:填写仓库地址或 `owner/repo`;标签留空查询最新稳定版,预发行版须指定标签 + +公开来源无需认证。私有来源可选择或创建独立下载认证;认证按来源和用途限制,不会交给插件进程。 +下载代理独立选择,默认直连,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份。 + +“下一步”只解析和校验包,不保存制品。检查名称、发布者、版本、平台、摘要和访问域后确认安装;上传、URL +和 GitHub 安装会接受该摘要声明的访问域。随宿主发行物导入的官方制品会显示为“待接受”,也必须先查看访问域并确认, +未接受的制品不能创建或编辑配置。相同摘要的确认可安全重试。 + +确认安装后,若该插件还没有任何配置,宿主会: + +1. 从 schema 填入普通配置默认值,不把敏感字段的默认值写入普通配置 +2. 为需要实例范围的贡献项生成默认绑定;管理页面和命令行按声明注册,入口认证不自动绑定 +3. 配置完整时直接启用并发布;仅缺少必填项时保存为停用并打开配置页 + +类型错误、非法 schema 或把敏感值混入普通配置会直接拒绝,不会伪装成“待配置”。更新已有插件时不会创建另一套配置, +管理端在包安装完成后提示确认切换,检查通过后应用目标版本,不兼容时保留原版本并打开待调整的设置。 + +### 五个访问域 + +安装确认只使用以下稳定访问域;具体调用仍受贡献项、当前调用上下文和宿主策略限制。 + +| 标识 | 安装摘要中的含义 | +| --- | --- | +| `network` | 访问网络 | +| `models` | 查询模型和 Client Key 基本信息并调用模型,可能产生消耗 | +| `accounts` | 读取和修改账号,包括访问原始凭据 | +| `requests` | 查看和处理请求、响应、路由及账号选择 | +| `public_endpoints` | 提供无需登录即可访问的资源与回调入口 | + +访问域不是操作列表或资源白名单。实例配置不能扩大、缩小或伪造制品声明;切换版本后使用目标制品已经接受的精确声明。 +插件进程与网关使用相同的系统身份,接受访问域不是操作系统沙箱,因此只安装可信来源的包。 + +## 配置与启用 + +在插件详情中直接启用或停用,设置会保留,无需重新填写。插件没有可设置的参数或认证映射时不显示设置入口。 + +“设置”按当前包的 schema 显示参数,敏感字段单独保存且不回显。首次补齐必填项后“保存并启用”;其他设置修改保持 +原有启停状态。请求处理能力随包自动注册,无需在安装时逐项勾选;有特殊需求时,可在“高级设置”中调整请求作用范围。 +客户端 Key、账号分组、Provider 和模型等范围同时满足才生效,同一范围内任一值匹配即可。 + +范围数组为空表示不限制该维度。自动创建的默认绑定不预先绑定 Client Key;普通网关请求仍以实际请求的 Key 和冻结策略 +执行。管理页面和命令行不保存功能 binding,页面调用模型时由页面每次明确选择一个 Client Key ID,宿主按当前 Key 状态、 +范围、预算、账本和普通请求链执行,插件看不到 Key 明文。 + +`frontend_authentication` 不会随安装自动接管入口。启用它时必须明确配置外部 principal 到既有 Client Key 的映射; +已停用或删除的 Key 不会因此恢复访问。已有多套配置保留在“历史配置”中,选择使用时确认替换当前配置;不再提供新增配置入口, +也不会自动删除历史配置、密钥或私有数据。升级前已经启用多套的插件会提示整理,宿主不会擅自选择停用对象,后续启用或更新 +必须收敛到一套配置。 + +“已启用”表示配置已经发布,不是外部服务的健康证明。“等待生效”表示候选仍在准备;“需要处理”会显示发布或运行失败原因。 +并发编辑冲突时应重新加载当前 revision 后确认,不能覆盖另一窗口的修改。 + +## 使用 + +| 功能 | 使用方式 | +| --- | --- | +| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 | +| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE | +| 请求中间件、模型路由、账号调度、请求观察 | 匹配功能范围的正常网关请求自动使用 | +| Provider 扩展 | 在账号管理中按插件支持的方式添加或授权账号 | +| 入口认证 | 客户端按插件协议访问,宿主把明确的 principal 映射到 Client Key | +| 维护任务 | 宿主按已声明能力和当前访问域调度,不提供没有后端支持的手动入口 | +| 命令行 | 在宿主环境运行 `codex-proxy-rs plugin <配置 ID> --help` | + +页面模型桥要求管理会话、当前实例/制品/revision 和 `models` 访问域同时有效。停用实例、切换版本或 revision、 +撤销相应访问资格后,宿主会在等待首帧和交付响应期间持续复核并取消请求;它不会把管理 Cookie、Key 明文或认证头交给插件。 + +## 更新、回退与卸载 + +- **检查更新**:GitHub 按保存的策略查询发布,URL 下载并解析当前地址的包;本地上传不支持查询。检查本身不会安装,GitHub 标签也不等于包内版本。 +- **更新插件**:选择新包并确认安装,再确认切换到目标版本。保留已填写的值,补充新版本缺失的默认值;字段改名、类型改变或删除等不兼容情况由用户调整,不猜测或静默清空参数与密钥。 +- **切换版本**:在版本列表中选择已安装版本,确认当前版本与目标版本后执行。若该版本已有恢复设置,使用它上次启用时提交的参数、密钥与功能范围;否则从当前设置生成草稿。切换保持启停状态,目标访问域以已接受的包为准。停用的历史配置引用旧版本时显示“历史配置”,不表示该版本正在运行。 +- **回退版本**:只列出同一插件已安装、已接受且保留恢复设置的较早版本,恢复对应设置后重新执行兼容检查。 + +每个精确插件包保留最近一次启用时提交的配置快照,停用期间未完成的草稿不会覆盖恢复点。升级宿主时会为已有启用配置建立 +恢复点,无法补回此前已经丢失的旧版本设置。修改下载来源会先单独保存,取消后续安装也会保留已保存来源。当前浏览器标签页 +记住未提交的安装参数和凭据引用,不保存认证明文或上传文件。 + +设置恢复不等于私有数据恢复。私有数据迁移仍由插件声明的迁移能力处理,不兼容且无迁移路径时不能切换;迁移失败会尝试 +恢复原版本,若恢复会覆盖并发修改则保持停用并提示处理。配置检查或候选准备失败不会覆盖当前设置。 + +宿主重启时逐个恢复已启用插件。不兼容、启动失败的实例显示异常并保留设置,管理员可重新启动、停用或切换版本; +其他插件、原生转发和管理端继续运行。运行中的插件进程退出时,也不会撤销整个宿主的运行配置。 +命中故障插件绑定的请求仍遵循既有拒绝或委托策略;入口认证插件失败不会绕过认证,插件 Provider 不可用时不能选择它执行请求。 + +| 操作 | 删除什么 | 保留什么 | +| --- | --- | --- | +| 停用 | 不删除;新请求不再使用 | 配置、密钥、私有数据和版本 | +| 删除配置 | 该配置及其密钥、私有数据、版本配置快照;无法撤销 | 已安装版本 | +| 删除版本 | 所选制品、对应配置快照及其已无引用的下载认证;最后一个版本还会清理来源规则;被配置或在途调用引用时不能删除 | 其他版本与配置、共享下载认证 | +| 卸载插件 | 确认范围内的配置、数据、版本、来源规则及已无引用的下载认证 | 其他插件仍使用的下载认证、审计记录 | + +卸载不是跨多个请求的事务;失败时会显示已完成结果,可重试剩余步骤,不会扩大到确认后新增的配置。只想暂时停用时不要卸载。 +完整卸载后重新安装可选择新的来源,不再沿用旧来源限制。独立创建但未被删除版本使用的下载认证不会被顺带删除。 +需要保留数据时,删除前按[部署、备份与恢复](../deploy/README.md)保存备份。 + +开发插件见 [SDK](../backend/crates/gateway-plugin/sdk/README.md) 与 [插件 CLI](../backend/apps/plugin-cli/README.md); +完整示例在独立的 `codex-proxy-plugins` 仓库维护。HTTP 字段与限制见 [API 参考](api.md#12-插件管理)。 diff --git a/docs/theme.md b/docs/theme.md index 371b43529..0febe0ab8 100644 --- a/docs/theme.md +++ b/docs/theme.md @@ -54,8 +54,8 @@ | 场景 | 参考入口 | 关注点 | | --- | --- | --- | -| 页面头部 | [BasePageHeader](../frontend/src/components/base/BasePageHeader.vue)、[系统概览](../frontend/src/views/dashboard/components/DashboardContent.vue) | 简短标题、必要的统计范围或状态、就近操作 | -| 内容与指标 | [BaseCard](../frontend/src/components/base/BaseCard.vue)、[用量概览卡片](../frontend/src/views/usage/components/UsageSummaryCards.vue) | 可选说明、紧凑摘要、数值与辅助信息的主次 | +| 页面头部 | `@codex-proxy/ui` 的 `BasePageHeader`、[系统概览](../frontend/src/views/dashboard/components/DashboardContent.vue) | 简短标题、必要的统计范围或状态、就近操作 | +| 内容与指标 | `@codex-proxy/ui` 的 `BaseCard`、[用量概览卡片](../frontend/src/views/usage/components/UsageSummaryCards.vue) | 可选说明、紧凑摘要、数值与辅助信息的主次 | | 表单帮助 | [API Key 账号字段](../frontend/src/views/accounts/components/AccountApiKeyFields.vue) | 直接命名字段,在对应位置提供短提示和示例 | ## 架构概览 @@ -77,24 +77,18 @@ flowchart LR 核心职责保持分离: -| 层 | 文件 | 职责 | +| 层 | 入口 | 职责 | | --- | --- | --- | -| 公开入口 | [`theme/index.ts`](../frontend/src/theme/index.ts) | 只汇总稳定的外部 API,不承载实现 | -| 领域类型 | [`theme/types.ts`](../frontend/src/theme/types.ts) | 唯一类型文件,包含持久化模型、解析结果与内部 Map 契约 | -| 设计输入 | [`theme/core/constants.ts`](../frontend/src/theme/core/constants.ts) | 默认 Seed、预置主题元数据、尺寸与可编辑白名单 | -| 颜色算法 | [`theme/core/color.ts`](../frontend/src/theme/core/color.ts) | Ant Design 色板角色、色调混合、透明度与对比度 | -| Map 派生 | [`theme/derive/colors.ts`](../frontend/src/theme/derive/colors.ts) | Surface、Primary、Semantic、Preset 与数据色 Map | -| Component 派生 | [`theme/derive/components.ts`](../frontend/src/theme/derive/components.ts) | 尺寸、阴影和 Component Token Map | -| 输入边界 | [`theme/core/normalize.ts`](../frontend/src/theme/core/normalize.ts) | 持久化配置与直接覆盖值的规范化 | -| 解析编排 | [`theme/core/resolve.ts`](../frontend/src/theme/core/resolve.ts) | 串联 Seed → Map → Alias → Component | -| Token 编译 | [`theme/core/tokens.ts`](../frontend/src/theme/core/tokens.ts) | 从类型化 Map 和短角色表生成 CSS Variables | -| 浏览器适配 | [`theme/runtime/browser.ts`](../frontend/src/theme/runtime/browser.ts) | 将解析结果提交为根作用域运行时样式表 | +| 基础组件 | `@codex-proxy/ui` | 稳定组件与通用函数,不承载业务状态 | +| 主题算法 | `@codex-proxy/ui/theme` | 规范化配置,派生 Seed → Map → Alias → Component,生成并提交 CSS Variables | +| 样式 | `@codex-proxy/ui/styles.css` | 基线、组件样式与完整工具类样式 | +| Tailwind 合同 | `@codex-proxy/ui/tailwind.css` | 公开 Token 名称,供应用自己的 Tailwind 构建使用 | | 状态 | [`stores/modules/theme.ts`](../frontend/src/stores/modules/theme.ts) | 持久化配置、系统明暗偏好、切换动作与动画 | | 编辑器状态 | [`useThemeEditor.ts`](../frontend/src/views/theme/composables/useThemeEditor.ts) | 草稿、修改计数、恢复与保存 | -| 样式桥接 | [`styles/index.css`](../frontend/src/styles/index.css) | 将 CSS Token 暴露为 Tailwind CSS 4 utilities | -| 样式基线 | [`styles/base.css`](../frontend/src/styles/base.css) | 通过 `@layer base` 提供浏览器基线,通过 `@utility` 提供通用原生滚动条 | -| 静态基元 | [`styles/tokens.css`](../frontend/src/styles/tokens.css) | 只保留白色、透明色和作用域 `color-scheme` | +独立仓库 `codex-proxy-ui` 是管理端和官方插件页面的共享组件源码;组件不得反向依赖管理端 Store、路由或 API。管理端通过 `@codex-proxy/ui` 公开入口消费组件,插件页面把组件和样式编译进自身静态资源,不在运行时借用宿主模块。 +插件页面的标题与副标题由宿主呈现,内容区只渲染业务;主题变化通过宿主桥同步,接入方式见 +[SDK 页面与宿主桥](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#页面与宿主桥)。 `theme/` 根目录只保留公开入口 `index.ts` 和唯一类型文件 `types.ts`;内部实现按 `core/`、`derive/`、`runtime/` 分层,不增加嵌套 barrel。 纯派生模块不访问 DOM,`theme/runtime/browser.ts` 不包含派生规则,Theme Store 不复制算法。 普通 Map 字段按 camelCase → kebab-case 统一生成 `--cp-*`;Semantic 与 Preset Color 仅维护各自的短角色表。 @@ -154,8 +148,7 @@ interface ThemeCustomization { Container 保证 3:1。功能色 Hover/Active 相对中性 Container 保证 3:1,Base 不做该校正。`text` 依据页面、 容器、浮层和交互填充等中性 Surface 校正;`on-container` 单独依据语义 Container 的默认、Hover、Active 三态 校正到至少 4.5:1,避免为了彩色底对比度而削弱中性表面上的颜色辨识度。 -`ensureContrast` 以每步 1% 的黑/白混合寻找满足所有配对的颜色;不能满足全部约束时返回最弱配对表现最好的候选, -因此不承诺任意互相矛盾的自定义前景/背景组合都能达标。 +对比度校正由 UI 库的主题算法统一执行;互相矛盾的自定义前景/背景组合不保证全部达标。 分类、图表与数据强调继续使用 Ant Design Preset Color 的角色结构。Blue、Green、Orange、Red 分别复用 `colorInfo`、`colorSuccess`、`colorWarning`、`colorError`,保证通用彩色与可编辑语义 Seed 同源;没有语义对应的 @@ -178,7 +171,7 @@ Preset 的实心色使用 P6,普通 Container、Strong Container 与边界按 Input、阴影与其他 Component Token 从 Fill、Surface、Primary 和 Semantic 派生,不在常量文件维护整套颜色表。 正常文字同时检查 Layout、Container、Elevated、文字交互背景、三级 Fill,以及控件透明填充在各宿主上的合成色 -和选中 / 选中 Hover 背景(新增配对用于浅色,暗色保持既有中性表面校正)。正文、标题和 Secondary 至少 7:1, +和浅色的选中 / 选中 Hover 背景。正文、标题和 Secondary 至少 7:1, Tertiary 至少 5.5:1,Quaternary 至少 4.5:1;这些是相对全部上述表面的最低目标,对 Container 的实测比值通常更高。 Disabled 保留独立的弱化颜色,不承担正常信息。控件填充由文字 Seed 和透明度派生,placeholder 继续消费 Quaternary;主按钮白字与功能色文字遵循各自的容器配对规则。 @@ -220,48 +213,32 @@ Token 直接覆盖时不会自动重算同组件的其他状态;需要保持 | Scrollbar | `--cp-scrollbar-thumb-bg / hover-bg` | | BrandMark | `--cp-brand-mark-bg` | -主按钮的颜色在 Component 派生入口统一生成:文字保留 `colorTextLightSolid`,默认背景从 Primary Seed 校正到 -4.5:1,Hover/Active 在该背景上分别混入 8% / 16% 黑色,使白字对比度逐级增强;明暗模式共用此规则。 -这组组件状态与全局 `colorPrimaryHover/Active` 分工明确,调整按钮不会反向改写主色 Seed。 -Input、Select、Textarea 与 NumberInput 共用 Input Component Token,普通按钮和图标按钮分别使用 Button Secondary 与 -Icon Button Secondary Token。组件合同统一,明暗配方分别派生:浅色需要改善宿主背景上的辨识度,暗色保留已有的 -Surface 混色与填充层次,不把浅色配方直接套入暗色。暗色输入的聚焦态直接复用原有 Hover 背景与外圈, -浅色两态都使用 Container 背景与半透明主色外圈,保证点击前后不切换形态。 +组件状态由 UI 库的 Component 派生层统一生成,页面不维护混色系数: -浅色填充与 Surface 共用外观距离,色源和透明度同步平滑过渡。中性基线的色源保留 `colorTextBase` 的色相与 -饱和度,明度向白色提升一半;控件常态、按钮 Hover / Active 的透明度为 7.5% / 10.5% / 14%,避免直接叠白后发灰。 -带色温主题直接使用文字 Seed,常态取 Secondary Fill 强度的 78%,按钮 Hover / Active 为 9% / 12%, -保留与 Surface 一致的深浅层级。透明填充随页面、卡片和浮层的宿主背景自然合成。 -输入控件常态保持无边,Hover 与 Focus 均使用 Container 背景(默认浅色为白色)和同色、同宽的 3px 半透明主色 -外圈;两种状态的外圈都不受装饰性阴影强度影响。暗色同样将 Hover 与 Focus 的背景、外圈和装饰阴影统一派生。 +| 角色 | 状态与可读性约束 | +| --- | --- | +| 主按钮 | 白字背景至少达到 4.5:1;Hover / Active 加深,不反向改写 Primary Seed | +| 输入控件 | Input、Select、Textarea、NumberInput 共用 Token;常态无边,Hover 与 Focus 保持相同背景和外圈 | +| 次级操作 | 普通按钮与图标按钮各用自己的 Secondary Token;透明填充与宿主表面合成,`ghost` 无常态底色 | +| 表格 | 表头、斑马纹、Hover 独立派生;固定列使用不透明合成背景,选中行保留 Primary 语义 | +| 错误与焦点 | Hover / Focus 不覆盖错误反馈;焦点外圈不受装饰性阴影强度影响 | +| 浮层头部 | 浅色取 Secondary Fill,深色取 Tertiary Fill;箭头与头部使用同一 Token,不借用表格背景 | -浅色表头、斑马纹、行 Hover 使用独立的内容填充,中性基线透明度为 9.5% / 4.5% / 9%;带色温主题分别复用 -Tertiary / Quaternary / Tertiary 的混色强度(6% / 3.5% / 6%),让斑马纹可辨、Hover 再深一档。 -这些值先与 Container 合成为不透明背景,避免固定列透出滚动内容。暗色表头、斑马纹和行 Hover 继续分别消费 -Tertiary Fill、Quaternary Fill 和 Text Hover,保持已有颜色。 -表格数据单元格的背景统一使用 150ms 淡入淡出,覆盖普通行、斑马纹和选中行,固定列同步过渡;系统偏好减少动态效果时关闭。 - -选中行使用 Primary Container;浅色选中行 Hover 使用 Primary Container Hover,暗色仅混入 4% 正文色以轻微提亮, -保留选中语义。错误状态使用 Error Container 与独立外圈,Hover 或 Focus 不覆盖错误反馈;`ghost` 延续无常态底色的 -轻操作角色。输入焦点外圈不乘以 `shadowStrength`,关闭装饰性阴影不会移除焦点反馈。 - -结构化浮层头部统一使用 `--cp-popover-header-bg`:浅色从 Secondary Fill 派生, -深色从 Tertiary Fill 派生。健康时间线朝向头部的箭头也使用该值; -不要把浮层头部重新绑定到表格斑马纹或普通悬停背景。该 Token 可在主题编辑器中单独调整。 +明暗模式分别派生,但共用组件合同。表格背景过渡覆盖普通行、选中行和固定列,尊重减少动态效果偏好。 Theme Editor 只开放真正由对应组件消费的 Component Token。全局 Alias 不放进组件目录,避免一次覆盖同时改变 多个无关组件。 应用内品牌图标使用 [`AppBrandMark.vue`](../frontend/src/components/AppBrandMark.vue):浅色模式取 `colorBgSpotlight`,深色模式取 `colorBgElevated`,保持中性暗面与白色图形;浏览器 favicon 继续使用固定黑白 -图标,不随主题改变。 +图标,不随主题改变,由 `frontend/public/favicon.svg` 提供,页面通过 `/favicon.svg` 引用。 ### 通用颜色消费 主题层禁止声明账号套餐、推理类型或具体页面名称。业务组件只能选择通用 Preset Color 角色,例如 Cyan Container、 Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板统一派生。`styles/tokens.css` 只保留白色、透明色与 作用域 `color-scheme`,不保存可换肤值或业务标识色。账户活动热力图以 Success Container 为起点、Success Solid -为终点,按 22% / 46% / 70% 生成中间密度;浅色与暗色使用同一规则,因此不会在浅色背景退化成近白方块。 +为终点生成中间密度;浅色与暗色使用同一规则。 图表数据系列也只引用通用 Preset Color Token。 ## 预置主题 @@ -386,12 +363,12 @@ Input、Button Secondary 与 Icon Button Secondary 的三个背景 Token 支持 仅当值需要在 CSS 函数、SVG 或局部派生中参与计算时,直接读取 `var(--cp-*)`。不在页面组件中重新实现色阶、 对比度或明暗算法。 -`@theme inline` 只注册 Tailwind 名称,不保存主题值。注册表按基础、排版、颜色、圆角、阴影、间距与尺寸排序; +共享包的 `styles/tailwind.css` 是唯一 `@theme inline` 注册表,只注册 Tailwind 名称,不保存主题值。注册表按基础、排版、颜色、圆角、阴影、间距与尺寸排序; 颜色再按基元、表面、主色与链接、语义、预设、数据、组件分组。 Preset 家族按字母排序,每个家族固定使用 `container → container-strong → border → solid → text → on-container`。主题值由 `initializeTheme()` 在 Vue 挂载前动态生成并提交,不增加 `theme:generate`、`theme:check` 或构建期快照。 -全局元素基线统一放进 `styles/base.css` 的 `@layer base`,确保组件 utility 可以按 Tailwind 层级正常覆盖;可复用的 +全局元素基线统一放进共享包 `styles/base.css` 的 `@layer base`,确保组件 utility 可以按 Tailwind 层级正常覆盖;可复用的 原生滚动条声明使用 Tailwind CSS 4 `@utility`。组件内能等价表达的简单 SVG、渐变、原生外观与伪元素优先使用 utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文本 `:deep()`、复杂纹理与关键帧继续保留局部 ` diff --git a/frontend/src/components/base/BaseConfirmModal.vue b/frontend/src/components/base/BaseConfirmModal.vue deleted file mode 100644 index 12b8c81dc..000000000 --- a/frontend/src/components/base/BaseConfirmModal.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseEmpty.vue b/frontend/src/components/base/BaseEmpty.vue deleted file mode 100644 index 592b4eebf..000000000 --- a/frontend/src/components/base/BaseEmpty.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseForm/FormItem.vue b/frontend/src/components/base/BaseForm/FormItem.vue deleted file mode 100644 index 655200407..000000000 --- a/frontend/src/components/base/BaseForm/FormItem.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseForm/context.ts b/frontend/src/components/base/BaseForm/context.ts deleted file mode 100644 index ca102ed87..000000000 --- a/frontend/src/components/base/BaseForm/context.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ComputedRef, InjectionKey } from 'vue' - -interface FormFieldContext { - controlId: ComputedRef - describedBy: ComputedRef - invalid: ComputedRef - required: ComputedRef -} - -export const formFieldKey: InjectionKey = Symbol('form-field') diff --git a/frontend/src/components/base/BaseForm/index.vue b/frontend/src/components/base/BaseForm/index.vue deleted file mode 100644 index 5f3ca31ed..000000000 --- a/frontend/src/components/base/BaseForm/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/frontend/src/components/base/BaseIconButton.vue b/frontend/src/components/base/BaseIconButton.vue deleted file mode 100644 index 5ca5a6616..000000000 --- a/frontend/src/components/base/BaseIconButton.vue +++ /dev/null @@ -1,91 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseInput.vue b/frontend/src/components/base/BaseInput.vue deleted file mode 100644 index 75faff77a..000000000 --- a/frontend/src/components/base/BaseInput.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - - diff --git a/frontend/src/components/base/BaseMarkdown/index.vue b/frontend/src/components/base/BaseMarkdown/index.vue deleted file mode 100644 index af8536ab2..000000000 --- a/frontend/src/components/base/BaseMarkdown/index.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseMarkdown/markdown.css b/frontend/src/components/base/BaseMarkdown/markdown.css deleted file mode 100644 index c028f4a31..000000000 --- a/frontend/src/components/base/BaseMarkdown/markdown.css +++ /dev/null @@ -1,102 +0,0 @@ -.markdown-body { - color: var(--cp-color-text); - font-family: var(--font-sans); - font-size: var(--cp-font-size-sm); - font-weight: var(--font-weight-emphasis); - line-height: 1.65; - overflow-wrap: anywhere; -} - -.markdown-body * { - box-sizing: border-box; - max-width: 100%; -} - -.markdown-body > :first-child { - margin-top: 0; -} - -.markdown-body > :last-child { - margin-bottom: 0; -} - -.markdown-body h1, -.markdown-body h2, -.markdown-body h3, -.markdown-body h4, -.markdown-body h5, -.markdown-body h6 { - margin: 12px 0 6px; - color: var(--cp-color-text); - font-size: inherit; - font-weight: var(--font-weight-heavy); - line-height: 1.4; -} - -.markdown-body p { - margin: 0 0 8px; -} - -.markdown-body ul, -.markdown-body ol { - margin: 0 0 8px; - padding-left: 1.5em; -} - -.markdown-body ul { - list-style-type: disc; -} - -.markdown-body ol { - list-style-type: decimal; -} - -.markdown-body li { - margin: 3px 0; -} - -.markdown-body a { - color: var(--cp-color-link); - font-weight: var(--font-weight-heavy); - text-decoration: none; -} - -.markdown-body a:hover { - color: var(--cp-color-link-hover); - text-decoration: underline; -} - -.markdown-body a:focus-visible { - border-radius: var(--cp-border-radius-sm); - outline: 2px solid var(--cp-control-outline); - outline-offset: 2px; -} - -.markdown-body code { - border-radius: var(--cp-border-radius-sm); - background: var(--cp-color-fill-tertiary); - color: var(--cp-color-text); - font-family: var(--font-mono); - font-size: var(--cp-font-size-xs); - padding: 1px 5px; -} - -.markdown-body pre { - margin: 8px 0; - overflow-x: auto; - border-radius: var(--cp-border-radius); - background: var(--cp-color-bg-container); - padding: 8px 10px; -} - -.markdown-body pre code { - background: transparent; - padding: 0; -} - -.markdown-body blockquote { - margin: 8px 0; - border-left: 3px solid var(--cp-color-split); - color: var(--cp-color-text-secondary); - padding-left: 10px; -} diff --git a/frontend/src/components/base/BaseMarkdown/markdown.ts b/frontend/src/components/base/BaseMarkdown/markdown.ts deleted file mode 100644 index 160179c1d..000000000 --- a/frontend/src/components/base/BaseMarkdown/markdown.ts +++ /dev/null @@ -1,22 +0,0 @@ -import DOMPurify from 'dompurify' -import { Marked } from 'marked' - -const marked = new Marked({ - async: false, - breaks: false, - gfm: true, -}) - -export function renderMarkdown(source?: string | null) { - const value = source?.trim() - if (!value) - return '' - - const html = marked - .parse(value, { async: false }) - .replaceAll('
-import { LoaderCircle } from '@lucide/vue' -import { computed } from 'vue' - -const props = withDefaults( - defineProps<{ - tone?: 'neutral' | 'destructive' - disabled?: boolean - loading?: boolean - }>(), - { - tone: 'neutral', - disabled: false, - loading: false, - }, -) - -defineSlots<{ - icon?: () => unknown - loading?: () => unknown - default: () => unknown -}>() - -const classes = computed(() => [ - 'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-sm border-0 bg-transparent px-3 text-left text-cp leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none', - 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-control-outline', - 'disabled:cursor-not-allowed disabled:text-cp-text-disabled', - props.tone === 'destructive' - ? 'text-cp-error-text hover:bg-cp-error-container-hover hover:text-cp-error-on-container' - : 'text-cp-text hover:bg-cp-bg-text-hover', -]) - - - diff --git a/frontend/src/components/base/BaseModal/bodyScrollLock.ts b/frontend/src/components/base/BaseModal/bodyScrollLock.ts deleted file mode 100644 index 4c803100d..000000000 --- a/frontend/src/components/base/BaseModal/bodyScrollLock.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useScrollLock } from '@vueuse/core' - -let bodyScrollLocked: ReturnType | undefined -let lockCount = 0 - -function getBodyScrollLock() { - if (typeof document === 'undefined') - return undefined - - bodyScrollLocked ??= useScrollLock(document.body) - return bodyScrollLocked -} - -export function lockBodyScroll() { - const scrollLock = getBodyScrollLock() - if (!scrollLock) - return - - lockCount += 1 - if (lockCount === 1) - scrollLock.value = true -} - -export function unlockBodyScroll() { - if (!bodyScrollLocked) - return - - lockCount = Math.max(0, lockCount - 1) - if (lockCount === 0) - bodyScrollLocked.value = false -} diff --git a/frontend/src/components/base/BaseModal/index.vue b/frontend/src/components/base/BaseModal/index.vue deleted file mode 100644 index 64a8752fd..000000000 --- a/frontend/src/components/base/BaseModal/index.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - diff --git a/frontend/src/components/base/BaseModal/useModalDrag.ts b/frontend/src/components/base/BaseModal/useModalDrag.ts deleted file mode 100644 index 992cf0066..000000000 --- a/frontend/src/components/base/BaseModal/useModalDrag.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { ShallowRef } from 'vue' - -import { onBeforeUnmount, onMounted, shallowRef } from 'vue' - -const VIEWPORT_GUTTER = 8 -const interactiveSelector = 'button, a, input, select, textarea, [role="button"], [data-modal-drag-ignore]' - -interface DragSession { - pointerId: number - startX: number - startY: number - originX: number - originY: number - minX: number - maxX: number - minY: number - maxY: number -} - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)) -} - -export function useModalDrag( - target: Readonly>, - enabled: () => boolean, -) { - const isDragging = shallowRef(false) - const offset = { x: 0, y: 0 } - let session: DragSession | null = null - - function applyPosition() { - if (!target.value) - return - - // 使用相对定位保留普通绘制,避免变换合成层在表格单元格之间产生像素缝。 - target.value.style.left = offset.x === 0 ? '' : `${offset.x}px` - target.value.style.top = offset.y === 0 ? '' : `${offset.y}px` - } - - function removeDragListeners() { - document.removeEventListener('pointermove', handlePointerMove) - document.removeEventListener('pointerup', endDrag) - document.removeEventListener('pointercancel', endDrag) - window.removeEventListener('blur', handleWindowBlur) - } - - function endDrag(event?: PointerEvent) { - if (event && session && event.pointerId !== session.pointerId) - return - - session = null - isDragging.value = false - removeDragListeners() - } - - function handlePointerMove(event: PointerEvent) { - if (!session || event.pointerId !== session.pointerId) - return - if (!enabled()) { - endDrag() - return - } - - event.preventDefault() - offset.x = clamp( - session.originX + event.clientX - session.startX, - session.minX, - session.maxX, - ) - offset.y = clamp( - session.originY + event.clientY - session.startY, - session.minY, - session.maxY, - ) - applyPosition() - } - - function handlePointerDown(event: PointerEvent) { - if (!enabled() || !event.isPrimary || event.button !== 0) - return - - const eventTarget = event.target - if (eventTarget instanceof Element && eventTarget.closest(interactiveSelector)) - return - - const element = target.value - if (!element) - return - - const rect = element.getBoundingClientRect() - const viewportWidth = document.documentElement.clientWidth - const viewportHeight = document.documentElement.clientHeight - - session = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: offset.x, - originY: offset.y, - minX: offset.x - rect.left + VIEWPORT_GUTTER, - maxX: offset.x + viewportWidth - rect.right - VIEWPORT_GUTTER, - minY: offset.y - rect.top + VIEWPORT_GUTTER, - maxY: offset.y + viewportHeight - rect.bottom - VIEWPORT_GUTTER, - } - isDragging.value = true - event.preventDefault() - - document.addEventListener('pointermove', handlePointerMove, { passive: false }) - document.addEventListener('pointerup', endDrag) - document.addEventListener('pointercancel', endDrag) - window.addEventListener('blur', handleWindowBlur) - } - - function handleWindowBlur() { - endDrag() - } - - function updatePosition() { - const element = target.value - if (!element) - return - - const rect = element.getBoundingClientRect() - const viewportWidth = document.documentElement.clientWidth - const viewportHeight = document.documentElement.clientHeight - - if (rect.left < VIEWPORT_GUTTER) - offset.x += VIEWPORT_GUTTER - rect.left - else if (rect.right > viewportWidth - VIEWPORT_GUTTER) - offset.x -= rect.right - viewportWidth + VIEWPORT_GUTTER - - if (rect.top < VIEWPORT_GUTTER) - offset.y += VIEWPORT_GUTTER - rect.top - else if (rect.bottom > viewportHeight - VIEWPORT_GUTTER) - offset.y -= rect.bottom - viewportHeight + VIEWPORT_GUTTER - - applyPosition() - } - - function resetPosition() { - endDrag() - offset.x = 0 - offset.y = 0 - applyPosition() - } - - function handleResize() { - endDrag() - updatePosition() - } - - onMounted(() => window.addEventListener('resize', handleResize)) - onBeforeUnmount(() => { - endDrag() - window.removeEventListener('resize', handleResize) - }) - - return { - cancelDrag: endDrag, - constrainPosition: updatePosition, - handlePointerDown, - isDragging, - resetPosition, - } -} diff --git a/frontend/src/components/base/BaseMotionIcon.vue b/frontend/src/components/base/BaseMotionIcon.vue deleted file mode 100644 index 4ee7f1991..000000000 --- a/frontend/src/components/base/BaseMotionIcon.vue +++ /dev/null @@ -1,304 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseNumberInput.vue b/frontend/src/components/base/BaseNumberInput.vue deleted file mode 100644 index 9dbefe118..000000000 --- a/frontend/src/components/base/BaseNumberInput.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/frontend/src/components/base/BasePageHeader.vue b/frontend/src/components/base/BasePageHeader.vue deleted file mode 100644 index 96dae4f0d..000000000 --- a/frontend/src/components/base/BasePageHeader.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/frontend/src/components/base/BasePopover.vue b/frontend/src/components/base/BasePopover.vue deleted file mode 100644 index b2660d4a0..000000000 --- a/frontend/src/components/base/BasePopover.vue +++ /dev/null @@ -1,412 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseRadio.vue b/frontend/src/components/base/BaseRadio.vue deleted file mode 100644 index d724ebe20..000000000 --- a/frontend/src/components/base/BaseRadio.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseRange.vue b/frontend/src/components/base/BaseRange.vue deleted file mode 100644 index 3c22da7a9..000000000 --- a/frontend/src/components/base/BaseRange.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - - diff --git a/frontend/src/components/base/BaseScrollbar.vue b/frontend/src/components/base/BaseScrollbar.vue deleted file mode 100644 index 053e31342..000000000 --- a/frontend/src/components/base/BaseScrollbar.vue +++ /dev/null @@ -1,499 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseSegmented.vue b/frontend/src/components/base/BaseSegmented.vue deleted file mode 100644 index 0598f5e9c..000000000 --- a/frontend/src/components/base/BaseSegmented.vue +++ /dev/null @@ -1,132 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseSelect.vue b/frontend/src/components/base/BaseSelect.vue deleted file mode 100644 index cd743f481..000000000 --- a/frontend/src/components/base/BaseSelect.vue +++ /dev/null @@ -1,420 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseSkeleton.vue b/frontend/src/components/base/BaseSkeleton.vue deleted file mode 100644 index 583173ec4..000000000 --- a/frontend/src/components/base/BaseSkeleton.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseSwitch.vue b/frontend/src/components/base/BaseSwitch.vue deleted file mode 100644 index 73b4a3380..000000000 --- a/frontend/src/components/base/BaseSwitch.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseTable/BaseTableColumnSettings.vue b/frontend/src/components/base/BaseTable/BaseTableColumnSettings.vue deleted file mode 100644 index 6f80c54ad..000000000 --- a/frontend/src/components/base/BaseTable/BaseTableColumnSettings.vue +++ /dev/null @@ -1,241 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseTable/BaseTablePagination.vue b/frontend/src/components/base/BaseTable/BaseTablePagination.vue deleted file mode 100644 index 2ca0ff9d3..000000000 --- a/frontend/src/components/base/BaseTable/BaseTablePagination.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseTable/columns.ts b/frontend/src/components/base/BaseTable/columns.ts deleted file mode 100644 index 4775b7d86..000000000 --- a/frontend/src/components/base/BaseTable/columns.ts +++ /dev/null @@ -1,297 +0,0 @@ -export type TableRow = object - -export type TableColumnKind - = | 'text' - | 'identity' - | 'meta' - | 'status' - | 'numeric' - | 'datetime' - | 'mono' - | 'index' - | 'selection' - | 'expander' - | 'actions' - | 'custom' - -export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' - -export type TableColumnAlign = 'left' | 'right' | 'center' -type TableColumnSticky = 'left' | 'right' - -export interface BaseTableColumn { - key: string - label?: string - kind?: TableColumnKind - size?: TableColumnSize - fixedWidth?: boolean - align?: TableColumnAlign - sortable?: boolean | string - /** 列设置中是否允许隐藏;false 时始终保留。 */ - hideable?: boolean - /** 未保存用户偏好时,列设置使用此默认值。 */ - defaultHidden?: boolean - format?: (value: unknown, row: Row) => unknown - emptyText?: string -} - -export interface BaseTableSort { - key: string - direction: 'asc' | 'desc' -} - -export interface BaseTableProps { - columns: BaseTableColumn[] - rows: Row[] - rowKey?: string | ((row: Row, index: number) => string | number) - selectedRowKeys?: Array - expandedRowKeys?: Array - density?: 'compact' | 'default' - loading?: boolean - emptyText?: string - showHeaderWhenEmpty?: boolean - scrollbarAlwaysVisible?: boolean - sort?: BaseTableSort -} - -interface ColumnRecipe { - size: TableColumnSize - basisWidth?: number - fixedWidth?: boolean - align: TableColumnAlign - truncate: boolean - contentClass?: string - paddingClass?: string - sticky?: TableColumnSticky -} - -const columnWidths: Record = { - 'xs': 64, - 'sm': 88, - 'md': 112, - 'lg': 144, - 'xl': 184, - '2xl': 240, - '3xl': 288, - '4xl': 352, -} - -const columnRecipes: Record = { - text: { - size: '2xl', - align: 'left', - truncate: true, - }, - identity: { - size: '2xl', - align: 'left', - truncate: true, - }, - meta: { - size: 'lg', - align: 'left', - truncate: true, - contentClass: 'text-cp-text-secondary', - }, - status: { - size: 'md', - align: 'center', - truncate: false, - }, - numeric: { - size: 'md', - align: 'right', - truncate: false, - contentClass: 'font-mono tabular-nums text-cp-text-secondary', - }, - datetime: { - size: 'xl', - align: 'left', - truncate: false, - contentClass: 'whitespace-nowrap font-mono text-cp-sm tabular-nums text-cp-text-secondary', - }, - mono: { - size: 'xl', - align: 'left', - truncate: true, - contentClass: 'font-mono text-cp-sm font-emphasis', - }, - index: { - size: 'xs', - fixedWidth: true, - align: 'center', - truncate: false, - contentClass: 'font-mono tabular-nums text-cp-text-secondary', - }, - selection: { - size: 'xs', - basisWidth: 48, - fixedWidth: true, - align: 'center', - truncate: false, - paddingClass: 'px-2', - sticky: 'left', - }, - expander: { - size: 'xs', - basisWidth: 40, - fixedWidth: true, - align: 'center', - truncate: false, - paddingClass: 'px-2', - sticky: 'left', - }, - actions: { - size: 'md', - fixedWidth: true, - align: 'left', - truncate: false, - paddingClass: 'px-3', - sticky: 'right', - }, - custom: { - size: 'lg', - align: 'left', - truncate: false, - }, -} - -export interface ResolvedTableColumn - extends BaseTableColumn { - kind: TableColumnKind - basisWidth: number - fixedWidth: boolean - align: TableColumnAlign - truncate: boolean - contentClass?: string - paddingClass?: string - sticky?: TableColumnSticky - stickyOffset?: number -} - -export function defineTableColumns(columns: BaseTableColumn[]) { - return columns -} - -export function resolveColumns( - columns: BaseTableColumn[], -): ResolvedTableColumn[] { - const resolved = columns.map((column): ResolvedTableColumn => { - const kind = column.kind ?? 'text' - const recipe = columnRecipes[kind] - const basisWidth = recipe.basisWidth ?? columnWidths[column.size ?? recipe.size] - - return { - ...column, - kind, - basisWidth, - fixedWidth: column.fixedWidth ?? recipe.fixedWidth ?? false, - align: column.align ?? recipe.align, - truncate: recipe.truncate, - contentClass: recipe.contentClass, - paddingClass: recipe.paddingClass, - sticky: recipe.sticky, - } - }) - - let leftOffset = 0 - for (const column of resolved) { - if (column.sticky !== 'left') - continue - column.stickyOffset = leftOffset - leftOffset += column.basisWidth - } - - let rightOffset = 0 - for (const column of [...resolved].reverse()) { - if (column.sticky !== 'right') - continue - column.stickyOffset = rightOffset - rightOffset += column.basisWidth - } - - return resolved -} - -export function minimumTableWidth(columns: ResolvedTableColumn[]) { - return columns.reduce((total, column) => total + column.basisWidth, 0) -} - -export function tableStyle(columns: ResolvedTableColumn[]) { - // 全部列都固定时按总宽度展示,避免浏览器再次均分剩余空间。 - if (columns.every(column => column.fixedWidth)) - return { width: `${minimumTableWidth(columns)}px` } - return { width: `max(100%, ${minimumTableWidth(columns)}px)` } -} - -export function columnStyle( - column: ResolvedTableColumn, - columns: ResolvedTableColumn[], -) { - const fixedWidth = columns.reduce((total, item) => total + (item.fixedWidth ? item.basisWidth : 0), 0) - const flexibleWidth = minimumTableWidth(columns) - fixedWidth - const ratio = flexibleWidth > 0 ? column.basisWidth / flexibleWidth : 0 - - return { - width: column.fixedWidth - ? `${column.basisWidth}px` - // 用容器宽度分配剩余空间;col 的百分比 calc 会被固定表格布局忽略。 - : `max(${column.basisWidth}px, calc(${ratio * 100}cqw - ${fixedWidth * ratio}px))`, - minWidth: `${column.basisWidth}px`, - } -} - -export function stickyStyle(column: ResolvedTableColumn) { - if (!column.sticky) - return undefined - return { [column.sticky]: `${column.stickyOffset ?? 0}px` } -} - -export function alignClass(column: ResolvedTableColumn) { - if (column.align === 'center') - return 'text-center' - if (column.align === 'right') - return 'text-right' - return 'text-left' -} - -export function cellValue(row: TableRow, key: string) { - return (row as Record)[key] -} - -function isEmptyCellValue(value: unknown) { - return value === undefined || value === null || value === '' -} - -export function cellDisplayValue(column: BaseTableColumn, row: Row) { - const rawValue = cellValue(row, column.key) - const value = column.format ? column.format(rawValue, row) : rawValue - return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value -} - -export function cellTitle(column: ResolvedTableColumn, row: Row) { - if (!column.truncate) - return undefined - const value = cellDisplayValue(column, row) - return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined -} - -export function cellContentClass(column: ResolvedTableColumn) { - if (column.kind === 'selection' || column.kind === 'expander') { - return [ - 'flex min-w-0 items-center overflow-visible leading-none', - column.align === 'right' - ? 'justify-end' - : column.align === 'center' - ? 'justify-center' - : 'justify-start', - ] - } - if (column.kind === 'actions') - return 'min-w-0 overflow-visible' - return ['min-w-0', column.truncate ? 'truncate' : undefined] -} - -export function columnSortKey(column: BaseTableColumn) { - return typeof column.sortable === 'string' ? column.sortable : column.key -} diff --git a/frontend/src/components/base/BaseTable/index.vue b/frontend/src/components/base/BaseTable/index.vue deleted file mode 100644 index 9e05f1171..000000000 --- a/frontend/src/components/base/BaseTable/index.vue +++ /dev/null @@ -1,330 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseTable/pagination.ts b/frontend/src/components/base/BaseTable/pagination.ts deleted file mode 100644 index ac6563e47..000000000 --- a/frontend/src/components/base/BaseTable/pagination.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { clamp, range } from 'es-toolkit' - -export interface BaseTablePagination { - currentPage: number - pageSize: number - total: number - pageSizes?: number[] -} - -export type PagerItem = number | 'ellipsis' - -const DEFAULT_PAGE_SIZES = [10, 20, 50, 100] - -export function getTotalPages(pagination?: BaseTablePagination) { - if (!pagination) { - return 0 - } - - if (pagination.total <= 0) { - return 1 - } - - return clamp(Math.ceil(pagination.total / pagination.pageSize), 1, Number.POSITIVE_INFINITY) -} - -export function getCurrentPage(pagination: BaseTablePagination | undefined, totalPages: number) { - if (!pagination || totalPages === 0) { - return 0 - } - - return clamp(pagination.currentPage, 1, totalPages) -} - -export function getPageSizeOptions(pagination?: BaseTablePagination) { - const pageSizes = pagination?.pageSizes?.length ? pagination.pageSizes : DEFAULT_PAGE_SIZES - - return pageSizes.map(pageSize => ({ - label: `${pageSize} 条/页`, - value: String(pageSize), - })) -} - -export function getPagerItems(total: number, current: number): PagerItem[] { - if (total <= 7) { - return range(1, total + 1) - } - - const pages = new Set([1, total, current, current - 1, current + 1]) - if (current <= 3) { - pages.add(2) - pages.add(3) - pages.add(4) - } - if (current >= total - 2) { - pages.add(total - 1) - pages.add(total - 2) - pages.add(total - 3) - } - - const sorted = [...pages].filter(page => page >= 1 && page <= total).sort((a, b) => a - b) - const items: PagerItem[] = [] - for (const page of sorted) { - const previous = items[items.length - 1] - if (typeof previous === 'number' && page - previous > 1) { - items.push('ellipsis') - } - items.push(page) - } - - return items -} diff --git a/frontend/src/components/base/BaseTable/useTableColumnMotion.ts b/frontend/src/components/base/BaseTable/useTableColumnMotion.ts deleted file mode 100644 index 8ef46adaa..000000000 --- a/frontend/src/components/base/BaseTable/useTableColumnMotion.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { MaybeRefOrGetter } from 'vue' -import { usePreferredReducedMotion } from '@vueuse/core' -import { nextTick, toValue, watch } from 'vue' - -export function useTableColumnMotion( - table: MaybeRefOrGetter, - columnKeys: () => string[], -) { - const preferredMotion = usePreferredReducedMotion() - - watch([columnKeys, preferredMotion], async ([keys, motion], [previousKeys], onCleanup) => { - const root = toValue(table) - if (!root || motion === 'reduce' - || keys.length !== previousKeys.length - || keys.every((key, index) => key === previousKeys[index]) - || keys.some(key => !previousKeys.includes(key))) { - return - } - - // 只处理已有列换位;在列宽更新前记录位置,固定列不参与移动。 - const elements = Array.from(root.querySelectorAll('[data-column-motion]')) - .filter(element => element.closest('table') === root) - const positions = new Map(elements.map(element => [element, element.getBoundingClientRect().left])) - const animations: Animation[] = [] - let cancelled = false - onCleanup(() => { - cancelled = true - animations.forEach(animation => animation.cancel()) - }) - - await nextTick() - if (cancelled || !root.isConnected) - return - - // 先统一读取新位置,再播放位移,避免逐个单元格交替读写布局。 - const movements = Array.from(positions) - .filter(([element]) => element.isConnected) - .map(([element, left]) => ({ element, offset: left - element.getBoundingClientRect().left })) - for (const { element, offset } of movements) { - if (Math.abs(offset) < 0.5) - continue - animations.push(element.animate([ - { transform: `translateX(${offset}px)` }, - { transform: 'translateX(0)' }, - ], { duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)' })) - } - }) -} diff --git a/frontend/src/components/base/BaseTable/useTableColumns.ts b/frontend/src/components/base/BaseTable/useTableColumns.ts deleted file mode 100644 index fc0f262d5..000000000 --- a/frontend/src/components/base/BaseTable/useTableColumns.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { MaybeRefOrGetter } from 'vue' -import type { BaseTableColumn, TableRow } from './columns' -import { useStorage } from '@vueuse/core' -import { computed, toValue } from 'vue' -import { resolveColumns } from './columns' - -export interface TableColumnOption { - key: string - label: string - visible: boolean - disabled: boolean - reorderable: boolean -} - -function readVisibility(value: string): Record { - try { - const parsed: unknown = JSON.parse(value) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) - return Object.fromEntries(Object.entries(parsed).filter(([, visible]) => typeof visible === 'boolean')) - } - catch { - // 损坏的本地偏好回退到列定义,不影响表格展示。 - } - return {} -} - -function readOrder(value: string): string[] { - try { - const parsed: unknown = JSON.parse(value) - if (Array.isArray(parsed)) - return [...new Set(parsed.filter((key): key is string => typeof key === 'string'))] - } - catch { - // 顺序偏好损坏时回退到列定义,保留独立存储的显隐偏好。 - } - return [] -} - -export function useTableColumns( - source: MaybeRefOrGetter[]>, - tableId: string, -) { - // 只保存用户覆盖;新增列继续使用自身默认值,不继承旧列表的隐藏状态。 - const overrides = useStorage>( - `codex-proxy:table-columns:${tableId}`, - {}, - undefined, - { - shallow: true, - writeDefaults: false, - serializer: { read: readVisibility, write: JSON.stringify }, - }, - ) - const order = useStorage( - `codex-proxy:table-column-order:${tableId}`, - [], - undefined, - { - shallow: true, - writeDefaults: false, - serializer: { read: readOrder, write: JSON.stringify }, - }, - ) - - const orderedColumns = computed(() => { - const columns = resolveColumns(toValue(source)) - const ranks = new Map(order.value.map((key, index) => [key, index])) - const movable = columns.filter(column => column.label && !column.sticky) - .sort((a, b) => (ranks.get(a.key) ?? Number.MAX_SAFE_INTEGER) - (ranks.get(b.key) ?? Number.MAX_SAFE_INTEGER)) - - // 固定列保留原位;新增列沿用定义顺序,接在用户已排序的普通列之后。 - let index = 0 - return columns.map(column => column.label && !column.sticky ? movable[index++]! : column) - }) - - const columnStates = computed(() => { - const states = orderedColumns.value.map((column) => { - const override = overrides.value[column.key] - return { - column, - visible: column.hideable === false || (typeof override === 'boolean' ? override : !column.defaultHidden), - } - }) - // 即使旧偏好隐藏了全部列,也保留一个可操作的表格。 - if (!states.some(state => state.visible) && states[0]) - states[0].visible = true - return states - }) - const visibleColumns = computed(() => columnStates.value.filter(state => state.visible).map(state => state.column)) - const columnOptions = computed(() => columnStates.value - .filter(({ column }) => column.label) - .map(({ column, visible }) => ({ - key: column.key, - label: column.label!, - visible, - disabled: column.hideable === false || (visible && visibleColumns.value.length === 1), - reorderable: !column.sticky, - }))) - - function setColumnVisible(key: string, visible: boolean) { - const option = columnOptions.value.find(option => option.key === key) - const column = toValue(source).find(column => column.key === key) - if (!column || !option || option.disabled) - return - - const next = { ...overrides.value } - if (visible === !column.defaultHidden) - delete next[key] - else next[key] = visible - overrides.value = next - } - - function setColumnOrder(keys: string[]) { - const movable = new Set(columnOptions.value.filter(option => option.reorderable).map(option => option.key)) - order.value = [...new Set(keys.filter(key => movable.has(key)))] - } - - function resetColumns() { - overrides.value = {} - order.value = [] - } - - return { visibleColumns, columnOptions, setColumnVisible, setColumnOrder, resetColumns } -} diff --git a/frontend/src/components/base/BaseTag.vue b/frontend/src/components/base/BaseTag.vue deleted file mode 100644 index b456e8b94..000000000 --- a/frontend/src/components/base/BaseTag.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/frontend/src/components/base/BaseTextarea.vue b/frontend/src/components/base/BaseTextarea.vue deleted file mode 100644 index eb6109103..000000000 --- a/frontend/src/components/base/BaseTextarea.vue +++ /dev/null @@ -1,89 +0,0 @@ - - -