fix(accounts): export native catalog from selected account

This commit is contained in:
Zyy
2026-09-24 14:00:22 +08:00
802 changed files with 109203 additions and 15199 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
File diff suppressed because one or more lines are too long
+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,
}
@@ -16,7 +16,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,
},
@@ -385,13 +385,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 流程启动结果。
@@ -426,8 +432,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 必须一起返回。
@@ -450,18 +456,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 并
@@ -495,6 +614,7 @@ impl PreparedAuthorizationCommit {
pub(crate) fn into_commit(
self,
settings: Option<AccountImportSettings>,
key: AuthorizationReceiptKey,
) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
@@ -503,13 +623,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,
@@ -520,13 +641,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(())
@@ -1069,6 +1185,62 @@ pub struct ProviderModelCatalogDocument {
pub observed_at: 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 {
@@ -1104,6 +1276,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,
@@ -1118,6 +1291,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,12 +15,11 @@ use crate::model::observability::{
};
use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModelCatalogDocument, ProviderModels, ProviderProfileAvatar, ProviderProfileStatistics,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderSubscription, explicit_plan_type,
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
};
use crate::model::{
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
@@ -118,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,
@@ -125,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,
@@ -151,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,
@@ -181,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(
@@ -189,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,
@@ -284,16 +311,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 的注册表。
@@ -313,17 +393,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))
}
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
@@ -348,7 +436,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),
@@ -364,14 +452,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,
ProviderModelCatalogDocument, ProviderModels, ProviderProfileAvatar, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, QuotaLocalUsageAttribution,
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
PrepareCredentialRefresh, ProviderModelCatalogDocument, 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::{
@@ -215,7 +220,7 @@ impl DefaultAccountsService {
) -> Result<
(
AccountPageItem,
Arc<dyn crate::ports::provider::ProviderAdmin>,
crate::ports::provider_extensions::ProviderAdminHandle,
),
AdminError,
> {
@@ -319,14 +324,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 {
@@ -347,6 +363,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,
@@ -363,6 +381,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()
@@ -398,7 +420,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!(
@@ -439,13 +461,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),
@@ -454,14 +485,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,
})
}
@@ -470,6 +502,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 之间"));
}
@@ -488,12 +524,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)
@@ -651,7 +696,7 @@ impl AccountsService for DefaultAccountsService {
let mut providers = BTreeMap::<
ProviderKind,
(
Arc<dyn crate::ports::provider::ProviderAdmin>,
crate::ports::provider_extensions::ProviderAdminHandle,
Vec<ProviderAccountId>,
),
>::new();
@@ -715,7 +760,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,
@@ -913,6 +966,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 {
@@ -928,12 +982,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(
@@ -66,6 +69,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>(
@@ -172,17 +179,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)),
@@ -200,23 +204,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)),
@@ -234,49 +229,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)),
))
}
@@ -288,13 +272,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)),
@@ -312,11 +322,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,14 +26,15 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, ProviderSubscription, RotateCredential, StartAuthorization,
AccountRefreshResult, AccountUsagePeriod, AuthorizationPollResult, AuthorizationStarted,
CompleteAuthorization, ConsumeProviderResetCredit, CredentialDeletion,
CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, PollAuthorization, ProviderDocument,
ProviderModelCatalogDocument, ProviderModels, ProviderProfileActivityInsights,
ProviderProfileAvatar, ProviderProfileDailyUsage, ProviderProfileInvocation,
ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota,
ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
ProviderSubscription, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -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")]

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