feat(plugin): add the complete plugin development and management workflow (#256)

Introduce SDK 0.4, manifest v3, protocol v4, the isolated plugin runtime,
and cpr-plugin packaging CLI for independently maintained plugins.

Support installation, permissions, one active configuration per plugin,
version-specific settings, rollback, management pages, and CLI extensions.
Isolate plugin failures so native gateway startup and requests remain available.

Unify plugin settings and version management, support custom provider icons,
and improve narrow layouts. Use the fixed GitHub UI dependency and pnpm 12
for standalone frontend and container builds. Reduce runtime test packaging
cost and align preparation modules with the repository layout.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-24 13:40:38 +08:00
co-authored by Codex
parent 60e0d4ee92
commit fd436532b8
799 changed files with 109110 additions and 13766 deletions
+1
View File
@@ -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) |
+79
View File
@@ -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#验证与交付)提供源码位置、能力/权限/绑定摘要、实际运行结果及验证缺口。
开发插件不默认授权安装并启用本机进程、调用真实上游、发布依赖或推送仓库;超出当前授权时交付可安装包及操作步骤。
@@ -0,0 +1,4 @@
interface:
display_name: "Codex Proxy 插件开发"
short_description: "从能力选择到 SDK 实现、管理页面、打包与验证的插件开发指南"
default_prompt: "使用 $plugin-dev 为 Codex Proxy RS 开发插件,先确认需要的能力,再按公开 SDK 实现、打包并验证。"
@@ -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。
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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
+75 -7
View File
@@ -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 无密钥签名。
## 📚 文档
+4 -4
View File
@@ -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
+4 -1
View File
@@ -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/
+12 -1
View File
@@ -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
+18 -9
View File
@@ -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) |
## 社区
+620 -5
View File
@@ -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",
]
+3
View File
@@ -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",
+1
View File
@@ -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" }
+312 -30
View File
@@ -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<String>,
pub name: Option<String>,
pub arguments: Vec<String>,
}
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<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
launch(Some(command)).await
}
async fn launch(
command: Option<PluginCommand>,
) -> Result<gateway_plugin_runtime::PluginCommandOutput, BootstrapError> {
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::<GatewayConfig>()?;
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<gateway_host::outbound::HttpClient>,
) -> Result<std::sync::Arc<gateway_plugin_runtime::PluginRuntime>, 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),
+71 -6
View File
@@ -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<dyn Error + Send + Sync>> {
fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<ExitCode, Box<dyn Error + Send + Sync>> {
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 <instance-id> <command> [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::<Result<Vec<_>, _>>()?;
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()),
}
}
+62 -4
View File
@@ -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::<Vec<_>>(),
);
}
}
}
@@ -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::<Vec<_>>(),
);
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<PathBuf, Vec<String>> {
let metadata = cargo_metadata_json();
let mut roots = BTreeMap::<PathBuf, Vec<String>>::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<PathBuf, ModuleLayout> {
super::rust_files(root)
.into_iter()
+48
View File
@@ -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;
+27
View File
@@ -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
+65
View File
@@ -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
```
+562
View File
@@ -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<String>,
}
#[derive(Debug, Clone, Copy)]
struct RuntimeTarget {
os: &'static str,
architecture: &'static str,
}
impl RuntimeTarget {
fn parse(triple: &str) -> Result<Self, PackagerError> {
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<BTreeMap<String, PathBuf>, 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<PackageOutput, PackagerError> {
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::<Vec<_>>();
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<String, PathBuf>,
used_mappings: &mut BTreeSet<String>,
) -> Result<PathBuf, PackagerError> {
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<Item = &'a String>,
) -> 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<String, Vec<u8>>,
) -> 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("<temporary archive>"),
source,
})?;
let limited = archive.into_inner().map_err(|source| PackagerError::Io {
action: "finish archive",
path: PathBuf::from("<temporary archive>"),
source,
})?;
limited
.into_inner()
.finish()
.map_err(|source| PackagerError::Io {
action: "finish compression",
path: PathBuf::from("<temporary archive>"),
source,
})?;
Ok(())
}
fn append_file<W: io::Write>(
archive: &mut Builder<W>,
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<W> {
inner: W,
written: u64,
maximum: u64,
}
impl<W> LimitedWriter<W> {
const fn new(inner: W, maximum: u64) -> Self {
Self {
inner,
written: 0,
maximum,
}
}
fn into_inner(self) -> W {
self.inner
}
}
impl<W> io::Write for LimitedWriter<W>
where
W: io::Write,
{
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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<Self, PackagerError> {
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<u64, PackagerError> {
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<Vec<u8>, 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<PathBuf, PackagerError> {
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<String, PackagerError> {
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())
}
+267
View File
@@ -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 <COMMAND>"), "{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#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 2h20v20H2z"/></svg>"#,
);
}
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<_>>(),
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"}
}"#
}
+2 -1
View File
@@ -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"
+123 -65
View File
@@ -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<PluginsService>,
plugin_management: Arc<PluginManagementService>,
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
key_usage: Arc<dyn KeyUsageService>,
@@ -192,13 +198,23 @@ pub struct AdminServices {
observability: Arc<dyn ObservabilityService>,
settings: Arc<dyn SettingsService>,
system: Arc<dyn SystemService>,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
credentials: Arc<CredentialsService>,
plugin_accounts: Arc<dyn PluginAccountAccess>,
backups: Arc<dyn BackupService>,
import_tasks: Arc<dyn ImportTasksService>,
}
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<dyn PluginAccountAccess> {
Arc::clone(&self.plugin_accounts)
}
#[must_use]
@@ -296,8 +313,13 @@ impl AdminBundle {
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
pub struct AdminRuntimePorts {
pub plugin_preparation: Arc<dyn ports::plugins::PluginPreparation>,
pub plugin_management: Arc<dyn ports::plugin_management::PluginManagement>,
pub published_snapshot: gateway_core::runtime::RuntimeSnapshotHandle,
pub plugin_distribution: Arc<dyn ports::plugins::PluginDistribution>,
pub plugin_inspector: Arc<dyn ports::plugins::PluginPackageInspector>,
pub pricing_source: Arc<dyn ports::pricing::PricingSource>,
pub providers: Vec<Arc<dyn ProviderAdmin>>,
pub providers: ProviderAdminRegistry,
pub snapshot: Arc<dyn SnapshotControl>,
pub account_probe: Arc<dyn AccountProbe>,
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
@@ -306,18 +328,44 @@ pub struct AdminRuntimePorts {
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
/// 校验配置、接入已组装的 Provider 注册表并完成默认管理员幂等初始化。
///
/// # Errors
///
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
/// 配置非法或默认管理员初始化失败时返回错误。
pub async fn initialize(
config: AdminConfig,
client_config: ClientConfig,
store: AdminStorePorts,
runtime: AdminRuntimePorts,
) -> Result<AdminBundle, AdminError> {
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<dyn PluginAccountAccess>,
) -> Result<AdminBundle, AdminError> {
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<Arc<dyn PluginAccountAccess>>,
) -> Result<AdminBundle, AdminError> {
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<dyn ports::store::AccountStore>,
snapshot: Arc<dyn gateway_core::runtime::SnapshotControl>,
) -> Arc<dyn PluginAccountAccess> {
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<dyn ports::store::ClientKeyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Arc<dyn PluginClientKeyAccess> {
let service: Arc<dyn ClientKeyService> =
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, AdminError> {
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 注册表初始化失败")
}
@@ -204,6 +204,8 @@ pub struct AccountPage {
pub items: Vec<AccountPageItem>,
pub total: u64,
pub summary: AccountSummary,
/// 全部持久化账号的平台,不受筛选、分页和插件启停影响。
pub providers: Vec<String>,
}
/// 同一状态快照下的账号事实与唯一状态投影。
@@ -101,7 +101,8 @@ impl From<ClientAuthenticationError> for LoginError {
fn from(error: ClientAuthenticationError) -> Self {
match error {
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
ClientAuthenticationError::SnapshotUnavailable
| ClientAuthenticationError::ProviderUnavailable => Self::Unavailable,
}
}
}
@@ -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<ProviderKind, gateway_core::account::OpaqueProviderData>;
pub type ProviderRequestProfileOverrideUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// 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<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -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<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
@@ -159,8 +162,7 @@ pub struct CreateClientKey {
/// 管理用例生成 ID 与明文后的持久化命令。
#[derive(Clone, PartialEq, Eq)]
pub struct NewClientKey {
pub openai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile_override: Option<gateway_core::account::OpaqueProviderData>,
pub request_profile_overrides: ProviderRequestProfileOverrides,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -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<Option<gateway_core::account::OpaqueProviderData>>,
pub xai_client_profile_override: Option<Option<gateway_core::account::OpaqueProviderData>>,
pub request_profile_override_updates: ProviderRequestProfileOverrideUpdates,
pub id: ClientApiKeyId,
pub name: String,
pub label: Option<String>,
@@ -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;
@@ -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<PluginClientKeyCursor>,
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<PluginClientKey>,
pub next_cursor: Option<PluginClientKeyCursor>,
}
@@ -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<String>,
}
#[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", &"<redacted>")
.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<DownloadPurpose>,
}
#[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<String>,
#[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<String>,
}
#[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<ReleaseAsset>,
pub queried_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
/// 下载意图不承载凭据;引用在 Admin 中解析后只交给 Host 的本次调用。
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum RemotePluginLocation {
Url {
url: String,
sha256: Option<String>,
},
Github {
repository: String,
tag: String,
asset: String,
#[serde(default)]
allow_prerelease: bool,
sha256: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RemotePluginVerify {
pub location: RemotePluginLocation,
#[serde(default)]
pub credential_ids: Vec<String>,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
/// 更新已有插件时锁定身份;首次安装从包内清单读取。
pub expected_plugin_id: Option<String>,
}
#[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<String>,
#[serde(default)]
pub outbound_proxy_id: Option<String>,
}
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,
}
@@ -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<String>,
#[serde(default)]
pub account_group_ids: Vec<String>,
#[serde(default)]
pub provider_ids: Vec<String>,
#[serde(default)]
pub models: Vec<String>,
#[serde(default)]
pub identity_bindings: Vec<PluginFrontendIdentityBinding>,
}
/// 外部 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<String, SecretString>,
pub grants: Vec<PluginPermissionGrant>,
pub bindings: Vec<PluginCapabilityBinding>,
pub revision: Revision,
}
#[derive(Clone)]
pub struct PluginInstanceSnapshot {
pub config_revision: Revision,
pub instances: Vec<PluginInstance>,
}
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<u64>,
pub actual_artifact_sha256: Option<String>,
pub failure: Option<PluginInstanceRuntimeFailure>,
pub draining_revisions: Vec<u64>,
pub retained_continuations: u32,
}
pub struct PluginInstanceView {
pub instance: PluginInstance,
pub configuration_required: bool,
pub running: bool,
pub published_revision: Option<u64>,
pub runtime: PluginInstanceRuntime,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ConfigurePluginInstance {
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口。
pub creation_id: Option<String>,
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容。
pub expected_revision: Option<u64>,
/// 管理员确认停用的同插件配置,与当前配置在同一事务中切换。
#[serde(default)]
pub replace_instances: Vec<PluginInstanceReplacement>,
pub name: String,
pub artifact_sha256: String,
pub enabled: bool,
pub configuration: serde_json::Value,
/// 缺省保留旧值;显式空对象清除全部 secret。
pub secrets: Option<BTreeMap<String, SecretString>>,
pub bindings: Vec<PluginCapabilityBinding>,
}
#[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<String>,
}
/// 静态候选不启动插件;实际回滚仍须复验配置、平台和私有状态兼容性。
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginRollbackPlan {
pub instance_revision: u64,
pub current_version: String,
pub targets: Vec<PluginRollbackTarget>,
}
/// 版本配置快照只用于服务端恢复,敏感值不实现 Debug 或 Serialize。
#[derive(Clone)]
pub struct PluginVersionConfiguration {
pub configuration: serde_json::Value,
pub secrets: BTreeMap<String, SecretString>,
pub bindings: Vec<PluginCapabilityBinding>,
}
/// 切换版本的只读草稿,不包含密钥值,也不会启动插件或写入配置。
#[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<String>,
pub bindings: Vec<PluginCapabilityBinding>,
pub restored: bool,
}
@@ -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<PluginManagementPage>,
pub routes: Vec<PluginManagementRoute>,
pub resources: Vec<PluginManagementResource>,
pub callbacks: Vec<PluginManagementCallback>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementCallback {
pub path: String,
pub response_content_types: Vec<String>,
}
#[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<String>,
pub entry: String,
pub icon: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginManagementRoute {
pub method: String,
pub path: String,
pub request_content_types: Vec<String>,
pub response_content_types: Vec<String>,
}
#[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<String>,
pub body: Vec<u8>,
pub request_id: String,
}
pub struct PluginManagementResponse {
pub status: u16,
pub content_type: String,
pub body: Arc<[u8]>,
}
@@ -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<u32>,
}
/// 随宿主发行物封口的插件合同;Runtime 与发布清单共用这一份声明。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginHostCompatibility {
pub schema_version: u32,
pub manifest_schema_versions: Vec<u32>,
pub protocol_versions: Vec<u32>,
pub capabilities: Vec<PluginHostCapability>,
pub permissions: Vec<String>,
}
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::<BTreeSet<_>>().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<String>,
}
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
outbound_proxy: Option<PluginSourceEgress>,
},
Github {
repository: String,
tag: String,
asset: String,
credential_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
outbound_proxy: Option<PluginSourceEgress>,
},
}
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<String>,
pub input_formats: Vec<String>,
pub output_formats: Vec<String>,
}
/// 制品清单中的展示图标;路径只用于选择已校验的包资源。
#[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<u8>,
}
/// 管理端使用的静态描述,不承载 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<String>,
pub description: String,
pub license: String,
pub sha256: String,
pub platforms: Vec<String>,
#[serde(default)]
pub icon: Option<PluginArtifactIcon>,
pub contributes: BTreeMap<String, PluginContribution>,
pub requested_permissions: Vec<String>,
pub configuration_schema: serde_json::Value,
pub secret_fields: Vec<String>,
#[serde(default)]
pub state_namespaces: Vec<state::PluginStateSchema>,
}
/// 由包检查端口产生的不可变制品;不派生 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<chrono::Utc>,
pub accepted_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub struct PluginArtifactMutation {
pub config_revision: Revision,
pub artifact: InstalledPluginArtifact,
}
pub struct PluginInstallResult {
pub mutation: PluginArtifactMutation,
pub default_instance_id: Option<String>,
pub configuration_required: bool,
}
@@ -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<Revision>,
}
@@ -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<u32>,
}
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<PluginStateSchema>,
}
/// 与实例保存同事务提交的状态配置;transition 只引用已完成的 staging 数据。
#[derive(Clone, Default)]
pub struct PluginStateCommit {
pub configuration: PluginStateConfiguration,
pub transition_id: Option<String>,
}
#[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<String, PluginStateNamespaceOwner>,
}
#[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<String, PluginStateNamespaceOwner>,
) -> 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<u64>,
}
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<PluginStateMigrationNamespace>,
}
#[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<String>,
pub records: Vec<PluginStateRecord>,
}
pub struct ApplyPluginStateMigration {
pub transition_id: String,
pub namespace: String,
pub cursor: Option<String>,
/// Runtime 刚读取的有序源键;Store 会在写入前重新读取并逐项比对。
pub expected_keys: Vec<String>,
pub changes: Vec<PluginStateMigrationChange>,
}
pub struct PluginStateMigrationChange {
pub key: String,
pub action: PluginStateMigrationAction,
}
pub enum PluginStateMigrationAction {
Keep,
Replace(serde_json::Value),
Delete,
}
@@ -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<Value>,
pub login: Option<ProviderLoginCapability>,
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,
}
@@ -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<ProviderAccountId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
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<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotation>),
}
/// 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<PreparedCredentialCreate>),
Reauthorize(Box<PreparedCredentialRotationFacts>),
}
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub key: AuthorizationReceiptKey,
pub settings: Option<AccountImportSettings>,
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<Self, AdminError> {
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<AuthorizedAccount>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorizedAccount {
pub account_id: ProviderAccountId,
pub credential_revision: Option<Revision>,
}
impl From<CredentialMutationResult> 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<String>,
pub settings: Option<AccountImportSettings>,
}
pub enum PreparedAuthorizationPoll {
Pending { retry_after: std::time::Duration },
Complete(Box<PreparedAuthorizationCommit>),
}
#[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<AccountImportSettings>,
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<DateTime<Utc>>,
}
/// 插件账号回调的服务端过滤条件;Provider 缺省时跨全部已注册类型分页。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountListQuery {
pub provider_kind: Option<ProviderKind>,
pub cursor: Option<ProviderAccountId>,
pub limit: PageSize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginAccountPage {
pub accounts: Vec<AccountRecord>,
pub next_cursor: Option<ProviderAccountId>,
}
/// 原始凭据与当前账号 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<String>,
pub projection: gateway_core::account::AccountStatusProjection,
@@ -1109,6 +1282,7 @@ pub struct AccountDirectoryPage {
pub items: Vec<AccountDirectoryItem>,
pub total: u64,
pub summary: AccountSummary,
pub providers: Vec<String>,
}
/// 凭据刷新提交后的完整账号结果。
@@ -43,6 +43,19 @@ pub struct QuotaForecastSource {
pub usd: Option<f64>,
}
/// 为目标周期选择唯一的预测源窗口;缺少同周期窗口时复用最短的可统计窗口。
///
/// 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 {
@@ -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<PublicModelId, UpstreamModelId>;
/// Provider-owned 请求画像选择;Provider ID 是唯一命名空间。
pub type ProviderRequestProfiles =
BTreeMap<ProviderKind, gateway_core::account::OpaqueProviderData>;
/// Provider-owned 请求画像的部分更新;`None` 只表示显式删除该 Provider 的历史选择。
pub type ProviderRequestProfileUpdates =
BTreeMap<ProviderKind, Option<gateway_core::account::OpaqueProviderData>>;
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
pub use gateway_core::account::RotationStrategy;
/// 完整运行设置事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeSettings {
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
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<gateway_core::account::OpaqueProviderData>,
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
/// 只覆盖提交的 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))
}
}
@@ -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;
@@ -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<PluginAccountPage, AdminError>;
async fn get_runtime(
&self,
account_id: &ProviderAccountId,
) -> Result<crate::model::accounts::AccountRecord, AdminError>;
async fn get_credential(
&self,
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError>;
async fn save(
&self,
command: PreparedPluginAccountSave,
context: &MutationContext,
) -> Result<PluginAccountSaveResult, AdminError>;
}
@@ -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<PluginClientKeyPage, AdminError>;
}
@@ -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<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError>;
async fn callback(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
state: &str,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError>;
async fn views(
&self,
published: &ExtensionSetReference,
) -> Result<Vec<PluginManagementView>, AdminError>;
async fn resource(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
path: &str,
public: bool,
) -> Result<PluginManagementResponse, AdminError>;
async fn handle(
&self,
published: &ExtensionSetReference,
target: &PluginManagementTarget,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError>;
}
@@ -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<Option<Arc<[u8]>>, OfficialPluginReleaseReadError>;
async fn artifact(&self, file_name: &str) -> Result<Arc<[u8]>, OfficialPluginReleaseReadError>;
}
@@ -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<bool, AdminError>;
async fn validate(
&self,
instance: PluginInstance,
) -> Result<PluginStateConfiguration, AdminError>;
/// 候选可使用未来 revision;其依赖的持久化事实必须按当前 source revision 读取。
/// 本次修改的实例 revision 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
async fn prepare(
&self,
source_revision: Revision,
snapshot: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError>;
/// 返回当前进程的只读运行事实;缺省实现让不托管插件进程的组合保持兼容。
async fn runtime_diagnostics(
&self,
_snapshot: &PluginInstanceSnapshot,
_published_revision: Option<u64>,
_published: Option<&ExtensionSetReference>,
) -> Option<BTreeMap<String, PluginInstanceRuntime>> {
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<T> = Result<T, PluginStateStoreError>;
/// 私有状态的窄端口;不暴露 SQL、连接或跨命名空间事务。
#[async_trait]
pub trait PluginStateStore: Send + Sync {
async fn load_owner(
&self,
request: PluginStateOwnerRequest,
) -> PluginStateStoreResult<Option<PluginStateOwner>>;
async fn get(
&self,
owner: &PluginStateOwner,
namespace: &str,
key: &str,
) -> PluginStateStoreResult<Option<PluginStateRecord>>;
async fn put(
&self,
owner: &PluginStateOwner,
command: PutPluginState,
) -> PluginStateStoreResult<PluginStateWrite>;
async fn delete(
&self,
owner: &PluginStateOwner,
command: DeletePluginState,
) -> PluginStateStoreResult<bool>;
async fn transition_required(
&self,
instance_id: &str,
target: &PluginStateConfiguration,
) -> PluginStateStoreResult<bool>;
async fn begin_transition(
&self,
instance_id: &str,
expected_instance_revision: Revision,
artifact_sha256: &str,
target: PluginStateConfiguration,
) -> PluginStateStoreResult<PluginStateTransition>;
async fn migration_batch(
&self,
transition_id: &str,
namespace: &str,
maximum_records: u32,
) -> PluginStateStoreResult<PluginStateMigrationBatch>;
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<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError>;
async fn download(
&self,
location: RemotePluginLocation,
credentials: Vec<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<DownloadedPlugin, AdminError>;
}
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin。
#[async_trait]
pub trait PluginPackageInspector: Send + Sync {
/// 当前宿主对权限标识的说明,供展示投影使用,不属于不可变制品元数据。
fn permission_descriptions(
&self,
permissions: &[String],
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
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<String>,
) -> Result<InspectedPluginArtifact, AdminError>;
/// 从已安装且摘要匹配的制品中读取可展示的静态图标,不启动插件实例。
async fn icon(
&self,
_archive: Arc<[u8]>,
_expected_sha256: String,
_theme: crate::model::plugins::PluginIconTheme,
) -> Result<Option<crate::model::plugins::PluginArtifactIconResource>, AdminError> {
Err(AdminError::unavailable("插件图标读取不可用"))
}
/// 只读取已校验包的静态宿主要求;不得准备或启动插件进程。
async fn compatibility(
&self,
_archive: Arc<[u8]>,
_expected_sha256: String,
) -> Result<PluginCompatibilityRequirements, AdminError> {
Err(AdminError::unavailable("插件兼容性检查不可用"))
}
}
#[async_trait]
pub trait PluginStore: Send + Sync {
/// 只复核精确管理目标的启用、接受事实和版本,不读取实例配置或密钥。
async fn management_target_is_current(
&self,
target: &crate::model::plugins::management::PluginManagementTarget,
) -> AdminStoreResult<bool>;
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot>;
/// 读取对应制品最近一次启用时提交的配置,密钥始终留在服务端。
async fn load_version_configuration(
&self,
_id: &str,
_digest: &str,
) -> AdminStoreResult<Option<PluginVersionConfiguration>> {
Ok(None)
}
/// 只返回有可恢复配置的制品摘要,不读取敏感值。
async fn configuration_versions(&self, _id: &str) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_instance(
&self,
instance: PluginInstance,
expected_revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation>;
async fn save_instance_with_state(
&self,
instance: PluginInstance,
expected_revision: Revision,
state: PluginStateCommit,
context: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
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<PluginInstanceMutation> {
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<Revision>;
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>>;
async fn change_update_source(
&self,
binding: PluginSourceBinding,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>>;
async fn load_source_credential(&self, id: &str) -> AdminStoreResult<SourceCredential>;
async fn save_source_credential(
&self,
credential: SourceCredential,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn delete_source_credential(
&self,
id: &str,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>>;
async fn load_artifact(&self, digest: &str) -> AdminStoreResult<InspectedPluginArtifact>;
async fn install_artifact(
&self,
artifact: InspectedPluginArtifact,
source: PluginSource,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 接受不可变制品声明的全部能力域;接受事实不随实例设置变化。
async fn accept_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation>;
/// 删除制品及其无引用下载凭据,最后一个版本同时删除来源规则,清理与审计必须原子提交。
async fn delete_artifact(
&self,
digest: &str,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
}
@@ -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<gateway_core::account::OpaqueProviderData> {
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<AuthorizationStarted, ProviderAdminError>;
async fn complete_authorization(
@@ -188,6 +208,14 @@ pub trait ProviderAdmin: Send + Sync {
command: CompleteAuthorization,
) -> Result<PreparedAuthorizationCommit, ProviderAdminError>;
async fn poll_authorization(
&self,
_command: crate::model::provider_credentials::PollAuthorization,
) -> Result<crate::model::provider_credentials::PreparedAuthorizationPoll, ProviderAdminError>
{
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<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
pub(super) providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
pub(super) extensions: Option<(
super::provider_extensions::ProviderAdminExtensionIndex,
gateway_core::runtime::RuntimeSnapshotHandle,
)>,
pub(super) snapshot: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
}
impl ProviderAdminRegistry {
#[must_use]
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
self.providers
/// 返回当前冻结代次中真正提供客户端画像选项的 Provider。
pub fn client_profile_providers(&self) -> Result<Vec<ProviderKind>, 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<crate::model::provider_capabilities::ProviderCredentialDescriptor>,
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<gateway_core::metering::PricingOverrides, ProviderAdminError> {
Ok(self
.freeze()?
.providers
.iter()
.map(|(kind, provider)| (kind.as_str().to_owned(), provider.pricing_catalog()))
.collect()
.collect())
}
/// 一个管理用例可显式冻结目录,批量操作不会混入下一份配置。
pub fn freeze(&self) -> Result<Self, ProviderAdminError> {
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<Arc<dyn ProviderAdmin>, ProviderAdminError> {
self.providers
.get(provider_kind)
.cloned()
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
) -> Result<super::provider_extensions::ProviderAdminHandle, ProviderAdminError> {
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<DashboardWireProfile> {
self.providers
) -> Result<Vec<DashboardWireProfile>, 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 分支。
@@ -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<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
sets: Arc<RwLock<BTreeMap<ExtensionSetId, Weak<ProviderAdminRegistry>>>>,
}
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<Item = Arc<dyn ProviderAdmin>>,
) -> Result<Arc<ProviderAdminRegistry>, 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(&registry));
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<RuntimeSnapshot>,
) -> Result<ProviderAdminRegistry, ProviderAdminError> {
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<dyn ProviderAdmin>,
pub(super) snapshot: Option<Arc<RuntimeSnapshot>>,
}
impl ProviderAdminHandle {
/// 管理准备与 Core 探测共享完整快照,不能在两次 await 之间重新解析插件版本。
#[must_use]
pub fn snapshot(&self) -> Option<Arc<RuntimeSnapshot>> {
self.snapshot.clone()
}
}
impl std::ops::Deref for ProviderAdminHandle {
type Target = dyn ProviderAdmin;
fn deref(&self) -> &Self::Target {
self.provider.as_ref()
}
}
impl AsRef<dyn ProviderAdmin> for ProviderAdminHandle {
fn as_ref(&self) -> &(dyn ProviderAdmin + 'static) {
self.provider.as_ref()
}
}
@@ -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<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作。
#[async_trait]
pub trait AccountStore: Send + Sync {
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页。
async fn list_plugin_accounts(
&self,
query: PluginAccountListQuery,
) -> AdminStoreResult<PluginAccountPage>;
async fn list_accounts(
&self,
query: AccountListQuery,
@@ -123,12 +130,24 @@ pub trait AccountStore: Send + Sync {
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
/// 插件按全局账号 ID 读取时,由数据库记录提供权威 Provider 归属。
async fn credential_details_by_id(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<CredentialDetails>>;
async fn load_credentials_for_export(
&self,
provider_kind: &gateway_core::routing::ProviderKind,
account_ids: &[gateway_core::account::ProviderAccountId],
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
/// 插件凭据读取只接收账号 ID,不接受调用方另行声明 Provider。
async fn load_credential_for_plugin(
&self,
account_id: &gateway_core::account::ProviderAccountId,
) -> AdminStoreResult<Option<ProviderExportCredentialInput>>;
async fn commit_credential_import(
&self,
command: CredentialImportCommit,
@@ -139,7 +158,13 @@ pub trait AccountStore: Send + Sync {
&self,
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult>;
) -> AdminStoreResult<crate::model::provider_credentials::AuthorizationCommitResult>;
/// 已提交的授权结果独立于插件进程和 Redis 临时状态;只有原管理员身份可读。
async fn authorization_receipt(
&self,
key: &crate::model::provider_credentials::AuthorizationReceiptKey,
) -> AdminStoreResult<Option<crate::model::provider_credentials::CredentialBatchMutationResult>>;
async fn commit_credential_rotation(
&self,
@@ -396,6 +421,9 @@ pub trait ObservabilityStore: Send + Sync {
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
/// 区间内请求及错误事实的平台集合,不依赖当前注册或账号是否仍存在。
async fn usage_provider_kinds(&self, range: TimeRange) -> AdminStoreResult<Vec<String>>;
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail>;
async fn usage_summary(
@@ -488,9 +516,25 @@ pub struct AdminStorePorts {
observability: Arc<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
}
impl AdminStorePorts {
#[must_use]
pub fn plugins(&self) -> Arc<dyn super::plugins::PluginStore> {
Arc::clone(&self.plugins)
}
#[must_use]
pub fn plugin_state(&self) -> Arc<dyn super::plugins::PluginStateStore> {
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<dyn ObservabilityStore>,
settings: Arc<dyn SettingsStore>,
backup: BackupStorePorts,
plugins: Arc<dyn super::plugins::PluginStore>,
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
) -> Self {
Self {
accounts,
@@ -507,6 +553,8 @@ impl AdminStorePorts {
observability,
settings,
backup,
plugins,
plugin_state,
}
}
@@ -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<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
/// Admin 对启用实例和制品作兼容决策;Host 只负责候选下载、校验与文件交换。
#[async_trait]
pub trait SystemUpdatePreflight: Send + Sync {
async fn validate(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError>;
/// 在文件交换临界点复核全局配置 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<String>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError>;
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn rollback(
&self,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError>;
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
}
@@ -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<dyn crate::ports::provider::ProviderAdmin>,
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<AccountDirectoryPage, AdminError> {
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(&quota),
@@ -449,14 +480,15 @@ impl AccountsService for DefaultAccountsService {
account: item.account,
projection: item.projection,
quota,
}
})
})
.collect();
.collect::<Result<Vec<_>, 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<ProviderAccountId>,
) -> Result<AccountExportBundle, AdminError> {
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::<Result<Vec<_>, 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<dyn crate::ports::provider::ProviderAdmin>,
crate::ports::provider_extensions::ProviderAdminHandle,
Vec<ProviderAccountId>,
),
>::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(&quota, 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
@@ -113,18 +113,11 @@ impl ClientKeyService for DefaultClientKeyService {
context: &MutationContext,
command: CreateClientKey,
) -> Result<CreatedClientKey, AdminError> {
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<ClientKeyMutation, AdminError> {
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"))?;
}
@@ -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<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl ProviderCredentials {
#[must_use]
pub(crate) fn new(
provider: ProviderAdminHandle,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
pub async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
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<AuthorizationStarted, AdminError> {
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<CredentialMutationResult, AdminError> {
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<CredentialDeletionResult, AdminError> {
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<CredentialMutationResult, AdminError> {
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<AuthorizationPollResult, AdminError> {
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<CredentialBatchMutationResult, AdminError> {
let result = outcome.result;
if outcome.newly_committed {
let ids = result
.accounts
.iter()
.map(|account| account.account_id.clone())
.collect::<Vec<_>>();
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<CredentialMutationResult, AdminError> {
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,
})
}
@@ -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<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl CredentialsService {
pub fn providers(
&self,
) -> Result<Vec<crate::model::provider_capabilities::ProviderCredentialDescriptor>, AdminError>
{
self.providers
.credential_descriptors()
.map_err(|error| super::map_provider_error(error, "provider capabilities"))
}
#[must_use]
pub fn new(
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
providers,
accounts,
proxies,
snapshot,
}
}
pub fn for_provider(&self, kind: &ProviderKind) -> Result<ProviderCredentials, AdminError> {
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(),
))
}
}
@@ -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<Registry>,
notify: Notify,
openai: Arc<dyn OpenAiService>,
xai: Arc<dyn XaiService>,
credentials: Arc<CredentialsService>,
}
impl DefaultImportTasksService {
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
pub(crate) fn new(credentials: Arc<CredentialsService>) -> Arc<Self> {
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
})
{
@@ -37,6 +37,12 @@ pub trait KeyUsageService: Send + Sync {
/// 验证 Key 并只读查询当前额度,不记录 Key 使用或执行推理准入。
async fn budget(&self, plaintext: &str) -> Result<Option<ClientBudgetStatus>, AdminError>;
/// 使用 Core 已认证的宿主身份查询额度,不接收插件自报的 Key ID。
async fn budget_for_client(
&self,
id: &ClientApiKeyId,
) -> Result<Option<ClientBudgetStatus>, AdminError>;
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, 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<Option<ClientBudgetStatus>, 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"))
@@ -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<dyn ProviderAdmin>,
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<CredentialDetails, AdminError> {
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<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
) -> Result<crate::model::provider_credentials::AuthorizationCommitResult, AdminError> {
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)
}
@@ -105,6 +105,7 @@ pub trait ObservabilityService: Send + Sync {
async fn dashboard_trend(&self, range: TimeRange, kind: TrendKind)
-> Result<Trend, AdminError>;
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError>;
async fn usage_provider_kinds(&self, range: TimeRange) -> Result<Vec<String>, AdminError>;
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError>;
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<Vec<String>, 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<UsageDetail, AdminError> {
if request_id.trim().is_empty() {
return Err(AdminError::invalid("用量记录 ID 不能为空"));
@@ -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<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn rotate(
&self,
command: RotateCredential,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultOpenAiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultOpenAiService {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
}
#[async_trait]
impl OpenAiService for DefaultOpenAiService {
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
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<AuthorizationStarted, AdminError> {
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<CredentialMutationResult, AdminError> {
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<CredentialMutationResult, AdminError> {
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<CredentialDeletionResult, AdminError> {
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)
}
}
@@ -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<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultPluginAccountAccess {
#[must_use]
pub(crate) fn new(
providers: ProviderAdminRegistry,
accounts: Arc<dyn AccountStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
providers,
accounts,
snapshot,
}
}
fn provider(
&self,
provider_kind: &ProviderKind,
) -> Result<crate::ports::provider_extensions::ProviderAdminHandle, AdminError> {
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<PluginAccountPage, AdminError> {
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<crate::model::accounts::AccountRecord, AdminError> {
Ok(
required_plugin_credential(
self.accounts.as_ref(),
account_id,
"plugin account runtime",
)
.await?
.credential,
)
}
async fn get_credential(
&self,
account_id: &ProviderAccountId,
) -> Result<PluginAccountCredential, AdminError> {
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<PluginAccountSaveResult, AdminError> {
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,
})
}
}
}
}
@@ -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<dyn ClientKeyService>,
}
impl DefaultPluginClientKeyAccess {
pub(crate) fn new(service: Arc<dyn ClientKeyService>) -> Self {
Self { service }
}
}
#[async_trait]
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
async fn list(
&self,
query: PluginClientKeyListQuery,
) -> Result<PluginClientKeyPage, AdminError> {
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,
})
}
}
@@ -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<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
}
impl PluginSystemUpdatePreflight {
pub(crate) fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
) -> Self {
Self { store, inspector }
}
}
#[async_trait]
impl SystemUpdatePreflight for PluginSystemUpdatePreflight {
async fn validate(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
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<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Invalid, message)
}
fn conflict(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Conflict, message)
}
fn internal(message: impl Into<String>) -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, message)
}
@@ -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<dyn PluginDistribution>,
pub(super) proxies: Arc<dyn ProxyStore>,
}
impl PluginDistributionPorts {
#[must_use]
pub fn new(transport: Arc<dyn PluginDistribution>, proxies: Arc<dyn ProxyStore>) -> Self {
Self { transport, proxies }
}
}
/// 所有安装入口在取得完整包后进入同一校验与事务流程。
pub struct PluginsService {
pub(super) store: Arc<dyn PluginStore>,
pub(super) inspector: Arc<dyn PluginPackageInspector>,
pub(super) distribution: PluginDistributionPorts,
pub(super) snapshots: Arc<dyn SnapshotControl>,
pub(super) preparation: Arc<dyn PluginPreparation>,
pub(super) published: gateway_core::runtime::RuntimeSnapshotHandle,
pub(super) state: PluginStateService,
}
impl PluginsService {
pub fn permission_descriptions(
&self,
permissions: &[String],
) -> Vec<crate::model::plugins::PluginPermissionDescription> {
self.inspector.permission_descriptions(permissions)
}
#[must_use]
pub fn new(
store: Arc<dyn PluginStore>,
inspector: Arc<dyn PluginPackageInspector>,
distribution: PluginDistributionPorts,
snapshots: Arc<dyn SnapshotControl>,
preparation: Arc<dyn PluginPreparation>,
published: gateway_core::runtime::RuntimeSnapshotHandle,
state: Arc<dyn PluginStateStore>,
) -> Self {
Self {
store,
inspector,
distribution,
snapshots,
preparation,
published,
state: PluginStateService::new(state),
}
}
pub async fn list(&self) -> Result<Vec<InstalledPluginArtifact>, AdminError> {
self.store
.list_artifacts()
.await
.map_err(|error| map_store_error(error, "plugin"))
}
pub async fn icon(
&self,
digest: &str,
theme: PluginIconTheme,
) -> Result<PluginArtifactIconResource, AdminError> {
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<VerifiedPluginArtifact, AdminError> {
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<String>,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
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<PluginInstallResult, AdminError> {
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<PluginInstallResult, AdminError> {
let digest = mutation.artifact.metadata.sha256.clone();
self.accept_artifact(&digest, context).await
}
async fn finish_install(
&self,
mut mutation: PluginArtifactMutation,
context: &MutationContext,
) -> Result<PluginInstallResult, AdminError> {
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<Option<(Option<String>, 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::<BTreeSet<_>>();
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<PluginArtifactMutation, AdminError> {
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<Revision, AdminError> {
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<serde_json::Value> {
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<PluginCapabilityBinding> {
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<PluginCapabilityBinding>,
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,
}
}
@@ -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<Vec<crate::model::plugins::distribution::PluginSourceBinding>, 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<Revision, AdminError> {
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<PluginUpdateCheck, AdminError> {
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<Vec<SourceCredentialInfo>, 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<SourceCredentialInfo, AdminError> {
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<Revision, AdminError> {
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<PluginRelease, AdminError> {
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<PluginInstallResult, AdminError> {
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<VerifiedPluginArtifact, AdminError> {
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<Option<PluginDistributionEgress>, 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<Vec<SourceCredential>, AdminError> {
if ids.len() > 8 || ids.iter().collect::<BTreeSet<_>>().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(())
}
@@ -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<Vec<PluginInstanceView>, 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::<BTreeMap<_, _>>();
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<PluginInstanceMutation, AdminError> {
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<PluginInstance>,
input: ConfigurePluginInstance,
context: &MutationContext,
) -> Result<PluginInstanceMutation, AdminError> {
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<PluginInstanceMutation, AdminError> {
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<Revision>,
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<Revision, AdminError> {
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<PluginInstanceMutation, AdminError> {
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::<usize>()
> 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::<BTreeSet<_>>().len()
!= binding.client_key_ids.len()
|| binding
.account_group_ids
.iter()
.collect::<BTreeSet<_>>()
.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::<BTreeSet<_>>()
.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, AdminError> {
Revision::new(
revision
.get()
.checked_add(1)
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
)
.map_err(|_| AdminError::internal("配置版本不合法"))
}
@@ -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<dyn PluginManagement>,
store: Arc<dyn PluginStore>,
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<crate::model::plugins::management::PluginManagementCallbackTicket, AdminError> {
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<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime
.callback(&reference, target, state, request)
.await
}
#[must_use]
pub fn new(
runtime: Arc<dyn PluginManagement>,
store: Arc<dyn PluginStore>,
published: RuntimeSnapshotHandle,
) -> Self {
Self {
runtime,
store,
published,
}
}
pub async fn views(&self) -> Result<Vec<PluginManagementView>, 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<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime
.resource(&reference, target, path, public)
.await
}
pub async fn handle(
&self,
target: &PluginManagementTarget,
request: PluginManagementRequest,
) -> Result<PluginManagementResponse, AdminError> {
let reference = self.authorize(target).await?;
self.runtime.handle(&reference, target, request).await
}
fn reference(&self) -> Result<Option<ExtensionSetReference>, AdminError> {
self.published
.acquire()
.map(|snapshot| snapshot.extensions().cloned())
.map_err(|_| AdminError::unavailable("插件发布视图暂不可用"))
}
async fn authorize(
&self,
target: &PluginManagementTarget,
) -> Result<ExtensionSetReference, AdminError> {
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
}
@@ -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;
@@ -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<OfficialPlugin>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct OfficialPlugin {
id: String,
version: String,
publisher: String,
stability: String,
artifacts: Vec<OfficialArtifact>,
}
#[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<OfficialPluginImport, AdminError> {
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<OfficialPlugin>,
plugins_len: usize,
}
fn validate_manifest(
bytes: &[u8],
identity: &OfficialPluginReleaseIdentity,
) -> Result<ValidatedManifest, AdminError> {
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<PluginHostCompatibility, AdminError> {
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<SealedReleaseManifest, AdminError> {
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<Platform> {
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<OfficialArtifact, AdminError> {
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<Platform> {
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",
},
];
@@ -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<dyn PluginStateStore>,
}
impl PluginStateService {
#[must_use]
pub(super) fn new(store: Arc<dyn PluginStateStore>) -> Self {
Self { store }
}
pub(super) async fn transition_required(
&self,
instance_id: &str,
target: &PluginStateConfiguration,
) -> Result<bool, AdminError> {
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<PluginStateTransition, AdminError> {
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("插件状态存储暂不可用"),
}
}
@@ -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<PluginInstanceMutation, AdminError> {
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(&current.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<PluginRollbackPlan, AdminError> {
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(&current.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<PluginVersionPlan, AdminError> {
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<PluginInstanceMutation, AdminError> {
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, &current.metadata, &target.metadata),
},
false,
))
}
}
fn version_bindings(
instance: &PluginInstance,
current: &PluginArtifactMetadata,
target: &PluginArtifactMetadata,
) -> Vec<crate::model::plugins::instances::PluginCapabilityBinding> {
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<Option<semver::Version>, AdminError> {
if current.plugin_id != target.plugin_id {
return Ok(None);
}
let current_version = semver::Version::parse(&current.version)
.map_err(|_| AdminError::invalid("当前插件版本无效"))?;
let target_version = semver::Version::parse(&target.version)
.map_err(|_| AdminError::invalid("回滚目标版本无效"))?;
Ok(target_version
.cmp_precedence(&current_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);
}
}
}
@@ -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<Vec<gateway_core::routing::ProviderKind>, 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<Arc<dyn crate::ports::provider::ProviderAdmin>, AdminError> {
) -> Result<crate::ports::provider_extensions::ProviderAdminHandle, AdminError> {
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<Vec<gateway_core::routing::ProviderKind>, 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<gateway_core::account::OpaqueProviderData, AdminError> {
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<RuntimeSettings, AdminError> {
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"))?;
}
}
@@ -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<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
}
impl DefaultSystemService {
#[must_use]
pub(crate) const fn new(operations: Arc<dyn SystemOperations>) -> Self {
Self { operations }
pub(crate) fn new(
operations: Arc<dyn SystemOperations>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> 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<SystemOperationAccepted, AdminError> {
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<SystemOperationAccepted, AdminError> {
@@ -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<CredentialImportResult, AdminError>;
async fn start_authorization(
&self,
command: StartAuthorization,
) -> Result<AuthorizationStarted, AdminError>;
async fn complete_authorization(
&self,
command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError>;
async fn delete(
&self,
command: CredentialDeletion,
) -> Result<CredentialDeletionResult, AdminError>;
}
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
impl DefaultXaiService {
#[must_use]
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
}
#[async_trait]
impl XaiService for DefaultXaiService {
async fn import_document(
&self,
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
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<AuthorizationStarted, AdminError> {
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<CredentialMutationResult, AdminError> {
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<CredentialDeletionResult, AdminError> {
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)
}
}
@@ -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(&quota(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);
@@ -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<Mutex<Vec<&'static str>>>;
pub(super) struct FakeProviderAdmin {
kind: ProviderKind,
credential_capabilities: Mutex<ProviderCredentialCapabilities>,
capabilities: Mutex<gateway_admin::model::provider_capabilities::ProviderAccountCapabilities>,
events: EventLog,
failure: Mutex<Option<ProviderAdminError>>,
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
retry_authorization_after_abort: Mutex<bool>,
authorization_accounts: Mutex<Option<Vec<PreparedCredentialCreate>>>,
authorization_polls: Mutex<
std::collections::VecDeque<
gateway_admin::model::provider_credentials::PreparedAuthorizationPoll,
>,
>,
export_inputs: Mutex<Vec<ProviderExportCredentialInput>>,
import_account_ids: Mutex<Vec<String>>,
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
@@ -86,15 +95,33 @@ pub(super) struct FakeProviderAdmin {
}
impl FakeProviderAdmin {
pub(super) fn set_authorization_accounts(&self, accounts: Vec<PreparedCredentialCreate>) {
*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<Self> {
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<AuthorizationStarted, ProviderAdminError> {
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<Vec<AccountRecord>>,
account_after_probe: Mutex<Option<AccountRecord>>,
fail_commit: Mutex<bool>,
receipts: Mutex<
std::collections::BTreeMap<
gateway_admin::model::provider_credentials::AuthorizationReceiptKey,
gateway_admin::model::provider_credentials::CredentialBatchMutationResult,
>,
>,
audit_requests: Mutex<Vec<String>>,
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
@@ -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<gateway_admin::model::provider_credentials::PluginAccountPage> {
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::<std::collections::BTreeSet<_>>()
.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<Option<CredentialDetails>> {
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<Option<ProviderExportCredentialInput>> {
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<CredentialMutationResult> {
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
{
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<gateway_admin::model::provider_credentials::CredentialBatchMutationResult>,
> {
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<RuntimeSettings> {
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<dyn ProviderAdmin>]).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<dyn ProviderAdmin>]).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::<Vec<_>>(),
["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<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Ok(AccountProbeResult {
@@ -2950,6 +3468,7 @@ impl AccountProbe for FailingAccountProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
let detail = ClientVisibleUpstreamError::new(
@@ -2979,6 +3498,7 @@ impl AccountProbe for FreeModelQuotaProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
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());
}
@@ -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<Vec<String>>,
list_queries: Mutex<Vec<ClientKeyListQuery>>,
list_response: Mutex<Option<ClientKeyPage>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
resets: Mutex<Vec<gateway_admin::model::client_keys::ResetClientKeyBudget>>,
@@ -44,6 +50,10 @@ impl ClientKeyStore for TestClientKeyStore {
}
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
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::<Arc<dyn ProviderAdmin>>::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,
@@ -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<Self>) -> Result<(), AdminError> {
self.0.lock().unwrap().push("claim.consume_failed");
Err(AdminError::unavailable("injected pending store failure"))
}
async fn abort(self: Box<Self>) -> 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<Self>) -> Result<(), AdminError> {
self.0.lock().unwrap().push("claim.commit");
Ok(())
}
async fn abort(self: Box<Self>) -> 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::<Vec<_>>(),
["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());
}
@@ -0,0 +1,3 @@
mod flow;
mod openai;
mod xai;
@@ -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<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> 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);
@@ -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<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
super::AdminHarness::new()
super::super::AdminHarness::new()
.provider(provider)
.accounts(store)
.build()
@@ -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<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Ok(AccountProbeResult {
@@ -216,6 +216,7 @@ impl AccountProbe for FailingProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Err(AccountProbeError::new(
@@ -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<Arc<dyn ProviderAdmin>>,
probe: Arc<dyn AccountProbe>,
system: Arc<dyn SystemOperations>,
plugin_store: Arc<dyn gateway_admin::ports::plugins::PluginStore>,
plugin_inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
client_key_verifier: Arc<dyn ClientKeyVerifier>,
}
@@ -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<dyn gateway_admin::ports::plugins::PluginStore>,
inspector: Arc<dyn gateway_admin::ports::plugins::PluginPackageInspector>,
) -> 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<gateway_admin::model::provider_credentials::PluginAccountPage> {
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<Option<CredentialDetails>> {
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<Option<ProviderExportCredentialInput>> {
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<gateway_admin::model::provider_credentials::CredentialBatchMutationResult>,
> {
Err(unavailable("authorization receipt"))
}
async fn commit_authorization(
&self,
_: AuthorizationCommit,
_: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
) -> AdminStoreResult<gateway_admin::model::provider_credentials::AuthorizationCommitResult>
{
Err(unavailable("authorization"))
}
@@ -682,6 +738,10 @@ impl ObservabilityStore for UnavailableStore {
Err(unavailable("usage records"))
}
async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult<Vec<String>> {
Err(unavailable("usage providers"))
}
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
Err(unavailable("usage detail"))
}
@@ -784,6 +844,10 @@ impl UnavailableProvider {
}
}
pub(super) fn test_provider(kind: &str) -> Arc<dyn ProviderAdmin> {
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<AuthorizationStarted, ProviderAdminError> {
Err(unsupported_provider())
}
@@ -955,6 +1019,7 @@ impl AccountProbe for UnavailableProbe {
fn probe(
&self,
_: AccountProbeRequest,
_: Option<Arc<gateway_core::routing::RuntimeSnapshot>>,
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
Box::pin(async {
Err(GatewayError::new(
@@ -985,6 +1050,7 @@ impl SystemOperations for UnavailableSystem {
async fn perform_update(
&self,
_: Option<String>,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
@@ -993,7 +1059,10 @@ impl SystemOperations for UnavailableSystem {
Err(unavailable_system())
}
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
async fn rollback(
&self,
_: Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Err(unavailable_system())
}
@@ -750,6 +750,10 @@ impl ObservabilityStore for FixtureObservabilityStore {
))
}
async fn usage_provider_kinds(&self, _: TimeRange) -> AdminStoreResult<Vec<String>> {
Ok(vec!["retired-provider".into()])
}
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
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<RuntimeSettings> {
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"),
@@ -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<Vec<Revision>>,
requirements: PluginCompatibilityRequirements,
}
impl Fixture {
fn new(requirements: PluginCompatibilityRequirements, revisions: &[u64]) -> Arc<Self> {
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<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
unreachable!()
}
async fn compatibility(
&self,
_: Arc<[u8]>,
expected_sha256: String,
) -> Result<PluginCompatibilityRequirements, AdminError> {
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<bool> {
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<PluginInstanceSnapshot> {
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<InspectedPluginArtifact> {
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<PluginInstanceMutation> {
unreachable!()
}
async fn delete_instance(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
unreachable!()
}
async fn change_update_source(
&self,
_: PluginSourceBinding,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
unreachable!()
}
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
unreachable!()
}
async fn save_source_credential(
&self,
_: SourceCredential,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn delete_source_credential(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
unreachable!()
}
async fn install_artifact(
&self,
_: InspectedPluginArtifact,
_: PluginSource,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
unreachable!()
}
async fn accept_artifact(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
unreachable!()
}
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
unreachable!()
}
}
struct PreflightingSystem {
candidate: SystemUpdateCandidate,
}
#[async_trait]
impl SystemOperations for PreflightingSystem {
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
unreachable!()
}
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
unreachable!()
}
fn update_events(&self) -> SystemUpdateEventStream {
Box::pin(futures::stream::empty())
}
async fn perform_update(
&self,
_: Option<String>,
preflight: Arc<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
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<SystemUpdateStatus, SystemOperationError> {
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<dyn SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
unreachable!()
}
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
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(),
}
}
@@ -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<u64>,
}
struct Fixture {
data: Mutex<FixtureData>,
}
impl Fixture {
fn new(metadata: PluginArtifactMetadata, existing: Option<PluginInstance>) -> Arc<Self> {
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<Self>) -> 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<bool, AdminError> {
Ok(instance
.configuration
.get("requiredName")
.is_some_and(serde_json::Value::is_string))
}
async fn validate(&self, _: PluginInstance) -> Result<PluginStateConfiguration, AdminError> {
Ok(PluginStateConfiguration {
namespaces: Vec::new(),
})
}
async fn prepare(
&self,
source_revision: Revision,
_: PluginInstanceSnapshot,
) -> Result<ExtensionSetReference, AdminError> {
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<bool> {
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<PluginInstanceSnapshot> {
Ok(self.data.lock().unwrap().snapshot.clone())
}
async fn save_instance(
&self,
mut instance: PluginInstance,
expected_revision: Revision,
_: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
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<Revision> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
Ok(Vec::new())
}
async fn change_update_source(
&self,
_: PluginSourceBinding,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
Ok(Vec::new())
}
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn save_source_credential(
&self,
_: SourceCredential,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn delete_source_credential(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
Ok(vec![self.data.lock().unwrap().artifact.clone()])
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn install_artifact(
&self,
_: InspectedPluginArtifact,
_: PluginSource,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
Err(store_error(AdminStoreErrorKind::Unavailable))
}
async fn accept_artifact(
&self,
digest: &str,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
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<Revision> {
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::<Vec<_>>(),
[
(
"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")
}
@@ -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<PluginSourceBinding>,
queries: Mutex<Vec<GithubReleaseQuery>>,
publications: Mutex<Vec<u64>>,
changes: Mutex<usize>,
query_fails: bool,
change_during_query: bool,
package: Option<InspectedPluginArtifact>,
downloads: Mutex<usize>,
change_during_download: bool,
proxy: Mutex<Option<ProxyRecord>>,
egresses: Mutex<Vec<Option<PluginSourceEgress>>>,
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<Self>) -> 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<ProxyImportReservation> {
unreachable!()
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
unreachable!()
}
async fn list_accounts(&self, _: ProxyAccountListQuery) -> AdminStoreResult<ProxyAccountPage> {
unreachable!()
}
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
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<Revision> {
unreachable!()
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
unreachable!()
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
unreachable!()
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn record_test(
&self,
_: &str,
_: Revision,
_: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
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<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError> {
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<SourceCredential>,
egress: Option<PluginDistributionEgress>,
) -> Result<DownloadedPlugin, AdminError> {
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<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
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<bool> {
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<Vec<PluginSourceBinding>> {
Ok(vec![self.binding.lock().unwrap().clone()])
}
async fn change_update_source(
&self,
binding: PluginSourceBinding,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
*self.binding.lock().unwrap() = binding;
*self.changes.lock().unwrap() += 1;
Ok(Revision::new(2).unwrap())
}
async fn load_instances(&self) -> AdminStoreResult<PluginInstanceSnapshot> {
unreachable!()
}
async fn save_instance(
&self,
_: PluginInstance,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
unreachable!()
}
async fn delete_instance(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
unreachable!()
}
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
Err(super::unavailable())
}
async fn save_source_credential(
&self,
_: SourceCredential,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn delete_source_credential(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
unreachable!()
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
unreachable!()
}
async fn install_artifact(
&self,
_: InspectedPluginArtifact,
_: PluginSource,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
panic!("检查更新不能安装产物")
}
async fn accept_artifact(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
unreachable!()
}
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
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::<PluginSourceBinding>(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, .. }
));
}
File diff suppressed because it is too large Load Diff
@@ -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<gateway_admin::ports::proxy::ProxyImportReservation> {
Err(unavailable())
}
async fn list(
&self,
_: gateway_admin::model::proxies::ProxyListQuery,
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyPage> {
Err(unavailable())
}
async fn list_accounts(
&self,
_: gateway_admin::model::proxies::ProxyAccountListQuery,
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyAccountPage> {
Err(unavailable())
}
async fn get(&self, _: &str) -> AdminStoreResult<gateway_admin::model::proxies::ProxyRecord> {
Err(unavailable())
}
async fn remove_account(
&self,
_: &str,
_: &gateway_core::account::ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn create(
&self,
_: gateway_admin::model::proxies::NewProxy,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyMutation> {
Err(unavailable())
}
async fn update(
&self,
_: gateway_admin::model::proxies::UpdateProxy,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyMutation> {
Err(unavailable())
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn record_test(
&self,
_: &str,
_: Revision,
_: gateway_admin::model::proxies::ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::proxies::ProxyRecord> {
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<gateway_admin::model::plugins::management::PluginManagementCallbackTicket, AdminError>
{
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<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
{
Err(AdminError::not_found("unused plugin fixture"))
}
async fn views(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
) -> Result<Vec<gateway_admin::model::plugins::management::PluginManagementView>, AdminError>
{
Ok(Vec::new())
}
async fn resource(
&self,
_: &gateway_core::runtime::extensions::ExtensionSetReference,
_: &gateway_admin::model::plugins::management::PluginManagementTarget,
_: &str,
_: bool,
) -> Result<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
{
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<gateway_admin::model::plugins::management::PluginManagementResponse, AdminError>
{
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<bool, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
async fn validate(
&self,
_: gateway_admin::model::plugins::instances::PluginInstance,
) -> Result<gateway_admin::model::plugins::state::PluginStateConfiguration, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
async fn prepare(
&self,
_: Revision,
_: gateway_admin::model::plugins::instances::PluginInstanceSnapshot,
) -> Result<gateway_core::runtime::extensions::ExtensionSetReference, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
}
fn unavailable() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
"plugin",
"unused plugin fixture",
)
}
fn state_unavailable<T>() -> PluginStateStoreResult<T> {
Err(PluginStateStoreError::new(
PluginStateStoreErrorKind::Unavailable,
))
}
#[async_trait]
impl PluginStateStore for TestPluginPorts {
async fn load_owner(
&self,
_: PluginStateOwnerRequest,
) -> PluginStateStoreResult<Option<PluginStateOwner>> {
state_unavailable()
}
async fn get(
&self,
_: &PluginStateOwner,
_: &str,
_: &str,
) -> PluginStateStoreResult<Option<PluginStateRecord>> {
state_unavailable()
}
async fn put(
&self,
_: &PluginStateOwner,
_: PutPluginState,
) -> PluginStateStoreResult<PluginStateWrite> {
state_unavailable()
}
async fn delete(
&self,
_: &PluginStateOwner,
_: DeletePluginState,
) -> PluginStateStoreResult<bool> {
state_unavailable()
}
async fn transition_required(
&self,
_: &str,
_: &PluginStateConfiguration,
) -> PluginStateStoreResult<bool> {
state_unavailable()
}
async fn begin_transition(
&self,
_: &str,
_: Revision,
_: &str,
_: PluginStateConfiguration,
) -> PluginStateStoreResult<PluginStateTransition> {
state_unavailable()
}
async fn migration_batch(
&self,
_: &str,
_: &str,
_: u32,
) -> PluginStateStoreResult<PluginStateMigrationBatch> {
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<bool> {
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<gateway_admin::model::plugins::instances::PluginInstanceSnapshot> {
Err(unavailable())
}
async fn save_instance(
&self,
_: gateway_admin::model::plugins::instances::PluginInstance,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<gateway_admin::model::plugins::instances::PluginInstanceMutation> {
Err(unavailable())
}
async fn delete_instance(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
Ok(Vec::new())
}
async fn change_update_source(
&self,
_: PluginSourceBinding,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
Ok(Vec::new())
}
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
Err(unavailable())
}
async fn save_source_credential(
&self,
_: SourceCredential,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn delete_source_credential(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(unavailable())
}
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
Ok(Vec::new())
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
Err(unavailable())
}
async fn install_artifact(
&self,
_: InspectedPluginArtifact,
_: PluginSource,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
Err(unavailable())
}
async fn accept_artifact(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
Err(unavailable())
}
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
Err(unavailable())
}
}
#[async_trait]
impl PluginPackageInspector for TestPluginPorts {
async fn inspect(
&self,
_: Arc<[u8]>,
_: Option<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
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<SourceCredential>,
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
) -> Result<PluginRelease, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
async fn download(
&self,
_: RemotePluginLocation,
_: Vec<SourceCredential>,
_: Option<gateway_admin::model::plugins::distribution::PluginDistributionEgress>,
) -> Result<DownloadedPlugin, AdminError> {
Err(AdminError::invalid("unused plugin fixture"))
}
}
@@ -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<Arc<[u8]>>,
archives: BTreeMap<String, Arc<[u8]>>,
inspected_publisher: String,
installs: std::sync::Mutex<Vec<(String, PluginSource)>>,
publications: std::sync::Mutex<Vec<u64>>,
fail_archive: Option<Vec<u8>>,
}
impl Fixture {
fn new(plugins: &[(&str, u8)]) -> Arc<Self> {
let artifacts = plugins
.iter()
.map(|(id, marker)| plugin_entry(id, *marker))
.collect::<Vec<_>>();
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<Self>) -> 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<Option<Arc<[u8]>>, OfficialPluginReleaseReadError> {
Ok(self.manifest.clone())
}
async fn artifact(&self, file_name: &str) -> Result<Arc<[u8]>, 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<String>,
) -> Result<InspectedPluginArtifact, AdminError> {
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<bool> {
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<PluginInstanceSnapshot> {
unreachable!()
}
async fn save_instance(
&self,
_: PluginInstance,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<PluginInstanceMutation> {
unreachable!()
}
async fn delete_instance(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_update_sources(&self) -> AdminStoreResult<Vec<PluginSourceBinding>> {
unreachable!()
}
async fn change_update_source(
&self,
_: PluginSourceBinding,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_source_credentials(&self) -> AdminStoreResult<Vec<SourceCredentialInfo>> {
unreachable!()
}
async fn load_source_credential(&self, _: &str) -> AdminStoreResult<SourceCredential> {
unreachable!()
}
async fn save_source_credential(
&self,
_: SourceCredential,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn delete_source_credential(
&self,
_: &str,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
unreachable!()
}
async fn list_artifacts(&self) -> AdminStoreResult<Vec<InstalledPluginArtifact>> {
unreachable!()
}
async fn load_artifact(&self, _: &str) -> AdminStoreResult<InspectedPluginArtifact> {
unreachable!()
}
async fn install_artifact(
&self,
artifact: InspectedPluginArtifact,
source: PluginSource,
_: &MutationContext,
) -> AdminStoreResult<PluginArtifactMutation> {
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<PluginArtifactMutation> {
unreachable!()
}
async fn delete_artifact(&self, _: &str, _: &MutationContext) -> AdminStoreResult<Revision> {
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:?}"),
}
}
@@ -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);
@@ -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<Option<UpdatePricing>>,
}
#[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<gateway_admin::model::pricing::StoredPricing> {
@@ -78,10 +144,65 @@ impl SettingsStore for UnusedSettingsStore {
}
}
#[async_trait]
impl SettingsStore for PricingSettingsStore {
async fn load_pricing(&self) -> AdminStoreResult<StoredPricing> {
Ok(StoredPricing::default())
}
async fn sync_pricing(
&self,
_: gateway_admin::model::pricing::PricingSyncChanges,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
panic!("unexpected pricing sync")
}
async fn update_pricing(
&self,
command: UpdatePricing,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
*self.updated.lock().expect("updated pricing") = Some(command);
Ok(Revision::new(2).expect("revision"))
}
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
Err(unused())
}
async fn replace_runtime_settings(
&self,
_: ReplaceRuntimeSettings,
_: &MutationContext,
) -> AdminStoreResult<RuntimeSettings> {
Err(unused())
}
async fn replace_admin_api_key(
&self,
_: AdminApiKey,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
Err(unused())
}
async fn delete_admin_api_key(
&self,
_: &MutationContext,
) -> AdminStoreResult<AdminApiKeyMutation> {
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(),
@@ -54,6 +54,7 @@ impl SystemOperations for RecordingSystemOperations {
async fn perform_update(
&self,
target_version: Option<String>,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
*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<SystemOperationAccepted, SystemOperationError> {
async fn rollback(
&self,
_: std::sync::Arc<dyn gateway_admin::ports::system::SystemUpdatePreflight>,
) -> Result<SystemOperationAccepted, SystemOperationError> {
Ok(SystemOperationAccepted::Rollback {
operation_id: "operation-rollback".to_owned(),
message: "accepted".to_owned(),
+1
View File
@@ -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"] }
@@ -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<Value>,
pub login: Option<AccountLoginCapabilityData>,
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<ProviderCredentialDescriptor> 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<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
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)))
}
@@ -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<Self, WireValidationError> {
match value.trim() {
"openai" => Ok(Self::OpenAi),
"xai" => Ok(Self::Xai),
_ => Err(WireValidationError::new("provider")),
}
}
fn parse_provider(value: &str) -> Result<ProviderKind, WireValidationError> {
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<String>,
@@ -123,12 +111,17 @@ pub struct StartAccountAuthorizationRequest {
pub name: String,
pub account_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
#[serde(default)]
pub input: Map<String, Value>,
}
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<String>,
pub settings: Option<AccountImportSettingsRequest>,
}
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<String> },
}
impl From<AuthorizationPollResult> 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<AccountConnectionUpdateRequest>,
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
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<UpdateAccount, WireValidationError> {
pub(super) fn into_command(
self,
) -> Result<(UpdateAccount, Option<ProviderDocument>), 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<String>,
}
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<String>,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub transport: Option<String>,
pub settings: Option<UpdateAccountRequest>,
}
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<RotateCredential, WireValidationError> {
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())
}
@@ -12,12 +12,15 @@ where
Router::new()
.merge(super::import_tasks::router::<S>())
.route("/api/admin/accounts", get(list_accounts::<S>))
.route(
"/api/admin/accounts/providers",
get(super::capabilities::providers::<S>),
)
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
.route("/api/admin/accounts/recover", post(recover_account::<S>))
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
.route("/api/admin/accounts/update", post(update_account::<S>))
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
.route(
@@ -62,6 +65,10 @@ where
"/api/admin/accounts/oauth/complete",
post(complete_account_authorization::<S>),
)
.route(
"/api/admin/accounts/oauth/poll",
post(poll_account_authorization::<S>),
)
}
async fn batch_update_accounts<S>(
@@ -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<S>(
async fn poll_account_authorization<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RotateAccountRequest>,
AdminJson(request): AdminJson<PollAccountAuthorizationRequest>,
) -> Result<impl IntoResponse, AdminError>
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<S>(
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)),
@@ -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::<Result<Vec<_>, WireValidationError>>()
@@ -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;
@@ -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<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
capabilities,
plan_type_display,
projection,
usage,
@@ -77,6 +79,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> 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,
@@ -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<Option<ProviderKind>, 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<AccountView>,
pub page: PageMeta,
pub summary: AccountSummaryView,
pub providers: Vec<String>,
}
/// 账号概览计数。
@@ -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<String>,
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<gateway_admin::model::provider_capabilities::ProviderAccountCapabilities>
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")]
@@ -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<String, serde_json::Map<String, serde_json::Value>>;
type ProviderRequestProfileOverrideUpdates =
BTreeMap<String, Option<serde_json::Map<String, serde_json::Value>>>;
fn parse_budget(
value: Option<String>,
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<serde_json::Map<String, serde_json::Value>>,
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
custom_key: Option<String>,
@@ -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<Option<serde_json::Map<String, serde_json::Value>>>,
#[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<serde_json::Map<String, serde_json::Value>>,
/// 固定兼容字段;值始终从 provider_request_profile_overrides 派生。
xai_client_profile_override: Option<serde_json::Map<String, serde_json::Value>>,
id: String,
name: String,
@@ -364,13 +375,17 @@ impl From<ClientKeyRecord> 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::<ProviderRequestProfileOverrides>();
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<serde_json::Map<String, serde_json::Value>>,
xai: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<gateway_admin::model::client_keys::ProviderRequestProfileOverrides, WireValidationError>
{
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::<Result<gateway_admin::model::client_keys::ProviderRequestProfileOverrides, _>>(
)?;
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<Option<serde_json::Map<String, serde_json::Value>>>,
xai: Option<Option<serde_json::Map<String, serde_json::Value>>>,
) -> 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::<Result<
gateway_admin::model::client_keys::ProviderRequestProfileOverrideUpdates,
_,
>>()?;
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<String, serde_json::Value>,
) -> 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));

Some files were not shown because too many files have changed in this diff Show More