mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e30aad4755 | ||
|
|
945f7912ac | ||
|
|
19038f264a | ||
|
|
71c3b03878 | ||
|
|
70d1557b55 | ||
|
|
b4d11694ae | ||
|
|
718b959fc1 | ||
|
|
11c076ef5a | ||
|
|
b37d790144 | ||
|
|
3a04e166c2 | ||
|
|
ec21d83415 | ||
|
|
fe78c230e5 | ||
|
|
d83d3eb803 | ||
|
|
faee3b2642 | ||
|
|
a5a844af16 | ||
|
|
d6bdb9d191 | ||
|
|
33aa161863 | ||
|
|
dc45aee5da | ||
|
|
b3fa5322f1 | ||
|
|
8fb42300c1 | ||
|
|
a1fc368474 | ||
|
|
9a1e90dd3d | ||
|
|
e7b0551f1a | ||
|
|
dde4a91974 | ||
|
|
bb859455f9 | ||
|
|
e06ca56f93 | ||
|
|
2a65b53b44 | ||
|
|
3915aa259b | ||
|
|
d310286e33 | ||
|
|
ea4e3059db | ||
|
|
3bf7030fba | ||
|
|
5d5965c6ca | ||
|
|
0710ed1f7c | ||
|
|
4634d8dc6d | ||
|
|
8a0759905e | ||
|
|
e830b2ef48 | ||
|
|
ae8da00459 | ||
|
|
7bbea42fba | ||
|
|
2e0669d462 | ||
|
|
2df9494e99 | ||
|
|
0534dd8f26 | ||
|
|
525f3d51c1 | ||
|
|
33cc427b86 | ||
|
|
d1bf90d5fd | ||
|
|
eb23a0fb3b | ||
|
|
ca90f2b586 | ||
|
|
cfcf7c486a | ||
|
|
b981e42342 | ||
|
|
02177af921 | ||
|
|
2d20f03bcb | ||
|
|
c12c600b42 | ||
|
|
1c6b5a8f10 | ||
|
|
589e1bc999 | ||
|
|
2956f07723 | ||
|
|
bb76399e59 | ||
|
|
66a3474baf | ||
|
|
5e7196cfbf | ||
|
|
a340bec472 | ||
|
|
5782c71f73 | ||
|
|
999fcc1770 | ||
|
|
b920dcec83 | ||
|
|
21c0711a66 | ||
|
|
f770ba127d | ||
|
|
66719f22e2 | ||
|
|
00a1956df7 | ||
|
|
f2ccf6b42c | ||
|
|
044bdc8663 | ||
|
|
24a9f12a78 | ||
|
|
1e14250b82 | ||
|
|
ea31bf0cf7 | ||
|
|
7a0502cabd | ||
|
|
d6eb319ac6 | ||
|
|
816677fc3d | ||
|
|
e0e51c7da6 | ||
|
|
dca573b748 | ||
|
|
d2f43f4edc | ||
|
|
00240fbb66 | ||
|
|
0f40d000d4 | ||
|
|
4759fe9e0b | ||
|
|
d7f6635346 | ||
|
|
2f545fb768 | ||
|
|
997b696953 | ||
|
|
687ebd0331 | ||
|
|
60e0d4ee92 | ||
|
|
312059ed74 | ||
|
|
0fba502b4d | ||
|
|
ef050837de | ||
|
|
be0715e96b | ||
|
|
16fa303a0c | ||
|
|
65088c0608 | ||
|
|
eab933584c | ||
|
|
f041140757 |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: cpr-dev-guide
|
||||
description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和文档修改入口;PR、Issue、插件创作与发版按任务另用对应技能
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
## 加载边界
|
||||
|
||||
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
|
||||
- 下表是条件导航,不是必读清单,只加载当前任务命中的技能与章节,进入新阶段再补读
|
||||
- 文档链接带 `#章节` 不代表读取工具会自动截取内容,先用 `rg -n '^#{1,4} ' 文件` 找边界,再按范围读取;常规定位不整份加载 CONTRIBUTING、架构、API、主题或全部 references,确需全文审查时再展开
|
||||
- 每次补读先明确要解决的未知项,已有资料足够就继续工作;文档提及 Rust、Vue、插件或发布,不等于要加载对应开发流程
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 当前任务 | 必读的相关部分 |
|
||||
| --- | --- |
|
||||
| 开发、排障、代码审查 | [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),需要定位代码时按 AGENTS 的 CodeGraph 约定执行 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| Rust 开发或审查 | `$rust-best-practices`、所属模块、[后端自审](../../../docs/architecture.md#后端自审),验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 页面开发 | `$frontend-design`、[文案与信息层级](../../../docs/theme.md#界面文案与信息层级)、[界面验证](../../../CONTRIBUTING.md#界面验证);修改已有页面前留存截图,主题算法仅在涉及时读取 |
|
||||
| 接口、部署或迁移 | 分别定位 [API](../../../docs/api.md)、[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)的对应章节 |
|
||||
| SDK、Runtime、宿主扩展或独立插件 | [插件职责边界](references/plugin-boundaries.md);只有创作、排查独立网关插件时使用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
|
||||
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
|
||||
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
|
||||
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
|
||||
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
|
||||
|
||||
## 执行与收尾
|
||||
|
||||
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
|
||||
2. 自审完整差异,处理本次引入的职责越界、重复规则、冗余状态与无依据分支;仅审查时报告,不自行修复
|
||||
3. 按变更范围读取[验证](../../../CONTRIBUTING.md#验证),区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
4. 交付说明实际结果与验证缺口;页面证据和后端生命周期检查按上表执行,不把构建通过当作行为验收
|
||||
|
||||
## 文档硬约束
|
||||
|
||||
**常规文档只描述当前状态,不写变更历史**,这项检查适用于每次开发收尾,不限于专门的文档任务
|
||||
|
||||
- 动笔前确定读者、所属章节与必须说明的当前事实,没有必要就不改文档
|
||||
- 有文档差异时执行[文档检查](references/documentation.md),逐段决定保留、改写、删除或移入任务报告,未完成内容审查不能称文档已检查
|
||||
- 没有文档差异时只确认现有说明是否失真;普通开发不自动修改 `release/notes.md`
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-dev-guide"
|
||||
short_description: "仓库开发、修复、排查、代码审查与文档修改的流程入口"
|
||||
default_prompt: "使用 $cpr-dev-guide 按当前任务定位问题与现有实现,完成已授权的开发或审查,并自审、验证和报告结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,38 @@
|
||||
# 文档检查
|
||||
|
||||
仅在准备修改文档或最终差异含文档时读取,职责与行文依据是 [文档职责与更新条件](../../../../CONTRIBUTING.md#文档职责与更新条件),只读该节
|
||||
|
||||
## 写之前
|
||||
|
||||
先明确这段内容服务谁、解决哪个当前使用或维护问题、属于哪个现有章节,再决定详略
|
||||
|
||||
接口保留调用合同,架构保留职责与不变量,部署保留操作与排障;同一事实只在所属位置完整说明,其他位置链接
|
||||
|
||||
常规文档在原章节修订,不追加本次变更小节,不为了“同步文档”补写修复总结
|
||||
|
||||
内部重构、实现经过、前后对比、验证结果应留在 PR、Issue 或任务报告
|
||||
|
||||
## 逐段验收
|
||||
|
||||
检查工作区和暂存区中本任务的最终文档差异,按内容用途处理,不只搜索关键词:
|
||||
|
||||
| 段落回答的问题 | 处理 |
|
||||
| --- | --- |
|
||||
| 现在如何使用、配置或理解结果 | 保留当前事实与必要边界,放回所属章节 |
|
||||
| 谁负责状态、事务、资源和失败处理 | 放入所属架构章节,避免在 API 或部署文档复述实现 |
|
||||
| 这次做了什么、以前怎样、为什么这样修、跑过哪些测试 | 移入 PR、Issue 或任务报告,不以换成现在时的方式留在常规文档 |
|
||||
| 特定配置或数据状态下如何升级、恢复 | 写明适用条件、操作及限制,删除演变过程 |
|
||||
| 别处已完整说明的事实 | 合并或改为必要摘要与链接 |
|
||||
|
||||
脱离提交日期、Issue 和本次任务背景后,读者仍应能独立理解正文
|
||||
|
||||
业务中的请求历史、审计记录、旧数据兼容与回滚操作是当前功能,不能因含“历史”“旧”等词而删除
|
||||
|
||||
## 交付检查
|
||||
|
||||
- 检查新段落的必要性、归属、重复与当前状态表达,发现变更叙述先改完再交付
|
||||
- 标题、短列表、表格和独立短段通常不加句号,长段按主题拆分,命令与字段保持准确
|
||||
- 检查相对链接、章节锚点、表格与命令示例,`git diff --check` 或构建通过不能代替内容审查
|
||||
- 报告实际覆盖和缺口即可,不把检查记录写进常规文档
|
||||
|
||||
只有明确的发布说明任务才进入 `cpr-release`,`release/notes.md` 用于该次发行,不向 README、API、架构或部署文档复制版本变化列表
|
||||
@@ -0,0 +1,11 @@
|
||||
# 插件职责边界
|
||||
|
||||
仅在开发或审查 SDK、Runtime、宿主扩展接口及独立网关插件时读取
|
||||
先看[插件扩展](../../../../docs/architecture.md#31-插件扩展)中的职责与执行边界,不通读整份架构
|
||||
|
||||
**宿主可以暴露底层能力,不能替插件完成业务**
|
||||
|
||||
- 宿主提供可独立组合的受控原生能力,负责授权、资源隔离、事务与审计,复用已有服务与状态归属
|
||||
- 插件自身的触发条件、跨资源关联、策略和流程编排由插件实现,不能把某个使用场景做成宿主专属流程、状态或补偿逻辑
|
||||
- 发现能力缺口时,界定可独立使用的基础接口,说明输入输出、权限、事务、失败及重复调用语义,不夹带插件私有数据模型或工作流
|
||||
- 自审调用链、合同、测试与文档中的业务判断、自动串联和专属状态,仅换成通用名称不算解除耦合
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: cpr-github-issue
|
||||
description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功能和协作规则反馈;单独排障或修复不自动发布 Issue
|
||||
---
|
||||
|
||||
# Issue 协作
|
||||
|
||||
## 目标与范围
|
||||
|
||||
从用户 URL、remote 和上下文确认接收仓库,fork 场景区分自己的仓库与上游
|
||||
|
||||
草拟只准备正文,明确创建或提交才发布;已有授权直接执行,自动命中技能本身不授权远程写入
|
||||
|
||||
补充已有 Issue 先读原帖和讨论,按授权更新或追加,不重复新建;无法推断的目标或关键事实才向用户补齐
|
||||
|
||||
## 按需取材
|
||||
|
||||
- 新建 Issue 时,本仓库只读[提交 Issue](../../../CONTRIBUTING.md#提交-issue)及本次匹配的一张表单:[接口问题](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)或[功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml);协作规则提案可用普通 Issue
|
||||
- 回复时读取原帖、相关讨论与支撑答复的证据,按下方回复口吻组织内容,不套用新建 Issue 的表单
|
||||
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
|
||||
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
|
||||
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
|
||||
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
|
||||
|
||||
## 正文与证据
|
||||
|
||||
标题说明症状或期望行为,区分发生问题的部署版本、实测结果、预期结果与根因假设
|
||||
|
||||
功能和规则提案说明场景、缺少的能力及建议结果,无需硬套缺陷复现步骤
|
||||
|
||||
YAML 表单按 `attributes.label` 和字段顺序组织正文,手工核对必填项、选项与确认项,CLI 不会代做表单校验
|
||||
|
||||
未知信息如实标明,影响理解或复现的关键事实先补齐,不提交原始 YAML、占位答案或未确认的勾选项
|
||||
|
||||
日志和附件仅含定位所需的脱敏片段,不公开凭据、完整配置或请求转储,不擅自修改代理、TUN、证书环境取证
|
||||
|
||||
附件须获授权且可访问,本机路径不算附件;labels 和 assignees 仅按模板或用户要求设置,并确认实际存在
|
||||
|
||||
Issue 可记录复现、排查和变更经过,不因此向常规文档追加历史;确需改文档时执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 Issue 回复时遵守以下约定:
|
||||
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现和验证写成“我已……”;未确认的事实如实说明,委婉表达也要说清结论、限制和下一步
|
||||
|
||||
## 发布与收尾
|
||||
|
||||
使用可用连接器或 `gh`,不为提交反馈安装工具、修改仓库配置或启用服务
|
||||
|
||||
多行正文通过结构化字段或 UTF-8 文件传递,以下变量须先从已确认的目标与正文赋值:
|
||||
|
||||
```bash
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前核对目标、必填内容、脱敏与重复记录,更新时保留无关原文;不擅自评论、重开或关闭重复 Issue
|
||||
|
||||
超时或结果不明确时先查询远端再重试,发布后回读确认并返回链接;仅草拟或发布失败时提供正文并注明未提交
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-issue"
|
||||
short_description: "整理、提交或补充 Issue,核对复现、重复反馈与发布范围"
|
||||
default_prompt: "使用 $cpr-github-issue 按当前请求整理、提交或补充 Issue,核对目标、复现证据、重复记录和脱敏,不将草拟自动变成发布。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: cpr-github-pr
|
||||
description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处理审查意见;普通 Git 提交和无 PR 上下文的排障不创建 PR
|
||||
---
|
||||
|
||||
# PR 协作
|
||||
|
||||
## 先确定范围
|
||||
|
||||
沿用当前会话的目标与授权,创建 PR 包含任务范围内的提交和推送,审查默认只读;二者都不包含合并
|
||||
|
||||
仅准备标题或正文不写远端,“能合并吗”按审查处理,发布评论、批准或请求修改须有对应授权
|
||||
|
||||
从 URL、remote 和上下文确认仓库,区分 fork 的推送端与 PR 接收端;存在无法消除的歧义时只补齐必要信息
|
||||
|
||||
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
|
||||
|
||||
## 按模式读取
|
||||
|
||||
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
|
||||
|
||||
| 当前动作 | 读取与执行 |
|
||||
| --- | --- |
|
||||
| 只整理标题或描述 | 当前 PR、最终 diff、实际模板、[AI 披露](../../../CONTRIBUTING.md#ai-使用披露),在授权范围内改文案;不读取推送或审查流程 |
|
||||
| 创建或更新 PR | [创建流程](references/create.md)、[PR 流程](../../../CONTRIBUTING.md#pr-流程)与[规则文件保护](../../../CONTRIBUTING.md#规则文件保护) |
|
||||
| 审查 PR | [审查流程](references/review.md)、[审查标准](../../../CONTRIBUTING.md#审查标准) |
|
||||
| 回复讨论或审查意见 | 目标评论、相关上下文与实际处理结果,按下方回复口吻答复;仅回复不加载创建或完整审查流程 |
|
||||
| 需要修改代码或文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应分支,完成自审与验证 |
|
||||
| 含页面改动 | [界面验证](../../../CONTRIBUTING.md#界面验证),必需截图须可访问且对应当前实现 |
|
||||
| 含文档改动 | [文档检查](../cpr-dev-guide/references/documentation.md),常规文档只说明当前状态,变更经过留在 PR |
|
||||
|
||||
## 回复口吻
|
||||
|
||||
草拟和发布 PR 讨论、审查意见的回复时遵守以下约定:
|
||||
|
||||
- 用第一人称“我”表达,称呼对方为“佬友”,称呼自然出现即可,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 语气客气、委婉,直接回应对方的具体问题;补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 使用自然短句,简单回复用短段,只有并列信息较多时才分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的修改和验证写成“我已……”;未采纳的意见说明依据,待处理或未验证的内容如实交代,委婉表达也要说清结论和下一步
|
||||
|
||||
## 创建与审查的公共核对
|
||||
|
||||
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
|
||||
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
|
||||
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
|
||||
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
|
||||
- 验证结果对应实际 head,区分通过、失败、跳过与未执行;无文档改动时只检查现有说明是否失真
|
||||
|
||||
远程操作优先使用可用连接器,`gh` 显式指定 `--repo`,多行正文使用结构化字段或 `--body-file`
|
||||
|
||||
返回链接或会话内结论,说明实际范围与缺口,不将检查通过称为维护者批准
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-github-pr"
|
||||
short_description: "提交、更新与审查 PR,核对问题、自审、页面截图和 AI 模型披露"
|
||||
default_prompt: "使用 $cpr-github-pr 按当前请求准备、提交、更新或审查 PR,核实问题与方案,检查自审、验证、页面截图和 AI 模型披露。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,35 @@
|
||||
# 创建或更新 PR
|
||||
|
||||
仅在创建、推送或更新 PR 时读取,先完成[入口](../SKILL.md)的范围判断与公共核对
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按入口的公共核对,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
按 `cpr-dev-guide` 完成实现自审;后端按 [后端自审](../../../../docs/architecture.md#后端自审) 核对归属、状态、失败出口与副作用,
|
||||
页面先复用已有设计并检查文案密度。处理本次引入的冗余与越界问题,功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按[验证](../../../../CONTRIBUTING.md#验证)执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
页面改动按入口链接的界面验证要求补齐截图与实际交互证据,核对模型披露是否完整。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、自审结论、验证与遗留事项,按模板附页面截图和 AI 使用情况。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建或保持 Draft PR,并解释原因;必需截图或 AI 模型披露待补时也不标为可审阅。具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建或更新后回读正文与附件,核对链接、base、head、草稿状态、模型披露和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
```bash
|
||||
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
--title "$pr_title" --body-file "$pr_body_file"
|
||||
```
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 审查 PR
|
||||
|
||||
仅在审查 PR 时读取,执行[入口](../SKILL.md)的公共核对,默认只在当前会话报告
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照原页面、主题、共用组件和同类页面,核对结构变化的必要性、文案是否重复及帮助是否适合收进 Popover。
|
||||
按页面类型检查必需截图是否齐全、可访问且对应待审实现;缺少必需截图时要求补齐并列为未具备审阅条件。
|
||||
浏览器可用时检查相关交互;未运行的检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
有文档差异时按[文档检查](../../cpr-dev-guide/references/documentation.md)检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
核对 AI 使用与模型披露,缺项明确指出。工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
|
||||
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: cpr-plugin-dev
|
||||
description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改宿主 Runtime、安装器或管理端时使用 cpr-dev-guide,不适用于 Codex 插件或 Vue 插件
|
||||
---
|
||||
|
||||
# 网关插件开发
|
||||
|
||||
插件在独立项目中通过公开 SDK 实现业务,不向宿主业务模块添加专属流程
|
||||
|
||||
同时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md)的对应任务分支,已加载则复用,不递归重读
|
||||
|
||||
## 范围与合同
|
||||
|
||||
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
|
||||
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
|
||||
- `contributes` 是能力,`permissions` 是访问域,`bindings` 是挂载与匹配范围;安装成功不等于功能生效,`trustedProcess` 也不是操作系统沙箱
|
||||
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
|
||||
|
||||
## 按当前步骤读取
|
||||
|
||||
只读取命中行与链接的指定章节,长文档先列标题再截取;不预读全部 SDK、示例、前端和打包资料
|
||||
|
||||
| 当前工作 | 入口 |
|
||||
| --- | --- |
|
||||
| 选择能力或确认能力缺口 | [能力与方法](../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)、所需能力章节、[插件职责边界](../cpr-dev-guide/references/plugin-boundaries.md) |
|
||||
| 清单与 Rust 处理器 | [实现参考](references/implementation.md)、所用 SDK 类型;只有修改清单字段时读[清单](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md)对应章节 |
|
||||
| 创建或修改页面 | [页面参考](references/frontend.md),不需要页面的插件不创建前端 |
|
||||
| 准备工程或联调依赖 | [工程与依赖](references/development.md#工程与依赖),需要示例时再读[复用示例](references/development.md#复用示例) |
|
||||
| 构建或打包 | [构建与打包](references/development.md#构建与打包) |
|
||||
| 实际验收 | [验证与交付](references/development.md#验证与交付),按实现范围验证 |
|
||||
| 分发插件 | [发布与安装来源](references/development.md#发布与安装来源),不自动进入宿主发版流程 |
|
||||
| 修改插件文档 | [文档检查](../cpr-dev-guide/references/documentation.md),只写当前用法、合同和限制,不附修复过程 |
|
||||
|
||||
## 交付
|
||||
|
||||
说明源码与产物位置、兼容范围、能力与权限、实际验证及缺口,只报告真正生成的安装包或完成的业务验证
|
||||
|
||||
修改源码不默认授权在目标实例安装启用、调用真实上游或发布,沿用用户当前授权判断后续动作
|
||||
|
||||
PR 任务才使用 [cpr-github-pr](../cpr-github-pr/SKILL.md),文档检查按开发入口收尾执行
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-plugin-dev"
|
||||
short_description: "网关插件开发、接入排障、SDK 实现、页面与打包验证"
|
||||
default_prompt: "使用 $cpr-plugin-dev 按当前请求开发、排查或打包 Codex Proxy RS 网关插件,复用公开 SDK 与已有页面并验证实际行为。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,115 @@
|
||||
# 构建、联调与验证
|
||||
|
||||
仅在准备工程、构建或验证插件时读取对应章节,分发规则只在准备分发时读取
|
||||
这里给出执行入口,不复制 SDK 协议或安装 API 的字段手册
|
||||
|
||||
## 工程与依赖
|
||||
|
||||
| 位置 | 用途 |
|
||||
| --- | --- |
|
||||
| 主仓 `backend/crates/gateway-plugin/sdk/` | Rust 公开合同、会话辅助与能力文档 |
|
||||
| 主仓 `backend/apps/plugin-cli/` | `cpr-plugin package`,只校验与打包,不编译插件源码 |
|
||||
| [codex-proxy-plugins](https://github.com/zyycn/codex-proxy-plugins) 的 `examples/workbench/` | 基础能力体验与文本工作流示例,按需选择功能,不整体复制 |
|
||||
| [codex-proxy-ui](https://github.com/zyycn/codex-proxy-ui) | 可选的 Vue UI 库、组件文档与 playground |
|
||||
|
||||
先检查用户给定工程、`modules/plugins` 子模块或同级仓库是否存在,读取实际仓库约定及当前步骤所需的 README 章节、依赖与锁文件,不预读无关前端配置
|
||||
只需中间件时不复制示例的管理 API、页面和相关权限;CLI 等能力从 SDK 对应合同起步,不给示例添加无关能力。
|
||||
|
||||
本仓库子模块联调使用[开发文档](../../../../docs/development.md#源码联调)中的 source 模式和本地 SDK 验证入口,不改写正式依赖或锁文件
|
||||
其他工程按其自身联调配置临时使用包级链接或 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` |
|
||||
| 管理接口 | `examples/workbench/backend/src/management/` |
|
||||
| 页面与主题 | `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` 路径与构建来源。已有工具只有在支持目标作者清单时才复用;
|
||||
遇到派生字段被要求手写或支持能力不符时,对照目标 SDK 和 CLI 合同,不通过改写清单迁就过期工具。
|
||||
需要从目标宿主源码构建工具时,在主仓根目录运行以下命令,把 `--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`、可执行文件和声明资源。
|
||||
安装时以本次输出的包摘要为准,核对包内版本、平台、能力和 `engines` 与实际运行宿主匹配;
|
||||
源码已更新不代表 `dist` 中旧包已重建。版本要求未满足时使用兼容宿主或确有依据的兼容声明,不跳过宿主检查。
|
||||
本地同版本反复试验与对外发布区分处理;发布新内容使用明确的新版本,不覆盖已发布版本。
|
||||
|
||||
## 发布与安装来源
|
||||
|
||||
仅在用户要求发布或准备分发时执行;沿用目标插件仓库的发布流程,核对源码提交、包内版本、目标平台与产物摘要。
|
||||
|
||||
- GitHub 安装读取 Release 附件,发布时上传实际生成的 `.tar.gz`/`.tgz` 和校验文件;仅推送 tag 或保留 GitHub 自动生成的源码归档,不能提供可安装插件
|
||||
- 稳定版应发布为非 Draft、非 Pre-release;需要支持留空标签安装时,再核对最新稳定版查询能返回预期 Release。版本号没有预发行后缀,不代表 GitHub 的发布状态已经是正式版
|
||||
- 当前宿主在标签留空时只查询最新稳定版;预发行版安装需要同时指定标签并允许预发行,不把该开关解释成自动发现预发行版
|
||||
- 多平台或多插件附件由用户按实际发布内容选择,文件名帮助辨识,包内清单才是平台、版本与兼容范围的依据
|
||||
- 发布完成后,从公开下载来源核对附件和摘要,再按已授权的目标环境验证安装;本地包验证不等于远程发行附件已经验证
|
||||
|
||||
## 验证与交付
|
||||
|
||||
按[插件使用](../../../../docs/plugins.md)验证“安装包 → 查看权限并安装 → 自动准备默认配置 → 实际使用”;只在缺少业务必填值时补配置,不添加授权或资源选择向导。
|
||||
只有获得目标环境操作授权才安装和启用;不把修改源码的授权扩展到生产实例或真实凭据操作。
|
||||
需要 API 自动化时查[插件管理 API](../../../../docs/api.md#12-插件管理),不要依据记忆拼路由。
|
||||
|
||||
按实际实现选择验证,不为不存在的能力增加测试任务:
|
||||
|
||||
| 实现范围 | 应验证的行为 |
|
||||
| --- | --- |
|
||||
| 中间件 | 匹配/不匹配范围、授权不足、下游错误与取消;支持流式时检查多帧、终态和断开;若涉及续链,使用真实目标客户端检查 HTTP/SSE、WS 及后续轮次 |
|
||||
| 模型路由/调度 | 真实入口能命中规则,未匹配请求保持原有行为;使用官方客户端正常携带的业务头和会话别名,确认投影不会拒绝合法请求,也不暴露身份字段 |
|
||||
| 生命周期/用量/WS 观察 | 完成一次实际模型请求并核对对应事件;WS 观察需确认实际上游采用 WS,HTTP 请求成功不能证明 WS 观察生效 |
|
||||
| 管理页面 | 从宿主打开,标题/副标题、明暗主题、加载与错误状态;管理桥请求成功;停用或版本切换后旧入口失效 |
|
||||
| 入口认证/CLI | 对应身份和账号范围、缺少授权、取消与错误;不把帮助输出、注册成功或一次健康探测当完整验收 |
|
||||
| 持久状态或升级 | 读写版本冲突、停用再启用后的持久状态、升级兼容性及迁移失败;版本配置恢复与私有状态迁移分别验证,不覆盖并发配置或把回滚版本误当恢复历史数据 |
|
||||
|
||||
真实请求按用户选择的客户端、模型和测试预算执行;没有这些能力或授权时列为缺口,不模拟成功。
|
||||
外部示例测试若依赖未提供的仓库或制品,应记录为未执行;当前示例可用于实际能力联调,但不能把被忽略的测试计为通过。
|
||||
临时账号、Key 或配置只在明确的测试范围内创建,记录并清理自己创建的对象,不输出密钥、原始请求或插件 secret。
|
||||
|
||||
交付简述:源码与产物位置、兼容范围、所需能力/权限/绑定、执行过的命令及结果、尚未验证的场景。
|
||||
只有确实生成了产物才报告“已打包”,只有目标环境业务验证成功才报告“已生效”;提交、推送与发布按用户授权另行执行。
|
||||
@@ -0,0 +1,11 @@
|
||||
# 插件页面
|
||||
|
||||
仅在创建或修改插件页面时读取,同时使用[开发入口](../../cpr-dev-guide/SKILL.md)的页面分支
|
||||
|
||||
- 沿用已有插件页面与 [管理端主题](../../../../docs/theme.md#界面文案与信息层级) 的设计和文案层级,详细帮助使用现有 Popover;页面 PR 按 [界面验证](../../../../CONTRIBUTING.md#界面验证) 提供实际截图
|
||||
- 优先参考独立示例仓库的 `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 实例
|
||||
- 管理路由的响应正文由插件业务定义,不默认套用宿主管理 API 的 `{ code, message, data }`;页面与处理器共享实际合同,GET 无正文时不附带 JSON 正文声明
|
||||
- `host.model.*` 子请求跳过发起插件,不能用它证明本插件中间件/路由已参与;需要完整请求链的示例使用页面模型桥或真实客户端
|
||||
- JS、CSS、图标等资源随包构建和声明;沿用宿主桥的主题同步。Vite 本地预览不等于已安装插件的热更新,宿主验证仍需重新构建、打包和切换版本
|
||||
@@ -0,0 +1,24 @@
|
||||
# 清单与处理器实现
|
||||
|
||||
仅在编写或排查插件清单、Rust 处理器时读取,涉及 Rust 时使用[开发入口](../../cpr-dev-guide/SKILL.md)的 Rust 分支
|
||||
选择能力时只读 SDK 的[能力与方法](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#能力与方法)及所需能力章节
|
||||
|
||||
## 清单与处理器
|
||||
|
||||
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
|
||||
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件阶段仍需显式选择。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
|
||||
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
|
||||
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
|
||||
- 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#状态日志与迁移)
|
||||
- 权限从目标 SDK 的[访问域](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#访问域)选择,不在技能中维护枚举副本;日志和自身状态无需授权项,安装器不配置 Key/账号白名单,资源选择属于插件业务
|
||||
- 模型、网络和亲和查询使用当前父调用的受管回调。管理/CLI 等独立入口调用模型时传明确的 Key ID;请求处理阶段继承父身份。见[Key、模型与模型调用](../../../../backend/crates/gateway-plugin/sdk/docs/capabilities.md#key模型与模型调用)
|
||||
- SDK 操作名不是管理员 HTTP 路由,不能据此拼接口地址;宿主身份、账单事实和调用授权也不能由插件覆盖
|
||||
- 路由与调度只读取宿主投影的请求事实,身份头可能被隐藏;使用已有会话和亲和合同,不依赖原始认证头或根据缓存键补造客户端身份
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: cpr-release
|
||||
description: Codex Proxy RS 的版本规划、发布说明、正式或预发行发布与失败恢复;部署重启和普通 Git tag 清理不属于发版
|
||||
---
|
||||
|
||||
# 发版
|
||||
|
||||
## 范围与授权
|
||||
|
||||
沿用用户确定的版本、范围与授权,准备版本或询问能否发布不执行远程写入;明确发布时完成已授权步骤,无需重复确认
|
||||
|
||||
仅打 tag 不自动扩展为完整发版,发布也不包含运行实例升级或合并实验功能
|
||||
|
||||
稳定版与 alpha、beta、rc 从 `main` 发布,exp 从对应 `experimental/<用途>` 分支发布
|
||||
|
||||
其他仓库使用其发布入口与规则,不套用本仓库路径;规则已在上下文中时不重复读取
|
||||
|
||||
## 按任务读取
|
||||
|
||||
只读当前阶段和指定章节,先定位标题再截取,不预读整份 CONTRIBUTING、工作流或所有参考文件;实际发布前仍须核对发布入口的完整副作用
|
||||
|
||||
| 任务 | 读取范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | [基线与版本](references/preparation.md#基线与版本) |
|
||||
| 编写或翻译发布说明 | [基线与版本](references/preparation.md#基线与版本)、[发布说明](references/preparation.md#发布说明) |
|
||||
| 检查发行条件 | [发行准备检查](references/preparation.md#发行准备检查) |
|
||||
| 实际发布 | 完成准备,再读[通道核对](references/operations.md#发行通道核对)和[执行发版](references/operations.md#执行发版) |
|
||||
| 失败恢复或验收 | [恢复与验收](references/operations.md#恢复与验收),先确定已完成步骤,不直接重跑入口 |
|
||||
| 实验版 | 在对应阶段补读[实验流程](references/experimental.md)的版本、文案或验收章节 |
|
||||
| 修复代码、工作流或常规文档 | 使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),有 PR 任务再使用 [cpr-github-pr](../cpr-github-pr/SKILL.md) |
|
||||
|
||||
## 必须保持的边界
|
||||
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明,常规文档只写当前状态,不追加版本历史;其变更执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "cpr-release"
|
||||
short_description: "版本规划、中文发布说明、正式与预发行发布及失败恢复"
|
||||
default_prompt: "使用 $cpr-release 按当前请求准备版本与中文说明、执行已授权的发版或恢复失败步骤,核对渠道与实际结果。"
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
# 实验版发布
|
||||
|
||||
适用于独立于主版本维护、供用户主动选择体验的实验功能。发布入口仍是仓库的 `release/publish`,
|
||||
构建复用目标实验分支的 `.github/workflows/release.yml` 与 `release/platforms.yaml`。
|
||||
按当前任务只读版本、发布能力、文案或验收章节,不因准备实验说明而读取执行流程
|
||||
适用于独立于主版本维护、供用户主动选择体验的实验功能,入口使用仓库的 `release/publish`,构建复用目标实验分支的工作流与平台清单
|
||||
|
||||
## 分支与版本
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
不为统一名称移动 tag 或替换原有产物。
|
||||
- GitHub Release 展示标题采用 `vX.Y.Z-<用途>.M`,`<用途>` 取实验分支名去掉 `experimental/` 前缀后的部分。
|
||||
发布前通过 `gh api` 核对相同正式版基线与用途的已有 Release 标题和旧命名 tag,新发行取已有最大编号加 1,首次从 1 开始。
|
||||
展示编号 `M` 独立于实际 tag 的 `exp.N`,旧命名发行也计入,不能因改用 `exp.N` 而重置;同一发行重试沿用已确定编号。
|
||||
展示编号 `M` 独立于实际 tag 的 `exp.N`,所有同基线、同用途的已有发行均计入,不随 tag 命名格式重置;同一发行重试沿用已确定编号。
|
||||
例如已有 `v3.10.0-codex-anti-degradation.1`,下一次 tag 即使是 `v3.10.0-exp.1`,标题也应为 `v3.10.0-codex-anti-degradation.2`。
|
||||
- tag 和产物继续使用 `exp.N`:镜像版本去掉 tag 开头的 `v`,安装包沿用项目的版本、系统和架构命名。
|
||||
发布说明首行仍匹配实际 tag;关联 Issue 和试验目的写在正文中。新标题规则不自动改名已有 Release。
|
||||
发布说明首行匹配实际 tag,关联 Issue 和试验目的写在正文中,已有 Release 不因新发行而改名
|
||||
|
||||
## 先核对发布能力
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
|
||||
## 执行与验收
|
||||
|
||||
按主技能准备说明和其他变更后,在实验分支执行 `release/publish <X.Y.Z-exp.N>`。
|
||||
按[版本与说明准备](preparation.md)的对应章节准备后,再按[执行发版](operations.md#执行发版)在实验分支调用入口
|
||||
入口创建版本提交和 tag,并从该分支 dispatch 发布工作流;不要从 `main` 发布实验代码。
|
||||
|
||||
按主技能的「发行通道核对」确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
按[发行通道核对](operations.md#发行通道核对)确认稳定入口未改变,并检查实验线隔离与上一版本的更新能力。
|
||||
检查展示标题的分支用途与历史续号、它所对应的实际 tag,以及目标 tag 的源码提交、完整产物、校验和、多架构平台和 Pre-release 标记。
|
||||
同基线的 `X.Y.Z-exp.N` 按 SemVer 排序低于正式 `X.Y.Z`;渠道隔离仍以更新器过滤和发布标签规则为准。
|
||||
|
||||
失败时按主技能从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
|
||||
失败时按[恢复与验收](operations.md#恢复与验收)从未完成步骤恢复,不通过换版本号或移动 tag 掩盖失败。
|
||||
仅维护已有页面文案时不重新构建;报告实际完成状态、发布链接及验证缺口。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 执行、恢复与验收
|
||||
|
||||
只在实际发布、恢复失败步骤或验收发行时读取对应章节,先核对[入口](../SKILL.md)中的范围与授权
|
||||
|
||||
## 发行通道核对
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 更新候选由本次检查或安装选择的通道决定:Stable 仅正式版,RC 包含正式版,Beta 包含 RC 与正式版,Alpha 包含所有常规阶段。
|
||||
仅接收同一大版本内严格更高的版本,可跟随后续小版本与补丁版本,默认按运行版本推导通道;临时选择不保存、不允许降级。
|
||||
exp 仅接收同基线、同一轮实验的后续编号,不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
|
||||
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
|
||||
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
|
||||
|
||||
## 执行发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
|
||||
```bash
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 版本与说明准备
|
||||
|
||||
只在规划版本、编写发布说明或准备发行时读取对应部分,不因准备文案加载执行流程
|
||||
|
||||
## 基线与版本
|
||||
|
||||
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
|
||||
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
|
||||
|
||||
## 发布说明
|
||||
|
||||
读取当前 [notes.md](../../../../release/notes.md)与[项目约定](../../../../CONTRIBUTING.md#项目约定)中的发布说明条目
|
||||
|
||||
- 只在发布说明任务中修改 `release/notes.md`,替换为本次版本内容,首行为目标 tag,正文使用中文
|
||||
- 按用户可感知的最终变化归并,省略空章节,不逐条翻译提交,不写排查过程或未经验证的承诺
|
||||
- 剔除回退、重复及未进入发行范围的内容,升级事项须能追溯到实际迁移、配置或兼容变化
|
||||
- 只翻译已有准确说明时保持事实与范围,不混入其他变更
|
||||
- 常规文档仍只描述当前状态;必要修订执行[文档检查](../../cpr-dev-guide/references/documentation.md),不复制发布说明中的版本变化列表
|
||||
|
||||
实际发布由 `release/publish` 提交说明与版本文件,准备说明时不要提前单独提交或把版本文件改成目标版本
|
||||
用户只要求准备时保留本地改动,纯说明修改无需重建前后端
|
||||
|
||||
## 发行准备检查
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
|
||||
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
|
||||
|
||||
- 对照同一大版本的上一正式版和本轮预发行/实验线的已发布版本,验证迁移未改写及升级路径
|
||||
- 区分未发布迁移、已执行迁移的源码部署实例与 CI 冻结基线,未发版不代表未执行,也不授权放宽冻结规则
|
||||
- 兼容性与 CI 检查分别报告,跨大版本不默认支持在线升级
|
||||
|
||||
正式执行时再读取[通道核对](operations.md#发行通道核对)与[执行发版](operations.md#执行发版),触发工作流不等于发布成功
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: dev-guide
|
||||
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
|
||||
---
|
||||
|
||||
# 开发指南
|
||||
|
||||
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
|
||||
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
|
||||
|
||||
## 按任务读取
|
||||
|
||||
| 任务 | 技能与文档入口 |
|
||||
| --- | --- |
|
||||
| 用户能力、快速开始 | [README](../../../README.md) |
|
||||
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
|
||||
| Rust 开发 | 加载并使用 `$rust-best-practices`,结合 [系统架构](../../../docs/architecture.md) 与 [项目约定](../../../CONTRIBUTING.md#项目约定) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 前端开发、管理端组件、主题 Token、视觉验证 | 按下方「前端任务」加载设计约束,结合 [管理端主题](../../../docs/theme.md) 与 [界面验证](../../../CONTRIBUTING.md#界面验证) |
|
||||
| 常规文档更新 | [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件):维护当前说明,在原章节就地修订,不记录变更历史 |
|
||||
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
|
||||
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
|
||||
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
|
||||
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
|
||||
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
|
||||
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
|
||||
|
||||
## 前端任务
|
||||
|
||||
- 加载并使用 `$frontend-design`,先读 [界面文案与信息层级](../../../docs/theme.md#界面文案与信息层级),将本项目的管理工具风格、现有字体与主题 Token 作为设计约束
|
||||
- 开发前按该章节的参考入口和相邻页面核对布局、文案密度与操作位置,再选择共用组件
|
||||
- 交付前按该章节检查界面文案中的句号、分号,以及过大的标题、重复说明和说明块占用的空间,结合 [界面验证](../../../CONTRIBUTING.md#界面验证) 核对实际页面
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "dev-guide"
|
||||
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
|
||||
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
name: github-issue
|
||||
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
|
||||
---
|
||||
|
||||
# 整理与提交 Issue
|
||||
|
||||
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),按场景选择当前的
|
||||
[接口问题反馈](../../../.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](../../../.github/ISSUE_TEMPLATE/general-bug-report.yml)
|
||||
或 [功能建议](../../../.github/ISSUE_TEMPLATE/feature-request.yml) 表单。从表单读取字段与必填项,不在技能中复制一份字段清单。
|
||||
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
|
||||
|
||||
## 确定目标和动作
|
||||
|
||||
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
|
||||
## 证据与去重
|
||||
|
||||
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
|
||||
本仓库功能建议有独立表单,协作规则提案允许普通 Issue;其他仓库按其配置选择入口。
|
||||
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
|
||||
同时核对相关 PR 与发布记录,关联已有修复及其发布版本;仅主分支已修复时明确尚未发布。
|
||||
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
|
||||
不因发现重复而擅自评论、重开或关闭已有 Issue。
|
||||
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
|
||||
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
|
||||
引用官方行为或提出实现方案时,按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 注明来源、版本与缺口;
|
||||
不把找到官方源码或证明根因作为提交症状反馈的前提。
|
||||
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
|
||||
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
|
||||
|
||||
## 编写正文
|
||||
|
||||
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
|
||||
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
|
||||
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
|
||||
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
|
||||
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
|
||||
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
|
||||
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
|
||||
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
|
||||
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
|
||||
|
||||
## 提交与收尾
|
||||
|
||||
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
|
||||
|
||||
```bash
|
||||
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
|
||||
```
|
||||
|
||||
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
|
||||
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-issue"
|
||||
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
|
||||
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: github-pr
|
||||
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。创建和审查都先核实问题是否成立,以官方源码为首要参考确定或评估方案,再按项目规范实现与验证。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
|
||||
---
|
||||
|
||||
# 创建与审查 PR
|
||||
|
||||
## 先确定模式和仓库
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
| --- | --- |
|
||||
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
|
||||
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
|
||||
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
|
||||
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
|
||||
|
||||
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
|
||||
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
|
||||
|
||||
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
|
||||
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
|
||||
|
||||
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
|
||||
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
|
||||
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
|
||||
|
||||
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
|
||||
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
|
||||
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
|
||||
## 创建和审查前的核对
|
||||
|
||||
创建和审查都按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实问题,优先依据官方源码确定或评估方案。
|
||||
用户指定其他仓库时,结合其贡献规范与适用的官方实现,不套用本项目的具体业务合同。
|
||||
|
||||
核对最新目标分支及同一场景相关的 open/closed PR 和 Issue,比较实际行为、触发条件与测试覆盖。
|
||||
区分已被主分支覆盖的实现、仍有价值的修复或回归测试,以及与目标无关的改动;不能仅凭标题或代码相似认定重复。
|
||||
说明原问题发生版本和当前剩余差异,创建时据此收敛范围,审查时报告保留或拆分建议;这不授权自动关闭或修改他人的 PR。
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
按 [贡献与审查的文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否需要更新文档。
|
||||
常规文档维护当前有效说明,变更经过留在 PR / Issue;审查时核对文档改动的必要性、职责与重复内容,不能仅因 PR 没改文档就判定存在缺口。
|
||||
|
||||
创建前和审查时逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否重复已有说明,脱离本次变更背景能否独立理解。仅说明实现经过、前后对比或验证结果的段落归入
|
||||
PR / Issue;必要的升级、恢复说明保留适用条件与操作。判断依据是内容用途,不按“新增”“不再”等词机械判错。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 按上述核对流程,在目标分支基线核实问题,追踪官方与本项目的相关调用链,先确定解决方向再实现。
|
||||
若改动已完成,也要据此复核问题与方案;需要修正时只修改已授权的范围。仅整理标题或正文时报告差异与缺口,
|
||||
不自行修改实现。原问题不成立时不将它包装成已修复的缺陷;证据不足时明确保留结论。
|
||||
实现应职责清晰、表达直接、复用合理;提交前主动清理本次变更引入的重复逻辑、冗余状态、死代码、
|
||||
多余抽象及无依据的兼容分支。功能可用和检查通过不能替代可维护性自查。
|
||||
4. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
5. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
按上述文档复核步骤检查最终 diff;没有文档差异时,核对行为变化是否使现有说明失真。
|
||||
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
|
||||
6. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题成立的依据和最终行为,
|
||||
再给必要的设计取舍、验证与遗留事项。涉及官方行为时,写明官方源码位置与版本或提交、官方处理方式、
|
||||
本次实现与它的关系及必要的偏离原因;区分源码推断和实际复现,不用检查通过替代方案论证。按规模压缩,不强填无关章节。
|
||||
7. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
8. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
|
||||
|
||||
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
|
||||
|
||||
```bash
|
||||
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
|
||||
--title "$pr_title" --body-file "$pr_body_file"
|
||||
```
|
||||
|
||||
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
|
||||
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
|
||||
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
|
||||
|
||||
## 审查 PR
|
||||
|
||||
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
|
||||
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
|
||||
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
|
||||
按上述核对流程收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
|
||||
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
|
||||
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
|
||||
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
|
||||
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
|
||||
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
|
||||
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
不能仅因功能可用、CI 通过而略过,也不把个人写法偏好判为缺陷。
|
||||
5. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
6. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
按上述文档复核步骤检查最终 diff,不能把构建或链接检查通过当作文档内容已经合格。
|
||||
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
7. 按 [审查标准](../../../CONTRIBUTING.md#审查标准) 用中文先回答原问题是否成立、解决方向是否合理,
|
||||
再列出实现问题与验证缺口,结论附具体依据。
|
||||
实现问题区分必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
8. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
|
||||
|
||||
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
|
||||
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
|
||||
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "github-pr"
|
||||
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
|
||||
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库的正式版、alpha/beta/rc 预发行版和 exp 实验版发版,维护中文发布说明。用于“发版”“发布 beta/rc/实验版”“准备 vX.Y.Z”“推荐版本号”“整理发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing stable, prerelease, or experimental releases; not for deploying/restarting an instance or authoring a release skill.
|
||||
---
|
||||
|
||||
# 发版
|
||||
|
||||
## 识别任务范围
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
|
||||
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
|
||||
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
|
||||
| 发布 alpha、beta、rc | 从 `main` 发布,确定目标正式版本和阶段,核对预发行标记与晋级路径 |
|
||||
| 发布实验版、exp 版本 | 从目标实验分支发布,按下方实验版流程确定版本、文案和渠道隔离 |
|
||||
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
|
||||
|
||||
沿用当前会话明确的版本、范围与授权;用户已授权发布目标版本且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
|
||||
稳定版以及面向下一正式版本的 alpha、beta、rc 从 `main` 发布,预发行 tag 使用 `vX.Y.Z-<阶段>.N`。
|
||||
发版前读取[版本命名与升级规则](../../../deploy/README.md#版本命名与升级规则),作为命名、阶段晋级和更新边界的权威约定。
|
||||
实验版从对应的 `experimental/<用途>` 分支发布,版本与 tag 采用 `vX.Y.Z-exp.N`,
|
||||
展示标题包含分支用途名;先读 [实验版发布](references/experimental.md) 确定标题与编号,再复用本文的准备、执行与验收步骤。
|
||||
实验功能是否合入 `main` 是单独的维护决策,发布实验版不包含合并功能的授权。
|
||||
|
||||
## 读取当前流程
|
||||
|
||||
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
|
||||
|
||||
| 事实 | 权威入口 |
|
||||
| --- | --- |
|
||||
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
|
||||
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
|
||||
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
|
||||
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
|
||||
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
|
||||
|
||||
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
|
||||
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
|
||||
|
||||
## 发行通道核对
|
||||
|
||||
- alpha 是下一正式版本的早期开发阶段,beta 是功能测试阶段,rc 是发布候选,exp 是临时功能实验。
|
||||
预发行采用 `vX.Y.Z-<阶段>.N`,`N` 从 1 开始;正式版不带预发行后缀,不新增未约定的通道名称。
|
||||
- 正式版只接收同一大版本内更新的正式版。alpha → beta → rc → 正式版只在相同 `X.Y.Z` 内晋级,
|
||||
可以跳过阶段,不能倒退;转正后不自动进入下一轮预发行。exp 仅接收同基线、同一轮实验的后续编号,
|
||||
不能跨实验线或转入其他阶段;不同实验分支不得共用一个 `X.Y.Z-exp.N` 系列。
|
||||
- 核对目标工作流的构建类型:正式版及 alpha、beta、rc 为 `release`,exp 为 `experimental`。
|
||||
所有预发行均标记为 Pre-release,不设置 GitHub Latest、不覆盖镜像 `latest`。
|
||||
发布预发行前记录这两个稳定入口,发布后回读确认未改变。
|
||||
- 验收更新器时,核对检查更新与执行更新使用相同规则。只有当前构建支持更新且存在允许的更高目标时
|
||||
才能返回 `hasUpdate=true`;被禁止的目标即使版本领先,也不能出现在更新标记、可用版本或发布说明中。
|
||||
检查失败须单独报告。核对上一发行实际包含的更新能力,不能因新包支持在线更新就宣称旧包也能在线安装它。
|
||||
|
||||
## 准备版本与说明
|
||||
|
||||
1. 确认发布类型、对应分支、upstream、目标仓库、工作区和完整待推送范围;
|
||||
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
|
||||
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
|
||||
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
|
||||
不能只用当前版本文件或最近几条提交判断“本次更新”。
|
||||
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
|
||||
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
|
||||
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
|
||||
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
|
||||
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
|
||||
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
|
||||
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
|
||||
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
|
||||
涉及数据库迁移时,对照同一大版本的上一正式 Release 及本轮预发行/实验线的已发布版本检查迁移是否保持不变,并验证升级路径;
|
||||
跨大版本按项目部署约定判断,不默认支持在线升级。区分未发布迁移的整理、已执行这些迁移的源码部署实例,
|
||||
以及 CI 实际采用的冻结基线。未发版不代表迁移从未被执行,CI 冻结检查失败也不等于正式版数据库升级失败;
|
||||
按各自证据报告兼容性与检查结果,不因未发版而自动放宽现有冻结规则。
|
||||
只修改说明时无需重建前后端;实际发版仍要跟踪发布工作流针对目标提交的检查与产物。
|
||||
|
||||
## 执行发版
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,将它与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
在准备完成且用户已授权发布后,将 `release_version` 设为已确定的版本,执行:
|
||||
|
||||
```bash
|
||||
release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建包含版本号和发布说明的单次提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "release"
|
||||
short_description: "按仓库流程准备稳定版或实验版的中文说明,完成发行与渠道验证"
|
||||
default_prompt: "使用 $release 按指定的稳定版或实验版渠道准备中文发布说明,并核对发版条件。"
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Build frontend
|
||||
description: Validate and build the frontend, optionally uploading release assets
|
||||
inputs:
|
||||
upload-artifact:
|
||||
description: Upload frontend-dist for downstream release jobs
|
||||
default: 'false'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
shell: bash
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Frontend format
|
||||
shell: bash
|
||||
run: pnpm --dir frontend format:check
|
||||
|
||||
- name: Frontend build
|
||||
shell: bash
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload-artifact == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
|
||||
ARG CACHE_STAMP
|
||||
RUN --mount=type=bind,target=/cache \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& cp -a /cache/registry/. /usr/local/cargo/registry/ \
|
||||
&& cp -a /cache/git/. /usr/local/cargo/git/ \
|
||||
&& cp -a /cache/target/. /app/backend/target/
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS collect
|
||||
ARG CACHE_STAMP
|
||||
# 一次导出三个 mount,避免逐目录创建镜像、载入 Docker 再复制文件。
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& mkdir -p /cache/registry /cache/git /cache/target \
|
||||
&& cp -a /usr/local/cargo/registry/. /cache/registry/ \
|
||||
&& cp -a /usr/local/cargo/git/. /cache/git/ \
|
||||
&& cp -a /app/backend/target/. /cache/target/
|
||||
|
||||
FROM scratch AS snapshot
|
||||
COPY --from=collect /cache/ /
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Cargo cache mounts
|
||||
description: Restore or export a validated Cargo snapshot for a BuildKit builder
|
||||
inputs:
|
||||
operation:
|
||||
description: Cache operation, restore or snapshot
|
||||
required: true
|
||||
builder:
|
||||
description: Buildx builder name
|
||||
required: true
|
||||
path:
|
||||
description: Cargo snapshot directory on the runner
|
||||
required: true
|
||||
architecture:
|
||||
description: Cargo target directory architecture, amd64 or arm64
|
||||
required: true
|
||||
outputs:
|
||||
usable:
|
||||
description: Whether the restored or exported snapshot contains Cargo dependencies
|
||||
value: ${{ steps.cache.outputs.usable }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: cache
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
BUILDER: ${{ inputs.builder }}
|
||||
CACHE_PATH: ${{ inputs.path }}
|
||||
ARCHITECTURE: ${{ inputs.architecture }}
|
||||
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
|
||||
exit 2
|
||||
fi
|
||||
operation="$1"
|
||||
builder="$2"
|
||||
directory="$(realpath -m "$3")"
|
||||
architecture="$4"
|
||||
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
|
||||
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
|
||||
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
|
||||
|
||||
usable() {
|
||||
local root="$1" path crates=false libraries=false
|
||||
# 目录或 buildstamp 存在不代表编译发生过,必须同时有下载包与目标依赖。
|
||||
for path in "$root"/registry/cache/*/*.crate; do
|
||||
if [ -f "$path" ] && [ -s "$path" ]; then crates=true; break; fi
|
||||
done
|
||||
for path in "$root"/target/"$architecture"/release/deps/*.rlib; do
|
||||
if [ -f "$path" ] && [ -s "$path" ]; then libraries=true; break; fi
|
||||
done
|
||||
[ "$crates" = true ] && [ "$libraries" = true ]
|
||||
}
|
||||
|
||||
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
|
||||
# mount 的内容不参与层指纹,用时间戳重跑复制;--no-cache 会重置 cache mount。
|
||||
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
|
||||
valid=false
|
||||
if [ "$operation" = restore ]; then
|
||||
if usable "$directory"; then
|
||||
mkdir -p "$directory"/{registry,git,target}
|
||||
"${command[@]}" --output type=cacheonly "$directory"
|
||||
valid=true
|
||||
else
|
||||
echo "No usable Cargo snapshot; cached image layers may still be available"
|
||||
fi
|
||||
else
|
||||
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
|
||||
mkdir -p "$(dirname "$directory")"
|
||||
temporary="$(mktemp -d "${directory}.XXXXXX")"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
mkdir "$temporary/context"
|
||||
"${command[@]}" --output "type=local,dest=$temporary/snapshot" "$temporary/context"
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
valid=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Cargo snapshot for $architecture: usable=$valid"
|
||||
if [ "$valid" = true ]; then du -sh "$directory"; fi
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
|
||||
@@ -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
|
||||
@@ -64,7 +68,10 @@ updates:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: docker
|
||||
directory: /deploy
|
||||
directories:
|
||||
- /deploy
|
||||
- /.github/actions/cargo-cache
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
## 问题与改动
|
||||
|
||||
<!-- 说明触发场景、问题依据和最终行为;涉及上游时注明官方源码版本与位置。 -->
|
||||
|
||||
## 自审与验证
|
||||
|
||||
<!-- 简述实际执行的检查、结果与缺口,不把跳过写成通过。
|
||||
后端说明相关职责归属、复用与边界自审结果;页面说明沿用的设计及必要调整。
|
||||
流程见 CONTRIBUTING.md;AI 开发执行 cpr-dev-guide,准备或更新 PR 执行 cpr-github-pr。 -->
|
||||
|
||||
## 页面截图
|
||||
|
||||
<!-- 无页面变更时删除本节。有页面变更必须嵌入或链接审查者可访问的实际截图:
|
||||
现有页面提供前后对比,新增页面提供完成态;补充受影响状态,浮层展示展开状态。
|
||||
注明入口、状态和对应提交;预览步骤、录屏、本机路径不能替代截图。截图待补时保留 Draft。 -->
|
||||
|
||||
## AI 使用
|
||||
|
||||
<!-- 写明未使用 AI;使用时列出工具名称与实际模型型号,多模型分别列出,可补充参与范围。
|
||||
不能只填 Codex、ChatGPT 等工具或产品名。型号不可得时如实标为待补,不猜测,补齐前保留 Draft。 -->
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Reusable Container
|
||||
name: Container
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -28,35 +28,30 @@ jobs:
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Cache Cargo mounts
|
||||
- name: Restore Cargo snapshot
|
||||
id: cargo-cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-${{ inputs.ref }}
|
||||
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
|
||||
key: container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
|
||||
restore-keys: |
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/**/Cargo.toml', 'deploy/Dockerfile') }}-
|
||||
container-cargo-v1-${{ runner.os }}-${{ runner.arch }}-
|
||||
container-cargo-v3-${{ runner.os }}-${{ runner.arch }}-
|
||||
container-cargo-v2-${{ runner.os }}-${{ runner.arch }}-
|
||||
|
||||
# The GHA layer exporter does not persist RUN --mount=type=cache contents.
|
||||
# Extraction runs before actions/cache saves its directories in post steps.
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0
|
||||
id: cargo-restore
|
||||
uses: ./.github/actions/cargo-cache
|
||||
with:
|
||||
operation: restore
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
scratch-dir: ${{ runner.temp }}/container-cache-dance
|
||||
cache-map: |
|
||||
{
|
||||
"${{ runner.temp }}/container-cargo/registry": "/usr/local/cargo/registry",
|
||||
"${{ runner.temp }}/container-cargo/git": "/usr/local/cargo/git",
|
||||
"${{ runner.temp }}/container-cargo/target": "/app/backend/target"
|
||||
}
|
||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
@@ -67,7 +62,7 @@ jobs:
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
|
||||
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
@@ -75,3 +70,23 @@ jobs:
|
||||
image: ${{ env.CPR_IMAGE }}
|
||||
platform: linux/amd64
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
|
||||
|
||||
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
|
||||
- name: Export Cargo snapshot
|
||||
if: >-
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
|
||||
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
|
||||
id: cargo-snapshot
|
||||
uses: ./.github/actions/cargo-cache
|
||||
with:
|
||||
operation: snapshot
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
|
||||
- name: Save Cargo snapshot
|
||||
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Reusable Quality
|
||||
name: Quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -7,11 +7,6 @@ on:
|
||||
description: Exact commit SHA to validate
|
||||
required: true
|
||||
type: string
|
||||
upload_frontend:
|
||||
description: Upload the built frontend for release jobs
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_backend:
|
||||
description: Run backend quality checks
|
||||
required: false
|
||||
@@ -111,6 +106,7 @@ jobs:
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Rust format
|
||||
working-directory: backend
|
||||
@@ -120,6 +116,19 @@ jobs:
|
||||
working-directory: backend
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
|
||||
- name: Build Rust tests once
|
||||
working-directory: backend
|
||||
run: |
|
||||
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
|
||||
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
|
||||
> "$RUNNER_TEMP/nextest-binaries.json"
|
||||
|
||||
- name: Configure PostgreSQL and Redis test authentication
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -144,7 +153,30 @@ jobs:
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: cargo test --test main --locked
|
||||
run: |
|
||||
cargo nextest run \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--no-tests fail
|
||||
|
||||
- name: Store tests with Redis ACL authentication
|
||||
working-directory: backend
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
redis_acl_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$redis_acl_password"
|
||||
docker exec "${{ job.services.redis.id }}" redis-cli -e --no-auth-warning \
|
||||
-u "$CPR_TEST_REDIS_URL" --user default ACL SETUSER cpr_acl reset on \
|
||||
">$redis_acl_password" '~*' '&*' '+@all' >/dev/null
|
||||
docker exec --env REDISCLI_AUTH="$redis_acl_password" "${{ job.services.redis.id }}" \
|
||||
redis-cli -e --user cpr_acl ACL SETUSER default off >/dev/null
|
||||
export CPR_TEST_REDIS_URL="redis://cpr_acl:${redis_acl_password}@127.0.0.1:6379"
|
||||
# 从同一份构建清单选择 Redis 测试,保留 workspace 特性图且不重复运行 PostgreSQL
|
||||
cargo nextest run \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--no-tests fail -E 'package(=gateway-store) & test(/^redis::/)'
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
@@ -158,35 +190,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm --dir frontend install --frozen-lockfile
|
||||
|
||||
- name: Frontend format
|
||||
run: pnpm --dir frontend format:check
|
||||
|
||||
- name: Frontend build
|
||||
run: pnpm --dir frontend build
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload_frontend
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Validate and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
workflow-lint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -206,5 +211,8 @@ jobs:
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
- name: Check workflow script syntax
|
||||
run: |
|
||||
bash -n release/publish
|
||||
shopt -s globstar nullglob
|
||||
for script in .github/**/*.sh; do bash -n "$script"; done
|
||||
|
||||
@@ -26,6 +26,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source: ${{ steps.filter.outputs.source }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -37,23 +38,40 @@ jobs:
|
||||
id: filter
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
|
||||
predicate-quantifier: some-with-excludes
|
||||
filters: |
|
||||
backend:
|
||||
- 'backend/**'
|
||||
- 'release/version.yaml'
|
||||
- '.github/workflows/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
frontend:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/actions/build-frontend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
source:
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- 'deploy/**'
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/**'
|
||||
- '.github/actions/verify-container/**'
|
||||
- '.github/actions/cargo-cache/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
|
||||
quality:
|
||||
needs: changes
|
||||
@@ -72,3 +90,60 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
|
||||
- name: Check UI source integration
|
||||
run: |
|
||||
pnpm --dir frontend build:source
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
- name: Check local SDK integration
|
||||
env:
|
||||
RUST_MIN_STACK: 16777216
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
|
||||
run: |
|
||||
sdk_check_dir="$RUNNER_TEMP/plugin-source"
|
||||
mkdir -p "$sdk_check_dir/backend"
|
||||
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
|
||||
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
|
||||
- name: Ensure source checks preserve lockfiles
|
||||
run: |
|
||||
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
|
||||
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
|
||||
|
||||
+133
-19
@@ -165,12 +165,64 @@ jobs:
|
||||
puts "build_type=#{version.split("+", 2).first.include?("-exp.") ? "experimental" : "release"}"
|
||||
RUBY
|
||||
|
||||
- name: Seal plugin release manifest
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
GIT_SHA: ${{ steps.version.outputs.git_sha }}
|
||||
run: |
|
||||
ruby <<'RUBY'
|
||||
require "fileutils"
|
||||
require "json"
|
||||
|
||||
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
|
||||
host = JSON.parse(File.read(host_path))
|
||||
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
|
||||
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
|
||||
host = host.merge("schema_version" => 1, "permissions" => [])
|
||||
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
|
||||
manifest = {
|
||||
"schema_version" => 1,
|
||||
"sealed" => true,
|
||||
"gateway_version" => ENV.fetch("VERSION"),
|
||||
"gateway_git_sha" => ENV.fetch("GIT_SHA"),
|
||||
"plugin_host" => host,
|
||||
"plugins" => [],
|
||||
}
|
||||
FileUtils.mkdir_p("dist/plugin-release-assets")
|
||||
File.write("dist/plugin-release-assets/plugin-release-manifest.json", JSON.pretty_generate(manifest) + "\n")
|
||||
RUBY
|
||||
|
||||
- name: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
quality:
|
||||
needs: [release-info]
|
||||
uses: ./.github/workflows/_quality.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
upload_frontend: true
|
||||
run_frontend: false
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Validate and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
upload-artifact: 'true'
|
||||
|
||||
build-docker-binary:
|
||||
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
@@ -194,10 +246,10 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -260,10 +312,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}-${{ needs.release-info.outputs.git_sha }}
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', '.github/workflows/release.yml') }}
|
||||
restore-keys: |
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -287,7 +338,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, quality, build-docker-binary, build-binary]
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -313,6 +364,12 @@ jobs:
|
||||
pattern: codex-proxy-rs-*
|
||||
path: dist/binaries
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
@@ -364,6 +421,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 +431,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 +445,24 @@ 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_manifest_path = release_dir / "plugin-release-manifest.json"
|
||||
shutil.copy2("dist/plugin-release-assets/plugin-release-manifest.json", plugin_manifest_path)
|
||||
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"
|
||||
@@ -412,10 +483,12 @@ jobs:
|
||||
path: dist/release
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
|
||||
compression-level: 0
|
||||
|
||||
build-docker-image:
|
||||
name: build-docker-image (${{ matrix.dockerPlatform }})
|
||||
needs: [release-info, quality, build-docker-binary]
|
||||
needs: [release-info, frontend, build-docker-binary]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.release-info.outputs.docker_matrix) }}
|
||||
@@ -448,6 +521,12 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
|
||||
- name: Prepare Docker prebuilt assets
|
||||
shell: bash
|
||||
env:
|
||||
@@ -457,6 +536,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import pathlib
|
||||
@@ -472,10 +552,40 @@ jobs:
|
||||
binary_dest = target_dir / app_name
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
|
||||
release_dir = pathlib.Path("dist/plugin-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
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -486,7 +596,7 @@ jobs:
|
||||
|
||||
- name: Build and push app image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
@@ -536,7 +646,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
needs: [release-info, package-assets, build-docker-image]
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -565,7 +676,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
@@ -684,6 +795,9 @@ jobs:
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
附件包含与当前宿主版本及提交绑定的 `plugin-release-manifest.json`,当前没有默认内置插件。
|
||||
教学示例由独立的 `codex-proxy-plugins` 仓库维护和发布,不随宿主发行物分发。
|
||||
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
## 📚 文档
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Agent Instructions Guard
|
||||
name: Rules Guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: agents-md-guard-${{ github.event.pull_request.number }}
|
||||
group: rules-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
- name: Enforce agent instructions policy
|
||||
- name: Check protected files
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
@@ -33,12 +33,15 @@ jobs:
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
const isInstructions = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
|
||||
const isProtectedRule = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|
||||
|| path === 'CONTRIBUTING.md'
|
||||
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|
||||
|| path === '.github' || path.startsWith('.github/'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
|
||||
core.info('No agent instruction changes detected.');
|
||||
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
|
||||
core.info('No protected contribution rule changes detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,27 +52,31 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// 通知标记保持稳定,以便复用机器人已有评论,避免重复提醒
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
|
||||
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
'麻烦先撤销这些变更,再重新打开 PR;如果希望调整协作规则,可以先提个 Issue 说明原因和建议,交由维护者处理,感谢理解',
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
|
||||
const notification = comments.find(comment => comment.user?.login === 'github-actions[bot]'
|
||||
&& comment.body?.includes(marker));
|
||||
if (!notified) {
|
||||
if (!notification) {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
} else if (notification.body !== body) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: notification.id, body });
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Unable to post policy explanation: ${error.message}`);
|
||||
}
|
||||
|
||||
await github.rest.pulls.update({ ...params, state: 'closed' });
|
||||
core.setFailed('Pull requests must not change AGENTS.md.');
|
||||
core.setFailed('Pull requests must not change protected contribution rules (AGENTS.md, CONTRIBUTING.md, .agents/skills/, .github/).');
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Security Scan
|
||||
name: Security
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-scan-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -27,20 +27,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --version 0.22.2 --locked
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
- name: Run Cargo audits
|
||||
run: |
|
||||
cargo-audit audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -54,7 +49,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
-2
@@ -6,9 +6,8 @@
|
||||
# Frontend
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.vite/
|
||||
/.pnpm-store/
|
||||
/frontend/.pnpm-store/
|
||||
/frontend/.pnpm-cache/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Release artifacts
|
||||
@@ -20,6 +19,9 @@
|
||||
|
||||
# Local configuration and secrets
|
||||
/deploy/config.yaml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editors and operating systems
|
||||
/.idea/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "modules/ui"]
|
||||
path = modules/ui
|
||||
url = https://github.com/zyycn/codex-proxy-ui.git
|
||||
[submodule "modules/plugins"]
|
||||
path = modules/plugins
|
||||
url = https://github.com/zyycn/codex-proxy-plugins.git
|
||||
@@ -1,8 +1,25 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
|
||||
按用户意图和当前会话主动加载并执行仓库技能,不要求用户写出 `$技能名`:
|
||||
|
||||
| 任务意图与常见说法 | 必用技能 |
|
||||
| --- | --- |
|
||||
| 开发、实现、修复、排查报错、审查问题或代码、修改文档 | [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) |
|
||||
| 提交/提个 PR、准备或更新 PR、看下 PR、回复 PR 讨论、处理 PR 审查意见 | [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md);需要修改时同时执行 cpr-dev-guide |
|
||||
| 提 Issue、反馈到仓库、把问题或建议整理成 Issue、补充或回复 Issue | [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) |
|
||||
| 写网关插件、修改插件能力、排查插件接入、打包插件 | [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md),同时执行 cpr-dev-guide |
|
||||
| 发版、准备版本、发布 beta/实验版、整理发布说明、打发行 tag | [cpr-release](.agents/skills/cpr-release/SKILL.md) |
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
只加载当前任务命中的技能、模式和文档章节,复用已有上下文,进入新阶段再补读;链接是按需入口,不是全文或全部参考文件的必读清单
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
Issue 和 PR 回复分别按对应技能的[Issue 回复口吻](.agents/skills/cpr-github-issue/SKILL.md#回复口吻)与[PR 回复口吻](.agents/skills/cpr-github-pr/SKILL.md#回复口吻)执行
|
||||
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
@@ -17,27 +34,31 @@ If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题;涉及 Codex 行为、协议或兼容性时,按[参考仓库优先级](.agents/skills/cpr-dev-guide/SKILL.md#参考仓库的优先级)以官方 Codex 仓库对应版本源码为第一手资料,三方仓库仅作参考
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 提交前自审最终差异,先处理本次引入的职责混淆、重复规则、冗余状态和无依据的兼容分支;后端按 [后端自审](docs/architecture.md#后端自审) 追踪状态与资源生命周期,不能把自审留给 CI 或 PR 审查者。
|
||||
- 页面变更先复用已有页面设计与组件,不因个人偏好重排页面或另建交互。文案取舍遵循 [界面文案与信息层级](docs/theme.md#界面文案与信息层级),页面 PR 必须提供 [实际截图](CONTRIBUTING.md#界面验证)。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 常规文档只描述当前状态,不记录变更历史;按[文档职责与更新条件](CONTRIBUTING.md#文档职责与更新条件)在所属章节就地修订,分清层次与详略,短段和列表少用句号,交付前逐段检查必要性、归属、重复与当前状态表达
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
- 使用 AI 的 PR 按 [AI 使用披露](CONTRIBUTING.md#ai-使用披露) 写明实际模型型号,不以工具名称代替模型。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### 审查范围
|
||||
|
||||
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
|
||||
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
|
||||
- 按[审查标准](CONTRIBUTING.md#审查标准)检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称
|
||||
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
|
||||
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
|
||||
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
- UI 变更对照原页面、项目基础组件、主题和同类页面,检查布局变化是否服务本次需求、是否堆叠重复文案,以及交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对页面 PR 按 [界面验证](CONTRIBUTING.md#界面验证) 提供的截图与实际交互证据;缺少必需截图时明确要求补齐,未检查的状态列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
|
||||
+111
-57
@@ -1,67 +1,83 @@
|
||||
# 贡献与审查
|
||||
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理
|
||||
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制
|
||||
|
||||
## 交流与反馈
|
||||
|
||||
[讨论区](https://github.com/zyycn/codex-proxy-rs/discussions) 用于使用问答、想法讨论、实验反馈和经验分享。
|
||||
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案。
|
||||
提问时按需补充版本、部署方式、已尝试的方法及脱敏信息;问题解决后,请在「使用问答」中标记有帮助的答案
|
||||
|
||||
明确的 Bug、具体功能需求或协作规则建议使用 Issue,无需先开讨论。
|
||||
讨论形成可执行事项后,可以新建 Issue 并互相附链接;已有 Issue 的后续信息继续补充在原帖,避免重复记录。
|
||||
围绕问题友善交流,欢迎不同使用场景和方案观点。
|
||||
围绕问题友善交流,欢迎不同使用场景和方案观点
|
||||
|
||||
### 提交 Issue
|
||||
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
1. **准备变更**:按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改。提交前自审完整差异,清理本次引入的重复逻辑、冗余状态与职责越界;后端按 [后端自审](docs/architecture.md#后端自审) 执行。涉及架构、公开合同或较大交互变化时,先说明方案与取舍
|
||||
2. **发起 PR**:目标分支使用 `main`,按 [PR 模板](.github/pull_request_template.md) 说明问题依据、触发场景、最终行为、自审和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。页面变更必须按 [界面验证](#界面验证) 附实际截图,使用 AI 时按 [AI 使用披露](#ai-使用披露) 写明模型。一个 PR 围绕一个明确目标,必要重构说明关联原因
|
||||
3. **自动检查与初审**:CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验
|
||||
4. **修改与合并**:更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档
|
||||
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求
|
||||
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
AI 开发、排查、代码审查和文档修改必须执行 `cpr-dev-guide`;准备、创建、更新及审查 PR 必须执行 `cpr-github-pr`,不以用户是否显式点名技能为条件。PR 内修复仍须经过开发流程,不能用填写 PR 正文代替实现自审与验证
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
|
||||
| [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` |
|
||||
| [cpr-dev-guide](.agents/skills/cpr-dev-guide/SKILL.md) | 项目开发与文档导航 | `$cpr-dev-guide 修复当前问题` |
|
||||
| [cpr-plugin-dev](.agents/skills/cpr-plugin-dev/SKILL.md) | 网关插件开发、打包与验证 | `$cpr-plugin-dev 开发一个添加响应头的插件` |
|
||||
| [cpr-github-pr](.agents/skills/cpr-github-pr/SKILL.md) | 准备、创建、更新或审查 PR | `$cpr-github-pr 创建当前分支的 PR` / `$cpr-github-pr 审查 PR #编号` |
|
||||
| [cpr-github-issue](.agents/skills/cpr-github-issue/SKILL.md) | 整理或提交 Issue | `$cpr-github-issue 提交当前问题` |
|
||||
| [cpr-release](.agents/skills/cpr-release/SKILL.md) | 准备发布说明或正式发版 | `$cpr-release 整理本次发布说明` / `$cpr-release 发布 vX.Y.Z` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查
|
||||
|
||||
### AI 使用披露
|
||||
|
||||
PR 正文说明提交者是否使用 AI。使用 AI 参与方案、代码、文档、测试或自审时,写明工具名称和实际模型型号;使用多个模型时分别列出,可简述各自参与范围。
|
||||
`Codex`、`ChatGPT`、`Claude Code` 等工具或产品名称不能代替模型型号,优先填写工具显示或调用配置中的具体模型标识。
|
||||
无法获知型号时如实标记「型号未提供,待补充」,不能猜测或伪造,也不能把待补信息称为已完成披露;补齐前保留 Draft。
|
||||
更新 PR 时同步新增的模型信息;这一披露不替代提交者自审、验证责任或已有提交署名约定
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
|
||||
- 根目录及任意子目录的 `AGENTS.md`
|
||||
- 根目录的 `CONTRIBUTING.md`
|
||||
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
|
||||
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
|
||||
|
||||
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
|
||||
|
||||
## 审查标准
|
||||
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告
|
||||
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证
|
||||
|
||||
| 维度 | 检查重点 |
|
||||
| --- | --- |
|
||||
@@ -77,51 +93,81 @@ PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或
|
||||
|
||||
审查意见区分三类:
|
||||
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷
|
||||
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则
|
||||
|
||||
## 项目约定
|
||||
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
|
||||
|
||||
## 文档职责与更新条件
|
||||
|
||||
常规文档说明当前有效的用法、接口合同、模块职责和维护约束,不承担变更历史。
|
||||
只有改动使对应说明失真、缺少必要信息,或改变读者需要执行的操作与判断时,才更新文档。
|
||||
内部修复、重构和界面微调不默认附带文档改动;审查时不能仅因 PR 没改文档就判定存在缺口。
|
||||
**文档只描述当前有效状态,不记录变更历史**,在所属章节就地修订,不追加“本次新增”“此前行为”或修复经过
|
||||
|
||||
- 按 [开发指南的文档入口](.agents/skills/dev-guide/SKILL.md#按任务读取) 找到内容所属文档,在原有章节就地修订,
|
||||
不按每次变更追加说明。同一事实只保留一处完整说明,其他文档按读者任务提供必要摘要和链接。
|
||||
- API 文档保留调用所需的字段、状态、语义和限制;架构文档保留职责与不变量;部署文档保留配置、操作和排障。
|
||||
不把界面布局、内部锁或索引实现写进接口说明,也不把前端库的实现选择写进部署说明。
|
||||
- 改动原因、前后对比、排查经过和本次验证结果放在 PR / Issue 或任务报告。版本变更与升级影响按维护者的发版安排维护
|
||||
`release/notes.md`,不为普通开发任务或 PR 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
只有用法、合同、职责、约束或读者需要执行的操作发生变化时才更新,内部重构和界面微调不默认附带文档改动
|
||||
|
||||
### 内容归属
|
||||
|
||||
| 文档 | 回答的问题 | 详略边界 |
|
||||
| --- | --- | --- |
|
||||
| [README](README.md) | 项目能做什么,如何开始 | 简短介绍、最短可用路径和文档导航 |
|
||||
| [部署与运维](deploy/README.md) | 如何安装、接入、排障和恢复 | 前置条件、配置、操作步骤、结果判断与风险 |
|
||||
| [开发与源码联调](docs/development.md) | 如何准备环境、运行源码和联调独立仓库 | 可执行命令、目录关系和依赖维护 |
|
||||
| [API 参考](docs/api.md) | 调用者如何正确请求和解释结果 | 鉴权、字段、默认值、错误、限制与幂等语义,不展开内部实现或界面布局 |
|
||||
| [系统架构](docs/architecture.md) | 业务事实归谁,调用如何协作 | 模块边界、状态所有权、生命周期和不变量,不重复字段清单 |
|
||||
| [管理端主题](docs/theme.md) | 界面如何保持一致,主题如何扩展 | 文案与视觉约束、Token 合同、实现入口和验收范围 |
|
||||
| [插件使用](docs/plugins.md) | 如何安装、授权、配置和使用插件 | 管理员操作与影响,不展开 SDK 调用或宿主事务实现 |
|
||||
| [插件 SDK](backend/crates/gateway-plugin/sdk/README.md) / [CLI](backend/apps/plugin-cli/README.md) | 如何编写和打包插件 | 作者合同、权限边界、示例与打包验证 |
|
||||
| [数据库迁移](backend/migrations/README.md) | 如何维护 schema 和准备测试库 | 冻结规则、升级约束和测试环境,不罗列迁移历史 |
|
||||
| 本文 | 如何协作、审查和交付 | 流程、判断标准与验证要求,任务入口由 [开发技能](.agents/skills/cpr-dev-guide/SKILL.md) 串联 |
|
||||
|
||||
- 同一事实只在所属文档完整说明,其他位置按读者任务保留必要摘要和链接
|
||||
- 改动原因、前后对比、排查经过和验证结果放在 PR、Issue 或任务报告;`release/notes.md` 是发版时使用的发布说明,不作为常规文档的历史章节,也不随普通开发任务自动追加
|
||||
- 仍影响升级、恢复或兼容使用的内容,写成明确的适用条件与操作,保留必要限制,不叙述演变过程
|
||||
|
||||
### 层次与行文
|
||||
|
||||
- 按读者任务组织章节,先给入口和操作,再解释限制;长篇参考文档提供导航,独立主题使用小标题
|
||||
- 快速开始只保留走通流程的必要步骤;API、配置、恢复和权限等容易误用的内容写清输入、默认值、边界与失败处理
|
||||
- 短说明直接成段,并列信息用列表,可比较的字段或职责用表格,避免一段堆叠多个主题
|
||||
- 标题、表格单元格、短列表项和独立短段通常不加句号;长段优先拆分,确需区分完整句意时保留句号,不机械替换成逗号或分号
|
||||
- 命令、URL、版本号、字段名和原始输出保持准确,不为统一标点改写代码示例
|
||||
|
||||
审查时逐段核对:是否属于当前状态、是否回答读者问题、是否放在正确文档、是否重复、脱离变更背景能否独立理解
|
||||
|
||||
没有文档改动本身不构成验证缺口,判断依据是当前说明是否仍然准确、完整
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有页面为依据,优先延续原页面的布局、信息层级与交互;设计技能不构成重做页面的理由
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
开发前查找原页面、同类页面及 `modules/ui/src/components` 中的基础组件,明确参考实现和可复用部分,再确定最小改动。只有原设计不能满足本次操作或存在具体问题时才调整结构,并在 PR 中说明原因;不顺带换风格、重排无关区域或增加重复的模式入口。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
提交者在浏览器中验证受影响路径,页面变更的 PR 必须提供实际运行截图,包括布局、文案、样式和交互状态的变化:
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
- 修改现有页面提供变更前后截图,尽量保持相同数据、主题和窗口尺寸;新增页面提供完成态截图。截图保留必要上下文,让审查者能判断布局和信息密度,不只截取孤立控件
|
||||
- 按影响范围补充浅色/深色、窄窗口及空/加载/错误等受影响状态;共用主题或基础组件变更按主题文档扩大验证范围。弹出层中的内容需展示展开状态,不能只截关闭后的页面
|
||||
- 在 PR 正文嵌入或链接审查者可访问的图片,注明页面入口、状态和对应提交。录屏、预览地址和复现步骤用于补充,不能替代截图;本机绝对路径不算可供远端审阅的附件
|
||||
- 视觉或交互继续修改后更新受影响截图,使证据对应待审实现。截图与模拟数据不得包含真实凭据,不用设计稿、生成图片或旧版截图冒充运行结果
|
||||
- 环境不足时说明缺少的截图和交互验证,不宣称验收通过;页面 PR 在截图补齐前保留 Draft,不标为可审阅或建议合并
|
||||
|
||||
截图用于视觉审阅,保存、切换、错误恢复和键盘操作等行为仍需实际验证并记录覆盖范围,不能仅凭静态图片推断交互正确
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
@@ -131,6 +177,10 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
环境准备、依赖安装和服务启动见 [开发与源码联调](docs/development.md),联合修改组件库或插件时使用其中的子模块入口
|
||||
|
||||
正式依赖固定到标签或提交,联调只临时替换解析路径,不改写正式依赖或锁文件
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
@@ -139,16 +189,20 @@ pnpm --dir frontend build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)
|
||||
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security.yml)
|
||||
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
|
||||
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
|
||||
|
||||
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
|
||||
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
|
||||
|
||||
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置
|
||||
|
||||
@@ -30,14 +30,17 @@
|
||||
| 账号 | `admin@cpr.local` |
|
||||
| 密码 | `039c18de2aeac46d23ead7766bb07bbbe3747233c66a128e` |
|
||||
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据。
|
||||
预览服务运行已发布版本,展示的账号、代理与使用记录均为模拟数据,每天北京时间 `00:00` 自动生成当天数据
|
||||
|
||||
这是公开共享的功能预览环境,不提供真实模型调用;请勿导入真实账号、密钥或其他敏感信息
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
### 一键安装
|
||||
|
||||
@@ -48,7 +51,9 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
```
|
||||
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
自动生成密码、设置目录权限并启动服务
|
||||
|
||||
完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
@@ -65,7 +70,7 @@ curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/in
|
||||
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
重复运行时请使用同一安装目录,检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级
|
||||
|
||||
### 手动安装
|
||||
@@ -74,9 +79,9 @@ INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
|
||||
### 登录管理端
|
||||
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
页面右上角的「密钥配置」支持复制 Codex 配置文件和导入 CCSwitch,导入时同时启用当前 Key 的日/周额度查询,默认刷新间隔为 30 分钟
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录
|
||||
|
||||
API Key 持有者可在登录页切换身份,查看自己的用量与额度,并通过「密钥配置」复制 Codex 配置或导入 CCSwitch
|
||||
|
||||
默认地址只能在服务器本机访问,从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)
|
||||
@@ -89,8 +94,9 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
合并到客户端配置后重启 Codex。完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入,合并配置后重启 Codex
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses API 客户端**:填写以下信息:
|
||||
|
||||
@@ -99,7 +105,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 +114,14 @@ 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) |
|
||||
| 开发宿主 | [开发与源码联调](docs/development.md) · [系统架构](docs/architecture.md) · [管理端主题](docs/theme.md) · [数据库迁移](backend/migrations/README.md) |
|
||||
| 参与协作 | [贡献、审查与验证](CONTRIBUTING.md) |
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+736
-126
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
@@ -32,6 +35,20 @@ needless_collect = { level = "deny", priority = 7 }
|
||||
[profile.dev]
|
||||
debug = true
|
||||
|
||||
# 测试保留真实密码参数和压缩数据量,优化热点依赖以减少 fixture 的计算开销。
|
||||
# test 继承 dev;workspace 代码仍保留默认调试与溢出检查。
|
||||
[profile.dev.package.argon2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."sha2@0.11.0"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."miniz_oxide@0.9.1"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.release]
|
||||
debug = false
|
||||
strip = "symbols"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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,70 +104,347 @@ 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) {
|
||||
let runtime = prepare_plugins(&store, plugin_cache, &version, 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, plugin_http.clone())?;
|
||||
let admin_providers = gateway_admin::ports::provider::ProviderAdminRegistry::new([
|
||||
openai.admin_provider(),
|
||||
xai.admin_provider(),
|
||||
])
|
||||
.map_err(|_| gateway_admin::model::AdminError::invalid("Provider 管理身份冲突"))?;
|
||||
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(),
|
||||
providers.clone(),
|
||||
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(
|
||||
admin_providers.clone(),
|
||||
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(
|
||||
admin_providers.clone(),
|
||||
store.admin_ports().client_keys(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_client_key_ports(&keys));
|
||||
let plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
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 settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
admin_providers,
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::service::Registry::new({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
});
|
||||
or_shutdown!(plugin_runtime, services.register_settings(&settings));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
or_shutdown!(plugin_runtime, store.start_command_line_writes());
|
||||
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(),
|
||||
providers,
|
||||
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 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 plugin_resources = gateway_admin::initialize_plugin_resources(
|
||||
store.admin_ports().plugin_resources(),
|
||||
core.snapshot_control(),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_resource_ports(&plugin_resources)
|
||||
);
|
||||
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 {
|
||||
service_middleware: {
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
std::sync::Arc::new(move || {
|
||||
let snapshot = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(snapshot.extensions()?)
|
||||
})
|
||||
},
|
||||
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(),
|
||||
)
|
||||
)
|
||||
.with_middleware({
|
||||
let snapshots = core.snapshots();
|
||||
let middleware = plugin_runtime.middleware_registry();
|
||||
move |snapshot| {
|
||||
if let Some(snapshot) = snapshot {
|
||||
return middleware.resolve(snapshot.extensions()?);
|
||||
}
|
||||
// 数据面未就绪时,管理与诊断路由仍可使用发布候选中的插件。
|
||||
let diagnostic = snapshots.snapshot_for_diagnostics()?;
|
||||
middleware.resolve(diagnostic.extensions()?)
|
||||
}
|
||||
});
|
||||
let http_dispatcher = api.dispatcher();
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_http(&http_dispatcher));
|
||||
host.report_startup_ready("API");
|
||||
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_services(&admin.services().public_services())
|
||||
);
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.push(gateway_host::retention::worker(store.retention())?);
|
||||
plan.extend(core.take_worker_contributions());
|
||||
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.push(plugin_runtime.maintenance_worker(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,
|
||||
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(),
|
||||
restart_circuit: Default::default(),
|
||||
},
|
||||
http,
|
||||
std::sync::Arc::new(gateway_host::process::ProcessSupervisor::default()),
|
||||
)
|
||||
.with_oauth_pending(store.provider_ports().oauth_pending()),
|
||||
))
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[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),
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -73,7 +76,12 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
"account/selection.rs" => Some(&["account", "concurrency", "identity", "validation"]),
|
||||
"concurrency.rs" => Some(&["error"]),
|
||||
"account/store.rs" => Some(&["account", "error", "identity", "validation"]),
|
||||
path if path.starts_with("policy/") => Some(&["account", "policy", "validation"]),
|
||||
path if path.starts_with("policy/") => {
|
||||
Some(&["account", "identity", "policy", "validation"])
|
||||
}
|
||||
"settings/values.rs" | "settings/compiled.rs" => {
|
||||
Some(&["account", "concurrency", "identity", "metering", "policy"])
|
||||
}
|
||||
path if path.starts_with("account/") => Some(&["account", "identity", "validation"]),
|
||||
_ => None,
|
||||
};
|
||||
@@ -149,6 +157,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,26 +181,36 @@ 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",
|
||||
"retention",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
@@ -207,10 +232,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 +247,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,17 +381,47 @@ 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 src = member_root.join("src");
|
||||
assert_module_tree(&src, &["lib.rs", "main.rs"]);
|
||||
assert_directory_modules_have_children(&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<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_directory_modules_have_children(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
|| relative.file_name().and_then(|value| value.to_str()) != Some("mod.rs")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let directory = relative.parent().expect("mod.rs parent");
|
||||
assert!(
|
||||
files
|
||||
.iter()
|
||||
.any(|candidate| candidate != relative && candidate.starts_with(directory)),
|
||||
"{} is a leaf module and must use {}",
|
||||
root.join(relative).display(),
|
||||
root.join(directory).with_extension("rs").display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModuleLayout {
|
||||
File,
|
||||
@@ -368,6 +430,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 +439,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 +475,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()
|
||||
@@ -440,6 +529,8 @@ fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
|
||||
|
||||
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
// 同一父模块的所有子文件复用一次语法解析,计数仍保留重复声明检查。
|
||||
let mut declarations = BTreeMap::<PathBuf, BTreeMap<String, usize>>::new();
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
@@ -502,7 +593,14 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
};
|
||||
let declaration_count = declaration_parents
|
||||
.iter()
|
||||
.map(|path| external_module_declaration_count(path, module_name))
|
||||
.map(|path| {
|
||||
declarations
|
||||
.entry(path.clone())
|
||||
.or_insert_with(|| external_module_declarations(path))
|
||||
.get(module_name)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
declaration_count,
|
||||
@@ -513,23 +611,21 @@ fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
|
||||
fn external_module_declarations(path: &Path) -> BTreeMap<String, usize> {
|
||||
if !path.is_file() {
|
||||
return 0;
|
||||
return BTreeMap::new();
|
||||
}
|
||||
let source = fs::read_to_string(path).expect("read parent module source");
|
||||
let syntax = syn::parse_file(&source).expect("parse parent module source");
|
||||
syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::Mod(module)
|
||||
if module.content.is_none() && module.ident == module_name
|
||||
)
|
||||
})
|
||||
.count()
|
||||
let mut declarations = BTreeMap::new();
|
||||
for item in syntax.items {
|
||||
if let Item::Mod(module) = item
|
||||
&& module.content.is_none()
|
||||
{
|
||||
*declarations.entry(module.ident.to_string()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
declarations
|
||||
}
|
||||
|
||||
pub(super) fn backend_root() -> PathBuf {
|
||||
|
||||
@@ -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;
|
||||
@@ -85,7 +133,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy).await;
|
||||
let result = probe.test(&proxy, false).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,53 @@
|
||||
# Codex Proxy Plugin CLI
|
||||
|
||||
`cpr-plugin` 校验并规范化作者清单、收集声明的资源,生成可安装归档及 SHA-256 校验文件。
|
||||
输出合同与 [SDK 版本](../../crates/gateway-plugin/sdk/README.md)一致
|
||||
|
||||
## 安装
|
||||
|
||||
从仓库根目录安装到本地工具目录:
|
||||
|
||||
```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` 必须显式声明版本 `3`,
|
||||
并从 `http`、`websocket`、`service`、`request`、`attempt` 中选择挂载阶段。
|
||||
CLI 与 SDK 的 `Manifest::from_author_slice`、`PluginBuilder::from_json` 共用规范化入口;归档中的
|
||||
`plugin.json` 写入补全后的贡献项字段,不保留另一份作者声明。
|
||||
完整规则见 [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`,可通过[子模块](../../../docs/development.md#源码联调)检出;本工具不负责源码构建、上传或启用插件
|
||||
|
||||
### 图标资源
|
||||
|
||||
在作者清单的 `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
|
||||
```
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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.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":2,
|
||||
"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":{"version":3,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
|
||||
"management":{}
|
||||
},
|
||||
|
||||
"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"}
|
||||
}"#
|
||||
}
|
||||
@@ -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"
|
||||
@@ -35,6 +36,9 @@ chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
|
||||
[dev-dependencies]
|
||||
prettyplease = "0.2"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full", "visit-mut"] }
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
model::backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, build_backup_seed, code,
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStatusTransition,
|
||||
BackupStorageConfig, BackupTriggerKind, build_backup_seed, code,
|
||||
},
|
||||
ports::backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
|
||||
@@ -224,8 +224,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
@@ -274,8 +273,7 @@ impl BackupTask {
|
||||
self.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
|
||||
StatusTransitionUpdate::default(),
|
||||
now,
|
||||
)
|
||||
@@ -402,8 +400,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
status_transition(BackupStatus::Dumping, BackupStatus::Uploading)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
@@ -471,8 +468,7 @@ impl BackupTask {
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
status_transition(BackupStatus::Uploading, BackupStatus::Completed)?,
|
||||
StatusTransitionUpdate::default(),
|
||||
Utc::now(),
|
||||
)
|
||||
@@ -555,7 +551,12 @@ impl BackupTask {
|
||||
};
|
||||
let _ = self
|
||||
.repository
|
||||
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
|
||||
.transition_status(
|
||||
&record.id,
|
||||
status_transition(record.status, BackupStatus::Failed)?,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
@@ -639,6 +640,14 @@ fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_transition(
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
) -> Result<BackupStatusTransition, WorkerTaskError> {
|
||||
BackupStatusTransition::try_new(from, to)
|
||||
.ok_or_else(|| WorkerTaskError::safe("backup status transition is invalid"))
|
||||
}
|
||||
|
||||
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(format!("backup store failed: {error}"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -21,35 +20,41 @@ pub mod backup;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
pub mod service;
|
||||
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 +187,9 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
public_services: Arc<service::Registry>,
|
||||
plugins: Arc<PluginsService>,
|
||||
plugin_management: Arc<PluginManagementService>,
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
@@ -192,13 +200,27 @@ 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 {
|
||||
pub fn public_services(&self) -> Arc<service::Registry> {
|
||||
self.public_services.clone()
|
||||
}
|
||||
|
||||
#[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()
|
||||
@@ -250,7 +272,6 @@ impl AdminServices {
|
||||
self.observability.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> &dyn SettingsService {
|
||||
self.settings.as_ref()
|
||||
}
|
||||
@@ -261,13 +282,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 +318,14 @@ impl AdminBundle {
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub service_middleware: service::PlanSource,
|
||||
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 +334,45 @@ 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 {
|
||||
service_middleware,
|
||||
plugin_preparation,
|
||||
plugin_management,
|
||||
published_snapshot,
|
||||
plugin_distribution,
|
||||
plugin_inspector,
|
||||
pricing_source,
|
||||
providers,
|
||||
snapshot,
|
||||
@@ -333,13 +388,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 +419,12 @@ 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(),
|
||||
snapshot.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 +432,41 @@ 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 settings = initialize_settings(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
let mut public_services = service::Registry::new(service_middleware);
|
||||
public_services.register_settings(&settings)?;
|
||||
let services = AdminServices {
|
||||
public_services: Arc::new(public_services),
|
||||
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(),
|
||||
@@ -420,15 +493,10 @@ pub async fn initialize(
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
)),
|
||||
settings: Arc::new(DefaultSettingsService::new(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
registry,
|
||||
pricing_source,
|
||||
)),
|
||||
settings,
|
||||
system,
|
||||
openai,
|
||||
xai,
|
||||
credentials,
|
||||
plugin_accounts,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
@@ -460,6 +528,38 @@ 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))
|
||||
}
|
||||
|
||||
/// 为 Runtime 组合实例自有资源写入;权限和归属在同一存储事务复核。
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_resources(
|
||||
store: Arc<dyn ports::plugin_resources::PluginResourceStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Arc<dyn ports::plugin_resources::PluginResourceAccess> {
|
||||
Arc::new(use_case::plugin_resources::DefaultPluginResourceAccess { store, snapshot })
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
@@ -510,23 +610,17 @@ fn freeze_recovery_worker_contribution(
|
||||
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 注册表初始化失败")
|
||||
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布。
|
||||
pub fn initialize_settings(
|
||||
store: Arc<dyn ports::store::SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
pricing_source: Arc<dyn ports::pricing::PricingSource>,
|
||||
) -> Arc<dyn SettingsService> {
|
||||
Arc::new(DefaultSettingsService::new(
|
||||
store,
|
||||
snapshot,
|
||||
providers,
|
||||
pricing_source,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ pub struct AccountGroupAccountSummary {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupCapacity {
|
||||
pub used_slots: Option<u64>,
|
||||
pub total_slots: u64,
|
||||
/// `None` 表示可用账号中存在不限制并发的账号。
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Successful, downstream-committed USD request costs for group accounts.
|
||||
@@ -55,7 +56,7 @@ pub struct AccountGroupMemberFact {
|
||||
pub group_id: AccountGroupId,
|
||||
pub account_id: String,
|
||||
pub status: AccountStatusFacts,
|
||||
pub total_slots: u64,
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// Lightweight group reference embedded in account and client-key views.
|
||||
|
||||
@@ -18,6 +18,18 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 账号可用的管理操作。
|
||||
#[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, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
@@ -211,6 +223,16 @@ pub struct AccountPage {
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
pub capacity: AccountCapacity,
|
||||
}
|
||||
|
||||
/// 网关配置的账号并发上限与查询时的占用,不包含排队请求或上游隐藏限制。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountCapacity {
|
||||
/// 实时租约读取失败时为 `None`,不能当作空闲。
|
||||
pub used_slots: Option<u64>,
|
||||
/// 应用账号覆盖或全局默认值后的上限;`None` 表示不限。
|
||||
pub total_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 管理写入的审计分类;适配器只能补充事务实际写入的字段与实体标识。
|
||||
|
||||
use super::{
|
||||
MutationActor, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind, admin_session_actor_ref},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MutationAuditOperation {
|
||||
ModelPricingSync,
|
||||
ModelPricingUpdate,
|
||||
RuntimeSettingsReplace,
|
||||
AdminApiKeyChanged { exists: bool },
|
||||
AccountGroupCreate,
|
||||
AccountGroupUpdate,
|
||||
AccountGroupEnabled { enabled: bool },
|
||||
AccountGroupDelete,
|
||||
ClientApiKeyResetBudget,
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
ProviderAccountAdaptConcurrency,
|
||||
ProviderAccountRecover,
|
||||
ProviderAccountBatchUpdate,
|
||||
ProviderAccountDelete,
|
||||
ProviderAccountExportCredentials,
|
||||
PluginArtifactInstall,
|
||||
PluginArtifactAccept,
|
||||
PluginArtifactDelete,
|
||||
PluginInstanceConfigure,
|
||||
PluginInstanceDelete,
|
||||
PluginSourceCredentialCreate,
|
||||
PluginSourceCredentialDelete,
|
||||
ClientApiKeyUpdateBudgetLimits,
|
||||
ClientApiKeyCreate,
|
||||
ClientApiKeyUpdate,
|
||||
ClientApiKeyEnabled { enabled: bool },
|
||||
ClientApiKeyDelete,
|
||||
PluginSourceChangeSource,
|
||||
PluginSourceDelete,
|
||||
OutboundProxyCreate,
|
||||
OutboundProxyUpdate,
|
||||
OutboundProxyDelete,
|
||||
OutboundProxyTest,
|
||||
AccountGroupPluginReconcile,
|
||||
ClientApiKeyPluginReconcile,
|
||||
ProviderAccountImportDocument,
|
||||
ProviderAccountRotateCredential,
|
||||
ProviderAccountRefreshCredential,
|
||||
ProviderAccountAuthorize,
|
||||
ProviderAccountReauthorize,
|
||||
}
|
||||
|
||||
impl MutationAuditOperation {
|
||||
#[must_use]
|
||||
pub const fn classification(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Self::ModelPricingSync => ("pricing.sync", "model_pricing"),
|
||||
Self::ModelPricingUpdate => ("pricing.update", "model_pricing"),
|
||||
Self::RuntimeSettingsReplace => ("settings.replace", "runtime_settings"),
|
||||
Self::AdminApiKeyChanged { exists } => (
|
||||
if exists {
|
||||
"admin_api_key.replace"
|
||||
} else {
|
||||
"admin_api_key.delete"
|
||||
},
|
||||
"runtime_settings",
|
||||
),
|
||||
Self::AccountGroupCreate => ("create", "account_group"),
|
||||
Self::AccountGroupUpdate => ("update", "account_group"),
|
||||
Self::AccountGroupEnabled { enabled } => {
|
||||
(if enabled { "enable" } else { "disable" }, "account_group")
|
||||
}
|
||||
Self::AccountGroupDelete => ("delete", "account_group"),
|
||||
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
|
||||
Self::ProviderAccountUpdate => ("update", "provider_account"),
|
||||
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
|
||||
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
|
||||
Self::ProviderAccountAdaptConcurrency => ("adapt_concurrency", "provider_account"),
|
||||
Self::ProviderAccountRecover => ("recover", "provider_account"),
|
||||
Self::ProviderAccountBatchUpdate => ("batch_update", "provider_account"),
|
||||
Self::ProviderAccountDelete => ("delete", "provider_account"),
|
||||
Self::ProviderAccountExportCredentials => ("export_credentials", "provider_account"),
|
||||
Self::PluginArtifactInstall => ("install", "plugin_artifact"),
|
||||
Self::PluginArtifactAccept => ("accept", "plugin_artifact"),
|
||||
Self::PluginArtifactDelete => ("delete", "plugin_artifact"),
|
||||
Self::PluginInstanceConfigure => ("configure", "plugin_instance"),
|
||||
Self::PluginInstanceDelete => ("delete", "plugin_instance"),
|
||||
Self::PluginSourceCredentialCreate => ("create", "plugin_source_credential"),
|
||||
Self::PluginSourceCredentialDelete => ("delete", "plugin_source_credential"),
|
||||
Self::ClientApiKeyUpdateBudgetLimits => ("update_budget_limits", "client_api_key"),
|
||||
Self::ClientApiKeyCreate => ("create", "client_api_key"),
|
||||
Self::ClientApiKeyUpdate => ("update", "client_api_key"),
|
||||
Self::ClientApiKeyEnabled { enabled } => {
|
||||
(if enabled { "enable" } else { "disable" }, "client_api_key")
|
||||
}
|
||||
Self::ClientApiKeyDelete => ("delete", "client_api_key"),
|
||||
Self::PluginSourceChangeSource => ("change_source", "plugin_source"),
|
||||
Self::PluginSourceDelete => ("delete", "plugin_source"),
|
||||
Self::OutboundProxyCreate => ("create", "outbound_proxy"),
|
||||
Self::OutboundProxyUpdate => ("update", "outbound_proxy"),
|
||||
Self::OutboundProxyDelete => ("delete", "outbound_proxy"),
|
||||
Self::OutboundProxyTest => ("test", "outbound_proxy"),
|
||||
Self::AccountGroupPluginReconcile => ("plugin_reconcile", "account_group"),
|
||||
Self::ClientApiKeyPluginReconcile => ("plugin_reconcile", "client_api_key"),
|
||||
Self::ProviderAccountImportDocument => ("import_document", "provider_account"),
|
||||
Self::ProviderAccountRotateCredential => ("rotate_credential", "provider_account"),
|
||||
Self::ProviderAccountRefreshCredential => ("refresh_credential", "provider_account"),
|
||||
Self::ProviderAccountAuthorize => ("authorize", "provider_account"),
|
||||
Self::ProviderAccountReauthorize => ("reauthorize", "provider_account"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类与稳定实体标识共同组成审计意图,字段差异由同一提交事务补齐。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MutationAuditIntent<'a> {
|
||||
pub operation: MutationAuditOperation,
|
||||
pub entity_ref: &'a str,
|
||||
}
|
||||
|
||||
impl MutationAuditIntent<'_> {
|
||||
#[must_use]
|
||||
pub fn event(self, context: &MutationContext, changed_fields: Vec<String>) -> AdminAuditEvent {
|
||||
let (actor_kind, actor_admin_user_id, actor_ref) = match &context.actor {
|
||||
MutationActor::AdminSession { admin_user_id } => (
|
||||
AuditActorKind::AdminSession,
|
||||
Some(admin_user_id.clone()),
|
||||
admin_session_actor_ref(admin_user_id),
|
||||
),
|
||||
MutationActor::AdminApiKey => (
|
||||
AuditActorKind::AdminApiKey,
|
||||
None,
|
||||
"admin_api_key".to_owned(),
|
||||
),
|
||||
MutationActor::System => (AuditActorKind::System, None, "system".to_owned()),
|
||||
};
|
||||
let (action, entity_kind) = self.operation.classification();
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: entity_kind.to_owned(),
|
||||
entity_ref: self.entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,14 @@ pub fn admin_session_actor_ref(admin_user_id: &str) -> String {
|
||||
/// 已认证的管理主体。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
Session { admin_user_id: String },
|
||||
Session {
|
||||
admin_user_id: String,
|
||||
},
|
||||
ApiKey,
|
||||
/// 安装者完整信任的插件;身份仅由内部 HTTP 分派端口签发。
|
||||
Plugin {
|
||||
instance_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 传给管理用例的安全请求上下文。
|
||||
@@ -33,6 +39,7 @@ impl AdminRequestContext {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
},
|
||||
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
|
||||
AdminPrincipal::Plugin { .. } => MutationActor::System,
|
||||
};
|
||||
MutationContext {
|
||||
actor,
|
||||
@@ -101,7 +108,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,33 @@ impl fmt::Display for BackupStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// 已通过领域状态机校验的显式迁移。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct BackupStatusTransition {
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
}
|
||||
|
||||
impl BackupStatusTransition {
|
||||
/// 校验并构造显式迁移;非法状态边返回 `None`。
|
||||
#[must_use]
|
||||
pub fn try_new(from: BackupStatus, to: BackupStatus) -> Option<Self> {
|
||||
from.allows_transition_to(to).then_some(Self { from, to })
|
||||
}
|
||||
|
||||
/// 迁移要求的当前状态。
|
||||
#[must_use]
|
||||
pub const fn from(self) -> BackupStatus {
|
||||
self.from
|
||||
}
|
||||
|
||||
/// 迁移完成后的目标状态。
|
||||
#[must_use]
|
||||
pub const fn to(self) -> BackupStatus {
|
||||
self.to
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateBackupStorageCommand {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -10,7 +10,15 @@ use gateway_core::{
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
use super::{
|
||||
AdminModelError, Revision, account_groups::AccountGroupRef,
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
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)]
|
||||
@@ -86,8 +94,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 +153,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 +165,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 +190,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>,
|
||||
@@ -218,6 +222,21 @@ pub struct ResetClientKeyBudget {
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
pub id: ClientApiKeyId,
|
||||
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
|
||||
}
|
||||
|
||||
/// 预算变更的调用来源;插件身份由 Runtime 给出,不能从插件请求反序列化。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientKeyBudgetMutationOrigin {
|
||||
Admin,
|
||||
Plugin(PluginResourceOwner),
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
@@ -11,11 +12,15 @@ pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod plugin_client_keys;
|
||||
pub mod plugin_resources;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
pub mod quota_forecast_sampling;
|
||||
pub mod retention;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
@@ -104,7 +109,10 @@ impl AdminError {
|
||||
}
|
||||
|
||||
/// PostgreSQL 中所有正整数 revision 的管理层表示。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct Revision(NonZeroU64);
|
||||
|
||||
impl Revision {
|
||||
@@ -153,7 +161,8 @@ impl PageSize {
|
||||
}
|
||||
|
||||
/// 可审计管理写操作的发起者。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum MutationActor {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
@@ -161,7 +170,8 @@ pub enum MutationActor {
|
||||
}
|
||||
|
||||
/// 管理写操作必须携带的审计上下文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub struct MutationContext {
|
||||
pub actor: MutationActor,
|
||||
pub request_id: String,
|
||||
|
||||
@@ -558,6 +558,9 @@ pub struct UsageListRecord {
|
||||
pub provider_account_email: Option<String>,
|
||||
/// 账号当前备注,按内部账号 ID 关联,不属于请求历史快照。
|
||||
pub provider_account_notes: Option<String>,
|
||||
/// 账号当前套餐,不属于请求历史快照。
|
||||
pub provider_account_plan_type: Option<String>,
|
||||
pub provider_account_plan_type_display: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -757,11 +760,20 @@ pub struct UsageSummary {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 诊断聚合结果,分母包含截取展示项之前的全部匹配请求。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DiagnosticsObservation {
|
||||
pub total_request_count: u64,
|
||||
pub items: Vec<DiagnosticObservation>,
|
||||
}
|
||||
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_provider_kind: Option<String>,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
@@ -772,6 +784,7 @@ pub struct DiagnosticObservation {
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -992,7 +1005,8 @@ pub struct DashboardWireAttribute {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DashboardCapacity {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub total_slots: u64,
|
||||
/// `None` 表示可用账号池不限制并发。
|
||||
pub total_slots: Option<u64>,
|
||||
pub used_slots: Option<u64>,
|
||||
pub available_slots: Option<u64>,
|
||||
}
|
||||
@@ -1141,6 +1155,8 @@ pub struct UsageInsights {
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub account_plan_type: Option<String>,
|
||||
pub account_plan_type_display: Option<String>,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
@@ -1153,7 +1169,6 @@ pub struct DiagnosticsItem {
|
||||
pub non_completion_rate: f64,
|
||||
pub retry_count: u64,
|
||||
pub retry_rate: f64,
|
||||
pub impact_score: f64,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! 插件可见的 Client Key 非秘密资源投影。
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 插件可读取的当前 Key 范围,不含密钥或配置秘密。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginClientKeyFacts {
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
}
|
||||
|
||||
#[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,42 @@
|
||||
//! 插件资源归属由宿主调用上下文给出,不能由插件参数选择实例或代次。
|
||||
|
||||
use super::Revision;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginResourceOwner {
|
||||
pub instance_id: String,
|
||||
pub artifact_sha256: String,
|
||||
pub revision: Revision,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManagedResource {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
pub struct ResourceMutation<T> {
|
||||
/// 没有事实变化时不递增配置 revision,也不触发发布。
|
||||
pub revision: Option<Revision>,
|
||||
pub value: T,
|
||||
}
|
||||
|
||||
pub struct GroupMembersChange {
|
||||
pub resource_key: String,
|
||||
pub add: Vec<String>,
|
||||
pub remove: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GroupMembersChanged {
|
||||
pub added: u64,
|
||||
pub removed: u64,
|
||||
}
|
||||
|
||||
/// 插件 Key 的首次创建设置;分组始终从本实例资源键解析。
|
||||
pub struct ManagedKeyConfig {
|
||||
pub name: String,
|
||||
pub limits: gateway_core::policy::RateLimits,
|
||||
pub budget: gateway_core::engine::budget::ClientBudgetLimits,
|
||||
}
|
||||
@@ -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,189 @@
|
||||
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 = "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, skip_serializing_if = "Option::is_none")]
|
||||
pub event: Option<String>,
|
||||
#[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 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,
|
||||
}
|
||||
|
||||
/// 当前发布视图与代次索引的只读诊断。
|
||||
#[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 struct PluginInstanceView {
|
||||
pub instance: PluginInstance,
|
||||
pub configuration_required: bool,
|
||||
pub compatibility_warning: Option<String>,
|
||||
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,91 @@
|
||||
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 headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
|
||||
pub body: Vec<u8>,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
pub struct PluginManagementResponse {
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub headers: Vec<gateway_core::engine::middleware::MiddlewareHeader>,
|
||||
pub body: Arc<[u8]>,
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
impl PluginHostCompatibility {
|
||||
/// 拒绝空集合、重复项和不受控标识,避免发行清单把“未知”解释为通配。
|
||||
#[must_use]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.schema_version != 2
|
||||
|| !valid_versions(&self.manifest_schema_versions)
|
||||
|| !valid_versions(&self.protocol_versions)
|
||||
|| self.capabilities.is_empty()
|
||||
|| self.capabilities.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;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn supports_capability(&self, capability: &str, version: u32) -> bool {
|
||||
self.capabilities
|
||||
.iter()
|
||||
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
|
||||
}
|
||||
}
|
||||
|
||||
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)>,
|
||||
}
|
||||
|
||||
/// 一次下载使用的非敏感出站代理身份;地址与认证由受管代理记录持有。
|
||||
#[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")]
|
||||
pub struct PluginContribution {
|
||||
pub id: String,
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub stages: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub input_formats: Vec<String>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
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>,
|
||||
}
|
||||
|
||||
/// 已校验制品的持久化投影;读取时忽略未知字段,可选字段缺省沿用清单语义。
|
||||
/// 身份与执行所需事实仍为必填,包清单及协议版本由 Runtime 独立校验。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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>,
|
||||
#[serde(default = "empty_configuration_schema")]
|
||||
pub configuration_schema: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub secret_fields: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub state_namespaces: Vec<state::PluginStateSchema>,
|
||||
}
|
||||
|
||||
fn empty_configuration_schema() -> serde_json::Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
|
||||
/// 由包检查端口产生的不可变制品;不派生 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")]
|
||||
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,
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PricingCatalog {
|
||||
pub defaults: PricingOverrides,
|
||||
pub overrides: PricingOverrides,
|
||||
@@ -27,6 +27,7 @@ pub struct PricingSyncPreview {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SyncPricing {
|
||||
pub preview: PricingSyncPreview,
|
||||
pub models: BTreeMap<String, BTreeSet<String>>,
|
||||
@@ -34,7 +35,7 @@ pub struct SyncPricing {
|
||||
|
||||
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum PricingChange {
|
||||
Replace(ModelPriceOverride),
|
||||
Multiplier(u32),
|
||||
@@ -42,7 +43,7 @@ pub enum PricingChange {
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UpdatePricing {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
|
||||
@@ -8,6 +8,7 @@ use chrono::{DateTime, NaiveDate, Utc};
|
||||
use futures::Stream;
|
||||
use gateway_core::{
|
||||
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
operation::RawJsonPayload,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -15,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,
|
||||
},
|
||||
@@ -384,8 +385,8 @@ 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,
|
||||
@@ -425,8 +426,8 @@ pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotation),
|
||||
Create(Box<PreparedCredentialCreate>),
|
||||
Reauthorize(Box<PreparedCredentialRotation>),
|
||||
}
|
||||
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
|
||||
@@ -449,18 +450,87 @@ impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthorizationCredentialCommit {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotationFacts),
|
||||
Create(Box<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: CredentialMutationResult,
|
||||
pub newly_committed: bool,
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
@@ -494,6 +564,7 @@ impl PreparedAuthorizationCommit {
|
||||
pub(crate) fn into_commit(
|
||||
self,
|
||||
settings: Option<AccountImportSettings>,
|
||||
key: AuthorizationReceiptKey,
|
||||
) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
@@ -502,13 +573,14 @@ impl PreparedAuthorizationCommit {
|
||||
PreparedAuthorizationCredential::Reauthorize(prepared) => {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
(
|
||||
AuthorizationCredentialCommit::Reauthorize(facts),
|
||||
AuthorizationCredentialCommit::Reauthorize(Box::new(facts)),
|
||||
Some(guard),
|
||||
)
|
||||
}
|
||||
};
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
key,
|
||||
settings,
|
||||
pending: self.pending,
|
||||
credential,
|
||||
@@ -519,13 +591,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(())
|
||||
@@ -616,6 +683,8 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub plan_type: Option<String>,
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
|
||||
pub preserve_profile: bool,
|
||||
/// 连接配置变更保留当前凭据健康状态与错误事实。
|
||||
pub preserve_credential_state: bool,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
@@ -637,6 +706,13 @@ impl PreparedCredentialRotation {
|
||||
Self { facts, guard }
|
||||
}
|
||||
|
||||
/// 仅修改连接配置时,不把配置更新视为凭据恢复成功。
|
||||
#[must_use]
|
||||
pub fn preserving_credential_state(mut self) -> Self {
|
||||
self.facts.preserve_credential_state = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn facts(&self) -> &PreparedCredentialRotationFacts {
|
||||
&self.facts
|
||||
@@ -1060,6 +1136,62 @@ pub struct ProviderModels {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModelCatalogDocument {
|
||||
pub document: RawJsonPayload,
|
||||
pub model_count: usize,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime 已完成 wire 校验、Admin 仍需绑定当前账号和 revision 的 prepared facts。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PreparedPluginAccountSave {
|
||||
Create(PreparedCredentialCreate),
|
||||
Replace {
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
authentication_kind: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAccountSaveResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Revision,
|
||||
}
|
||||
|
||||
/// Provider 执行 refresh 时所需的当前公共账号事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PrepareCredentialRefresh {
|
||||
@@ -1095,6 +1227,8 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub capacity: super::accounts::AccountCapacity,
|
||||
pub capabilities: super::accounts::ProviderAccountCapabilities,
|
||||
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
|
||||
pub plan_type_display: Option<String>,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_core::account::{OutboundProxy, RequestLocation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
@@ -57,6 +58,7 @@ pub struct ProxyAccountPage {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyTestResult {
|
||||
pub location: ProxyLocationDetection,
|
||||
pub success: bool,
|
||||
pub latency_ms: u64,
|
||||
pub exit_ip: Option<std::net::IpAddr>,
|
||||
@@ -67,6 +69,8 @@ pub struct ProxyTestResult {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub auto_location: bool,
|
||||
pub detected_location: Option<DetectedProxyLocation>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -89,6 +93,9 @@ pub struct ProxyPage {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub auto_location: bool,
|
||||
/// 由管理用例探测,随配置原子保存,不接受 HTTP 调用方提供结果。
|
||||
pub test: Option<ProxyTestResult>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
@@ -96,6 +103,8 @@ pub struct NewProxy {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
pub auto_location: Option<bool>,
|
||||
pub test: Option<ProxyTestResult>,
|
||||
/// 外层为空保留配置,内层为空恢复全局继承。
|
||||
pub location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
pub id: String,
|
||||
@@ -109,3 +118,62 @@ pub struct ProxyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
|
||||
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(tag = "status", rename_all = "camelCase")]
|
||||
pub enum ProxyLocationDetection {
|
||||
#[default]
|
||||
NotRequested,
|
||||
Detected {
|
||||
location: RequestLocation,
|
||||
},
|
||||
Failed {
|
||||
message: String,
|
||||
},
|
||||
Conflict,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DetectedProxyLocation {
|
||||
pub location: RequestLocation,
|
||||
pub exit_ipv4: Option<std::net::Ipv4Addr>,
|
||||
pub exit_ipv6: Option<std::net::Ipv6Addr>,
|
||||
pub detected_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl ProxyRecord {
|
||||
#[must_use]
|
||||
pub fn effective_location(&self) -> Option<&RequestLocation> {
|
||||
if self.auto_location {
|
||||
self.detected_location
|
||||
.as_ref()
|
||||
.map(|detected| &detected.location)
|
||||
} else {
|
||||
self.location.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 未知出口可暂用上次成功结果,已知出口变化或时区冲突必须撤销旧覆盖。
|
||||
#[must_use]
|
||||
pub fn detected_location_after_test(
|
||||
&self,
|
||||
result: &ProxyTestResult,
|
||||
) -> Option<DetectedProxyLocation> {
|
||||
match &result.location {
|
||||
ProxyLocationDetection::Detected { location } => Some(DetectedProxyLocation {
|
||||
location: location.clone(),
|
||||
exit_ipv4: result.exit_ipv4,
|
||||
exit_ipv6: result.exit_ipv6,
|
||||
detected_at: Utc::now(),
|
||||
}),
|
||||
ProxyLocationDetection::Failed { .. } => self.detected_location.clone().filter(|old| {
|
||||
!result.success
|
||||
|| (old.exit_ipv4 == result.exit_ipv4 && old.exit_ipv6 == result.exit_ipv6)
|
||||
}),
|
||||
ProxyLocationDetection::Conflict => None,
|
||||
ProxyLocationDetection::NotRequested => self.detected_location.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@ pub struct AccountQuotaForecast {
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_usd: Option<f64>,
|
||||
/// 剩余估算始终属于源窗口,不随目标周期折算。
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -43,6 +40,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 +63,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 {
|
||||
@@ -71,8 +78,6 @@ pub fn account_quota_forecasts(
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
@@ -181,14 +186,13 @@ impl AccountQuotaForecast {
|
||||
let remaining_tokens = Some(sample.usage.tokens)
|
||||
.filter(|tokens| *tokens > 0)
|
||||
.and_then(|value| estimate(value as f64, remaining_factor));
|
||||
self.remaining_tokens = remaining_tokens.and_then(|value| estimate_tokens(value, 1.0));
|
||||
self.estimated_tokens = remaining_tokens
|
||||
.and_then(|remaining| estimate_tokens(usage.tokens as f64 + remaining, factor));
|
||||
self.remaining_usd = Some(&sample.usage)
|
||||
let remaining_usd = Some(&sample.usage)
|
||||
.filter(|usage| usage.known_cost_count > 0 && usage.usd.is_finite() && usage.usd >= 0.0)
|
||||
.and_then(|usage| estimate(usage.usd, remaining_factor));
|
||||
self.estimated_usd = usd
|
||||
.zip(self.remaining_usd)
|
||||
.zip(remaining_usd)
|
||||
.and_then(|(used, remaining)| estimate(used + remaining, factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 历史数据保留规则;存储实现只接受已校验的窗口。
|
||||
|
||||
use super::AdminError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionTarget {
|
||||
ModelRequests,
|
||||
OpsEvents,
|
||||
AdminAuditEvents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RetentionPolicy {
|
||||
usage_days: u32,
|
||||
ops_days: u32,
|
||||
audit_days: u32,
|
||||
}
|
||||
|
||||
impl RetentionPolicy {
|
||||
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
|
||||
if usage_days < 31 || ops_days == 0 || audit_days == 0 {
|
||||
return Err(AdminError::invalid(
|
||||
"请求保留期至少 31 天,事件与审计保留期必须为正数",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
usage_days,
|
||||
ops_days,
|
||||
audit_days,
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn days(self, target: RetentionTarget) -> u32 {
|
||||
match target {
|
||||
RetentionTarget::ModelRequests => self.usage_days,
|
||||
RetentionTarget::OpsEvents => self.ops_days,
|
||||
RetentionTarget::AdminAuditEvents => self.audit_days,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,29 @@ 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)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
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,
|
||||
@@ -31,6 +39,7 @@ pub struct RuntimeSettings {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -44,14 +53,20 @@ pub struct RuntimeSettings {
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub xai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新。
|
||||
pub expected_revision: Revision,
|
||||
/// 只覆盖提交的 Provider;未提交项保留当前持久值。
|
||||
pub request_profile_updates: ProviderRequestProfileUpdates,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -63,6 +78,7 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub responses_max_decompressed_body_bytes: u64,
|
||||
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
@@ -76,10 +92,14 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AdminApiKey(String);
|
||||
|
||||
impl AdminApiKey {
|
||||
@@ -107,13 +127,16 @@ impl fmt::Debug for AdminApiKey {
|
||||
}
|
||||
|
||||
/// 管理员 API Key 更新结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminApiKeyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// 新管理员 API Key 的一次性返回结果。
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RegeneratedAdminApiKey {
|
||||
pub mutation: AdminApiKeyMutation,
|
||||
pub key: AdminApiKey,
|
||||
@@ -134,10 +157,50 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
expected_revision: settings.config_revision,
|
||||
request_profile_updates: settings
|
||||
.request_profiles
|
||||
.into_iter()
|
||||
.map(|(provider, value)| (provider, Some(value)))
|
||||
.collect(),
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings: settings.model_mappings,
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: settings.refresh_concurrency,
|
||||
max_concurrent_per_account: settings.max_concurrent_per_account,
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
|
||||
smart_scheduling: settings.smart_scheduling,
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
usage_retention_days: settings.usage_retention_days,
|
||||
ops_event_retention_days: settings.ops_event_retention_days,
|
||||
audit_retention_days: settings.audit_retention_days,
|
||||
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
|
||||
account_auto_freeze_threshold: settings.account_auto_freeze_threshold,
|
||||
account_auto_freeze_window_seconds: settings.account_auto_freeze_window_seconds,
|
||||
account_auto_freeze_duration_seconds: settings.account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled: settings.account_auto_freeze_probe_enabled,
|
||||
account_auto_freeze_probe_model: settings.account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency: settings
|
||||
.account_auto_freeze_adaptive_concurrency,
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SystemUpdateChannel {
|
||||
Stable,
|
||||
Alpha,
|
||||
Beta,
|
||||
Rc,
|
||||
#[serde(rename = "exp")]
|
||||
Experimental,
|
||||
}
|
||||
|
||||
impl SystemUpdateChannel {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Stable => "stable",
|
||||
Self::Alpha => "alpha",
|
||||
Self::Beta => "beta",
|
||||
Self::Rc => "rc",
|
||||
Self::Experimental => "exp",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Host 返回的本次检查通道及当前构建允许选择的范围。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SystemUpdatePolicy {
|
||||
pub channel: SystemUpdateChannel,
|
||||
pub available_channels: Vec<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
/// 当前运行版本及更新检查摘要。
|
||||
///
|
||||
@@ -23,6 +57,7 @@ pub struct SystemVersion {
|
||||
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateDetail {
|
||||
pub policy: SystemUpdatePolicy,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
@@ -127,3 +162,21 @@ impl SystemOperationAccepted {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 重启前的只读确认快照;目标发行与全局配置版本一起绑定用户确认。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemRestartPlan {
|
||||
pub target_version: Option<String>,
|
||||
pub release_manifest_sha256: Option<String>,
|
||||
pub config_revision: u64,
|
||||
pub incompatible_plugins: Vec<SystemIncompatiblePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SystemIncompatiblePlugin {
|
||||
pub instance_id: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ use crate::model::{
|
||||
MutationContext,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
|
||||
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
BackupRecordSeed, BackupSettings, BackupStatusTransition, BackupStorageConfig,
|
||||
ConnectionTestResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
},
|
||||
};
|
||||
use crate::ports::store::AdminStoreResult;
|
||||
@@ -96,8 +96,7 @@ pub trait BackupRepository: Send + Sync {
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
transition: BackupStatusTransition,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
@@ -333,8 +332,7 @@ impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn transition_status(
|
||||
&self,
|
||||
_id: &str,
|
||||
_from: BackupStatus,
|
||||
_to: BackupStatus,
|
||||
_transition: BackupStatusTransition,
|
||||
_update: StatusTransitionUpdate,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
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 plugin_resources;
|
||||
pub mod plugins;
|
||||
pub mod pricing;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod retention;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! 插件宿主访问账号权威所需的窄端口。
|
||||
|
||||
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>;
|
||||
|
||||
/// 只读 Provider 已有额度观测;实现不得主动刷新上游或附加用量预测。
|
||||
async fn get_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
Err(AdminError::unavailable("插件额度事实查询暂不可用"))
|
||||
}
|
||||
|
||||
/// 通过 Provider 原生管理路径刷新额度;不向插件暴露凭据或执行额度重置。
|
||||
async fn refresh_quota(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<crate::model::provider_credentials::ProviderQuota, AdminError> {
|
||||
Err(AdminError::unavailable("插件额度观测刷新暂不可用"))
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
command: PreparedPluginAccountSave,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginAccountSaveResult, AdminError>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! 插件 Client Key 非秘密目录与受控预算管理端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
|
||||
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
|
||||
async fn update_budget_limits(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
async fn reset_budget(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: ResetClientKeyBudget,
|
||||
context: &MutationContext,
|
||||
) -> Result<ClientApiKeyId, AdminError>;
|
||||
|
||||
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 validate_target(
|
||||
&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,63 @@
|
||||
//! 插件自有资源的管理入口与原子持久化端口。
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{CreateAccountGroup, NewAccountGroup},
|
||||
client_keys::NewClientKey,
|
||||
plugin_resources::{
|
||||
GroupMembersChange, GroupMembersChanged, ManagedKeyConfig, ManagedResource,
|
||||
PluginResourceOwner, ResourceMutation,
|
||||
},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginResourceAccess: Send + Sync {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
command: CreateAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError>;
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
groups: Vec<String>,
|
||||
command: ManagedKeyConfig,
|
||||
context: &MutationContext,
|
||||
) -> Result<ManagedResource, AdminError>;
|
||||
async fn change_members(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: GroupMembersChange,
|
||||
context: &MutationContext,
|
||||
) -> Result<GroupMembersChanged, AdminError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginResourceStore: Send + Sync {
|
||||
async fn ensure_group(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
command: NewAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
|
||||
async fn ensure_key(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
resource_key: String,
|
||||
groups: Vec<String>,
|
||||
command: NewClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<ManagedResource>>;
|
||||
async fn change_members(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: GroupMembersChange,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ResourceMutation<GroupMembersChanged>>;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
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: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
/// 静态判断业务配置是否完整,不启动进程;不完整的安装保留为待配置。
|
||||
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 必须等于候选 config_revision,准备失败时拒绝提交;历史故障可隔离。
|
||||
async fn prepare(
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
}
|
||||
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None。
|
||||
#[async_trait]
|
||||
pub trait PluginRuntimeDiagnostics: Send + Sync {
|
||||
async fn runtime_diagnostics(
|
||||
&self,
|
||||
snapshot: &PluginInstanceSnapshot,
|
||||
published_revision: Option<u64>,
|
||||
published: Option<&ExtensionSetReference>,
|
||||
) -> Option<BTreeMap<String, PluginInstanceRuntime>>;
|
||||
}
|
||||
|
||||
/// 发布实现必须显式承担状态激活、排空和迁移,不能以缺省成功跳过生命周期。
|
||||
#[async_trait]
|
||||
pub trait PluginStateLifecycle: Send + Sync {
|
||||
async fn activate_state(
|
||||
&self,
|
||||
prepared: &ExtensionSetReference,
|
||||
instance: &PluginInstance,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn quiesce_instance(&self, instance_id: &str, artifact_sha256: &str, revision: Revision);
|
||||
async fn migrate_state(
|
||||
&self,
|
||||
prepared: &ExtensionSetReference,
|
||||
transition: PluginStateTransition,
|
||||
) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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 disable_instances(
|
||||
&self,
|
||||
_ids: &[String],
|
||||
_expected_revision: Revision,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::store::AdminStoreError::new(
|
||||
super::store::AdminStoreErrorKind::Unavailable,
|
||||
"plugin",
|
||||
"atomic plugin disable is unavailable",
|
||||
))
|
||||
}
|
||||
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>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Provider 管理能力与动态注册表。
|
||||
//! 原生 Provider 管理能力与启动时注册表。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
@@ -15,9 +15,9 @@ use crate::model::observability::{
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModelCatalogDocument, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
@@ -117,6 +117,15 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询。
|
||||
fn account_capabilities(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
_authentication_kind: &str,
|
||||
) -> crate::model::accounts::ProviderAccountCapabilities {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// 提供该 Provider 的可选客户端身份;通用管理层不解释内部字段。
|
||||
fn client_profile_options(
|
||||
&self,
|
||||
@@ -124,6 +133,11 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 没有持久选择时使用的 Provider 默认画像;只返回已准备的本地事实。
|
||||
fn default_client_profile(&self) -> Option<gateway_core::account::OpaqueProviderData> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 校验并投影客户端身份,结果不含认证或账号材料。
|
||||
fn preview_client_profile(
|
||||
&self,
|
||||
@@ -150,7 +164,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
|
||||
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
|
||||
fn connection_test_operation(
|
||||
async fn connection_test_operation(
|
||||
&self,
|
||||
upstream_model: &UpstreamModelId,
|
||||
input_text: &str,
|
||||
@@ -180,7 +194,7 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
pending: crate::model::provider_credentials::PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError>;
|
||||
|
||||
async fn complete_authorization(
|
||||
@@ -266,20 +280,27 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError>;
|
||||
|
||||
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn export_credentials(
|
||||
&self,
|
||||
credentials: Vec<ProviderExportCredentialInput>,
|
||||
) -> Result<ProviderExport, ProviderAdminError>;
|
||||
}
|
||||
|
||||
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
|
||||
/// 启动时注册原生 Provider,按 ProviderKind 查找管理能力。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminRegistry {
|
||||
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
#[must_use]
|
||||
pub fn pricing_catalog(&self) -> gateway_core::metering::PricingOverrides {
|
||||
self.providers
|
||||
.iter()
|
||||
@@ -339,7 +360,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),
|
||||
@@ -365,7 +386,7 @@ impl ProviderAdminRegistry {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
|
||||
/// 按平台计算请求费用。
|
||||
pub fn calculated_billing(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
|
||||
@@ -49,7 +49,7 @@ pub trait ProxyStore: Send + Sync {
|
||||
revision: Revision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord>;
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
@@ -62,5 +62,5 @@ pub struct ProxyImportReservation {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
|
||||
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//! 有界保留期删除,不承载周期、暂停或重试策略。
|
||||
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::retention::{RetentionPolicy, RetentionTarget};
|
||||
|
||||
#[async_trait]
|
||||
pub trait RetentionStore: Send + Sync {
|
||||
async fn load_policy(&self) -> AdminStoreResult<RetentionPolicy>;
|
||||
|
||||
/// 每次最多删除 limit 行;请求关联事件随请求事务级联删除。
|
||||
async fn purge_batch(
|
||||
&self,
|
||||
target: RetentionTarget,
|
||||
now: DateTime<Utc>,
|
||||
policy: RetentionPolicy,
|
||||
limit: NonZeroU32,
|
||||
) -> AdminStoreResult<u64>;
|
||||
}
|
||||
@@ -22,17 +22,19 @@ use crate::model::{
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled, UpdateClientKey,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
|
||||
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticsObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
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 +90,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 +131,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 +159,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::CredentialMutationResult>>;
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
@@ -307,10 +333,19 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
/// 局部更新预算上限,保留其他策略和账本;无变化时不产生配置版本或审计。
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
command: UpdateClientKeyBudgetLimits,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<Revision>>;
|
||||
|
||||
/// 仅修改运行时账本并原子记录审计,不推进配置版本。
|
||||
async fn reset_client_key_budget(
|
||||
&self,
|
||||
command: ResetClientKeyBudget,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
@@ -409,7 +444,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
@@ -488,9 +523,31 @@ 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>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
|
||||
self.plugin_resources.clone()
|
||||
}
|
||||
|
||||
#[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 +556,9 @@ 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>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -507,6 +567,9 @@ impl AdminStorePorts {
|
||||
observability,
|
||||
settings,
|
||||
backup,
|
||||
plugins,
|
||||
plugin_state,
|
||||
plugin_resources,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
SystemOperationAccepted, SystemUpdateChannel, 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,35 @@ 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>;
|
||||
|
||||
/// 回滚保持严格预检,不能把旧目标当成具有当前重启确认能力的版本。
|
||||
async fn validate_rollback(
|
||||
&self,
|
||||
candidate: SystemUpdateCandidate,
|
||||
) -> Result<Revision, SystemOperationError> {
|
||||
self.validate(candidate).await
|
||||
}
|
||||
|
||||
/// 在文件交换临界点复核全局配置 CAS,不能用较早快照掩盖实例变更。
|
||||
async fn confirm_revision(&self, expected: Revision) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 重启临界区内复查候选,确认后停用不兼容插件,再允许进程退出。
|
||||
#[async_trait]
|
||||
pub trait SystemRestartPreflight: Send + Sync {
|
||||
async fn prepare(
|
||||
&self,
|
||||
candidate: Option<SystemUpdateCandidate>,
|
||||
) -> Result<(), SystemOperationError>;
|
||||
}
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
@@ -59,6 +106,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
@@ -66,11 +114,26 @@ pub trait SystemOperations: Send + Sync {
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
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>;
|
||||
/// 返回下一次启动实际使用的发行;源码运行没有封存发行清单。
|
||||
async fn restart_candidate(
|
||||
&self,
|
||||
) -> Result<Option<SystemUpdateCandidate>, SystemOperationError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn restart(
|
||||
&self,
|
||||
preflight: Arc<dyn SystemRestartPreflight>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹。
|
||||
|
||||
mod registry;
|
||||
mod settings;
|
||||
pub use registry::Registry;
|
||||
|
||||
use std::{future::Future, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::middleware::FrozenMiddlewarePlan,
|
||||
middleware::{
|
||||
compose,
|
||||
service::{Context, Error, Value},
|
||||
},
|
||||
};
|
||||
|
||||
use crate::model::{AdminError, AdminErrorKind};
|
||||
|
||||
pub type PlanSource = Arc<dyn Fn() -> Option<FrozenMiddlewarePlan> + Send + Sync>;
|
||||
|
||||
/// 空计划也是快照,只有未解析的外部入口才读取当前发布集合。
|
||||
#[derive(Clone, Default)]
|
||||
pub enum Plan {
|
||||
#[default]
|
||||
Current,
|
||||
Frozen(Option<FrozenMiddlewarePlan>),
|
||||
}
|
||||
|
||||
/// 非服务入口也有父调用身份和取消信号;发布计划在首次进入公开服务时解析。
|
||||
#[derive(Clone)]
|
||||
pub struct Origin {
|
||||
pub request_id: String,
|
||||
pub call_id: String,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
pub extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
pub plan: Plan,
|
||||
}
|
||||
|
||||
impl From<Context> for Origin {
|
||||
fn from(context: Context) -> Self {
|
||||
Self {
|
||||
request_id: context.request_id,
|
||||
call_id: context.call_id,
|
||||
cancellation: context.cancellation,
|
||||
extensions: context.extensions,
|
||||
plan: Plan::Frozen(Some(context.plan)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task_local! { static CURRENT: Origin; }
|
||||
|
||||
/// task local 随 future poll 进入/退出,不泄漏给并行任务。
|
||||
pub async fn scope<T>(context: impl Into<Origin>, future: impl Future<Output = T>) -> T {
|
||||
CURRENT.scope(context.into(), future).await
|
||||
}
|
||||
|
||||
pub(crate) async fn invoke(
|
||||
source: &PlanSource,
|
||||
operation: &'static str,
|
||||
input: Value,
|
||||
terminal: impl FnOnce(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + 'static,
|
||||
) -> Result<Value, Error> {
|
||||
let parent = CURRENT.try_with(Clone::clone).ok();
|
||||
let plan = match parent.as_ref().map(|parent| &parent.plan) {
|
||||
Some(Plan::Frozen(plan)) => plan.clone(),
|
||||
_ => source(),
|
||||
};
|
||||
let call_id = uuid::Uuid::now_v7().to_string();
|
||||
let parent_call_id = parent.as_ref().map(|parent| parent.call_id.clone());
|
||||
let origin = Origin {
|
||||
request_id: parent
|
||||
.as_ref()
|
||||
.map_or_else(|| call_id.clone(), |parent| parent.request_id.clone()),
|
||||
cancellation: parent
|
||||
.as_ref()
|
||||
.map(|parent| parent.cancellation.clone())
|
||||
.unwrap_or_default(),
|
||||
extensions: parent.map(|parent| parent.extensions).unwrap_or_default(),
|
||||
call_id,
|
||||
plan: Plan::Frozen(plan.clone()),
|
||||
};
|
||||
let Some(plan) = plan.filter(FrozenMiddlewarePlan::has_service) else {
|
||||
return tokio::select! {
|
||||
biased;
|
||||
() = origin.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
|
||||
result = scope(origin.clone(), terminal(input)) => result,
|
||||
};
|
||||
};
|
||||
let context = Context {
|
||||
operation,
|
||||
request_id: origin.request_id,
|
||||
call_id: origin.call_id,
|
||||
parent_call_id,
|
||||
cancellation: origin.cancellation,
|
||||
extensions: origin.extensions,
|
||||
plan: plan.clone(),
|
||||
};
|
||||
let inner = context.clone();
|
||||
let next = compose(Vec::new(), move |input| {
|
||||
Box::pin(scope(inner, terminal(input)))
|
||||
});
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = context.cancellation.cancelled() => Err(Error::unavailable("服务调用已取消")),
|
||||
result = plan.handle_service(context.clone(), input, next) => result,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode_error(error: AdminError) -> Error {
|
||||
Error {
|
||||
kind: match error.kind() {
|
||||
AdminErrorKind::Invalid => "invalid",
|
||||
AdminErrorKind::Unauthorized => "unauthorized",
|
||||
AdminErrorKind::Forbidden => "forbidden",
|
||||
AdminErrorKind::NotFound => "not_found",
|
||||
AdminErrorKind::Conflict => "conflict",
|
||||
AdminErrorKind::RateLimited => "rate_limited",
|
||||
AdminErrorKind::BadGateway => "bad_gateway",
|
||||
AdminErrorKind::UpstreamResultUnknown => "upstream_result_unknown",
|
||||
AdminErrorKind::Unavailable => "unavailable",
|
||||
AdminErrorKind::Internal => "internal",
|
||||
}
|
||||
.into(),
|
||||
message: error.message().to_owned(),
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 公开操作登记在所属服务;注册表只校验类型和分派,不实现业务。
|
||||
|
||||
use crate::model::AdminError;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::middleware::service::{Error, Value};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
type Handler = Arc<dyn Fn(Value) -> BoxFuture<'static, Result<Value, Error>> + Send + Sync>;
|
||||
|
||||
pub struct Registry {
|
||||
middleware: super::PlanSource,
|
||||
operations: BTreeMap<&'static str, Handler>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
pub fn new(middleware: super::PlanSource) -> Self {
|
||||
Self {
|
||||
middleware,
|
||||
operations: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 网关与命令行复用同一组设置服务;注册不会执行读取或写入。
|
||||
pub fn register_settings(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::SettingsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
super::settings::register(self, service)
|
||||
}
|
||||
|
||||
pub(crate) fn register<I, O>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
handler: impl Fn(I) -> BoxFuture<'static, Result<O, AdminError>> + Send + Sync + 'static,
|
||||
) -> Result<(), AdminError>
|
||||
where
|
||||
I: DeserializeOwned + Send + 'static,
|
||||
O: Serialize + Send + 'static,
|
||||
{
|
||||
let std::collections::btree_map::Entry::Vacant(entry) = self.operations.entry(name) else {
|
||||
return Err(AdminError::internal(format!("公开服务重复登记:{name}")));
|
||||
};
|
||||
let handler = Arc::new(handler);
|
||||
entry.insert(Arc::new(move |input| {
|
||||
let handler = handler.clone();
|
||||
Box::pin(async move {
|
||||
let input = serde_json::from_value(input)
|
||||
.map_err(|_| Error::invalid("服务输入类型无效"))?;
|
||||
let output = handler(input).await.map_err(super::encode_error)?;
|
||||
serde_json::to_value(output).map_err(|_| Error::unavailable("服务结果编码失败"))
|
||||
})
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn call(&self, operation: &str, input: Value) -> Result<Value, Error> {
|
||||
let (operation, handler) =
|
||||
self.operations
|
||||
.get_key_value(operation)
|
||||
.ok_or_else(|| Error {
|
||||
details: None,
|
||||
kind: "not_found".into(),
|
||||
message: format!("公开服务不存在:{operation}"),
|
||||
})?;
|
||||
let handler = handler.clone();
|
||||
super::invoke(&self.middleware, operation, input, move |input| {
|
||||
handler(input)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn operations(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.operations.keys().copied()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 设置服务的唯一公开操作声明;SDK 操作和数据合同由对应合同测试生成。
|
||||
|
||||
use crate::{
|
||||
SettingsService,
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
pricing::{SyncPricing, UpdatePricing},
|
||||
settings::ReplaceRuntimeSettings,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn SettingsService>,
|
||||
) -> Result<(), AdminError> {
|
||||
// 声明保留 SDK 类型名和原生参数转换,分派名称由业务方法名直接派生。
|
||||
macro_rules! register {
|
||||
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
|
||||
let service = service.clone();
|
||||
registry.register(concat!("settings.", stringify!($method)), move |$pattern: $input| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move { service.$method($($argument),*).await })
|
||||
})?;
|
||||
}};
|
||||
}
|
||||
register!(Load, load, (), ());
|
||||
register!(
|
||||
Replace,
|
||||
replace,
|
||||
(MutationContext, ReplaceRuntimeSettings),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(ApiKeyExists, admin_api_key_exists, (), ());
|
||||
register!(
|
||||
RegenerateApiKey,
|
||||
regenerate_admin_api_key,
|
||||
MutationContext,
|
||||
context,
|
||||
&context
|
||||
);
|
||||
register!(
|
||||
DeleteApiKey,
|
||||
delete_admin_api_key,
|
||||
MutationContext,
|
||||
context,
|
||||
&context
|
||||
);
|
||||
register!(Pricing, pricing, (), ());
|
||||
register!(PreviewPricingSync, preview_pricing_sync, (), ());
|
||||
register!(
|
||||
Sync,
|
||||
sync_pricing,
|
||||
(MutationContext, SyncPricing),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(
|
||||
Update,
|
||||
update_pricing,
|
||||
(MutationContext, UpdatePricing),
|
||||
(context, command),
|
||||
&context,
|
||||
command
|
||||
);
|
||||
register!(
|
||||
ClientProfileOptions,
|
||||
client_profile_options,
|
||||
String,
|
||||
provider,
|
||||
&provider
|
||||
);
|
||||
register!(
|
||||
PreviewClientProfile,
|
||||
preview_client_profile,
|
||||
(String, Option<gateway_core::account::OpaqueProviderData>),
|
||||
(provider, configuration),
|
||||
&provider,
|
||||
configuration.as_ref()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -231,8 +231,8 @@ fn project_group_runtime(
|
||||
})
|
||||
})
|
||||
},
|
||||
total_slots: available_ids.iter().fold(0_u64, |sum, account_id| {
|
||||
sum.saturating_add(slots_by_account.get(*account_id).copied().unwrap_or(0))
|
||||
total_slots: available_ids.iter().try_fold(0_u64, |sum, account_id| {
|
||||
Some(sum.saturating_add(slots_by_account.get(*account_id).copied().flatten()?))
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! 统一账号目录与跨 Provider 动态分派。
|
||||
//! 统一账号目录与原生 Provider 分派。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
@@ -23,12 +26,14 @@ use crate::{
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
AccountRefreshResult, AccountUsagePeriod, ConsumeProviderResetCredit,
|
||||
PrepareCredentialRefresh, 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::{
|
||||
@@ -140,6 +145,11 @@ pub trait AccountsService: Send + Sync {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, AdminError>;
|
||||
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, AdminError>;
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
@@ -210,7 +220,7 @@ impl DefaultAccountsService {
|
||||
) -> Result<
|
||||
(
|
||||
AccountPageItem,
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
),
|
||||
AdminError,
|
||||
> {
|
||||
@@ -314,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 {
|
||||
@@ -342,6 +363,9 @@ impl DefaultAccountsService {
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
capacity: stored.capacity,
|
||||
capabilities: provider
|
||||
.account_capabilities(account_id, &stored.account.authentication_kind),
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
stored.account.provider_kind.as_str(),
|
||||
&mut stored.account.plan_type,
|
||||
@@ -358,6 +382,7 @@ impl DefaultAccountsService {
|
||||
#[async_trait]
|
||||
impl AccountsService for DefaultAccountsService {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
|
||||
let providers = &self.providers;
|
||||
let runtime = self
|
||||
.account_runtime
|
||||
.active_rate_limits()
|
||||
@@ -378,6 +403,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
.iter()
|
||||
.map(|item| item.account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
// 只读取当前页的租约占用;观测失败不能把账号误报为空闲或拖垮目录。
|
||||
let in_flight = if ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match self.account_runtime.account_runtime(&ids).await {
|
||||
Ok(runtime) => runtime.in_flight,
|
||||
Err(error) => {
|
||||
tracing::warn!(error = %error, "account capacity projection is unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let rolling_usage = self
|
||||
.accounts
|
||||
.load_account_usage(rolling_range, &ids)
|
||||
@@ -393,7 +430,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|_| AdminError::invalid("Provider 账号 ID 不合法"))?;
|
||||
// 单个账号的 quota 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
|
||||
let provider = match self.providers.require(&account.provider_kind) {
|
||||
let provider = match providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
@@ -434,13 +471,28 @@ 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 {
|
||||
capacity: crate::model::accounts::AccountCapacity {
|
||||
used_slots: in_flight
|
||||
.as_ref()
|
||||
.map(|counts| counts.get(&item.account.id).copied().unwrap_or(0)),
|
||||
..item.capacity
|
||||
},
|
||||
capabilities,
|
||||
plan_type_display: providers.resolve_account_plan(
|
||||
item.account.provider_kind.as_str(),
|
||||
&mut item.account.plan_type,
|
||||
Some("a),
|
||||
@@ -449,9 +501,9 @@ impl AccountsService for DefaultAccountsService {
|
||||
account: item.account,
|
||||
projection: item.projection,
|
||||
quota,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
Ok(AccountDirectoryPage {
|
||||
config_revision: page.config_revision,
|
||||
items,
|
||||
@@ -465,6 +517,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
context: &MutationContext,
|
||||
account_ids: Vec<ProviderAccountId>,
|
||||
) -> Result<AccountExportBundle, AdminError> {
|
||||
let providers = &self.providers;
|
||||
if account_ids.is_empty() || account_ids.len() > 200 {
|
||||
return Err(AdminError::invalid("账号导出数量必须在 1 到 200 之间"));
|
||||
}
|
||||
@@ -483,12 +536,18 @@ 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"))?;
|
||||
// 混选时先确认全部 Provider 已注册,拒绝路径不读取任何一组明文凭据。
|
||||
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"))?;
|
||||
Ok((provider_kind, provider, ids))
|
||||
})
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
let mut documents = Vec::with_capacity(export_groups.len());
|
||||
for (provider_kind, provider, ids) in export_groups {
|
||||
let credentials = self
|
||||
.accounts
|
||||
.load_credentials_for_export(&provider_kind, &ids)
|
||||
@@ -646,7 +705,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let mut providers = BTreeMap::<
|
||||
ProviderKind,
|
||||
(
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
@@ -710,7 +769,15 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "forecast quota snapshot"))?;
|
||||
let now = Utc::now();
|
||||
let mut samples = Vec::new();
|
||||
for (window, _) in quota.usage_windows() {
|
||||
let mut selected_keys = BTreeSet::new();
|
||||
for period in [AccountUsagePeriod::Weekly, AccountUsagePeriod::Monthly] {
|
||||
let Some((window, _)) = quota_forecast_source_window("a, period) else {
|
||||
continue;
|
||||
};
|
||||
// 缺少一个周期时两个结果会复用同一窗口,只查询一次历史快照。
|
||||
if !selected_keys.insert(window.key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(mut query), Some(observed), Some(percent)) = (
|
||||
quota_usage_window(account_id.as_str(), window),
|
||||
quota.observed_at,
|
||||
@@ -887,6 +954,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "provider model catalog"))
|
||||
}
|
||||
|
||||
async fn model_catalog_document(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderModelCatalogDocument, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.model_catalog_document(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider model catalog document"))
|
||||
}
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
@@ -897,6 +975,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let model = upstream_model.as_str().to_owned();
|
||||
let operation = provider
|
||||
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider connection test"))?;
|
||||
let initial = vec![
|
||||
AccountConnectionTestEvent::Started {
|
||||
@@ -912,12 +991,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,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(result) => result
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user