mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c2b249c2 | ||
|
|
0b3ebc23f5 | ||
|
|
65bb9ab9cf | ||
|
|
50d6f7444d | ||
|
|
bcfea792e8 | ||
|
|
1e7ea8d46f | ||
|
|
bd61e0391a | ||
|
|
499b43043e | ||
|
|
ae2da31380 | ||
|
|
1e6d69a349 | ||
|
|
dac57a81c6 | ||
|
|
d162f9b7f6 | ||
|
|
866f71e3fb | ||
|
|
47ab48b4cf | ||
|
|
e3cf4ab8be | ||
|
|
4fb10f33f1 | ||
|
|
c9318c54cd | ||
|
|
8d9aaf0bbd | ||
|
|
52d24d29c7 | ||
|
|
3fbd447d94 | ||
|
|
5a5cdbdcde | ||
|
|
cca89f2f96 | ||
|
|
e25aad2231 | ||
|
|
84382761fc | ||
|
|
0a09c1572f | ||
|
|
90deabdfe3 | ||
|
|
b1c24e276b | ||
|
|
c8fdc2b5cd | ||
|
|
dfabf0f9dd | ||
|
|
817b0f2d6d | ||
|
|
a975868046 | ||
|
|
cc2dfab19a | ||
|
|
040a1e82a6 | ||
|
|
4f39bfc5a8 | ||
|
|
3e3616863d | ||
|
|
1bbae67040 | ||
|
|
fc07ac0803 | ||
|
|
c7823a7821 | ||
|
|
61d1bcb405 | ||
|
|
21a436b76a | ||
|
|
849aa73a28 | ||
|
|
fb89bda177 | ||
|
|
9e6c8c9734 | ||
|
|
3f69bc861c | ||
|
|
cea4e1d8e9 | ||
|
|
539764f7eb | ||
|
|
c9be17778d | ||
|
|
d8bc91efce | ||
|
|
e8e2f18927 | ||
|
|
0977de3713 | ||
|
|
eaa37d9faa | ||
|
|
4e588ffa1d | ||
|
|
b9e919f510 | ||
|
|
32f9f3675d | ||
|
|
221d557d5e | ||
|
|
4105c1ece6 | ||
|
|
4d59687cbc | ||
|
|
8621e24e7c | ||
|
|
3bebccac5d | ||
|
|
98287195ca | ||
|
|
fd1b159f71 | ||
|
|
7b03fdc8ab | ||
|
|
851a50962d | ||
|
|
b6e17db0c6 | ||
|
|
b98c2f01a5 | ||
|
|
14e7967e1c | ||
|
|
a4efb77223 | ||
|
|
5041d6ba26 | ||
|
|
322b602154 | ||
|
|
de15722ee1 | ||
|
|
380f04bace | ||
|
|
194cc0e0c9 | ||
|
|
d3acb42d37 | ||
|
|
bfca754714 | ||
|
|
881e78975e | ||
|
|
eb3baef3aa | ||
|
|
5205c07d60 | ||
|
|
60eb08cb96 | ||
|
|
fd4a49e64f | ||
|
|
be2ee7f3b8 | ||
|
|
757abdb4d1 | ||
|
|
cb5e0ba4c0 | ||
|
|
d2b418f8cc | ||
|
|
2390783900 | ||
|
|
c75f0e7fd9 | ||
|
|
d5e9360ed7 | ||
|
|
3fcb8e5f2d | ||
|
|
8cdaf8b2e6 |
@@ -0,0 +1,41 @@
|
||||
---
|
||||
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、视觉验证 | 加载并使用 `$frontend-design`,结合 [管理端主题](../../../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) |
|
||||
|
||||
## 执行顺序
|
||||
|
||||
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
|
||||
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
|
||||
3. 按 [问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据) 核实触发条件、问题发生版本与目标分支现状,
|
||||
优先依据适用的官方源码确定方案。结合上表读取所需文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;
|
||||
按 [文档职责与更新条件](../../../CONTRIBUTING.md#文档职责与更新条件) 判断是否修订,不为每次改动追加文档或发布说明。
|
||||
5. 交付前按文档职责逐段复核最终文档 diff:新增内容是否回答当前读者的使用或维护问题,是否属于该文档,
|
||||
是否与其他位置重复,脱离本次变更背景能否独立理解。仅解释实现经过、前后对比或验证结果的内容移到
|
||||
任务报告或 PR;仍影响升级、恢复的说明保留适用条件与操作。没有文档差异时,只核对行为变化是否使现有说明失真。
|
||||
6. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
|
||||
|
||||
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "dev-guide"
|
||||
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
|
||||
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
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 确认仓库、标题和正文,返回链接及必要的信息缺口。
|
||||
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "github-issue"
|
||||
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
|
||||
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
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。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "github-pr"
|
||||
short_description: "创建与审查拉取请求,先核实问题,以官方源码为首要依据确定或评估实现方案"
|
||||
default_prompt: "使用 $github-pr 按当前请求创建或审查 PR,先核实问题是否成立,以官方源码为首要参考确定或评估解决方案,说明源码依据及必要的偏离原因,并按仓库规范实现与验证或报告审查结论。"
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: release
|
||||
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
|
||||
---
|
||||
|
||||
# 发版
|
||||
|
||||
## 识别任务范围
|
||||
|
||||
| 用户意图 | 执行范围 |
|
||||
| --- | --- |
|
||||
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
|
||||
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
|
||||
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
|
||||
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
|
||||
|
||||
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
|
||||
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
|
||||
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
|
||||
|
||||
## 读取当前流程
|
||||
|
||||
先读 [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) |
|
||||
|
||||
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
|
||||
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
|
||||
|
||||
## 准备版本与说明
|
||||
|
||||
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
|
||||
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
|
||||
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 之后的本地文件不会改变该次构建。
|
||||
|
||||
## 恢复与验收
|
||||
|
||||
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
|
||||
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "release"
|
||||
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
|
||||
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
|
||||
@@ -0,0 +1,174 @@
|
||||
name: 接口问题反馈
|
||||
description: 客户端调用模型、搜索等 API 时的报错、断流、超时或响应异常
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
管理端页面、账号导入或服务部署的问题请选择“常规问题反馈”;新增能力请选择“功能建议”。
|
||||
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 说明发生了什么、实际结果以及出现的频率;已有正常账号、模型或客户端的对照结果也可说明。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 服务版本
|
||||
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。
|
||||
placeholder: 完整版本号或提交号
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- Docker Compose
|
||||
- 其他容器部署
|
||||
- 二进制运行
|
||||
- 源码运行
|
||||
- 其他方式(请在环境信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 填写服务端与客户端的操作系统及版本;性能或超时问题可补充 CPU、内存和容器资源限制。
|
||||
placeholder: |
|
||||
服务端操作系统与版本:
|
||||
客户端操作系统与版本:
|
||||
相关资源限制(如有):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-name
|
||||
attributes:
|
||||
label: 客户端名称
|
||||
description: 区分 CLI、桌面端、编辑器扩展、SDK 或自编脚本。
|
||||
placeholder: 例如 Codex CLI、Codex Desktop 或某个编辑器扩展
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: 客户端版本
|
||||
description: 填写完整版本号;Codex CLI 可填写 codex --version 的输出。无法取得时说明原因。
|
||||
placeholder: 完整版本号或相关 SDK 版本
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: client-source
|
||||
attributes:
|
||||
label: 客户端安装来源与启动方式
|
||||
description: 说明安装来源,以及是否通过包装脚本、启动器或第三方修改版运行;不确定时填写已知部分。
|
||||
placeholder: 例如通过 npm 安装 @openai/codex,直接执行 codex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: endpoint
|
||||
attributes:
|
||||
label: 请求方法与端点
|
||||
description: 填写发生异常的请求方法与路径,无需真实域名;不清楚端点时说明客户端当时执行的具体操作。
|
||||
placeholder: 例如 POST /v1/responses、POST /v1/alpha/search 或 WebSocket /v1/responses
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: intermediary
|
||||
attributes:
|
||||
label: 是否经过中转或反向代理
|
||||
description: |
|
||||
指客户端到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
|
||||
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
|
||||
options:
|
||||
- 否,直接连接本服务
|
||||
- 是,仅经过 API 中转服务
|
||||
- 是,仅经过反向代理或 CDN
|
||||
- 是,同时经过 API 中转和反向代理或 CDN
|
||||
- 不确定
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-chain
|
||||
attributes:
|
||||
label: 请求链路
|
||||
description: |
|
||||
按调用顺序填写中间服务的名称与版本,例如:Codex → New API → Nginx → 本服务。不确定时填写已知部分。
|
||||
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
|
||||
placeholder: |
|
||||
调用顺序与各中间服务版本:
|
||||
是否启用请求参数或请求头改写(如已知):
|
||||
直连本服务的对照结果(如已测试):
|
||||
|
||||
- type: textarea
|
||||
id: account-model
|
||||
attributes:
|
||||
label: 平台、账号类型与模型
|
||||
description: 填写与失败请求相关的信息,不要提供账号凭据;不涉及模型或无法确认的信息请注明。
|
||||
placeholder: |
|
||||
平台与认证类型:
|
||||
账号套餐(如相关):
|
||||
请求模型与映射后的上游模型(如已知):
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
|
||||
placeholder: |
|
||||
1. 使用什么配置或输入
|
||||
2. 执行什么操作
|
||||
3. 出现什么结果
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-response
|
||||
attributes:
|
||||
label: 请求、响应与诊断附件
|
||||
description: |
|
||||
提供脱敏后的最小请求、网关实际状态/响应片段和客户端显示,区分上游、中转、网关与客户端的错误来源。
|
||||
可在此直接上传管理端默认诊断包并检查其缺失说明;原始错误与日志片段需另行审阅脱敏,粘贴时请使用 Markdown 代码块。
|
||||
按部署文档的“请求错误排查”检查关联 ID、错误详情与 application 日志,不默认开启完整 dump;无法取得的信息请注明。
|
||||
placeholder: |
|
||||
发生时间与时区:
|
||||
传输(HTTP JSON / SSE / WebSocket):
|
||||
脱敏后的最小请求:
|
||||
网关关联 ID(x-gateway-request-id;配置的入口头请单独注明):
|
||||
上游关联 ID(x-request-id / x-oai-request-id,注明来源):
|
||||
网关实际状态、Content-Type 与响应片段:
|
||||
中转服务的状态与错误(如有):
|
||||
客户端显示的错误:
|
||||
诊断包或无法取得的信息:
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
|
||||
placeholder: |
|
||||
服务或客户端升级前版本 → 当前版本(如有):
|
||||
服务前后端是否同步更新(如有升级):
|
||||
最近的配置变更:
|
||||
其他相关环境:
|
||||
@@ -0,0 +1,39 @@
|
||||
name: 功能建议
|
||||
description: 说明使用场景、当前不便和期望行为,提出新能力或体验改进
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
如果已有功能无法正常使用,请选择对应的故障模板。提交前可搜索已有 Issues,相关讨论请附链接。
|
||||
每个 Issue 尽量围绕一个使用场景;截图和示例请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景与当前不便
|
||||
description: 说明你希望完成什么任务、目前如何操作,以及现有能力有什么不足。
|
||||
placeholder: |
|
||||
希望完成的任务:
|
||||
当前做法与不便:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 期望行为
|
||||
description: 描述改进后希望如何使用,最好给出一个具体例子;不要求先设计技术方案。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 已尝试的替代方案
|
||||
description: 如果已有配置、操作方式或其他工具能部分满足需求,说明仍缺少什么;未尝试可留空。
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 可附当前使用的具体版本、相关 Issue、截图或参考链接;引用其他项目时说明希望借鉴的具体行为。
|
||||
@@ -0,0 +1,143 @@
|
||||
name: 常规问题反馈
|
||||
description: 管理端页面、账号导入、安装部署、升级或其他非客户端 API 问题
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
客户端调用模型、搜索等 API 的问题请选择“接口问题反馈”;新增能力或体验改进请选择“功能建议”。
|
||||
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
|
||||
|
||||
- type: dropdown
|
||||
id: issue-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
options:
|
||||
- 页面或交互异常
|
||||
- 配置或部署异常
|
||||
- 升级异常
|
||||
- 其他问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 说明发生了什么、实际结果以及出现的频率。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 服务版本
|
||||
description: 填写实际运行的完整版本号,不要只写“最新”或 latest;源码运行填写提交号。服务无法启动时填写准备运行的版本。
|
||||
placeholder: 完整版本号或提交号
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- Docker Compose
|
||||
- 其他容器部署
|
||||
- 二进制运行
|
||||
- 源码运行
|
||||
- 其他方式(请在环境信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 填写操作系统与版本,并补充与问题相关的浏览器、访问方式或资源信息。
|
||||
placeholder: |
|
||||
操作系统与版本:
|
||||
浏览器与版本(页面问题):
|
||||
页面访问方式(HTTP / HTTPS,localhost / IP / 域名,无需真实地址):
|
||||
CPU 核数与内存、容器资源限制(部署或性能问题):
|
||||
Docker / Compose 版本(容器部署问题):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: intermediary
|
||||
attributes:
|
||||
label: 是否经过中转或反向代理
|
||||
description: |
|
||||
指客户端或浏览器到本服务之间的链路。API 中转包括 New API、One API、CCH 等;反向代理或 CDN 包括 Nginx、Caddy、Cloudflare Tunnel 等。
|
||||
如有中转,请在下方填写链路详情;本服务到上游的出站代理请在补充信息中说明。
|
||||
options:
|
||||
- 否,直接连接本服务
|
||||
- 是,仅经过 API 中转服务
|
||||
- 是,仅经过反向代理或 CDN
|
||||
- 是,同时经过 API 中转和反向代理或 CDN
|
||||
- 不确定
|
||||
- 不适用(例如服务尚未启动)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: request-chain
|
||||
attributes:
|
||||
label: 请求链路
|
||||
description: |
|
||||
按调用顺序填写中间服务的名称与版本,例如:浏览器 → Cloudflare → Nginx → 本服务。不确定时填写已知部分。
|
||||
如果已测试过直连本服务,请说明是否仍能复现;未测试可注明,不要求先完成对照测试。
|
||||
placeholder: |
|
||||
调用顺序与各中间服务版本:
|
||||
是否启用请求参数或请求头改写(如已知):
|
||||
直连本服务的对照结果(如已测试):
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 描述触发问题的操作;批量操作补充数量,升级问题说明升级方式。无法稳定复现时,说明已知触发条件。
|
||||
placeholder: |
|
||||
1. 使用什么配置或输入
|
||||
2. 执行什么操作
|
||||
3. 出现什么结果
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 错误信息与日志
|
||||
description: |
|
||||
提供脱敏后的页面提示、浏览器控制台错误或相关启动、升级日志;如有失败请求,补充状态码与响应片段。
|
||||
可以在此上传诊断包或日志附件;粘贴日志时请使用 Markdown 代码块。不要上传完整配置或请求转储。
|
||||
placeholder: |
|
||||
发生时间与时区:
|
||||
页面提示或终端错误:
|
||||
浏览器控制台 / 服务日志:
|
||||
失败请求的状态码与响应片段(如有):
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 最近有升级时填写升级前后版本及前后端是否同步更新;网络问题补充出站代理、系统代理、TUN 和自定义证书等相关环境。
|
||||
placeholder: |
|
||||
升级前版本 → 当前版本(如有):
|
||||
前后端是否同步更新(如有升级):
|
||||
最近的配置变更:
|
||||
其他相关环境:
|
||||
+17
-1
@@ -21,11 +21,27 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
# 按官方 Codex 锁文件、协议指纹或已审计 fork 固定;完成对应核对与实测后再手动升级。
|
||||
ignore:
|
||||
- dependency-name: aws-lc-rs
|
||||
- dependency-name: hyper
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: hyper-tls
|
||||
- dependency-name: hyper-util
|
||||
- dependency-name: native-tls
|
||||
- dependency-name: openssl
|
||||
- dependency-name: openssl-src
|
||||
- dependency-name: openssl-sys
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
- dependency-name: rustls-native-certs
|
||||
- dependency-name: rustls-pki-types
|
||||
- dependency-name: tokio
|
||||
- dependency-name: tokio-native-tls
|
||||
- dependency-name: tokio-rustls
|
||||
- dependency-name: tokio-tungstenite
|
||||
- dependency-name: tungstenite
|
||||
- dependency-name: zstd
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
@@ -205,3 +205,6 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check release script syntax
|
||||
run: bash -n release/publish
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Agent Instructions Guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- edited
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: agents-md-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
- name: Enforce agent instructions policy
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const params = { owner, repo, pull_number: pr.number };
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
const isInstructions = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
|
||||
core.info('No agent instruction changes detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 旧事件不处理后续提交,也不重复处理已关闭的 PR。
|
||||
const { data: current } = await github.rest.pulls.get(params);
|
||||
if (current.state !== 'open' || current.head.sha !== pr.head.sha) {
|
||||
core.info('Pull request state has changed; skipping this event.');
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
|
||||
'',
|
||||
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
|
||||
'请撤销相关变更后重新打开 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]'
|
||||
&& comment.body?.includes(marker));
|
||||
if (!notified) {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, 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.');
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
- 'release/notes.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -315,6 +315,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ruby <<'RUBY' > release-platforms.json
|
||||
@@ -349,7 +350,18 @@ jobs:
|
||||
|
||||
shutil.rmtree(release_dir, ignore_errors=True)
|
||||
release_dir.mkdir(parents=True)
|
||||
archives = []
|
||||
config_template = pathlib.Path("deploy/config.example.yaml")
|
||||
shutil.copy2(config_template, release_dir / config_template.name)
|
||||
compose = pathlib.Path("deploy/compose.yaml").read_text()
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['IMAGE_NAME']}"
|
||||
default_image = "${CPR_IMAGE:-" + image + ":latest}"
|
||||
if compose.count(default_image) != 1:
|
||||
raise SystemExit("Expected exactly one default application image in deploy/compose.yaml")
|
||||
# 发布附件固定镜像版本,防止后续 pull 与随包配置模板脱节。
|
||||
versioned_image = "${CPR_IMAGE:-" + f"{image}:{os.environ['DOCKER_VERSION']}" + "}"
|
||||
compose = compose.replace(default_image, versioned_image)
|
||||
(release_dir / "compose.yaml").write_text(compose)
|
||||
assets = [release_dir / config_template.name, release_dir / "compose.yaml"]
|
||||
|
||||
for platform in platforms:
|
||||
platform_name = f"{platform['os']}-{platform['arch']}"
|
||||
@@ -367,6 +379,9 @@ jobs:
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
deploy_dir = package_root / "deploy"
|
||||
deploy_dir.mkdir()
|
||||
shutil.copy2(config_template, deploy_dir / config_template.name)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
archive_path = release_dir / archive_name
|
||||
@@ -375,12 +390,12 @@ jobs:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
assets.append(archive_path)
|
||||
|
||||
checksum_path = release_dir / "checksums.txt"
|
||||
with checksum_path.open("w", encoding="utf-8") as file:
|
||||
for archive in archives:
|
||||
file.write(f"{sha256(archive)} {archive.name}\n")
|
||||
for asset in assets:
|
||||
file.write(f"{sha256(asset)} {asset.name}\n")
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
@@ -655,25 +670,27 @@ jobs:
|
||||
env:
|
||||
INSTALLATION_NOTES: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
> **跨大版本升级:** 不支持跨大版本在线升级。请使用全新的 `.runtime/` 数据目录重新部署,再重新导入或重新授权服务商账号,并重新导入客户端密钥。详见[升级说明](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md#镜像升级与源码构建)。
|
||||
|
||||
## 📥 Installation
|
||||
## 📥 安装
|
||||
|
||||
**Docker:**
|
||||
**Docker 部署:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
# 从 GitHub 容器镜像仓库拉取
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
Download the appropriate archive for your platform from the assets below.
|
||||
请从本版本附件下载 `compose.yaml` 和 `config.example.yaml`。Compose 默认固定到本版本镜像;升级前请对比模板与已有配置。
|
||||
|
||||
Release archives are covered by GitHub artifact attestations. Docker images are published with SBOM/provenance attestations and keyless cosign signatures.
|
||||
**手动下载:**
|
||||
请从下方附件中下载适合当前平台的归档包。
|
||||
|
||||
## 📚 Documentation
|
||||
发布归档提供 GitHub 构建产物证明。Docker 镜像附带软件物料清单(SBOM)、构建来源证明和 cosign 无密钥签名。
|
||||
|
||||
- [GitHub Repository](https://github.com/${{ github.repository }})
|
||||
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
|
||||
## 📚 文档
|
||||
|
||||
- [GitHub 仓库](https://github.com/${{ github.repository }})
|
||||
- [部署指南](https://github.com/${{ github.repository }}/blob/${{ needs.release-info.outputs.tag }}/deploy/README.md)
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
|
||||
@@ -40,7 +40,16 @@ jobs:
|
||||
|
||||
- name: Run cargo audit
|
||||
working-directory: backend
|
||||
run: cargo audit
|
||||
shell: bash
|
||||
run: |
|
||||
# 临时接受此中危风险以保持 Codex 0.154.0 的 TLS 指纹;直接升级会新增 ML-DSA 签名算法。
|
||||
# 仅豁免本条公告,2026-09-21 UTC 起恢复阻断;兼容升级验证完成后应提前删除豁免。
|
||||
# https://github.com/rustls/rustls/security/advisories/GHSA-2mjx-qc3c-rqvc
|
||||
audit_advisory_args=()
|
||||
if [[ "$(date -u +%F)" < "2026-09-21" ]]; then
|
||||
audit_advisory_args+=(--ignore RUSTSEC-2026-0285)
|
||||
fi
|
||||
cargo audit "${audit_advisory_args[@]}"
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -52,7 +61,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ Thumbs.db
|
||||
# Local tools and worktrees
|
||||
/.codegraph/
|
||||
/.codex-ws-audit/
|
||||
/.agents/
|
||||
/.reasonix/
|
||||
/.claude
|
||||
/.worktrees/
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
||||
|
||||
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
|
||||
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
|
||||
|
||||
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
|
||||
<!-- CODEGRAPH_END -->
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 开发和审查按 [问题与方案依据](CONTRIBUTING.md#问题与方案依据) 先核实问题,以官方源码为首要参考确定或评估方案。
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### 审查范围
|
||||
|
||||
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
|
||||
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
|
||||
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
|
||||
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
|
||||
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
- 用中文先回答原问题是否成立、解决方向是否合理,依据与判断标准见 [贡献与审查](CONTRIBUTING.md#审查标准),再报告实现问题和验证缺口。
|
||||
- 实现问题聚焦本次变更新引入或加重的、可定位的问题,包含文件位置、触发场景或规范依据、影响和最小修复方向。
|
||||
- 区分必须修复的问题、可选建议和验证缺口。个人偏好标为建议,不作为必须修复项;没有问题时不要凑数。
|
||||
- 严重程度按实际影响判断,不为绕过工具的优先级过滤而升级普通规范问题。说明审查和验证的实际覆盖范围,AI 未报告问题不等于维护者批准。
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
# 贡献与审查
|
||||
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
|
||||
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
|
||||
|
||||
## 提交 Issue
|
||||
|
||||
按场景选择 [接口问题反馈](.github/ISSUE_TEMPLATE/api-bug-report.yml)、[常规问题反馈](.github/ISSUE_TEMPLATE/general-bug-report.yml) 或 [功能建议](.github/ISSUE_TEMPLATE/feature-request.yml) 表单,按对应提示提供信息。故障反馈填写实际运行版本和中转链路;接口问题区分客户端显示与网关实际响应,页面问题附相关截图或录屏。
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议说明使用场景、当前不便和期望行为;协作规则调整可直接提交普通 Issue,说明原因和建议方案。
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
开发、创建 PR 和审查都先核实待解决的问题、触发条件与预期行为,再确定或评估实现。区分问题发生版本、目标分支现状和本次变更后的行为,不把需求描述、PR 描述或第三方项目的做法直接当成事实。目标分支已修复的问题,应说明当前仍需补齐的差异,不能据此否定旧版本中的问题。
|
||||
|
||||
涉及上游协议、客户端行为或兼容性时,以对应版本的官方源码为首要资料。优先读取用户提供或本机已有的官方源码,核对仓库来源、版本或提交;本地资料不足时查官方仓库的对应版本源码,再结合官方测试、文档、发布说明和运行证据。第三方资料只作线索或补充。引用具体文件、符号或行号,对外引用优先使用固定提交的源码链接;源码不可得或版本不匹配时说明证据缺口,不把最新实现当成当前部署行为。无上游依赖的内部变更,以本项目代码、合同和运行证据为依据。
|
||||
|
||||
官方已有同一场景的处理方式时,优先沿用其协议语义、职责归属和解决路径,检查是否只需复用已有能力或正确传递官方合同。新增覆盖配置、补偿逻辑或采用第三方兼容方案,应说明官方路径为何不足;偏离官方方案需有本项目的实际约束和验证依据,不机械照搬源码,也不默认接受已有提案。
|
||||
|
||||
反馈 Issue 时区分观察到的症状、根因假设和方案建议,关联已有 Issue、PR 及包含修复的发布版本。能说明问题的反馈可以先提交,不要求报告者先找到官方源码或证明根因;引用官方行为或提出实现方案时,按上述原则注明依据与缺口。
|
||||
|
||||
## PR 流程
|
||||
|
||||
1. **准备变更。** 按上述依据核实问题与方案,找到所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
|
||||
2. **发起 PR。** 目标分支使用 `main`。说明问题成立的依据、触发场景、改动后的行为和实际验证结果;涉及官方行为时注明源码位置、版本和方案关系。界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
|
||||
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
|
||||
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
|
||||
|
||||
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
|
||||
|
||||
### 项目技能
|
||||
|
||||
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
|
||||
|
||||
| 技能 | 用途 | 调用示例 |
|
||||
| --- | --- | --- |
|
||||
| [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` |
|
||||
|
||||
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
|
||||
|
||||
### 规则文件保护
|
||||
|
||||
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
|
||||
|
||||
## 审查标准
|
||||
|
||||
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
|
||||
|
||||
结论先回答原问题是否成立(成立、部分成立、不成立或证据不足),说明触发条件和依据;再判断解决方向是否合理,交代适用的官方方案、本次实现与它的关系及必要调整,最后报告实现问题与验证缺口。原问题是否成立与 PR 实现是否正确分别判断。源码推断、实际复现和检查结果分别说明,CI 通过不能代替问题与方案的论证。
|
||||
|
||||
| 维度 | 检查重点 |
|
||||
| --- | --- |
|
||||
| 设计与架构 | 职责归属、依赖方向、公开接口是否合理;是否融入已有体系,有无绕过边界的旁路 |
|
||||
| 正确性与兼容性 | 正常、异常和边界输入;并发、取消、重试与副作用;是否改变既有 API、协议或数据合同 |
|
||||
| 安全与数据 | 权限、输入校验、敏感信息、状态隔离和持久化一致性;日志与诊断是否符合脱敏边界 |
|
||||
| 简洁性与复用 | 重复业务规则、冗余状态、无效代码和过早抽象;能否用更直接的实现解决当前问题 |
|
||||
| 性能与资源 | 实际数据规模下的查询、请求、内存与连接成本;资源释放、背压和异常时的生命周期 |
|
||||
| 可读性与风格 | 命名是否准确,控制流是否易懂,格式是否一致;注释解释约束与原因而非复述代码 |
|
||||
| 测试与验证 | 是否覆盖真实触发条件和结果,能否发现回归;证据是否对应本次变更,有无跳过或缺失项 |
|
||||
| 界面与交互 | 共用组件和主题、信息层级、状态反馈、响应式、键盘操作及可访问性;是否有实际页面证据 |
|
||||
| 文档与升级 | 行为、配置、API 和部署说明是否同步;数据迁移和兼容影响是否交代清楚 |
|
||||
|
||||
审查意见区分三类:
|
||||
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
|
||||
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
|
||||
|
||||
## 项目约定
|
||||
|
||||
- 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 自动追加发布说明。
|
||||
- 仍影响实际升级、恢复或兼容使用的限制与操作应保留,并注明适用条件;不能仅因涉及旧版就删除。
|
||||
审查文档改动时核对必要性、所属职责与重复内容,区分当前使用说明和仅解释此次修复的内容。
|
||||
|
||||
## 界面验证
|
||||
|
||||
AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方案以本项目的用户任务、[管理端主题](docs/theme.md) 和已有组件为依据,在任务范围内改进布局、视觉与交互。
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
|
||||
涉及浏览器能力或访问环境差异时,按项目支持的部署方式核对相关 HTTP/HTTPS、localhost/实际部署地址与浏览器版本,记录实际覆盖的条件;本地预览正常不能替代部署环境中的交互验收。
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
| Rust | Rustfmt、严格 Clippy、后端测试、架构边界检查 | 相关失败场景和集成行为 |
|
||||
| 前端 | ESLint、类型检查与构建 | 页面一致性、交互和实际数据状态 |
|
||||
| 数据库迁移 | 冻结清单校验与 PR 基线比对 | 升级行为及兼容说明 |
|
||||
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
|
||||
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
|
||||
|
||||
前端从仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pnpm --dir frontend format:check
|
||||
pnpm --dir frontend build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
`build` 已包含类型检查;不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
|
||||
|
||||
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
|
||||
|
||||
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
|
||||
|
||||
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 服务或更改分支设置。
|
||||
@@ -5,97 +5,76 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
面向 Codex 的自托管多账号 AI 网关。
|
||||
面向 Codex 的自托管多账号 AI 网关
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](LICENSE)
|
||||
|
||||
[功能](#功能) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
|
||||
|
||||
</div>
|
||||
|
||||
将 OpenAI、xAI 账号接入同一个服务,通过网页管理账号、分配客户端密钥和查看使用情况。
|
||||
客户端使用统一的访问地址与代理密钥,上游账号凭据由服务端管理。
|
||||
|
||||
## 功能
|
||||
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
|
||||
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
|
||||
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
|
||||
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
|
||||
- **部署运维**:Docker Compose 部署、S3/R2 数据库备份、同大版本在线更新与回滚。
|
||||
|
||||
图片生成、编辑和 Codex 独立搜索由 OpenAI 账号提供;模型与功能的可用性取决于上游账号权限。
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API,不支持 `/v1/chat/completions`。接入前请确认客户端支持 Responses 协议。
|
||||
|
||||
## 快速开始
|
||||
|
||||
使用 Docker Compose 部署发布镜像 `ghcr.io/zyycn/codex-proxy-rs:latest`,同时启动 PostgreSQL 和 Redis。
|
||||
使用 Docker Compose 部署版本固定的发布镜像,同时启动 PostgreSQL 和 Redis。
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL。已有部署请先看
|
||||
[升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置。
|
||||
|
||||
### 1. 下载部署文件并配置
|
||||
### 一键安装
|
||||
|
||||
请确保当前用户能访问 Docker,并可通过 `sudo` 或 root 设置目录权限。
|
||||
|
||||
```bash
|
||||
mkdir -p codex-proxy-rs/deploy
|
||||
cd codex-proxy-rs
|
||||
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/compose.yaml \
|
||||
-o deploy/compose.yaml
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/config.example.yaml \
|
||||
-o deploy/config.example.yaml
|
||||
|
||||
mkdir -p .runtime/data .runtime/logs
|
||||
install -d -m 0750 .runtime/postgres .runtime/redis
|
||||
cp deploy/config.example.yaml deploy/config.yaml
|
||||
sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
sudo chown -R "$(id -u):10001" .runtime/data .runtime/logs
|
||||
chmod 0770 .runtime/data .runtime/logs
|
||||
curl -fsSL https://raw.githubusercontent.com/zyycn/codex-proxy-rs/main/deploy/install.sh -o install.sh && bash install.sh
|
||||
```
|
||||
|
||||
分别生成数据库和 Redis 密码:
|
||||
[安装脚本](deploy/install.sh) 默认安装到当前目录下的 `codex-proxy-rs/`,下载同一正式 Release 的部署文件,
|
||||
自动生成密码、设置目录权限并启动服务。完成后会显示访问地址和管理员密码,请保存密码,再按下方步骤
|
||||
[添加账号与客户端密钥](#添加账号与客户端密钥)。
|
||||
|
||||
可在执行 `bash install.sh` 时传入环境变量:
|
||||
|
||||
| 变量 | 用途 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `INSTALL_DIR` | 安装目录,建议使用绝对路径 | 当前目录下的 `codex-proxy-rs/` |
|
||||
| `CPR_RELEASE_TAG` | 指定发布标签 | 最新正式版本 |
|
||||
| `ADMIN_PASSWORD` | 管理员初始密码,至少 12 位,不能包含 `$`,不能使用常见弱口令 | 随机生成 |
|
||||
|
||||
例如,自定义安装目录:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
INSTALL_DIR="$HOME/services/codex-proxy-rs" bash install.sh
|
||||
```
|
||||
|
||||
编辑 `deploy/config.yaml`,填好以下三项:
|
||||
重复运行时请使用同一安装目录。检测到 `deploy/config.yaml` 后,脚本保留现有配置和部署文件,
|
||||
忽略传入的管理员密码,也不执行版本升级。
|
||||
|
||||
| 配置项 | 填写内容 |
|
||||
| --- | --- |
|
||||
| `store.database.password` | 第一个生成的 48 位十六进制密码 |
|
||||
| `store.redis.password` | 第二个生成的 48 位十六进制密码 |
|
||||
| `admin.default_password` | 管理员初始密码,至少 12 位,不能包含 `$` |
|
||||
### 手动安装
|
||||
|
||||
不需要创建 `.env`。管理员初始密码只在首次创建账号时使用。
|
||||
自行下载部署文件、配置密码和启动服务的完整步骤见 [部署文档](deploy/README.md#手动安装)。
|
||||
|
||||
### 2. 启动服务
|
||||
### 登录管理端
|
||||
|
||||
```bash
|
||||
docker compose -f deploy/compose.yaml config --quiet
|
||||
docker compose -f deploy/compose.yaml pull
|
||||
docker compose -f deploy/compose.yaml up -d --no-build --wait
|
||||
curl -i http://127.0.0.1:8080/healthz
|
||||
```
|
||||
|
||||
健康检查返回 `204 No Content` 后,打开 `http://127.0.0.1:8080`,
|
||||
使用 `admin@cpr.local` 和刚设置的管理员密码登录。
|
||||
部署完成后,打开 `http://127.0.0.1:8080`,使用 `admin@cpr.local` 和管理员密码登录。
|
||||
API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage` 查看自己的用量、趋势、请求日志、额度与健康时间线;不能访问管理员页面。
|
||||
|
||||
默认地址只能在服务器本机访问。从其他设备使用时,需要配置
|
||||
[HTTPS 反向代理](deploy/README.md#公网访问)。
|
||||
|
||||
### 3. 添加账号与客户端密钥
|
||||
### 添加账号与客户端密钥
|
||||
|
||||
1. 在「账号」中添加 OpenAI 或 xAI 账号,完成授权或导入。
|
||||
1. 在「账号」中添加账号,完成授权或导入。
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
|
||||
账号自动冻结默认关闭,可在「系统设置 → 运行参数」中启用。启用后,账号频繁命中上游容量错误时
|
||||
会暂停调度并显示为限流中;若开启恢复前探测,冷却到期后需探测成功才恢复。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
@@ -106,7 +85,7 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
| 配置 | 值 |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的 `sk_...` 客户端密钥 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
|
||||
可用模型以该密钥查询到的模型列表为准:
|
||||
|
||||
@@ -123,6 +102,7 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [管理端主题](docs/theme.md)
|
||||
- [数据库迁移](backend/migrations/README.md)
|
||||
- [贡献与审查](CONTRIBUTING.md)
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+230
-37
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -154,9 +154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.1"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"untrusted 0.7.1",
|
||||
@@ -165,22 +165,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.45.0"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
|
||||
checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
|
||||
checksum = "ef47857a1d4488b528f4a5d5715fa7c3300820897824152234d3fa22b1426657"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -206,9 +205,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.144.0"
|
||||
version = "1.146.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
|
||||
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -479,9 +478,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -709,6 +708,7 @@ checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -749,9 +749,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
version = "4.6.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -808,6 +808,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1192,7 +1202,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1291,6 +1301,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -1383,7 +1408,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1458,6 +1483,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
@@ -1465,6 +1491,7 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
@@ -1473,6 +1500,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1482,6 +1510,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono-tz",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"semver",
|
||||
@@ -1512,6 +1541,7 @@ dependencies = [
|
||||
"scraper",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
@@ -1877,6 +1907,22 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1894,9 +1940,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2234,6 +2282,23 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe 0.1.6",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
@@ -2289,12 +2354,66 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -2602,6 +2721,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cookie",
|
||||
"flate2",
|
||||
"futures",
|
||||
@@ -2613,6 +2733,9 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
@@ -2739,7 +2862,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2838,9 +2961,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||
checksum = "2acbc41a996f7652b2ddd9dfd98cc4ff602cfd742ae35382f07f608405ab50ed"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -2917,6 +3040,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@@ -2925,9 +3049,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
@@ -2939,6 +3066,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
@@ -3009,7 +3137,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3030,21 +3158,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"openssl-probe 0.2.1",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework 3.7.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -3128,6 +3256,19 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3135,7 +3276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -3203,7 +3344,17 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_ignored"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3661,9 +3812,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3690,6 +3841,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -3711,7 +3883,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3740,7 +3912,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
"syn 3.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3809,9 +3981,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -3834,6 +4006,16 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -4182,9 +4364,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -4417,6 +4599,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -7,26 +7,32 @@ use serde::Deserialize;
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
/// 各启动配置忽略未知字段,允许升级时保留旧配置;已知字段仍按类型和业务约束校验。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
#[serde(default)]
|
||||
client: gateway_admin::ClientConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
#[serde(default, rename = "services")]
|
||||
_compose_services: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
const EXTERNAL_SECTIONS: &'static [&'static str] = &["services"];
|
||||
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.host.resolve_and_validate(source_dir)?;
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.host
|
||||
.resolve_and_validate(source_dir, &self.api.asset_directory)?;
|
||||
let runtime_data_dir = self.host.runtime_data_dir().to_path_buf();
|
||||
self.store
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
@@ -34,9 +40,9 @@ impl LoadableConfig for GatewayConfig {
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.api
|
||||
self.client
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
.map_err(|_| ConfigError::InvalidField("client"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(&runtime_data_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
@@ -55,10 +61,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
client,
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
_compose_services: _,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
@@ -75,15 +81,17 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
host.report_startup_ready("Core");
|
||||
let mut admin = gateway_admin::initialize(
|
||||
admin,
|
||||
client,
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
(
|
||||
core.account_probe(),
|
||||
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
),
|
||||
host.client_distribution_resolver(),
|
||||
host.system_operations(),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
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?;
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
@@ -70,6 +70,8 @@ fn core_value_owners_do_not_depend_on_execution_or_routing() {
|
||||
"identity.rs" => Some(&["validation"]),
|
||||
"upstream.rs" => Some(&["validation"]),
|
||||
"event.rs" => Some(&["metering", "operation", "upstream", "validation"]),
|
||||
"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("account/") => Some(&["account", "identity", "validation"]),
|
||||
@@ -176,7 +178,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
|
||||
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-api", &["admin", "openai"]),
|
||||
("crates/gateway-api", &["admin", "auth", "openai"]),
|
||||
(
|
||||
"crates/gateway-host",
|
||||
&[
|
||||
|
||||
@@ -168,6 +168,50 @@ fn config_loader_should_resolve_paths_relative_to_config_file() {
|
||||
assert!(debug.contains("frontend/dist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_share_resolved_assets_with_system_update() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_UPDATE_ASSETS_CHILD";
|
||||
let Ok(case) = std::env::var(CHILD_ENV) else {
|
||||
// 使用子进程覆盖无环境变量、Docker 路径和相对路径,避免污染并行测试。
|
||||
for (case, web_dist) in [
|
||||
("binary", None),
|
||||
("docker", Some("/app/web/dist")),
|
||||
("relative", Some("../custom/dist")),
|
||||
] {
|
||||
let mut child = Command::new(std::env::current_exe().expect("test executable"));
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_share_resolved_assets_with_system_update",
|
||||
])
|
||||
.env(CHILD_ENV, case)
|
||||
.env_remove("CPR_WEB_DIST_DIR");
|
||||
if let Some(web_dist) = web_dist {
|
||||
child.env("CPR_WEB_DIST_DIR", web_dist);
|
||||
}
|
||||
let output = child.output().expect("isolated configuration test");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{case}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let mut document = valid_config_document();
|
||||
document["api"]["asset_directory"] = serde_json::json!("../web/dist");
|
||||
let (config, directory) = parse_config(&document.to_string()).expect("binary configuration");
|
||||
let assets = match case.as_str() {
|
||||
"docker" => std::path::PathBuf::from("/app/web/dist"),
|
||||
"relative" => directory.path().join("deploy/../custom/dist"),
|
||||
_ => directory.path().join("deploy/../web/dist"),
|
||||
};
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains(&format!("asset_directory: {assets:?}")));
|
||||
assert!(debug.contains(&format!("web_dist_dir: Some({assets:?})")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_runtime_data_dir() {
|
||||
let config = valid_config().replace(" runtime_data_dir: '../.runtime/data'\n", "");
|
||||
@@ -228,16 +272,107 @@ fn bootstrap_config_debug_should_redact_all_passwords() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unknown_fields() {
|
||||
assert_rejected(format!(
|
||||
"{}\nunknown_terminal_field: true\n",
|
||||
valid_config()
|
||||
));
|
||||
fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["system_update"] = serde_json::json!({});
|
||||
document["store"]["pool"] = serde_json::json!({});
|
||||
for path in [
|
||||
"",
|
||||
"/host",
|
||||
"/host/listen",
|
||||
"/host/logging",
|
||||
"/host/logging/file",
|
||||
"/host/system_update",
|
||||
"/store",
|
||||
"/store/database",
|
||||
"/store/redis",
|
||||
"/store/pool",
|
||||
"/admin",
|
||||
"/client",
|
||||
"/api",
|
||||
"/openai",
|
||||
"/openai/wire_profile",
|
||||
"/xai",
|
||||
"/xai/wire_profile",
|
||||
] {
|
||||
let mut extended = document.clone();
|
||||
extended.pointer_mut(path).expect("configuration section")["unused_setting"] =
|
||||
serde_json::json!({"nested": [true, null, "ignored"]});
|
||||
parse_config(&extended.to_string())
|
||||
.unwrap_or_else(|error| panic!("unknown field in {path}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_tls_section() {
|
||||
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
|
||||
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["tls"] = serde_json::json!({});
|
||||
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
|
||||
parse_config(&document.to_string()).expect("removed provider settings are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
|
||||
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
|
||||
let mut document = valid_config_document();
|
||||
document["host"]["listen"]["unused_setting"] = serde_json::json!(true);
|
||||
document["host"]["listen"]["port"] = port;
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
const CHILD_ENV: &str = "CPR_TEST_CONFIG_DIAGNOSTICS_CHILD";
|
||||
const UNUSED_SECRET: &str = "unused-value-must-not-appear-in-diagnostics";
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
gateway_host::load_config::<GatewayConfig>().expect("startup configuration");
|
||||
return;
|
||||
}
|
||||
for case in ["normal", "unused", "missing"] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
} else if case == "missing" {
|
||||
document["host"]["listen"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port");
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(directory.path().join("deploy")).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("deploy/config.yaml"),
|
||||
document.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let output = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_report_startup_configuration_diagnostics",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.current_dir(directory.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(
|
||||
output.status.success(),
|
||||
case != "missing",
|
||||
"{case}: {stderr}"
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile.location") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
_ => assert!(!stderr.contains("警告"), "{stderr}"),
|
||||
}
|
||||
assert!(!stderr.contains(UNUSED_SECRET), "{stderr}");
|
||||
assert!(!stderr.contains("services"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,11 +414,34 @@ fn config_loader_should_reject_admin_password_with_compose_interpolation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"openai:\n",
|
||||
"openai:\n fingerprint:\n browser: removed\n",
|
||||
));
|
||||
fn config_loader_should_reject_zero_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
*config
|
||||
.pointer_mut("/client/session_ttl_minutes")
|
||||
.expect("example client session TTL") = serde_json::json!(0);
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_default_missing_client_section() {
|
||||
let mut config = valid_config_document();
|
||||
config
|
||||
.as_object_mut()
|
||||
.expect("example config mapping")
|
||||
.remove("client")
|
||||
.expect("example client section");
|
||||
parse_config(&config.to_string()).expect("client defaults when the section is omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_client_session_ttl() {
|
||||
let mut config = valid_config_document();
|
||||
config["client"]
|
||||
.as_object_mut()
|
||||
.expect("example client mapping")
|
||||
.remove("session_ttl_minutes")
|
||||
.expect("example client session TTL");
|
||||
assert_rejected(config.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -310,6 +468,25 @@ fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '8109'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_request_location_setting() {
|
||||
let original = valid_config();
|
||||
parse_config(&original).expect("location belongs to runtime settings");
|
||||
for value in [
|
||||
"null",
|
||||
"{}",
|
||||
"{ country: 'US', region: 'Ohio', city: 'Piketon', timezone: 'America/New_York' }",
|
||||
] {
|
||||
let legacy = original.replacen(
|
||||
" residency: us",
|
||||
&format!(" residency: us\n location: {value}"),
|
||||
1,
|
||||
);
|
||||
assert_ne!(legacy, original);
|
||||
parse_config(&legacy).expect("removed request location does not prevent startup");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
@@ -332,6 +509,18 @@ fn valid_config() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_config_document() -> serde_json::Value {
|
||||
// 按字段修改样例,避免注释或排版变化让测试输入悄悄失效;JSON 仍可由 YAML 文件入口加载。
|
||||
config::Config::builder()
|
||||
.add_source(config::File::from_str(
|
||||
&valid_config(),
|
||||
config::FileFormat::Yaml,
|
||||
))
|
||||
.build()
|
||||
.and_then(config::Config::try_deserialize)
|
||||
.expect("example config document")
|
||||
}
|
||||
|
||||
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
|
||||
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
|
||||
let deploy = directory.path().join("deploy");
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
// 组合根是否混入业务策略由代码审查判断,行数和标识符/注释关键词不能证明职责越界。
|
||||
// 依赖 DAG、公开模块面和模块镜像继续由 architecture 检查,源码纪律由下方测试维护。
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
@@ -75,28 +78,6 @@ fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owns_only_bundle_wiring() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let bootstrap = fs::read_to_string(root.join("bootstrap.rs")).expect("read bootstrap");
|
||||
for forbidden in [
|
||||
"sqlx::",
|
||||
"redis::",
|
||||
"Repository",
|
||||
"impl Provider",
|
||||
"impl ExecutionService",
|
||||
"tokio::spawn",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
] {
|
||||
assert!(
|
||||
!bootstrap.contains(forbidden),
|
||||
"bootstrap owns `{forbidden}`"
|
||||
);
|
||||
}
|
||||
assert!(bootstrap.lines().count() <= 300);
|
||||
}
|
||||
|
||||
fn rust_files(root: &Path) -> BTreeSet<PathBuf> {
|
||||
let mut result = BTreeSet::new();
|
||||
collect_rust_files(root, root, &mut result);
|
||||
|
||||
@@ -36,7 +36,7 @@ tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
|
||||
[dev-dependencies]
|
||||
sha2 = "0.11"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "test-util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! 容量熔断冻结的恢复编排:到期探测、失败顺延与自适应并发下调。
|
||||
//!
|
||||
//! 冻结事实保存在可丢失的 Redis 冷却中,本服务只组合 Admin 端口:
|
||||
//! 观测(`AccountRuntimeStore`)、探测(`AccountsService`)与写回
|
||||
//! (原子降低并发 / 按冻结代次结束探测)。旧观测不会覆盖管理员操作。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::account::{AccountConcurrencyLimit, ProviderAccountId};
|
||||
use gateway_core::routing::UpstreamModelId;
|
||||
use gateway_core::task::{ScheduledTask, WorkerCycleContext, WorkerTaskError};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::model::accounts::{
|
||||
AccountConnectionTestEvent, AccountFreeze, AccountPageItem, AccountRuntimeSnapshot,
|
||||
};
|
||||
use crate::model::{MutationActor, MutationContext};
|
||||
use crate::ports::store::{AccountRuntimeStore, AccountStore, SettingsStore};
|
||||
use crate::use_case::accounts::AccountsService;
|
||||
|
||||
/// 自适应并发下调保留系数:下调到观测在途峰值的 80%。
|
||||
const ADAPTIVE_CONCURRENCY_FACTOR: f64 = 0.8;
|
||||
/// 自适应并发下限:过低的并发让账号几乎不可用,宁可保持冻结。
|
||||
const ADAPTIVE_CONCURRENCY_FLOOR: u32 = 2;
|
||||
/// worker 周期;冷却观测与探测都依赖该节奏。
|
||||
pub const FREEZE_RECOVERY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
pub const FREEZE_RECOVERY_WORKER_OWNER: &str = "account-freeze-recovery";
|
||||
pub const WORKER_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
|
||||
pub const WORKER_MAXIMUM_BACKOFF: Duration = Duration::from_secs(60);
|
||||
pub const WORKER_LEASE_TTL: Duration = Duration::from_secs(15 * 60);
|
||||
pub const WORKER_LEASE_RENEWAL: Duration = Duration::from_secs(5 * 60);
|
||||
/// 系统审计上下文的固定请求 ID;账号并发更新审计由此溯源到自动冻结编排。
|
||||
const SYSTEM_REQUEST_ID: &str = "account-freeze-recovery";
|
||||
|
||||
/// 冻结恢复所需的管理端口组合。
|
||||
#[derive(Clone)]
|
||||
pub struct FreezeRecoveryDeps {
|
||||
pub accounts: Arc<dyn AccountsService>,
|
||||
pub store: Arc<dyn AccountStore>,
|
||||
pub runtime: Arc<dyn AccountRuntimeStore>,
|
||||
pub settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
pub struct FreezeRecoveryTask {
|
||||
deps: FreezeRecoveryDeps,
|
||||
}
|
||||
|
||||
impl FreezeRecoveryTask {
|
||||
pub fn new(deps: FreezeRecoveryDeps) -> Self {
|
||||
Self { deps }
|
||||
}
|
||||
|
||||
fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: SYSTEM_REQUEST_ID.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取冻结策略;读取或校验失败跳过本轮,不能把未知配置当作允许解冻。
|
||||
async fn freeze_policy(&self) -> Option<gateway_core::provider_ports::ProviderFreezePolicy> {
|
||||
let settings = self.deps.settings.load_runtime_settings().await.ok()?;
|
||||
gateway_core::provider_ports::ProviderFreezePolicy::try_new(
|
||||
settings.account_auto_freeze_enabled,
|
||||
settings.account_auto_freeze_threshold,
|
||||
settings.account_auto_freeze_window_seconds,
|
||||
settings.account_auto_freeze_duration_seconds,
|
||||
settings.account_auto_freeze_probe_enabled,
|
||||
settings.account_auto_freeze_probe_model,
|
||||
settings.account_auto_freeze_adaptive_concurrency,
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
async fn active_freezes(&self) -> BTreeMap<String, AccountFreeze> {
|
||||
self.deps.runtime.active_freezes().await.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn run_cycle_inner(&self) {
|
||||
let Some(policy) = self.freeze_policy().await else {
|
||||
return;
|
||||
};
|
||||
let freezes = self.active_freezes().await;
|
||||
if freezes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if policy.enabled() && policy.adaptive_concurrency() {
|
||||
self.adapt_concurrency_limits(&freezes, &policy).await;
|
||||
}
|
||||
self.recover_due_freezes(&freezes, &policy).await;
|
||||
}
|
||||
|
||||
/// 对冻结中的账号执行自适应并发下调:目标为观测在途峰值的 80%(下限 2),
|
||||
/// 只降不升;未观测到峰值证据的账号保持现状。
|
||||
async fn adapt_concurrency_limits(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let account_ids = freezes.keys().cloned().collect::<Vec<_>>();
|
||||
let Ok(peaks) = self.deps.runtime.capacity_peaks(&account_ids).await else {
|
||||
return;
|
||||
};
|
||||
for (account_id, peak) in peaks {
|
||||
let Some(target) = adaptive_target(peak) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(account) = ProviderAccountId::new(account_id.clone()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(limit) = AccountConcurrencyLimit::new(target) else {
|
||||
continue;
|
||||
};
|
||||
match self
|
||||
.deps
|
||||
.accounts
|
||||
.lower_concurrency_limit(&Self::system_context(), account, limit)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
tracing::info!(
|
||||
account_id,
|
||||
adapted_limit = target,
|
||||
freeze_seconds = policy.freeze_duration().as_secs(),
|
||||
"容量熔断:按观测在途峰值下调账号并发上限",
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
warn!(account_id, error = %error, "自适应并发下调失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_account(&self, account_id: &str) -> Option<Option<AccountPageItem>> {
|
||||
let account_id = ProviderAccountId::new(account_id.to_owned()).ok()?;
|
||||
self.deps
|
||||
.store
|
||||
.load_account(account_id.as_str(), AccountRuntimeSnapshot::default())
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// 到达探测时间后才恢复;关闭自动冻结或探测时,已有冻结仍等待原冷却结束。
|
||||
async fn recover_due_freezes(
|
||||
&self,
|
||||
freezes: &BTreeMap<String, AccountFreeze>,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
for (account_id, freeze) in freezes {
|
||||
if freeze.until > Utc::now() {
|
||||
continue;
|
||||
}
|
||||
if policy.enabled() && policy.probe_enabled() && freeze.requires_probe {
|
||||
self.probe_and_recover(account_id, freeze, policy).await;
|
||||
} else if let Err(error) = self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account_id, freeze, None)
|
||||
.await
|
||||
{
|
||||
warn!(account_id, error = %error, "解除到期冻结失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_and_recover(
|
||||
&self,
|
||||
account_id: &str,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let Some(Some(item)) = self.load_account(account_id).await else {
|
||||
return;
|
||||
};
|
||||
if !item.account.enabled {
|
||||
// 停用账号没有自动恢复意义;解冻交给管理员手动恢复。
|
||||
return;
|
||||
}
|
||||
let Ok(account) = ProviderAccountId::new(account_id.to_owned()) else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = self.resolve_probe_model(&account, policy).await else {
|
||||
// 没有可用探测模型时按失败处理,顺延冻结等待下一轮。
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
return;
|
||||
};
|
||||
let probe_succeeded = match self
|
||||
.deps
|
||||
.accounts
|
||||
.test_connection(account.clone(), model)
|
||||
.await
|
||||
{
|
||||
Ok(events) => drain_probe(events).await,
|
||||
Err(error) => {
|
||||
tracing::debug!(account_id, error = %error, "冻结恢复探测发起失败");
|
||||
false
|
||||
}
|
||||
};
|
||||
if probe_succeeded {
|
||||
match self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account_id, freeze, None)
|
||||
.await
|
||||
{
|
||||
Ok(true) => tracing::info!(account_id, "冻结恢复探测成功:账号已解冻"),
|
||||
Ok(false) => {}
|
||||
Err(error) => warn!(account_id, error = %error, "解除冻结失败"),
|
||||
}
|
||||
} else {
|
||||
self.postpone(&account, freeze, policy).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测失败只顺延本次仍存在的冻结,旧探测不能重新冻结已手动恢复的账号。
|
||||
async fn postpone(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
freeze: &AccountFreeze,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) {
|
||||
let Some(until) = SystemTime::now().checked_add(policy.freeze_duration()) else {
|
||||
return;
|
||||
};
|
||||
let until = DateTime::<Utc>::from(until);
|
||||
match self
|
||||
.deps
|
||||
.runtime
|
||||
.finish_freeze(account.as_str(), freeze, Some(until))
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
tracing::info!(
|
||||
account_id = account.as_str(),
|
||||
postpone_seconds = policy.freeze_duration().as_secs(),
|
||||
"冻结恢复探测失败:账号冻结顺延",
|
||||
);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
warn!(
|
||||
account_id = account.as_str(),
|
||||
error = %error,
|
||||
"冻结顺延失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_probe_model(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
policy: &gateway_core::provider_ports::ProviderFreezePolicy,
|
||||
) -> Option<UpstreamModelId> {
|
||||
if let Some(model) = policy.probe_model() {
|
||||
return UpstreamModelId::new(model.to_owned()).ok();
|
||||
}
|
||||
let models = self.deps.accounts.models(account, false).await.ok()?;
|
||||
models.models.into_iter().next().map(|model| model.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduledTask for FreezeRecoveryTask {
|
||||
fn run_cycle(
|
||||
&self,
|
||||
context: WorkerCycleContext,
|
||||
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
if context.cancellation().is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle_inner().await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应目标并发:观测峰值 × 0.8 向下取整,下限 2;无证据(峰值为 0)不调整。
|
||||
fn adaptive_target(peak_in_flight: u32) -> Option<u32> {
|
||||
if peak_in_flight == 0 {
|
||||
return None;
|
||||
}
|
||||
let scaled = (f64::from(peak_in_flight) * ADAPTIVE_CONCURRENCY_FACTOR).floor() as u32;
|
||||
Some(scaled.max(ADAPTIVE_CONCURRENCY_FLOOR))
|
||||
}
|
||||
|
||||
/// 消耗连接测试事件流并返回是否以 `Completed` 终止。
|
||||
async fn drain_probe(mut events: crate::model::accounts::AccountConnectionTestEventStream) -> bool {
|
||||
let mut completed = false;
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
AccountConnectionTestEvent::Completed => completed = true,
|
||||
AccountConnectionTestEvent::Failed { .. } => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
completed
|
||||
}
|
||||
@@ -5,27 +5,32 @@
|
||||
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, WorkerRegistration,
|
||||
WorkerRunnable,
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerLeaseRequest,
|
||||
WorkerRegistration, WorkerRunnable, WorkerSchedule,
|
||||
},
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod backup;
|
||||
pub mod freeze_recovery;
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
mod use_case;
|
||||
|
||||
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, observability::ObservabilityService, openai::OpenAiService,
|
||||
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
client_keys::ClientKeyService, import_tasks::ImportTasksService,
|
||||
observability::ObservabilityService, openai::OpenAiService, proxies::ProxiesService,
|
||||
settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
@@ -58,6 +63,7 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
];
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
const DEFAULT_CLIENT_SESSION_TTL_MINUTES: u64 = 24 * 60;
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -91,13 +97,40 @@ impl fmt::Debug for InitialAdminPassword {
|
||||
|
||||
/// 管理控制面的启动配置。
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
pub default_username: String,
|
||||
pub default_password: InitialAdminPassword,
|
||||
}
|
||||
|
||||
/// Client 登录域的通用启动配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct ClientConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
}
|
||||
|
||||
impl Default for ClientConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
session_ttl_minutes: DEFAULT_CLIENT_SESSION_TTL_MINUTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
/// 校验 Client session TTL;当前配置不含相对路径。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 会话有效期为零或无法安全换算时返回错误。
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("client.session_ttl_minutes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径。
|
||||
///
|
||||
@@ -151,6 +184,7 @@ pub enum AdminConfigError {
|
||||
pub struct AdminServices {
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
key_usage: Arc<dyn KeyUsageService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
@@ -161,9 +195,26 @@ pub struct AdminServices {
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
backups: Arc<dyn BackupService>,
|
||||
import_tasks: Arc<dyn ImportTasksService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn import_tasks(&self) -> &dyn ImportTasksService {
|
||||
self.import_tasks.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn key_usage(&self) -> &dyn KeyUsageService {
|
||||
self.key_usage.as_ref()
|
||||
}
|
||||
|
||||
/// 取得账号服务的共享句柄;后台编排(冻结恢复 worker)需要持有 Arc。
|
||||
#[must_use]
|
||||
pub fn accounts_handle(&self) -> Arc<dyn AccountsService> {
|
||||
Arc::clone(&self.accounts)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
@@ -237,12 +288,23 @@ impl AdminBundle {
|
||||
self.services.clone()
|
||||
}
|
||||
|
||||
/// 取出 Backup Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
|
||||
/// 取出 Admin Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
|
||||
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
|
||||
std::mem::take(&mut self.worker_contributions)
|
||||
}
|
||||
}
|
||||
|
||||
/// 组合根提供给控制面的运行能力;与配置和存储端口分别传入。
|
||||
pub struct AdminRuntimePorts {
|
||||
pub providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
pub snapshot: Arc<dyn SnapshotControl>,
|
||||
pub account_probe: Arc<dyn AccountProbe>,
|
||||
pub proxy_probe: Arc<dyn ports::proxy::ProxyProbe>,
|
||||
pub client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
pub system: Arc<dyn SystemOperations>,
|
||||
pub client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
///
|
||||
/// # Errors
|
||||
@@ -250,17 +312,25 @@ impl AdminBundle {
|
||||
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
|
||||
pub async fn initialize(
|
||||
mut config: AdminConfig,
|
||||
mut client_config: ClientConfig,
|
||||
store: AdminStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
|
||||
client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
runtime: AdminRuntimePorts,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let (probe, proxy_probe) = probes;
|
||||
let AdminRuntimePorts {
|
||||
providers,
|
||||
snapshot,
|
||||
account_probe: probe,
|
||||
proxy_probe,
|
||||
client_distribution,
|
||||
system,
|
||||
client_key_verifier,
|
||||
} = runtime;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
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)?)
|
||||
@@ -272,7 +342,9 @@ pub async fn initialize(
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
client_config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
client_key_verifier,
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
@@ -296,7 +368,28 @@ pub async fn initialize(
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
|
||||
auth.clone(),
|
||||
store.client_keys(),
|
||||
store.observability(),
|
||||
));
|
||||
let openai = Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
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 import_task = use_case::import_tasks::ImportTaskWorker(import_tasks.clone());
|
||||
let services = AdminServices {
|
||||
key_usage,
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
@@ -304,7 +397,7 @@ pub async fn initialize(
|
||||
registry.clone(),
|
||||
)),
|
||||
auth,
|
||||
accounts,
|
||||
accounts: accounts.clone(),
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
store.account_groups(),
|
||||
store.account_runtime(),
|
||||
@@ -326,21 +419,33 @@ pub async fn initialize(
|
||||
snapshot.clone(),
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
openai: Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
xai: Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
openai,
|
||||
xai,
|
||||
import_tasks,
|
||||
backups,
|
||||
};
|
||||
let worker_contributions = backup_worker_contribution(backup_task)?;
|
||||
let freeze_recovery =
|
||||
freeze_recovery::FreezeRecoveryTask::new(freeze_recovery::FreezeRecoveryDeps {
|
||||
accounts: Arc::clone(&accounts) as Arc<dyn AccountsService>,
|
||||
store: store.accounts(),
|
||||
runtime: store.account_runtime(),
|
||||
settings: store.settings(),
|
||||
});
|
||||
let mut worker_contributions = backup_worker_contribution(backup_task)?;
|
||||
let id = WorkerId::try_new(WorkerKind::AccountImport, "admin")
|
||||
.map_err(|_| AdminError::internal("导入 Worker ID 不合法"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("导入 Worker 重启策略不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(import_task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
|
||||
worker_contributions.push(WorkerContribution::Registration(registration));
|
||||
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
@@ -366,6 +471,37 @@ fn backup_worker_contribution(
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
/// 冻结恢复 Worker 注册:按固定周期扫描活跃冻结,owner 固定。
|
||||
fn freeze_recovery_worker_contribution(
|
||||
task: freeze_recovery::FreezeRecoveryTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(
|
||||
WorkerKind::AccountFreezeRecovery,
|
||||
freeze_recovery::FREEZE_RECOVERY_WORKER_OWNER,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker ID 不合法"))?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
freeze_recovery::FREEZE_RECOVERY_INTERVAL,
|
||||
freeze_recovery::WORKER_INITIAL_BACKOFF,
|
||||
freeze_recovery::WORKER_MAXIMUM_BACKOFF,
|
||||
freeze_recovery::WORKER_LEASE_TTL,
|
||||
freeze_recovery::WORKER_LEASE_RENEWAL,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 调度配置不合法"))?;
|
||||
let lease = WorkerLeaseRequest::try_new(id.clone(), freeze_recovery::WORKER_LEASE_TTL)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 租约配置不合法"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Scheduled {
|
||||
schedule,
|
||||
lease: Some(lease),
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("冻结恢复 Worker 注册信息不合法"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
|
||||
ProviderKind::new(value).map_err(|_| AdminError::internal("内置 Provider 类型不合法"))
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ pub use gateway_core::account::{
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
|
||||
/// 导入时统一应用的账号备注、调度与分组设置;缺省时保留原有导入语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountImportSettings {
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
@@ -67,11 +69,20 @@ pub struct AccountListQuery {
|
||||
/// Admin query service 从运行态存储取得的当前账号冷却快照。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountRuntimeSnapshot {
|
||||
pub rate_limited_until: BTreeMap<String, DateTime<Utc>>,
|
||||
pub cooldown: BTreeMap<String, gateway_core::account::AccountCooldown>,
|
||||
/// `None` 表示实时 lease 存储不可用;`Some` 中未出现的账号当前使用量为零。
|
||||
pub in_flight: Option<BTreeMap<String, u64>>,
|
||||
}
|
||||
|
||||
/// 恢复任务读取的冻结快照;generation 将异步结果绑定到本次冻结。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountFreeze {
|
||||
pub credential_revision: Revision,
|
||||
pub until: DateTime<Utc>,
|
||||
pub generation: String,
|
||||
pub requires_probe: bool,
|
||||
}
|
||||
|
||||
/// Optional account membership filter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountGroupFilter {
|
||||
@@ -86,6 +97,7 @@ pub struct AccountRecord {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
@@ -98,6 +110,7 @@ pub struct AccountRecord {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
@@ -217,9 +230,12 @@ pub struct AccountSummary {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
/// 缺省保留备注;空字符串清空备注。
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
@@ -231,14 +247,15 @@ pub struct AccountUpdateResult {
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 一批账号可编辑事实的一次性替换命令。
|
||||
/// 仅修改显式字段的批量账号设置命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
pub weight: Option<AccountWeight>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<gateway_core::routing::AccountGroupId>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 管理员认证、会话和安全审计事实。
|
||||
//! 控制面认证、统一会话和管理员安全审计事实。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{engine::execution::ClientAuthenticationError, policy::ClientApiKeyId};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
@@ -40,43 +41,82 @@ impl AdminRequestContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员登录命令。
|
||||
/// 登录方式只决定凭据验证流程,不能直接授予会话权限。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginCommand {
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
pub enum LoginCommand {
|
||||
Admin {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
},
|
||||
Key {
|
||||
api_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginCommand {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginCommand")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
match self {
|
||||
Self::Admin { username, .. } => formatter
|
||||
.debug_struct("AdminLogin")
|
||||
.field("username", username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Key { .. } => formatter
|
||||
.debug_struct("KeyLogin")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后返回的会话事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginResult {
|
||||
pub session_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub session: AuthSession,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginResult {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginResult")
|
||||
.field("session_id", &"[REDACTED]")
|
||||
.field("session", &self.session)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录状态机可被 API 精确映射的失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("invalid administrator credentials")]
|
||||
#[error("invalid login credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("administrator authentication is unavailable")]
|
||||
#[error("too many login attempts")]
|
||||
TooManyAttempts { retry_after_seconds: u64 },
|
||||
#[error("authentication is unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Redis 中可恢复的管理员会话。
|
||||
impl From<ClientAuthenticationError> for LoginError {
|
||||
fn from(error: ClientAuthenticationError) -> Self {
|
||||
match error {
|
||||
ClientAuthenticationError::InvalidKey => Self::InvalidCredentials,
|
||||
ClientAuthenticationError::SnapshotUnavailable => Self::Unavailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端已验证的身份绑定,不保存密码或原始 API Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminSession {
|
||||
pub admin_user_id: String,
|
||||
pub enum SessionSubject {
|
||||
Admin { admin_user_id: String },
|
||||
Key { client_key_id: ClientApiKeyId },
|
||||
}
|
||||
|
||||
/// 两种登录方式共用的固定有效期会话。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthSession {
|
||||
pub subject: SessionSubject,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
@@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub custom_key: Option<PlaintextClientApiKey>,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 进程内账号导入任务;只保存执行所需输入与可公开的结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{MutationContext, provider_credentials::ImportCredentials};
|
||||
|
||||
pub const MAX_IMPORT_TASK_ITEMS: usize = 200;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportTaskInput {
|
||||
pub provider: ProviderKind,
|
||||
pub command: ImportCredentials,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SubmitImportTask {
|
||||
pub submission_id: Uuid,
|
||||
/// API 对完整输入计算摘要;用于区分丢失响应后的重试与修改后的新提交。
|
||||
pub fingerprint: [u8; 32],
|
||||
pub context: MutationContext,
|
||||
pub items: Vec<ImportTaskInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImportItemStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Unknown,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl ImportItemStatus {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Running => "running",
|
||||
Self::Succeeded => "succeeded",
|
||||
Self::Failed => "failed",
|
||||
Self::Unknown => "unknown",
|
||||
Self::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskItem {
|
||||
pub index: usize,
|
||||
pub provider: ProviderKind,
|
||||
pub status: ImportItemStatus,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ImportTaskCounts {
|
||||
pub pending: usize,
|
||||
pub running: usize,
|
||||
pub succeeded: usize,
|
||||
pub failed: usize,
|
||||
pub unknown: usize,
|
||||
pub skipped: usize,
|
||||
pub imported_accounts: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskSummary {
|
||||
pub task_id: Uuid,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub stop_requested: bool,
|
||||
pub total: usize,
|
||||
pub counts: ImportTaskCounts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportTaskDetail {
|
||||
pub summary: ImportTaskSummary,
|
||||
pub items: Vec<ImportTaskItem>,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! 单页 Key 用量的查询合同;身份范围不由调用方提供。
|
||||
|
||||
use super::{
|
||||
PageSize,
|
||||
client_keys::ClientKeyRecord,
|
||||
observability::{
|
||||
HealthTimeline, OpsErrorPage, RequestMetricPoint, TimeRange, UsageOverview, UsagePage,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum KeyUsageRecordKind {
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyUsageRecordsQuery {
|
||||
pub usage: KeyUsageQuery,
|
||||
pub kind: KeyUsageRecordKind,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
pub struct KeyUsageOverview {
|
||||
pub key: ClientKeyRecord,
|
||||
pub overview: UsageOverview,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub health_timeline: HealthTimeline,
|
||||
}
|
||||
|
||||
pub enum KeyUsageRecords {
|
||||
Success(UsagePage),
|
||||
Error(OpsErrorPage),
|
||||
}
|
||||
@@ -8,9 +8,13 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod quota_forecast;
|
||||
pub mod quota_forecast_sampling;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
@@ -19,6 +23,7 @@ pub mod system;
|
||||
pub enum AdminErrorKind {
|
||||
Invalid,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Conflict,
|
||||
RateLimited,
|
||||
|
||||
@@ -15,7 +15,7 @@ use serde_json::{Map, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
AdminError, MutationActor, MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
|
||||
},
|
||||
@@ -49,56 +49,6 @@ impl fmt::Debug for ProviderDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 列表稳定游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialCursor {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
/// Provider credential 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialListQuery {
|
||||
pub credential_state: Option<CredentialStateFilter>,
|
||||
pub enabled: Option<bool>,
|
||||
pub window: CredentialListWindow,
|
||||
}
|
||||
|
||||
/// Credential 状态筛选。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialStateFilter {
|
||||
Exact(CredentialState),
|
||||
AnyOf(Vec<CredentialState>),
|
||||
}
|
||||
|
||||
impl CredentialStateFilter {
|
||||
#[must_use]
|
||||
pub fn matches(&self, credential_state: CredentialState) -> bool {
|
||||
match self {
|
||||
Self::Exact(expected) => *expected == credential_state,
|
||||
Self::AnyOf(expected) => expected.contains(&credential_state),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 目录的集合窗口;完整列表与游标分页互斥。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialListWindow {
|
||||
All,
|
||||
Page {
|
||||
cursor: Option<CredentialCursor>,
|
||||
page_size: PageSize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provider credential 列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub next_cursor: Option<CredentialCursor>,
|
||||
}
|
||||
|
||||
/// Provider credential 详情。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDetails {
|
||||
@@ -149,6 +99,7 @@ impl fmt::Debug for PrepareCredentialImport {
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
@@ -634,6 +585,7 @@ pub struct CredentialDeletionResult {
|
||||
pub struct RotateCredential {
|
||||
pub mutation: CredentialMutation,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub settings: Option<super::accounts::UpdateAccount>,
|
||||
}
|
||||
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入。
|
||||
@@ -662,6 +614,8 @@ pub struct PreparedCredentialRotationFacts {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
/// Token 刷新保留提交时的资料,不以准备阶段的副本覆盖新套餐。
|
||||
pub preserve_profile: bool,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
@@ -713,6 +667,7 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
pub settings: Option<super::accounts::UpdateAccount>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RotateCredential {
|
||||
@@ -721,6 +676,7 @@ impl fmt::Debug for RotateCredential {
|
||||
.debug_struct("RotateCredential")
|
||||
.field("mutation", &self.mutation)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.field("settings", &self.settings)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -771,6 +727,13 @@ pub struct ProviderQuotaWindow {
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum AccountUsagePeriod {
|
||||
Weekly,
|
||||
Monthly,
|
||||
}
|
||||
|
||||
/// Provider 解释 quota 所需的公共请求事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaRequest {
|
||||
@@ -800,6 +763,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 按需查询的订阅周期,不参与额度或账号可用性判断。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderSubscription {
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountPersonalInfo {
|
||||
pub profile: Result<ProviderProfileStatistics, AdminError>,
|
||||
pub subscription: Option<ProviderSubscription>,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料中的累计摘要。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderProfileStatisticsSummary {
|
||||
@@ -921,23 +902,37 @@ impl ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
|
||||
///
|
||||
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
|
||||
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
|
||||
/// 同一优先级保持 Provider 投影顺序。
|
||||
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
|
||||
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
|
||||
#[must_use]
|
||||
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, window)| {
|
||||
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
&& window.window_seconds.is_some()
|
||||
&& window.local_usage.is_some()
|
||||
})
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.and_then(|(_, window)| window.local_usage.as_ref())
|
||||
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.usage_windows()
|
||||
.filter(|(window, _)| window.local_usage.is_some())
|
||||
.min_by_key(|(_, period)| *period)
|
||||
}
|
||||
|
||||
/// 统计与预测共用窗口归属规则,避免把模型专属桶或短期限流当作账号容量。
|
||||
pub(crate) fn usage_windows(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.windows.iter().filter_map(|window| {
|
||||
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
|| window.reset_at.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let seconds = window.window_seconds?;
|
||||
let period = if is_week_window(seconds) {
|
||||
AccountUsagePeriod::Weekly
|
||||
} else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 {
|
||||
AccountUsagePeriod::Monthly
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some((window, period))
|
||||
})
|
||||
}
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
|
||||
@@ -65,6 +65,7 @@ pub struct ProxyTestResult {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
@@ -86,12 +87,15 @@ pub struct ProxyPage {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
/// 外层为空保留配置,内层为空恢复全局继承。
|
||||
pub location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
pub id: String,
|
||||
pub revision: Revision,
|
||||
pub name: String,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! 账号额度的只读容量估算,不参与金额结算或账号调度。
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
use super::provider_credentials::{AccountUsagePeriod, ProviderQuota, ProviderQuotaWindow};
|
||||
use super::quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample};
|
||||
|
||||
const DAY_SECONDS: u64 = 86_400;
|
||||
const MIN_USED_PERCENT: f64 = 5.0;
|
||||
const LOW_SAMPLE_PERCENT: f64 = 10.0;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountQuotaForecastReport {
|
||||
pub account_id: String,
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub forecasts: [AccountQuotaForecast; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountQuotaForecast {
|
||||
pub period: AccountUsagePeriod,
|
||||
pub target_seconds: u64,
|
||||
pub extrapolated: bool,
|
||||
pub source: Option<QuotaForecastSource>,
|
||||
pub unavailable_reason: Option<&'static str>,
|
||||
pub low_sample: bool,
|
||||
pub incomplete_cost: bool,
|
||||
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)]
|
||||
pub struct QuotaForecastSource {
|
||||
pub label: String,
|
||||
pub used_percent: Option<f64>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub reset_at: DateTime<Utc>,
|
||||
pub tokens: Option<u64>,
|
||||
pub usd: Option<f64>,
|
||||
}
|
||||
|
||||
/// 优先预测真实的对应窗口;缺少对应周期时只给出明确标识的 7/30 天容量折算。
|
||||
/// 本地日志不能证明站外消耗或完整留存,因此即使样本充足也不声称官方额度。
|
||||
#[must_use]
|
||||
pub fn account_quota_forecasts(
|
||||
quota: &ProviderQuota,
|
||||
account_added_at: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
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 mut forecast = AccountQuotaForecast {
|
||||
period,
|
||||
target_seconds: match period {
|
||||
AccountUsagePeriod::Weekly => 7 * DAY_SECONDS,
|
||||
AccountUsagePeriod::Monthly => 30 * DAY_SECONDS,
|
||||
},
|
||||
extrapolated: false,
|
||||
source: None,
|
||||
unavailable_reason: Some("没有可统计的周/月额度窗口,请先刷新账号额度。"),
|
||||
low_sample: false,
|
||||
incomplete_cost: false,
|
||||
incomplete_tokens: false,
|
||||
estimated_tokens: None,
|
||||
estimated_usd: None,
|
||||
remaining_tokens: None,
|
||||
remaining_usd: None,
|
||||
};
|
||||
if let Some((window, source_period)) = selected {
|
||||
forecast.project(
|
||||
window,
|
||||
source_period,
|
||||
quota.observed_at,
|
||||
account_added_at,
|
||||
now,
|
||||
samples.iter().find(|sample| sample.key == window.key),
|
||||
);
|
||||
}
|
||||
forecast
|
||||
})
|
||||
}
|
||||
|
||||
impl AccountQuotaForecast {
|
||||
fn project(
|
||||
&mut self,
|
||||
window: &ProviderQuotaWindow,
|
||||
source_period: AccountUsagePeriod,
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
account_added_at: DateTime<Utc>,
|
||||
now: DateTime<Utc>,
|
||||
sample: Option<&QuotaForecastSample>,
|
||||
) {
|
||||
let (Some(seconds), Some(reset_at)) = (window.window_seconds, window.reset_at) else {
|
||||
return;
|
||||
};
|
||||
self.extrapolated = source_period != self.period;
|
||||
if !self.extrapolated {
|
||||
self.target_seconds = seconds;
|
||||
}
|
||||
let percent = window
|
||||
.used_percent
|
||||
.filter(|p| p.is_finite() && (0.0..=100.0).contains(p));
|
||||
let usage = sample.map(|sample| &sample.usage);
|
||||
let usd = usage
|
||||
.filter(|usage| usage.known_cost_count > 0)
|
||||
.map(|usage| usage.usd)
|
||||
.filter(|value| value.is_finite() && *value >= 0.0);
|
||||
let start = i64::try_from(seconds)
|
||||
.ok()
|
||||
.and_then(Duration::try_seconds)
|
||||
.and_then(|duration| reset_at.checked_sub_signed(duration));
|
||||
self.source = Some(QuotaForecastSource {
|
||||
label: window.label.clone(),
|
||||
used_percent: percent,
|
||||
observed_at,
|
||||
reset_at,
|
||||
tokens: usage.map(|usage| usage.tokens),
|
||||
usd,
|
||||
});
|
||||
self.incomplete_cost = usage.is_none_or(|usage| {
|
||||
usage.unavailable_cost_count > 0
|
||||
|| usage.known_cost_count == 0
|
||||
|| usage.known_cost_count != usage.request_count
|
||||
});
|
||||
self.incomplete_tokens = usage.is_some_and(|usage| usage.missing_token_count > 0);
|
||||
let method = sample.map_or(QuotaForecastMethod::Cumulative, |sample| sample.method);
|
||||
let Some(start) = start.filter(|start| *start <= now && now < reset_at) else {
|
||||
self.unavailable_reason = Some("额度窗口已过期或边界无效,请刷新账号额度后重试。");
|
||||
return;
|
||||
};
|
||||
if !observed_at.is_some_and(|observed| start <= observed && observed <= now) {
|
||||
self.unavailable_reason = Some("缺少本周期的额度快照,请先刷新账号额度。");
|
||||
return;
|
||||
}
|
||||
if sample.is_some_and(|sample| sample.discontinuous) {
|
||||
self.unavailable_reason =
|
||||
Some("额度观测出现回落或累计记录不连续,正在重新积累配对样本。");
|
||||
return;
|
||||
}
|
||||
if account_added_at > start && method == QuotaForecastMethod::Cumulative {
|
||||
self.unavailable_reason = Some(
|
||||
"本周期开始时的记录不完整,正在积累至少 5 个百分点的配对观测,无需等待下次重置。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Some(percent) = percent else {
|
||||
self.unavailable_reason = Some("已用比例未知,请刷新额度后查看预测。");
|
||||
return;
|
||||
};
|
||||
let Some(usage) = usage.filter(|usage| usage.request_count > 0) else {
|
||||
self.unavailable_reason = Some("本周期没有网关用量记录,暂时无法预测额度。");
|
||||
return;
|
||||
};
|
||||
let Some(sample) = sample.filter(|sample| {
|
||||
sample.end_at == observed_at.unwrap_or(now)
|
||||
&& start <= sample.start_at
|
||||
&& sample.start_at <= sample.end_at
|
||||
&& sample.sampled_percent.is_finite()
|
||||
&& sample.sampled_percent >= MIN_USED_PERCENT
|
||||
&& sample.sampled_percent <= percent
|
||||
}) else {
|
||||
self.unavailable_reason =
|
||||
Some("有效额度进度不足 5 个百分点或采样边界无效,请继续积累用量。");
|
||||
return;
|
||||
};
|
||||
self.low_sample = sample.sampled_percent < LOW_SAMPLE_PERCENT
|
||||
|| (method == QuotaForecastMethod::Incremental && sample.block_count < 2);
|
||||
// 漏记和个别缺失只影响精度,仍按已记录数值估算,不按请求数补齐未知消耗。
|
||||
// 预测是近似展示值;不复用为账单金额,也不把月折算当成自然月或额外余额。
|
||||
let capacity_factor = 100.0 / sample.sampled_percent;
|
||||
let factor = capacity_factor * self.target_seconds as f64 / seconds as f64;
|
||||
let tokens = Some(usage.tokens).filter(|tokens| *tokens > 0);
|
||||
self.estimated_tokens = tokens.and_then(|value| estimate_tokens(value, factor));
|
||||
self.estimated_usd = usd.and_then(|value| estimate(value, factor));
|
||||
let remaining_factor = (100.0 - percent) / sample.sampled_percent;
|
||||
self.remaining_tokens = tokens.and_then(|value| estimate_tokens(value, remaining_factor));
|
||||
self.remaining_usd = usd.and_then(|value| estimate(value, remaining_factor));
|
||||
self.unavailable_reason = if self.estimated_tokens.is_none() && self.estimated_usd.is_none()
|
||||
{
|
||||
Some("本周期暂无可用于估算的 Token 或费用数据,请积累用量后重试。")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate(value: f64, factor: f64) -> Option<f64> {
|
||||
let estimate = value * factor;
|
||||
(estimate.is_finite() && estimate >= 0.0).then_some(estimate)
|
||||
}
|
||||
|
||||
fn estimate_tokens(value: u64, factor: f64) -> Option<u64> {
|
||||
estimate(value as f64, factor)
|
||||
.filter(|value| value.round() < u64::MAX as f64)
|
||||
.map(|value| value.round() as u64)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! 预测专用的配对采样;不改变完整交付用量或费用账本的统计口径。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::provider_credentials::ProviderDocument;
|
||||
|
||||
/// 限制带 Provider 文档的历史点数量;累计用量仍覆盖整个查询区间。
|
||||
pub const MAX_FORECAST_HISTORY_POINTS: usize = 128;
|
||||
const MIN_BLOCK_PERCENT: f64 = 5.0;
|
||||
const RECENT_BLOCKS: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct QuotaForecastUsage {
|
||||
pub request_count: u64,
|
||||
pub tokens: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub missing_token_count: u64,
|
||||
pub known_cost_count: u64,
|
||||
pub unavailable_cost_count: u64,
|
||||
pub usd: f64,
|
||||
pub excluded_request_count: u64,
|
||||
}
|
||||
|
||||
impl QuotaForecastUsage {
|
||||
/// 累计计数倒退表示不能配对,不用饱和减法掩盖数据断点。
|
||||
fn difference(&self, baseline: &Self) -> Option<Self> {
|
||||
let usd = self.usd - baseline.usd;
|
||||
if !usd.is_finite() || usd < -1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
request_count: self.request_count.checked_sub(baseline.request_count)?,
|
||||
tokens: self.tokens.checked_sub(baseline.tokens)?,
|
||||
input_tokens: self.input_tokens.checked_sub(baseline.input_tokens)?,
|
||||
output_tokens: self.output_tokens.checked_sub(baseline.output_tokens)?,
|
||||
cached_tokens: self.cached_tokens.checked_sub(baseline.cached_tokens)?,
|
||||
missing_token_count: self
|
||||
.missing_token_count
|
||||
.checked_sub(baseline.missing_token_count)?,
|
||||
known_cost_count: self
|
||||
.known_cost_count
|
||||
.checked_sub(baseline.known_cost_count)?,
|
||||
unavailable_cost_count: self
|
||||
.unavailable_cost_count
|
||||
.checked_sub(baseline.unavailable_cost_count)?,
|
||||
usd: usd.max(0.0),
|
||||
excluded_request_count: self
|
||||
.excluded_request_count
|
||||
.checked_sub(baseline.excluded_request_count)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastHistoryPoint {
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: DateTime<Utc>,
|
||||
pub usage: QuotaForecastUsage,
|
||||
pub provider_observation: ProviderDocument,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QuotaForecastHistory {
|
||||
pub points: Vec<QuotaForecastHistoryPoint>,
|
||||
pub usage: QuotaForecastUsage,
|
||||
pub pending_request_count: u64,
|
||||
}
|
||||
|
||||
/// 具体 Provider 从自己的历史文档解释出的额度事实。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct QuotaForecastObservation {
|
||||
pub used_percent: f64,
|
||||
pub reset_at: DateTime<Utc>,
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastPoint {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub used_percent: f64,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaForecastMethod {
|
||||
Cumulative,
|
||||
Incremental,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuotaForecastSample {
|
||||
pub key: String,
|
||||
pub method: QuotaForecastMethod,
|
||||
pub start_at: DateTime<Utc>,
|
||||
pub end_at: DateTime<Utc>,
|
||||
pub baseline_percent: f64,
|
||||
pub sampled_percent: f64,
|
||||
pub block_count: usize,
|
||||
pub observation_count: usize,
|
||||
pub pending_request_count: u64,
|
||||
pub discontinuous: bool,
|
||||
pub usage: QuotaForecastUsage,
|
||||
}
|
||||
|
||||
/// 对同一额度段的累计点形成至少 5 个百分点的连续块,使用最近三个完整块及尾部。
|
||||
/// 不平均逐请求比值;重复百分比不增加块数,5/10 个百分点仅是量化噪声门槛。
|
||||
#[must_use]
|
||||
pub fn select_forecast_sample(
|
||||
key: String,
|
||||
start_at: DateTime<Utc>,
|
||||
current: QuotaForecastPoint,
|
||||
mut history: Vec<QuotaForecastPoint>,
|
||||
pending_request_count: u64,
|
||||
) -> QuotaForecastSample {
|
||||
history.retain(|point| {
|
||||
start_at <= point.observed_at
|
||||
&& point.observed_at < current.observed_at
|
||||
&& point.used_percent.is_finite()
|
||||
&& (0.0..100.0).contains(&point.used_percent)
|
||||
});
|
||||
history.sort_by_key(|point| point.observed_at);
|
||||
let mut sample = QuotaForecastSample {
|
||||
key,
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
start_at,
|
||||
end_at: current.observed_at,
|
||||
baseline_percent: 0.0,
|
||||
sampled_percent: current.used_percent,
|
||||
block_count: 0,
|
||||
observation_count: history.len(),
|
||||
pending_request_count,
|
||||
discontinuous: false,
|
||||
usage: current.usage.clone(),
|
||||
};
|
||||
history.push(current);
|
||||
let mut anchors: Vec<&QuotaForecastPoint> = Vec::new();
|
||||
let mut high_water: Option<&QuotaForecastPoint> = None;
|
||||
for point in &history {
|
||||
if let Some(previous) = high_water {
|
||||
if point.observed_at <= previous.observed_at {
|
||||
continue;
|
||||
}
|
||||
if point.used_percent < previous.used_percent {
|
||||
// 一个百分点内的回落可能是量化/并发乱序;更大的回落不能跨越。
|
||||
if previous.used_percent - point.used_percent <= 1.0 {
|
||||
continue;
|
||||
}
|
||||
sample.discontinuous = true;
|
||||
anchors.clear();
|
||||
}
|
||||
}
|
||||
high_water = Some(point);
|
||||
if anchors
|
||||
.last()
|
||||
.is_none_or(|anchor| point.used_percent - anchor.used_percent >= MIN_BLOCK_PERCENT)
|
||||
{
|
||||
anchors.push(point);
|
||||
}
|
||||
}
|
||||
let Some(current) = history.last() else {
|
||||
return sample;
|
||||
};
|
||||
if anchors.len() < 2 {
|
||||
return sample;
|
||||
}
|
||||
let baseline_index = anchors.len().saturating_sub(RECENT_BLOCKS + 1);
|
||||
let baseline = anchors[baseline_index];
|
||||
let progress = current.used_percent - baseline.used_percent;
|
||||
if !progress.is_finite() || progress < MIN_BLOCK_PERCENT {
|
||||
return sample;
|
||||
}
|
||||
let Some(usage) = current.usage.difference(&baseline.usage) else {
|
||||
sample.discontinuous = true;
|
||||
return sample;
|
||||
};
|
||||
if usage.request_count == 0 {
|
||||
return sample;
|
||||
}
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = baseline.observed_at;
|
||||
sample.baseline_percent = baseline.used_percent;
|
||||
sample.sampled_percent = progress;
|
||||
sample.block_count = anchors.len() - baseline_index - 1;
|
||||
sample.usage = usage;
|
||||
// 断点后的新基线已有足够进度时,只消费新段;旧段从不参与本次估算。
|
||||
sample.discontinuous = false;
|
||||
sample
|
||||
}
|
||||
@@ -18,34 +18,58 @@ pub use gateway_core::account::RotationStrategy;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u32,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
|
||||
@@ -19,7 +19,11 @@ use crate::model::provider_credentials::{
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
|
||||
};
|
||||
use crate::model::{
|
||||
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
|
||||
quota_forecast_sampling::QuotaForecastObservation,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
@@ -166,12 +170,37 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
command: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
|
||||
|
||||
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
|
||||
async fn account_configuration(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
/// 历史观测的协议字段仅由具体 Provider 解释;不支持时保留累计估算。
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
_document: &ProviderDocument,
|
||||
_window: &ProviderQuotaWindow,
|
||||
) -> Option<QuotaForecastObservation> {
|
||||
None
|
||||
}
|
||||
|
||||
/// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。
|
||||
/// 只由个人信息显式查询,不在额度刷新或后台任务中预取。
|
||||
async fn subscription(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{collections::BTreeMap, net::IpAddr, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -20,7 +20,7 @@ use crate::model::{
|
||||
AccountUpdateResult, AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult,
|
||||
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
@@ -32,9 +32,9 @@ use crate::model::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
ProviderExportCredentialInput,
|
||||
CredentialMutationResult, CredentialRotationCommit, ProviderExportCredentialInput,
|
||||
},
|
||||
quota_forecast_sampling::QuotaForecastHistory,
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
StaleRevision,
|
||||
DuplicateName,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
}
|
||||
@@ -110,11 +111,11 @@ pub trait AccountStore: Send + Sync {
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
|
||||
|
||||
async fn list_credentials(
|
||||
/// 从同一数据库语句取得截止快照的累计用量和有界历史观测。
|
||||
async fn load_quota_forecast_history(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
query: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage>;
|
||||
window: &AccountUsageWindowQuery,
|
||||
) -> AdminStoreResult<QuotaForecastHistory>;
|
||||
|
||||
async fn credential_details(
|
||||
&self,
|
||||
@@ -158,6 +159,14 @@ pub trait AccountStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
/// 在事务内按最新启用状态、账号上限和全局默认值判断,只降低并发上限。
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Option<AccountUpdateResult>>;
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
@@ -192,9 +201,28 @@ pub trait AccountRuntimeStore: Send + Sync {
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot>;
|
||||
|
||||
/// 容量熔断自动冻结中的账号与其冻结截止时间;429 临时限流不包含在内。
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, crate::model::accounts::AccountFreeze>>;
|
||||
|
||||
/// 读取容量失败窗口内观测到的在途并发峰值(自适应并发下调的证据)。
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>>;
|
||||
|
||||
/// 仅当冻结快照仍匹配时解除或顺延;旧探测不得覆盖手动恢复或新一轮冻结。
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected: &crate::model::accounts::AccountFreeze,
|
||||
postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
}
|
||||
|
||||
/// 管理员密码、会话和安全审计。
|
||||
/// 控制面凭据、统一会话、登录限流与管理员安全审计。
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
@@ -207,19 +235,38 @@ pub trait AuthStore: Send + Sync {
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AdminSession)
|
||||
-> AdminStoreResult<()>;
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()>;
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>>;
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 原子消费来源桶与全局桶的一次登录尝试;被拒绝时返回建议重试间隔。
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
source_ip: IpAddr,
|
||||
source_limit: u32,
|
||||
global_limit: u32,
|
||||
window: Duration,
|
||||
) -> AdminStoreResult<Option<Duration>>;
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理写入。
|
||||
/// Client API Key 资料读取与管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key。
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>>;
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
|
||||
async fn reveal_client_key(
|
||||
|
||||
@@ -192,11 +192,7 @@ fn project_group_runtime(
|
||||
let mut accounts_by_group = BTreeMap::<&str, Vec<&str>>::new();
|
||||
for member in members {
|
||||
let mut status = member.status.clone();
|
||||
status.rate_limited_until = runtime
|
||||
.rate_limited_until
|
||||
.get(&member.account_id)
|
||||
.copied()
|
||||
.map(Into::into);
|
||||
status.cooldown = runtime.cooldown.get(&member.account_id).copied();
|
||||
status_by_account
|
||||
.entry(member.account_id.as_str())
|
||||
.or_insert_with(|| resolve_account_status(&status, now).status);
|
||||
|
||||
@@ -17,17 +17,19 @@ use crate::{
|
||||
AdminError, MutationContext,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, UpdateAccount,
|
||||
AccountPageItem, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountsUpdateResult, BatchUpdateAccounts, UpdateAccount,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
|
||||
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts},
|
||||
quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
@@ -71,23 +73,42 @@ pub trait AccountsService: Send + Sync {
|
||||
command: UpdateAccount,
|
||||
) -> Result<AccountUpdateResult, AdminError>;
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
) -> Result<Option<AccountUpdateResult>, AdminError>;
|
||||
|
||||
async fn batch_update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: BatchUpdateAccounts,
|
||||
) -> Result<AccountsUpdateResult, AdminError>;
|
||||
|
||||
async fn account_configuration(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<AccountDirectoryItem, AdminError>;
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn quota_forecast(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountQuotaForecastReport, AdminError>;
|
||||
|
||||
async fn personal_info(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持个人信息"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
@@ -213,6 +234,7 @@ impl DefaultAccountsService {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|window| window.local_usage.is_none())
|
||||
.filter_map(|window| quota_usage_window(&item.account.id, window))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -242,6 +264,37 @@ impl DefaultAccountsService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_api_key_usage(
|
||||
&self,
|
||||
accounts: &[AccountPageItem],
|
||||
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
|
||||
let now = Utc::now();
|
||||
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.filter(|item| item.account.authentication_kind == "api_key")
|
||||
.map(|item| AccountUsageWindowQuery {
|
||||
account_id: item.account.id.clone(),
|
||||
key: "account-lifetime".to_owned(),
|
||||
range: TimeRange {
|
||||
start: item.account.created_at,
|
||||
end: now,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if windows.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
Ok(self
|
||||
.accounts
|
||||
.load_account_usage_by_windows(&windows)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "API Key account usage"))?
|
||||
.into_iter()
|
||||
.map(|result| (result.account_id, result.usage))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_directory_item(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -279,7 +332,15 @@ impl DefaultAccountsService {
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
let usage = self
|
||||
.load_api_key_usage(std::slice::from_ref(&stored))
|
||||
.await?
|
||||
.remove(&stored.account.id)
|
||||
.or_else(|| {
|
||||
quota
|
||||
.usage_window()
|
||||
.and_then(|(window, _)| window.local_usage.clone())
|
||||
});
|
||||
Ok(AccountDirectoryItem {
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
stored.account.provider_kind.as_str(),
|
||||
@@ -367,12 +428,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
self.attach_quota_local_usage(&page.items, &mut quotas)
|
||||
.await?;
|
||||
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(mut item, quota)| {
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
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(
|
||||
item.account.provider_kind.as_str(),
|
||||
@@ -523,6 +589,27 @@ impl AccountsService for DefaultAccountsService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
) -> Result<Option<AccountUpdateResult>, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let result = self
|
||||
.accounts
|
||||
.lower_concurrency_limit(&account_id, limit, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?;
|
||||
if let Some(result) = &result {
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn batch_update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
@@ -558,7 +645,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
if !enabled {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
@@ -569,6 +656,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn account_configuration(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.account_configuration(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider account configuration"))
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -577,15 +675,127 @@ impl AccountsService for DefaultAccountsService {
|
||||
self.load_directory_item(account_id, refresh).await
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
async fn quota_forecast(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.profile_statistics(account_id)
|
||||
) -> Result<AccountQuotaForecastReport, AdminError> {
|
||||
let (stored, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider profile statistics"))
|
||||
.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 (Some(mut query), Some(observed), Some(percent)) = (
|
||||
quota_usage_window(account_id.as_str(), window),
|
||||
quota.observed_at,
|
||||
window.used_percent,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
query.range.start = query.range.start.max(stored.account.created_at);
|
||||
if query.range.start >= observed
|
||||
|| observed > now
|
||||
|| now >= query.range.end
|
||||
|| !percent.is_finite()
|
||||
|| !(0.0..=100.0).contains(&percent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let reset_at = query.range.end;
|
||||
query.range.end = observed;
|
||||
let history = self
|
||||
.accounts
|
||||
.load_quota_forecast_history(&query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "forecast paired usage"))?;
|
||||
let mut points = Vec::new();
|
||||
let mut interrupted = false;
|
||||
for point in history.points {
|
||||
let Some(fact) =
|
||||
provider.quota_forecast_observation(&point.provider_observation, window)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let same_plan = quota
|
||||
.plan_type
|
||||
.as_deref()
|
||||
.zip(fact.plan_type.as_deref())
|
||||
.is_some_and(|(current, previous)| current.eq_ignore_ascii_case(previous));
|
||||
// 仅容纳已观测到的秒级量化抖动,不用宽时间容差合并实际重置。
|
||||
// 不匹配的段截断基线;之后的有效观测可以重新积累。
|
||||
if !same_plan || (fact.reset_at - reset_at).abs() > Duration::seconds(2) {
|
||||
points.clear();
|
||||
interrupted = true;
|
||||
continue;
|
||||
}
|
||||
points.push(QuotaForecastPoint {
|
||||
observed_at: point.completed_at,
|
||||
used_percent: fact.used_percent,
|
||||
usage: point.usage,
|
||||
});
|
||||
}
|
||||
let mut sample = select_forecast_sample(
|
||||
window.key.clone(),
|
||||
query.range.start,
|
||||
QuotaForecastPoint {
|
||||
observed_at: observed,
|
||||
used_percent: percent,
|
||||
usage: history.usage,
|
||||
},
|
||||
points,
|
||||
history.pending_request_count,
|
||||
);
|
||||
if interrupted
|
||||
&& sample.method
|
||||
== crate::model::quota_forecast_sampling::QuotaForecastMethod::Cumulative
|
||||
{
|
||||
sample.discontinuous = true;
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
Ok(AccountQuotaForecastReport {
|
||||
account_id: account_id.to_string(),
|
||||
generated_at: now,
|
||||
forecasts: account_quota_forecasts("a, stored.account.created_at, now, &samples),
|
||||
})
|
||||
}
|
||||
|
||||
async fn personal_info(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountPersonalInfo, AdminError> {
|
||||
let (initial, provider) = self.provider_for_account(account_id).await?;
|
||||
// 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。
|
||||
let (profile, subscription) = futures::join!(
|
||||
provider.profile_statistics(account_id),
|
||||
provider.subscription(account_id),
|
||||
);
|
||||
let profile =
|
||||
profile.map_err(|error| map_provider_error(error, "provider profile statistics"));
|
||||
let subscription = subscription
|
||||
.map_err(|error| map_provider_error(error, "provider subscription"))
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。
|
||||
let current = self.load_account(account_id).await?;
|
||||
if current.account.provider_kind != initial.account.provider_kind
|
||||
|| current.account.credential_revision != initial.account.credential_revision
|
||||
|| current.account.upstream_user_id != initial.account.upstream_user_id
|
||||
|| current.account.upstream_account_id != initial.account.upstream_account_id
|
||||
{
|
||||
return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试"));
|
||||
}
|
||||
Ok(AccountPersonalInfo {
|
||||
profile,
|
||||
subscription,
|
||||
})
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
@@ -758,14 +968,12 @@ fn quota_usage_window(
|
||||
account_id: &str,
|
||||
window: &ProviderQuotaWindow,
|
||||
) -> Option<AccountUsageWindowQuery> {
|
||||
if window.local_usage.is_some()
|
||||
|| window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
{
|
||||
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide {
|
||||
return None;
|
||||
}
|
||||
let reset_at = window.reset_at?;
|
||||
let seconds = i64::try_from(window.window_seconds?).ok()?;
|
||||
let start = reset_at.checked_sub_signed(Duration::seconds(seconds))?;
|
||||
let start = reset_at.checked_sub_signed(Duration::try_seconds(seconds)?)?;
|
||||
let range = TimeRange::new(start, reset_at).ok()?;
|
||||
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
|
||||
Some(AccountUsageWindowQuery {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
//! 管理员认证状态机。
|
||||
//! 控制面登录、会话恢复与身份校验的唯一 owner。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{net::IpAddr, sync::Arc, time::Duration as StdDuration};
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_core::engine::execution::ClientKeyVerifier;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
AdminError, AdminErrorKind,
|
||||
auth::{
|
||||
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
|
||||
AdminAuditEvent, AuditActorKind, AuthSession, LoginCommand, LoginError, LoginResult,
|
||||
SessionSubject,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
@@ -22,61 +24,108 @@ use crate::{
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// API 鉴权与管理员登录消费的统一服务。
|
||||
/// 所有控制面接口消费同一个会话服务,权限由服务端身份决定。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
|
||||
/// `Utc::now() + session_ttl` 永不越界。
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
const LOGIN_WINDOW: StdDuration = StdDuration::from_secs(60);
|
||||
const LOGIN_ATTEMPTS_PER_SOURCE: u32 = 10;
|
||||
const LOGIN_ATTEMPTS_GLOBAL: u32 = 200;
|
||||
|
||||
/// 单管理员认证策略的最终实现。
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
session_ttl: Duration,
|
||||
admin_session_ttl: Duration,
|
||||
key_session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
default_admin_user_id: impl Into<String>,
|
||||
session_ttl_minutes: u64,
|
||||
admin_session_ttl_minutes: u64,
|
||||
key_session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
verifier: Arc<dyn ClientKeyVerifier>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES);
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
session_ttl: Duration::minutes(minutes),
|
||||
admin_session_ttl: session_ttl(admin_session_ttl_minutes),
|
||||
key_session_ttl: session_ttl(key_session_ttl_minutes),
|
||||
store,
|
||||
verifier,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
|
||||
fn auth_audit(&self, action: &str, admin_user_id: &str) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::AdminSession,
|
||||
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(&self.default_admin_user_id),
|
||||
actor_admin_user_id: Some(admin_user_id.to_owned()),
|
||||
actor_ref: crate::model::auth::admin_session_actor_ref(admin_user_id),
|
||||
request_id: None,
|
||||
action: action.to_owned(),
|
||||
entity_kind: "admin_session".to_owned(),
|
||||
entity_ref: self.default_admin_user_id.clone(),
|
||||
entity_ref: admin_user_id.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields: Vec::new(),
|
||||
occurred_at,
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate(&self, command: LoginCommand) -> Result<SessionSubject, LoginError> {
|
||||
match command {
|
||||
LoginCommand::Admin { username, password } => {
|
||||
if username.as_deref().unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Admin {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
})
|
||||
}
|
||||
LoginCommand::Key { api_key } => {
|
||||
if api_key.is_empty() || api_key.len() > 4096 {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let client_key_id = self.verifier.verify_client_key(&api_key)?;
|
||||
if !self
|
||||
.store
|
||||
.client_key_enabled(&client_key_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
Ok(SessionSubject::Key { client_key_id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,22 +140,51 @@ impl AuthService for DefaultAuthService {
|
||||
.map_err(|error| map_store_error(error, "administrator"))
|
||||
}
|
||||
|
||||
async fn session(&self, session_id: Option<&str>) -> Result<Option<AuthSession>, AdminError> {
|
||||
let Some(session_id) = session_id.filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(session) = self
|
||||
.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if session.expires_at <= Utc::now() {
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
if let SessionSubject::Key { client_key_id } = &session.subject
|
||||
&& !self
|
||||
.store
|
||||
.client_key_enabled(client_key_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "session key"))?
|
||||
{
|
||||
let _ = self.store.delete_session(session_id).await;
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(session))
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
let Some(session_id) = session_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map(|session| {
|
||||
session
|
||||
.filter(|session| session.expires_at > Utc::now())
|
||||
.map(|session| session.admin_user_id)
|
||||
})
|
||||
.map_err(|error| map_store_error(error, "administrator session"))
|
||||
match self
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Admin { admin_user_id }) => Ok(Some(admin_user_id)),
|
||||
Some(SessionSubject::Key { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问管理接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
|
||||
@@ -124,69 +202,77 @@ impl AuthService for DefaultAuthService {
|
||||
}))
|
||||
}
|
||||
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
|
||||
if command
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
async fn login(
|
||||
&self,
|
||||
command: LoginCommand,
|
||||
source_ip: IpAddr,
|
||||
previous_session_id: Option<&str>,
|
||||
) -> Result<LoginResult, LoginError> {
|
||||
if let Some(retry_after) = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let session_id = random_session_token();
|
||||
let expires_at = Utc::now() + self.session_ttl;
|
||||
self.store
|
||||
.store_session(
|
||||
&session_id,
|
||||
&AdminSession {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
expires_at,
|
||||
},
|
||||
.consume_login_attempt(
|
||||
source_ip,
|
||||
LOGIN_ATTEMPTS_PER_SOURCE,
|
||||
LOGIN_ATTEMPTS_GLOBAL,
|
||||
LOGIN_WINDOW,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: retry_after.as_secs().max(1),
|
||||
});
|
||||
}
|
||||
let subject = self.authenticate(command).await?;
|
||||
let ttl = match subject {
|
||||
SessionSubject::Admin { .. } => self.admin_session_ttl,
|
||||
SessionSubject::Key { .. } => self.key_session_ttl,
|
||||
};
|
||||
let session = AuthSession {
|
||||
subject,
|
||||
expires_at: Utc::now() + ttl,
|
||||
};
|
||||
let session_id = random_session_token();
|
||||
self.store
|
||||
.store_session(&session_id, &session)
|
||||
.await
|
||||
.is_err()
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if let SessionSubject::Admin { admin_user_id } = &session.subject
|
||||
&& self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", admin_user_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
// 新身份验证成功后才撤销旧会话;撤销失败时不向浏览器提交新会话。
|
||||
if let Some(previous) = previous_session_id.filter(|value| !value.is_empty())
|
||||
&& self.logout(previous).await.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
Ok(LoginResult {
|
||||
session_id,
|
||||
expires_at,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
|
||||
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
|
||||
}
|
||||
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
|
||||
let session = self
|
||||
.store
|
||||
.delete_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if let Some(session) = session {
|
||||
let mut event = self.auth_audit("admin.logout", Utc::now());
|
||||
event.actor_admin_user_id = Some(session.admin_user_id.clone());
|
||||
event.actor_ref = crate::model::auth::admin_session_actor_ref(&session.admin_user_id);
|
||||
event.entity_ref = session.admin_user_id;
|
||||
.map_err(|error| map_store_error(error, "authentication session"))?;
|
||||
if let Some(AuthSession {
|
||||
subject: SessionSubject::Admin { admin_user_id },
|
||||
..
|
||||
}) = session
|
||||
{
|
||||
self.store
|
||||
.append_audit_event(event)
|
||||
.append_audit_event(self.auth_audit("admin.logout", &admin_user_id))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator audit"))?;
|
||||
}
|
||||
@@ -194,6 +280,14 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_ttl(minutes: u64) -> Duration {
|
||||
Duration::minutes(
|
||||
i64::try_from(minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES),
|
||||
)
|
||||
}
|
||||
|
||||
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes())
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::ClientKeyStore,
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
@@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
|
||||
let plaintext = if let Some(key) = command.custom_key {
|
||||
key.expose_for_auth().to_owned()
|
||||
} else {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
};
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.create_client_key(
|
||||
@@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(CreatedClientKey {
|
||||
config_revision,
|
||||
@@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.update_client_key(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
.map_err(map_client_key_write_error)?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
@@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
|
||||
match error.kind() {
|
||||
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
|
||||
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
|
||||
_ => map_store_error(error, "client API key"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
let Some(cursor) = &query.cursor else {
|
||||
return Ok(());
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! 导入队列由 Host 管理生命周期,HTTP 只负责接受输入和读取快照。
|
||||
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
panic::AssertUnwindSafe,
|
||||
sync::{Arc, Mutex, MutexGuard},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{openai::OpenAiService, xai::XaiService};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationActor, MutationContext,
|
||||
import_tasks::{
|
||||
ImportItemStatus, ImportTaskCounts, ImportTaskDetail, ImportTaskInput, ImportTaskItem,
|
||||
ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
|
||||
},
|
||||
};
|
||||
|
||||
const CONCURRENCY: usize = 3;
|
||||
const MAX_ACTIVE_TASKS: usize = 8;
|
||||
const MAX_RETAINED_TASKS: usize = 100;
|
||||
const RETENTION: Duration = Duration::from_secs(3600);
|
||||
|
||||
pub trait ImportTasksService: Send + Sync {
|
||||
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError>;
|
||||
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary>;
|
||||
fn detail(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError>;
|
||||
fn stop(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError>;
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
task_id: Uuid,
|
||||
submission_id: Uuid,
|
||||
fingerprint: [u8; 32],
|
||||
owner: MutationActor,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
finished_at: Option<chrono::DateTime<Utc>>,
|
||||
finished_instant: Option<Instant>,
|
||||
stop_requested: bool,
|
||||
items: Vec<ImportTaskItem>,
|
||||
inputs: Vec<Option<ImportTaskInput>>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn summary(&self) -> ImportTaskSummary {
|
||||
let mut counts = ImportTaskCounts::default();
|
||||
for item in &self.items {
|
||||
match item.status {
|
||||
ImportItemStatus::Pending => counts.pending += 1,
|
||||
ImportItemStatus::Running => counts.running += 1,
|
||||
ImportItemStatus::Succeeded => counts.succeeded += 1,
|
||||
ImportItemStatus::Failed => counts.failed += 1,
|
||||
ImportItemStatus::Unknown => counts.unknown += 1,
|
||||
ImportItemStatus::Skipped => counts.skipped += 1,
|
||||
}
|
||||
counts.imported_accounts += item.account_ids.len();
|
||||
}
|
||||
ImportTaskSummary {
|
||||
task_id: self.task_id,
|
||||
created_at: self.created_at,
|
||||
finished_at: self.finished_at,
|
||||
stop_requested: self.stop_requested,
|
||||
total: self.items.len(),
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
fn detail(&self) -> ImportTaskDetail {
|
||||
ImportTaskDetail {
|
||||
summary: self.summary(),
|
||||
items: self.items.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_if_done(&mut self) {
|
||||
if self.finished_at.is_none()
|
||||
&& self.items.iter().all(|item| {
|
||||
!matches!(
|
||||
item.status,
|
||||
ImportItemStatus::Pending | ImportItemStatus::Running
|
||||
)
|
||||
})
|
||||
{
|
||||
self.finished_at = Some(Utc::now());
|
||||
self.finished_instant = Some(Instant::now());
|
||||
// 终态仅保留结果,连已清空的输入槽位分配也一并释放。
|
||||
self.inputs = Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&mut self) {
|
||||
if self.finished_at.is_some() {
|
||||
return;
|
||||
}
|
||||
self.stop_requested = true;
|
||||
for (item, input) in self.items.iter_mut().zip(&mut self.inputs) {
|
||||
if item.status == ImportItemStatus::Pending {
|
||||
item.status = ImportItemStatus::Skipped;
|
||||
*input = None;
|
||||
}
|
||||
}
|
||||
self.finish_if_done();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
tasks: VecDeque<Entry>,
|
||||
shutting_down: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultImportTasksService {
|
||||
registry: Mutex<Registry>,
|
||||
notify: Notify,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
}
|
||||
|
||||
impl DefaultImportTasksService {
|
||||
pub(crate) fn new(openai: Arc<dyn OpenAiService>, xai: Arc<dyn XaiService>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
registry: Mutex::default(),
|
||||
notify: Notify::new(),
|
||||
openai,
|
||||
xai,
|
||||
})
|
||||
}
|
||||
|
||||
fn registry(&self) -> MutexGuard<'_, Registry> {
|
||||
let mut registry = self
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
registry.tasks.retain(|entry| {
|
||||
entry
|
||||
.finished_instant
|
||||
.is_none_or(|at| at.elapsed() < RETENTION)
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
fn take_next(&self) -> Option<(Uuid, usize, ImportTaskInput)> {
|
||||
let mut registry = self.registry();
|
||||
if registry.shutting_down {
|
||||
return None;
|
||||
}
|
||||
let task_index = registry
|
||||
.tasks
|
||||
.iter()
|
||||
.position(|entry| entry.inputs.iter().any(Option::is_some))?;
|
||||
// 轮转任务,避免大批次独占后续全部执行机会。
|
||||
let mut entry = registry.tasks.remove(task_index)?;
|
||||
let index = entry.inputs.iter().position(Option::is_some)?;
|
||||
let input = entry.inputs[index].take()?;
|
||||
entry.items[index].status = ImportItemStatus::Running;
|
||||
let id = entry.task_id;
|
||||
registry.tasks.push_back(entry);
|
||||
Some((id, index, input))
|
||||
}
|
||||
|
||||
async fn execute(&self, id: Uuid, index: usize, input: ImportTaskInput) {
|
||||
let operation = async {
|
||||
match input.provider.as_str() {
|
||||
"openai" => self.openai.import_document(input.command).await,
|
||||
"xai" => self.xai.import_document(input.command).await,
|
||||
_ => Err(AdminError::invalid("不支持的导入平台")),
|
||||
}
|
||||
};
|
||||
let result = AssertUnwindSafe(operation).catch_unwind().await;
|
||||
let mut registry = self.registry();
|
||||
let Some(entry) = registry.tasks.iter_mut().find(|entry| entry.task_id == id) else {
|
||||
return;
|
||||
};
|
||||
let item = &mut entry.items[index];
|
||||
match result {
|
||||
Ok(Ok(result)) => {
|
||||
item.status = ImportItemStatus::Succeeded;
|
||||
item.account_ids = result.credential_ids;
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
// 存储或发布异常也可能发生在提交之后;不自动重放可能轮换 RT 的操作。
|
||||
item.status = if matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::UpstreamResultUnknown
|
||||
| AdminErrorKind::Internal
|
||||
| AdminErrorKind::Unavailable
|
||||
) {
|
||||
ImportItemStatus::Unknown
|
||||
} else {
|
||||
ImportItemStatus::Failed
|
||||
};
|
||||
item.message = Some(error.message().to_owned());
|
||||
}
|
||||
Err(_) => {
|
||||
item.status = ImportItemStatus::Unknown;
|
||||
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
|
||||
}
|
||||
}
|
||||
entry.finish_if_done();
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportTasksService for DefaultImportTasksService {
|
||||
fn submit(&self, command: SubmitImportTask) -> Result<ImportTaskSummary, AdminError> {
|
||||
if command.items.is_empty()
|
||||
|| command.items.len() > MAX_IMPORT_TASK_ITEMS
|
||||
|| command.items.iter().any(|item| {
|
||||
!matches!(item.provider.as_str(), "openai" | "xai")
|
||||
|| item.command.context.actor != command.context.actor
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("导入任务需包含 1–200 条有效输入"));
|
||||
}
|
||||
let mut registry = self.registry();
|
||||
if let Some(existing) = registry.tasks.iter().find(|entry| {
|
||||
entry.owner == command.context.actor && entry.submission_id == command.submission_id
|
||||
}) {
|
||||
return if existing.fingerprint == command.fingerprint {
|
||||
Ok(existing.summary())
|
||||
} else {
|
||||
Err(AdminError::new(
|
||||
AdminErrorKind::Conflict,
|
||||
"提交标识已用于其他导入内容",
|
||||
))
|
||||
};
|
||||
}
|
||||
if registry.shutting_down {
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::Unavailable,
|
||||
"服务正在关闭,暂不接受导入任务",
|
||||
));
|
||||
}
|
||||
if registry
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|entry| entry.finished_at.is_none())
|
||||
.count()
|
||||
>= MAX_ACTIVE_TASKS
|
||||
|| registry.tasks.len() >= MAX_RETAINED_TASKS
|
||||
{
|
||||
return Err(AdminError::new(
|
||||
AdminErrorKind::RateLimited,
|
||||
"导入任务已满,请稍后再试",
|
||||
));
|
||||
}
|
||||
let items = command
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, input)| ImportTaskItem {
|
||||
index: index + 1,
|
||||
provider: input.provider.clone(),
|
||||
status: ImportItemStatus::Pending,
|
||||
account_ids: Vec::new(),
|
||||
message: None,
|
||||
})
|
||||
.collect();
|
||||
let entry = Entry {
|
||||
task_id: Uuid::now_v7(),
|
||||
submission_id: command.submission_id,
|
||||
fingerprint: command.fingerprint,
|
||||
owner: command.context.actor,
|
||||
created_at: Utc::now(),
|
||||
finished_at: None,
|
||||
finished_instant: None,
|
||||
stop_requested: false,
|
||||
items,
|
||||
inputs: command.items.into_iter().map(Some).collect(),
|
||||
};
|
||||
let summary = entry.summary();
|
||||
registry.tasks.push_back(entry);
|
||||
drop(registry);
|
||||
self.notify.notify_one();
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
fn list(&self, context: &MutationContext) -> Vec<ImportTaskSummary> {
|
||||
let mut tasks: Vec<_> = self
|
||||
.registry()
|
||||
.tasks
|
||||
.iter()
|
||||
.filter(|entry| entry.owner == context.actor)
|
||||
.map(Entry::summary)
|
||||
.collect();
|
||||
tasks.sort_by_key(|entry| std::cmp::Reverse(entry.task_id));
|
||||
tasks
|
||||
}
|
||||
|
||||
fn detail(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError> {
|
||||
self.registry()
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
|
||||
.map(Entry::detail)
|
||||
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))
|
||||
}
|
||||
|
||||
fn stop(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
task_id: Uuid,
|
||||
) -> Result<ImportTaskDetail, AdminError> {
|
||||
let mut registry = self.registry();
|
||||
let entry = registry
|
||||
.tasks
|
||||
.iter_mut()
|
||||
.find(|entry| entry.task_id == task_id && entry.owner == context.actor)
|
||||
.ok_or_else(|| AdminError::not_found("导入任务不存在或已过期"))?;
|
||||
entry.stop();
|
||||
Ok(entry.detail())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
|
||||
|
||||
impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let mut active = FuturesUnordered::new();
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter。
|
||||
let cancelled = cancellation.cancelled();
|
||||
tokio::pin!(cancelled);
|
||||
let mut cleanup = tokio::time::interval(Duration::from_secs(60));
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
let mut registry = self.0.registry();
|
||||
registry.shutting_down = true;
|
||||
for entry in &mut registry.tasks {
|
||||
entry.stop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
while active.len() < CONCURRENCY {
|
||||
let Some((id, index, input)) = self.0.take_next() else {
|
||||
break;
|
||||
};
|
||||
active.push(self.0.execute(id, index, input));
|
||||
}
|
||||
tokio::select! {
|
||||
() = &mut cancelled => {},
|
||||
_ = active.next(), if !active.is_empty() => {},
|
||||
() = self.0.notify.notified() => {},
|
||||
_ = cleanup.tick() => { drop(self.0.registry()); },
|
||||
}
|
||||
}
|
||||
// Host 提供总关闭预算;此处让已发出的交换与入库尽量完成。
|
||||
while active.next().await.is_some() {}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Key 自助查询只从统一会话取得范围,复用现有观测和额度账本。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
AuthService,
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
auth::SessionSubject,
|
||||
key_usage::{
|
||||
KeyUsageOverview, KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecords,
|
||||
KeyUsageRecordsQuery,
|
||||
},
|
||||
observability::{
|
||||
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
|
||||
},
|
||||
},
|
||||
ports::store::{ClientKeyStore, ObservabilityStore},
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
|
||||
use super::{map_store_error, observability::health_timeline_at};
|
||||
|
||||
#[async_trait]
|
||||
pub trait KeyUsageService: Send + Sync {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError>;
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultKeyUsageService {
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
}
|
||||
|
||||
impl DefaultKeyUsageService {
|
||||
pub(crate) fn new(
|
||||
auth: Arc<dyn AuthService>,
|
||||
keys: Arc<dyn ClientKeyStore>,
|
||||
observations: Arc<dyn ObservabilityStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth,
|
||||
keys,
|
||||
observations,
|
||||
}
|
||||
}
|
||||
|
||||
async fn key_id(&self, session_id: Option<&str>) -> Result<Option<ClientApiKeyId>, AdminError> {
|
||||
match self
|
||||
.auth
|
||||
.session(session_id)
|
||||
.await?
|
||||
.map(|session| session.subject)
|
||||
{
|
||||
Some(SessionSubject::Key { client_key_id }) => Ok(Some(client_key_id)),
|
||||
Some(SessionSubject::Admin { .. }) => Err(AdminError::new(
|
||||
AdminErrorKind::Forbidden,
|
||||
"当前身份无权访问密钥用量接口",
|
||||
)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
|
||||
UsageFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model,
|
||||
..UsageFilter::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KeyUsageService for DefaultKeyUsageService {
|
||||
async fn overview(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageQuery,
|
||||
) -> Result<Option<KeyUsageOverview>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(key) = self
|
||||
.keys
|
||||
.get_client_key(&id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage profile"))?
|
||||
.filter(|key| key.enabled)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = usage_filter(&id, query.model);
|
||||
let now = Utc::now();
|
||||
// 健康条始终展示北京时间今日,不随历史范围或模型筛选改变。
|
||||
let today = TimeRange {
|
||||
start: china_day_start(now),
|
||||
end: now,
|
||||
};
|
||||
let (overview, trend, health_points) = futures::try_join!(
|
||||
self.observations.usage_summary(query.range, filter.clone()),
|
||||
self.observations.usage_trend(query.range, filter),
|
||||
self.observations
|
||||
.usage_trend(today, usage_filter(&id, None)),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "key usage overview"))?;
|
||||
Ok(Some(KeyUsageOverview {
|
||||
key,
|
||||
overview,
|
||||
trend,
|
||||
health_timeline: health_timeline_at(&health_points, now),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn records(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
query: KeyUsageRecordsQuery,
|
||||
) -> Result<Option<KeyUsageRecords>, AdminError> {
|
||||
let Some(id) = self.key_id(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let result = match query.kind {
|
||||
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
|
||||
self.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage records"))?,
|
||||
),
|
||||
KeyUsageRecordKind::Error => KeyUsageRecords::Error(
|
||||
self.observations
|
||||
.list_ops_errors(OpsErrorQuery {
|
||||
range: query.usage.range,
|
||||
filter: OpsErrorFilter {
|
||||
client_api_key_ref: Some(id.as_str().to_owned()),
|
||||
model: query.usage.model,
|
||||
..OpsErrorFilter::default()
|
||||
},
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "key usage errors"))?,
|
||||
),
|
||||
};
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod proxies;
|
||||
@@ -13,6 +15,8 @@ pub mod settings;
|
||||
pub mod system;
|
||||
pub mod xai;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
@@ -22,7 +26,7 @@ use crate::{
|
||||
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
StartAuthorization,
|
||||
ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -40,9 +44,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
AdminStoreErrorKind::StaleRevision
|
||||
| AdminStoreErrorKind::DuplicateName
|
||||
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
|
||||
@@ -97,6 +101,43 @@ async fn publish_committed(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_credentials_and_observe_quota(
|
||||
provider: &Arc<dyn ProviderAdmin>,
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
provider.account_facts_changed(account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果。
|
||||
let provider = Arc::clone(provider);
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn required_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
@@ -305,12 +346,19 @@ async fn commit_authorization(
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
settings: Option<crate::model::accounts::UpdateAccount>,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -332,7 +380,13 @@ async fn commit_credential_refresh(
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
prepared: facts,
|
||||
settings: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
|
||||
@@ -852,7 +852,10 @@ impl DefaultObservabilityService {
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
|
||||
pub(super) fn health_timeline_at(
|
||||
records: &[RequestMetricPoint],
|
||||
now: DateTime<Utc>,
|
||||
) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
|
||||
@@ -3,17 +3,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{account::ProviderAccountId, runtime::SnapshotControl};
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
|
||||
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
|
||||
ProviderQuotaRequest, RotateCredential, StartAuthorization,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -21,18 +20,14 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
|
||||
map_store_error, pending_authorization, publish_committed, required_credential,
|
||||
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
|
||||
map_store_error, pending_authorization, publish_committed,
|
||||
publish_credentials_and_observe_quota, required_credential, validate_authorization_commit,
|
||||
validate_prepared_import, validate_prepared_rotation,
|
||||
};
|
||||
|
||||
/// OpenAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait OpenAiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -77,55 +72,10 @@ impl DefaultOpenAiService {
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
|
||||
let provider = Arc::clone(&self.provider);
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"OpenAI initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OpenAiService for DefaultOpenAiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential"))
|
||||
}
|
||||
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
account_id,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -167,12 +117,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
drop(proxy_reservation);
|
||||
self.provider
|
||||
.account_facts_changed(&result.credential_ids)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
|
||||
self.observe_initial_quotas(&result.credential_ids, &context.request_id);
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -220,14 +172,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
self.observe_initial_quotas(
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
);
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -237,6 +189,17 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let account_id = command.mutation.account_id;
|
||||
if command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.account_id != account_id.as_str())
|
||||
{
|
||||
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
|
||||
}
|
||||
let disable_account = command
|
||||
.settings
|
||||
.as_ref()
|
||||
.is_some_and(|settings| !settings.enabled);
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
@@ -257,10 +220,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
command.settings,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if disable_account {
|
||||
self.provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
|
||||
@@ -181,6 +181,11 @@ impl ProxiesService for DefaultProxiesService {
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
command.location = command
|
||||
.location
|
||||
.map(|location| location.normalized())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
let result = self
|
||||
.store
|
||||
.create(command, context)
|
||||
@@ -196,6 +201,11 @@ impl ProxiesService for DefaultProxiesService {
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
command.location = command
|
||||
.location
|
||||
.map(|location| location.map(|value| value.normalized()).transpose())
|
||||
.transpose()
|
||||
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
|
||||
let result = self
|
||||
.store
|
||||
.update(command, context)
|
||||
|
||||
@@ -114,15 +114,23 @@ impl SettingsService for DefaultSettingsService {
|
||||
}
|
||||
|
||||
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
|
||||
let valid = command.refresh_margin_seconds > 0
|
||||
let valid = command.request_location.validate().is_ok()
|
||||
&& command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_concurrent_per_account > 0
|
||||
&& command.max_waiting_per_key <= 1_000
|
||||
&& command.max_waiting_per_account <= 1_000
|
||||
&& (1..=120).contains(&command.concurrency_wait_timeout_seconds)
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok();
|
||||
&& valid_probe_model(command.account_auto_freeze_probe_model.as_deref())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok()
|
||||
&& (2..=1_000).contains(&command.account_auto_freeze_threshold)
|
||||
&& (60..=3_600).contains(&command.account_auto_freeze_window_seconds)
|
||||
&& (300..=604_800).contains(&command.account_auto_freeze_duration_seconds);
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -133,3 +141,12 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
fn valid_client_version(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
|
||||
}
|
||||
|
||||
fn valid_probe_model(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value == value.trim()
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutationResult, CredentialPage, ImportCredentials,
|
||||
PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
|
||||
CredentialMutationResult, ImportCredentials, PrepareCredentialImport,
|
||||
StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -20,14 +20,13 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
commit_authorization, delete_credentials, map_provider_error, map_store_error,
|
||||
pending_authorization, publish_committed, validate_authorization_commit,
|
||||
validate_prepared_import,
|
||||
pending_authorization, publish_committed, publish_credentials_and_observe_quota,
|
||||
validate_authorization_commit, validate_prepared_import,
|
||||
};
|
||||
|
||||
/// xAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait XaiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -72,13 +71,6 @@ impl DefaultXaiService {
|
||||
|
||||
#[async_trait]
|
||||
impl XaiService for DefaultXaiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential"))
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
@@ -120,18 +112,14 @@ impl XaiService for DefaultXaiService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
drop(proxy_reservation);
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
|
||||
for account_id in &result.credential_ids {
|
||||
let _ = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
&result.credential_ids,
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -179,7 +167,14 @@ impl XaiService for DefaultXaiService {
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
publish_credentials_and_observe_quota(
|
||||
&self.provider,
|
||||
self.snapshot.as_ref(),
|
||||
result.config_revision,
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -633,22 +633,39 @@ impl AuthStore for FakeAuthStore {
|
||||
async fn load_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn store_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_session: &gateway_admin::model::auth::AdminSession,
|
||||
_session: &gateway_admin::model::auth::AuthSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audit.lock().expect("audit").push(event);
|
||||
Ok(())
|
||||
|
||||
@@ -23,6 +23,14 @@ mod backup;
|
||||
mod model;
|
||||
mod use_case;
|
||||
|
||||
#[test]
|
||||
fn client_config_should_default_to_a_one_day_session() {
|
||||
assert_eq!(
|
||||
gateway_admin::ClientConfig::default().session_ttl_minutes,
|
||||
1440
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_should_reject_zero() {
|
||||
assert!(Revision::new(0).is_err());
|
||||
@@ -88,7 +96,7 @@ fn admin_api_key_debug_should_redact_plaintext() {
|
||||
|
||||
#[test]
|
||||
fn login_command_debug_should_redact_password() {
|
||||
let command = LoginCommand {
|
||||
let command = LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "secret-password".to_owned(),
|
||||
};
|
||||
@@ -157,7 +165,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
|
||||
fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
|
||||
let usage = AccountUsage {
|
||||
account_id: "acct_xai".to_owned(),
|
||||
request_count: 5,
|
||||
@@ -194,14 +202,14 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: Some(usage.clone()),
|
||||
local_usage: Some(usage),
|
||||
provider_data: None,
|
||||
}],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_window_usage(), Some(&usage));
|
||||
assert_eq!(quota.usage_window(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
|
||||
mod backup;
|
||||
mod provider_credentials;
|
||||
mod quota_forecast;
|
||||
mod quota_forecast_sampling;
|
||||
|
||||
@@ -1,12 +1,135 @@
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
|
||||
AccountUsagePeriod, AuthorizationMutationTarget, AuthorizationOwnerBinding,
|
||||
PendingAuthorizationMutation, ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole,
|
||||
QuotaLocalUsageAttribution,
|
||||
};
|
||||
use gateway_admin::model::{MutationActor, MutationContext};
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_prefer_weekly_without_changing_dashboard_selection() {
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("short", "shortTerm", 18_000),
|
||||
usage_window("month", "monthly", 30 * 86_400),
|
||||
usage_window("week", "shortTerm", 7 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("week", AccountUsagePeriod::Weekly))
|
||||
);
|
||||
assert_eq!(
|
||||
quota
|
||||
.representative_window()
|
||||
.map(|window| window.key.as_str()),
|
||||
Some("short")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_use_monthly_when_weekly_is_unavailable() {
|
||||
let mut model_week = usage_window("model-week", "shortTerm", 7 * 86_400);
|
||||
model_week.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let mut incomplete_week = usage_window("incomplete-week", "shortTerm", 7 * 86_400);
|
||||
incomplete_week.reset_at = None;
|
||||
let mut unknown_usage = usage_window("unknown-week", "shortTerm", 7 * 86_400);
|
||||
unknown_usage.local_usage = None;
|
||||
let quota = usage_quota(vec![
|
||||
model_week,
|
||||
incomplete_week,
|
||||
unknown_usage,
|
||||
usage_window("month", "monthly", 30 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("month", AccountUsagePeriod::Monthly))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_not_fallback_to_short_daily_or_incomplete_windows() {
|
||||
let mut missing_duration = usage_window("unknown-month", "monthly", 30 * 86_400);
|
||||
missing_duration.window_seconds = None;
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("short", "shortTerm", 18_000),
|
||||
usage_window("day", "shortTerm", 86_400),
|
||||
usage_window("invalid-month", "monthly", 86_400),
|
||||
missing_duration,
|
||||
]);
|
||||
|
||||
assert_eq!(quota.usage_window(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_window_should_preserve_provider_order_for_equal_periods() {
|
||||
let quota = usage_quota(vec![
|
||||
usage_window("first-week", "shortTerm", 7 * 86_400),
|
||||
usage_window("second-week", "shortTerm", 7 * 86_400),
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
selected_usage_window("a),
|
||||
Some(("first-week", AccountUsagePeriod::Weekly))
|
||||
);
|
||||
}
|
||||
|
||||
fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePeriod)> {
|
||||
quota
|
||||
.usage_window()
|
||||
.map(|(window, period)| (window.key.as_str(), period))
|
||||
}
|
||||
|
||||
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
|
||||
ProviderQuotaWindow {
|
||||
key: key.to_owned(),
|
||||
group: group.to_owned(),
|
||||
label: key.to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(seconds),
|
||||
used_percent: Some(50.0),
|
||||
reset_at: Some(chrono::Utc::now()),
|
||||
limit_reached: false,
|
||||
local_usage: Some(gateway_admin::model::accounts::AccountUsage {
|
||||
account_id: "acct_test".to_owned(),
|
||||
request_count: 0,
|
||||
success_count: 0,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: None,
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: None,
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}),
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::{
|
||||
accounts::{AccountCost, AccountUsage},
|
||||
observability::CostCoverage,
|
||||
provider_credentials::{ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution},
|
||||
quota_forecast::{AccountQuotaForecast, account_quota_forecasts},
|
||||
quota_forecast_sampling::{QuotaForecastMethod, QuotaForecastSample, QuotaForecastUsage},
|
||||
};
|
||||
|
||||
fn now() -> DateTime<Utc> {
|
||||
"2026-09-12T00:00:00Z".parse().unwrap()
|
||||
}
|
||||
|
||||
fn window(key: &str, days: u64) -> ProviderQuotaWindow {
|
||||
ProviderQuotaWindow {
|
||||
key: key.to_owned(),
|
||||
group: if days > 7 { "monthly" } else { "shortTerm" }.to_owned(),
|
||||
label: key.to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(days * 86_400),
|
||||
used_percent: Some(20.0),
|
||||
reset_at: Some(now() + Duration::days(1)),
|
||||
limit_reached: false,
|
||||
local_usage: Some(AccountUsage {
|
||||
account_id: "acct_forecast".to_owned(),
|
||||
request_count: 10,
|
||||
success_count: 10,
|
||||
input_tokens: Some(900),
|
||||
output_tokens: Some(100),
|
||||
cached_tokens: Some(800),
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(1_000),
|
||||
cost_coverage: CostCoverage {
|
||||
calculated_count: 10,
|
||||
..Default::default()
|
||||
},
|
||||
costs: vec![AccountCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: "2".parse().unwrap(),
|
||||
}],
|
||||
last_used_at: Some(now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}),
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
plan_type: None,
|
||||
observed_at: Some(now()),
|
||||
refresh_token_expires_at: None,
|
||||
windows,
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn forecast(quota: &ProviderQuota) -> [AccountQuotaForecast; 2] {
|
||||
account_quota_forecasts(quota, now() - Duration::days(60), now(), &samples(quota))
|
||||
}
|
||||
|
||||
fn samples(quota: &ProviderQuota) -> Vec<QuotaForecastSample> {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter_map(|window| {
|
||||
let usage = window.local_usage.as_ref()?;
|
||||
let usd = usage.costs.iter().find(|cost| cost.currency == "USD");
|
||||
Some(QuotaForecastSample {
|
||||
key: window.key.clone(),
|
||||
method: QuotaForecastMethod::Cumulative,
|
||||
start_at: window.reset_at?.checked_sub_signed(Duration::try_seconds(
|
||||
i64::try_from(window.window_seconds?).ok()?,
|
||||
)?)?,
|
||||
end_at: quota.observed_at.unwrap_or(now()),
|
||||
baseline_percent: 0.0,
|
||||
sampled_percent: window.used_percent.unwrap_or(0.0),
|
||||
block_count: 0,
|
||||
observation_count: 0,
|
||||
pending_request_count: 0,
|
||||
discontinuous: false,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: usage.request_count,
|
||||
tokens: usage.total_tokens.unwrap_or(0),
|
||||
input_tokens: usage.input_tokens.unwrap_or(0),
|
||||
output_tokens: usage.output_tokens.unwrap_or(0),
|
||||
cached_tokens: usage.cached_tokens.unwrap_or(0),
|
||||
missing_token_count: u64::from(usage.total_tokens.is_none()),
|
||||
known_cost_count: if usd.is_some() {
|
||||
usage.cost_coverage.known_count()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
unavailable_cost_count: usage.cost_coverage.partial_count
|
||||
+ usage.cost_coverage.unavailable_count,
|
||||
usd: usd.map_or(0.0, |cost| cost.amount.as_str().parse().unwrap()),
|
||||
excluded_request_count: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecasts_use_actual_periods_and_do_not_extrapolate_remaining_capacity() {
|
||||
let [week, month] = forecast("a(vec![window("week", 7)]));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert!(!week.extrapolated);
|
||||
assert!(month.extrapolated);
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert!((month.estimated_usd.unwrap() - 10.0 * 30.0 / 7.0).abs() < 1e-10);
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corresponding_actual_window_takes_priority_and_retains_its_duration() {
|
||||
let [week, month] = forecast("a(vec![window("month", 31), window("week", 7)]));
|
||||
assert_eq!(week.source.unwrap().label, "week");
|
||||
assert_eq!(month.source.unwrap().label, "month");
|
||||
assert!(!month.extrapolated);
|
||||
assert_eq!(month.target_seconds, 31 * 86_400);
|
||||
assert_eq!(month.estimated_tokens, Some(5_000));
|
||||
let [week, _] = forecast("a(vec![window("month", 31)]));
|
||||
assert!(week.extrapolated);
|
||||
assert_eq!(week.estimated_tokens, Some(1_129));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_or_model_specific_windows_are_not_account_capacity() {
|
||||
let mut model = window("model", 7);
|
||||
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
for item in forecast("a(vec![window("day", 1), model])) {
|
||||
assert!(item.source.is_none());
|
||||
assert!(item.unavailable_reason.is_some());
|
||||
assert!(item.estimated_tokens.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_tiny_percentages_cannot_produce_estimates() {
|
||||
for percent in [
|
||||
None,
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
Some(-1.0),
|
||||
Some(101.0),
|
||||
Some(0.0),
|
||||
Some(0.5),
|
||||
Some(1.0),
|
||||
Some(4.9),
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.used_percent = percent;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.unavailable_reason.is_some(), "{percent:?}");
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert!(week.estimated_usd.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_samples_warn_and_exhaustion_uses_raw_not_display_percentages() {
|
||||
let mut sample = window("week", 7);
|
||||
sample.used_percent = Some(5.0);
|
||||
sample.limit_reached = true;
|
||||
let mut source = quota(vec![sample]);
|
||||
source.limit_reached = true;
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(week.low_sample);
|
||||
assert_eq!(week.estimated_tokens, Some(20_000));
|
||||
source.windows[0].used_percent = Some(100.0);
|
||||
let [week, _] = forecast(&source);
|
||||
assert!(!week.low_sample);
|
||||
assert_eq!(week.remaining_tokens, Some(0));
|
||||
assert_eq!(week.remaining_usd, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_future_or_missing_snapshots_do_not_predict() {
|
||||
for observed in [
|
||||
None,
|
||||
Some(now() - Duration::days(8)),
|
||||
Some(now() + Duration::seconds(1)),
|
||||
] {
|
||||
let mut source = quota(vec![window("week", 7)]);
|
||||
source.observed_at = observed;
|
||||
assert!(forecast(&source)[0].unavailable_reason.is_some());
|
||||
}
|
||||
let mut sample = window("week", 7);
|
||||
sample.reset_at = Some(now());
|
||||
assert!(
|
||||
forecast("a(vec![sample]))[0]
|
||||
.unavailable_reason
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_account_history_or_missing_usage_keep_diagnostics_without_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let [week, _] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &samples(&source));
|
||||
assert!(week.unavailable_reason.unwrap().contains("记录不完整"));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage = None;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.source.is_some());
|
||||
assert!(
|
||||
week.unavailable_reason
|
||||
.unwrap()
|
||||
.contains("没有网关用量记录")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_sample_supports_mid_cycle_accounts_and_remaining_uses_current_percent() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.method = QuotaForecastMethod::Incremental;
|
||||
sample.start_at = now() - Duration::hours(6);
|
||||
sample.baseline_percent = 10.0;
|
||||
sample.sampled_percent = 10.0;
|
||||
sample.block_count = 2;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(1), now(), &[sample]);
|
||||
assert_eq!(week.estimated_tokens, Some(10_000));
|
||||
assert_eq!(week.remaining_tokens, Some(8_000));
|
||||
assert_eq!(month.remaining_tokens, Some(8_000));
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert!(!week.low_sample);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_keep_estimates_from_recorded_usage() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage.missing_token_count = 1;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.source.unwrap().tokens, Some(1_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discontinuous_samples_never_fall_back_to_cumulative_predictions() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.discontinuous = true;
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.unwrap().contains("不连续"));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_costs_keep_estimates_from_known_amounts() {
|
||||
for coverage in [
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
partial_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
CostCoverage {
|
||||
calculated_count: 9,
|
||||
unavailable_count: 1,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().cost_coverage = coverage;
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_tokens_and_costs_do_not_block_recorded_capacity_or_remaining_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
// 完整请求之外混有缺少计量的成功请求,不把已有用量和费用一并作废。
|
||||
sample.usage.request_count += 2;
|
||||
sample.usage.missing_token_count = 2;
|
||||
sample.usage.unavailable_cost_count = 2;
|
||||
sample.usage.excluded_request_count = 1;
|
||||
sample.pending_request_count = 1;
|
||||
let [week, month] =
|
||||
account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.incomplete_tokens);
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
assert_eq!(week.remaining_tokens, Some(4_000));
|
||||
assert_eq!(week.remaining_usd, Some(8.0));
|
||||
assert_eq!(month.estimated_tokens, Some(21_429));
|
||||
assert_eq!(month.remaining_tokens, week.remaining_tokens);
|
||||
assert_eq!(month.remaining_usd, week.remaining_usd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_costs_leave_only_money_estimates_unavailable() {
|
||||
let mut sample = window("week", 7);
|
||||
let usage = sample.local_usage.as_mut().unwrap();
|
||||
usage.cost_coverage = CostCoverage {
|
||||
unavailable_count: usage.request_count,
|
||||
..Default::default()
|
||||
};
|
||||
usage.costs.clear();
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.incomplete_cost);
|
||||
assert!(week.unavailable_reason.is_none());
|
||||
assert_eq!(week.estimated_tokens, Some(5_000));
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entirely_unknown_tokens_and_costs_do_not_invent_zero_estimates() {
|
||||
let source = quota(vec![window("week", 7)]);
|
||||
let mut sample = samples(&source).remove(0);
|
||||
sample.usage = QuotaForecastUsage {
|
||||
request_count: 2,
|
||||
missing_token_count: 2,
|
||||
unavailable_cost_count: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let [week, _] = account_quota_forecasts(&source, now() - Duration::days(60), now(), &[sample]);
|
||||
assert!(week.unavailable_reason.is_some());
|
||||
assert_eq!(week.estimated_tokens, None);
|
||||
assert_eq!(week.estimated_usd, None);
|
||||
assert_eq!(week.remaining_tokens, None);
|
||||
assert_eq!(week.remaining_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_known_cost_is_valid_but_missing_usd_is_not_invented() {
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().costs[0].amount = "0".parse().unwrap();
|
||||
assert_eq!(
|
||||
forecast("a(vec![sample.clone()]))[0].estimated_usd,
|
||||
Some(0.0)
|
||||
);
|
||||
sample.local_usage.as_mut().unwrap().costs[0].currency = "EUR".to_owned();
|
||||
assert_eq!(forecast("a(vec![sample]))[0].estimated_usd, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_window_or_estimate_is_unavailable_without_panics_or_saturation() {
|
||||
let mut sample = window("month", 30);
|
||||
sample.window_seconds = Some(i64::MAX as u64);
|
||||
assert!(
|
||||
forecast("a(vec![sample]))[1]
|
||||
.unavailable_reason
|
||||
.is_some()
|
||||
);
|
||||
let mut sample = window("week", 7);
|
||||
sample.local_usage.as_mut().unwrap().total_tokens = Some(u64::MAX);
|
||||
let [week, _] = forecast("a(vec![sample]));
|
||||
assert!(week.estimated_tokens.is_none());
|
||||
assert_eq!(week.estimated_usd, Some(10.0));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::quota_forecast_sampling::{
|
||||
QuotaForecastMethod, QuotaForecastPoint, QuotaForecastSample, QuotaForecastUsage,
|
||||
select_forecast_sample,
|
||||
};
|
||||
|
||||
fn time(minutes: i64) -> DateTime<Utc> {
|
||||
"2026-09-12T00:00:00Z".parse::<DateTime<Utc>>().unwrap() + Duration::minutes(minutes)
|
||||
}
|
||||
|
||||
fn point(minute: i64, percent: f64, tokens: u64) -> QuotaForecastPoint {
|
||||
QuotaForecastPoint {
|
||||
observed_at: time(minute),
|
||||
used_percent: percent,
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: minute as u64,
|
||||
tokens,
|
||||
input_tokens: tokens,
|
||||
known_cost_count: minute as u64,
|
||||
usd: tokens as f64 / 1_000.0,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn select(points: Vec<QuotaForecastPoint>, current: QuotaForecastPoint) -> QuotaForecastSample {
|
||||
select_forecast_sample("week".to_owned(), time(0), current, points, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_readings_do_not_make_independent_blocks_or_shift_the_baseline() {
|
||||
let points = (1..100)
|
||||
.map(|minute| point(minute, 20.0, minute as u64 * 100))
|
||||
.collect();
|
||||
let sample = select(points, point(100, 25.0, 10_000));
|
||||
assert_eq!(sample.method, QuotaForecastMethod::Incremental);
|
||||
assert_eq!(sample.block_count, 1);
|
||||
assert_eq!(sample.start_at, time(1));
|
||||
assert_eq!(sample.sampled_percent, 5.0);
|
||||
assert_eq!(sample.usage.tokens, 9_900);
|
||||
assert_eq!(sample.pending_request_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_blocks_and_tail_replace_old_workload_without_averaging_individual_ratios() {
|
||||
let points = vec![
|
||||
point(1, 0.0, 0),
|
||||
point(2, 5.0, 100),
|
||||
point(3, 10.0, 200),
|
||||
point(4, 15.0, 400),
|
||||
point(5, 20.0, 600),
|
||||
point(6, 25.0, 800),
|
||||
];
|
||||
let sample = select(points, point(7, 27.0, 900));
|
||||
assert_eq!(sample.baseline_percent, 10.0);
|
||||
assert_eq!(sample.sampled_percent, 17.0);
|
||||
assert_eq!(sample.usage.tokens, 700);
|
||||
assert_eq!(sample.block_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_at_a_block_boundary_keeps_only_three_recent_blocks() {
|
||||
let points = (0..6)
|
||||
.map(|i| point(i + 1, i as f64 * 5.0, i as u64 * 100))
|
||||
.collect();
|
||||
let sample = select(points, point(7, 30.0, 600));
|
||||
assert_eq!(sample.baseline_percent, 15.0);
|
||||
assert_eq!(sample.sampled_percent, 15.0);
|
||||
assert_eq!(sample.usage.tokens, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_percentage_reversal_restarts_sampling_without_crossing_old_consumption() {
|
||||
let sample = select(
|
||||
vec![point(1, 40.0, 100), point(2, 50.0, 200), point(3, 1.0, 210)],
|
||||
point(4, 8.0, 280),
|
||||
);
|
||||
assert_eq!(sample.baseline_percent, 1.0);
|
||||
assert_eq!(sample.usage.tokens, 70);
|
||||
assert_eq!(sample.sampled_percent, 7.0);
|
||||
assert!(!sample.discontinuous);
|
||||
let waiting = select(
|
||||
vec![point(1, 40.0, 100), point(2, 50.0, 200)],
|
||||
point(3, 2.0, 210),
|
||||
);
|
||||
assert!(waiting.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_point_reversal_is_skipped_without_counting_an_extra_block() {
|
||||
let sample = select(
|
||||
vec![
|
||||
point(1, 10.0, 100),
|
||||
point(2, 15.0, 200),
|
||||
point(3, 14.0, 210),
|
||||
],
|
||||
point(4, 20.0, 300),
|
||||
);
|
||||
assert_eq!(sample.block_count, 2);
|
||||
assert_eq!(sample.usage.tokens, 200);
|
||||
assert!(!sample.discontinuous);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_token_and_cost_counts_are_local_to_the_selected_interval() {
|
||||
let mut baseline = point(1, 30.0, 1_000);
|
||||
baseline.usage.missing_token_count = 2;
|
||||
baseline.usage.unavailable_cost_count = 3;
|
||||
let mut end = point(3, 40.0, 2_000);
|
||||
end.usage.missing_token_count = 2;
|
||||
end.usage.unavailable_cost_count = 4;
|
||||
let sample = select(vec![baseline], end);
|
||||
assert_eq!(sample.usage.missing_token_count, 0);
|
||||
assert_eq!(sample.usage.unavailable_cost_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_regressions_fail_closed_and_future_points_are_not_used() {
|
||||
let invalid = select(vec![point(1, 10.0, 1_000)], point(2, 20.0, 500));
|
||||
assert!(invalid.discontinuous);
|
||||
let future = select(vec![point(10, 1.0, 1)], point(2, 20.0, 500));
|
||||
assert_eq!(future.method, QuotaForecastMethod::Cumulative);
|
||||
assert_eq!(future.observation_count, 0);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, PageSize, Revision,
|
||||
@@ -168,13 +168,35 @@ impl AccountRuntimeStore for FakeRuntimeStore {
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
*self.requested_accounts.lock().expect("requested accounts") = account_ids.to_vec();
|
||||
Ok(AccountRuntimeSnapshot {
|
||||
rate_limited_until: BTreeMap::from([(
|
||||
cooldown: BTreeMap::from([(
|
||||
"acct_limited".to_owned(),
|
||||
Utc::now() + Duration::minutes(5),
|
||||
std::time::SystemTime::from(Utc::now() + Duration::minutes(5)).into(),
|
||||
)]),
|
||||
in_flight: Some(BTreeMap::from([("acct_available".to_owned(), 2)])),
|
||||
})
|
||||
}
|
||||
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
_account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
_account_id: &str,
|
||||
_expected: &gateway_admin::model::accounts::AccountFreeze,
|
||||
_postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_record() -> AccountGroupRecord {
|
||||
@@ -215,7 +237,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::default(),
|
||||
rate_limited_until: None,
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
},
|
||||
|
||||
@@ -33,16 +33,18 @@ use gateway_admin::{
|
||||
AuthorizationCommit, AuthorizationCommitGuard, AuthorizationCredentialCommit,
|
||||
AuthorizationMutationTarget, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialCommitGuard, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
|
||||
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
@@ -65,16 +67,22 @@ pub(super) struct FakeProviderAdmin {
|
||||
kind: ProviderKind,
|
||||
events: EventLog,
|
||||
failure: Mutex<Option<ProviderAdminError>>,
|
||||
import_gate: Mutex<Option<Arc<tokio::sync::Semaphore>>>,
|
||||
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
|
||||
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
|
||||
retry_authorization_after_abort: Mutex<bool>,
|
||||
export_inputs: Mutex<Vec<ProviderExportCredentialInput>>,
|
||||
import_account_ids: Mutex<Vec<String>>,
|
||||
quota_requests: Mutex<Vec<ProviderQuotaRequest>>,
|
||||
quota_started: tokio::sync::Notify,
|
||||
quota_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
|
||||
quota: Mutex<ProviderQuota>,
|
||||
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
|
||||
current_credential_revision: Mutex<Revision>,
|
||||
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
|
||||
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
|
||||
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
|
||||
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
|
||||
}
|
||||
|
||||
impl FakeProviderAdmin {
|
||||
@@ -83,23 +91,43 @@ impl FakeProviderAdmin {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
events,
|
||||
failure: Mutex::new(None),
|
||||
import_gate: Mutex::new(None),
|
||||
quota_failure: Mutex::new(None),
|
||||
pending: Arc::new(Mutex::new(None)),
|
||||
retry_authorization_after_abort: Mutex::new(false),
|
||||
export_inputs: Mutex::new(Vec::new()),
|
||||
import_account_ids: Mutex::new(vec!["acct_prepared".to_owned()]),
|
||||
quota_requests: Mutex::new(Vec::new()),
|
||||
quota_started: tokio::sync::Notify::new(),
|
||||
quota_gate: Mutex::new(None),
|
||||
quota: Mutex::new(empty_quota()),
|
||||
quota_refresh_account: Mutex::new(None),
|
||||
current_credential_revision: Mutex::new(revision(1)),
|
||||
reset_credit_commands: Mutex::new(Vec::new()),
|
||||
profile_result: Mutex::new(Ok(empty_profile_statistics())),
|
||||
subscription_result: Mutex::new(Ok(None)),
|
||||
personal_info_barrier: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn block_imports(&self) -> Arc<tokio::sync::Semaphore> {
|
||||
let gate = Arc::new(tokio::sync::Semaphore::new(0));
|
||||
*self.import_gate.lock().expect("import gate") = Some(gate.clone());
|
||||
gate
|
||||
}
|
||||
|
||||
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
|
||||
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
|
||||
}
|
||||
|
||||
async fn wait_personal_info_queries(&self) {
|
||||
let barrier = self.personal_info_barrier.lock().unwrap().clone();
|
||||
if let Some(barrier) = barrier {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
|
||||
*self.failure.lock().expect("provider failure") =
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
@@ -138,6 +166,26 @@ impl FakeProviderAdmin {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn pause_next_quota(&self) -> tokio::sync::oneshot::Sender<()> {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
*self.quota_gate.lock().expect("quota gate") = Some(receiver);
|
||||
sender
|
||||
}
|
||||
|
||||
pub(super) async fn wait_for_quota_requests(&self, expected: usize) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
let notified = self.quota_started.notified();
|
||||
if self.quota_requests().len() >= expected {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("quota observation started");
|
||||
}
|
||||
|
||||
fn reset_credit_commands(&self) -> Vec<ConsumeProviderResetCredit> {
|
||||
self.reset_credit_commands
|
||||
.lock()
|
||||
@@ -195,6 +243,7 @@ impl FakeProviderAdmin {
|
||||
name: account.name.clone(),
|
||||
email: account.email.clone(),
|
||||
plan_type: account.plan_type.clone(),
|
||||
preserve_profile: false,
|
||||
provider_material: document(),
|
||||
has_refresh_token: account.has_refresh_token,
|
||||
access_token_expires_at: account
|
||||
@@ -213,6 +262,32 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn profile_statistics(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
|
||||
self.record("provider.profile_statistics");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.profile_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
async fn subscription(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
|
||||
self.record("provider.subscription");
|
||||
self.wait_personal_info_queries().await;
|
||||
self.subscription_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map_err(ProviderAdminError::new)
|
||||
}
|
||||
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
format!("{} display: {plan_type}", self.kind)
|
||||
}
|
||||
@@ -266,6 +341,10 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
_command: PrepareCredentialImport,
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
self.record("provider.prepare_import");
|
||||
let gate = self.import_gate.lock().expect("import gate").clone();
|
||||
if let Some(gate) = gate {
|
||||
gate.acquire().await.expect("import permit").forget();
|
||||
}
|
||||
self.require_available()?;
|
||||
let account_ids = self
|
||||
.import_account_ids
|
||||
@@ -377,6 +456,11 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
.lock()
|
||||
.expect("provider quota requests")
|
||||
.push(request);
|
||||
self.quota_started.notify_one();
|
||||
let gate = self.quota_gate.lock().expect("quota gate").take();
|
||||
if let Some(gate) = gate {
|
||||
gate.await.expect("release paused quota");
|
||||
}
|
||||
if let Some(kind) = self
|
||||
.quota_failure
|
||||
.lock()
|
||||
@@ -404,6 +488,21 @@ impl ProviderAdmin for FakeProviderAdmin {
|
||||
})
|
||||
}
|
||||
|
||||
fn quota_forecast_observation(
|
||||
&self,
|
||||
document: &ProviderDocument,
|
||||
_: &ProviderQuotaWindow,
|
||||
) -> Option<gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation> {
|
||||
let fields = document.expose_to_provider().expose_to_provider();
|
||||
Some(
|
||||
gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation {
|
||||
used_percent: fields.get("percent")?.as_f64()?,
|
||||
reset_at: fields.get("reset")?.as_str()?.parse().ok()?,
|
||||
plan_type: fields.get("plan")?.as_str().map(str::to_owned),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
@@ -444,6 +543,9 @@ pub(super) struct FakeAccountStore {
|
||||
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
|
||||
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
|
||||
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
|
||||
quota_forecast_history: Mutex<QuotaForecastHistory>,
|
||||
update_commands: Mutex<Vec<UpdateAccount>>,
|
||||
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
|
||||
}
|
||||
|
||||
impl FakeAccountStore {
|
||||
@@ -451,7 +553,7 @@ impl FakeAccountStore {
|
||||
Self::with_account(account_record(kind), events)
|
||||
}
|
||||
|
||||
fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
|
||||
pub(super) fn with_account(account: AccountRecord, events: EventLog) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
events,
|
||||
accounts: Mutex::new(vec![account]),
|
||||
@@ -461,9 +563,19 @@ impl FakeAccountStore {
|
||||
import_settings: Mutex::new(Vec::new()),
|
||||
quota_window_usage: Mutex::new(Vec::new()),
|
||||
quota_window_queries: Mutex::new(Vec::new()),
|
||||
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
|
||||
update_commands: Mutex::new(Vec::new()),
|
||||
lowered_limits: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn update_commands(&self) -> Vec<UpdateAccount> {
|
||||
self.update_commands
|
||||
.lock()
|
||||
.expect("update commands")
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn fail_next_commit(&self) {
|
||||
*self.fail_commit.lock().expect("store failure") = true;
|
||||
}
|
||||
@@ -529,7 +641,7 @@ impl FakeAccountStore {
|
||||
credential_state: account.credential_state,
|
||||
access_token_expires_at: account.access_token_expires_at.map(Into::into),
|
||||
quota: account.quota,
|
||||
rate_limited_until: None,
|
||||
cooldown: None,
|
||||
last_error_reason: account.last_error_reason,
|
||||
last_error_message: account.last_error_message.clone(),
|
||||
};
|
||||
@@ -612,21 +724,15 @@ impl AccountStore for FakeAccountStore {
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
async fn load_quota_forecast_history(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
_: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage> {
|
||||
self.record("store.list_credentials");
|
||||
let accounts = self.accounts.lock().expect("accounts").clone();
|
||||
Ok(CredentialPage {
|
||||
config_revision: revision(1),
|
||||
items: accounts
|
||||
.into_iter()
|
||||
.filter(|account| &account.provider_kind == provider_kind)
|
||||
.collect(),
|
||||
next_cursor: None,
|
||||
})
|
||||
window: &AccountUsageWindowQuery,
|
||||
) -> AdminStoreResult<QuotaForecastHistory> {
|
||||
self.quota_window_queries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(window.clone());
|
||||
Ok(self.quota_forecast_history.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn credential_details(
|
||||
@@ -757,12 +863,45 @@ impl AccountStore for FakeAccountStore {
|
||||
self.record("store.update_account");
|
||||
self.record_context(context);
|
||||
self.require_commit()?;
|
||||
self.update_commands
|
||||
.lock()
|
||||
.expect("update commands")
|
||||
.push(command.clone());
|
||||
Ok(AccountUpdateResult {
|
||||
config_revision: revision(2),
|
||||
account_id: ProviderAccountId::new(command.account_id).expect("account ID"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
limit: gateway_core::account::AccountConcurrencyLimit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<AccountUpdateResult>> {
|
||||
let mut accounts = self.accounts.lock().expect("accounts");
|
||||
let Some(account) = accounts
|
||||
.iter_mut()
|
||||
.find(|account| account.id == account_id.as_str())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !account.enabled
|
||||
|| account.concurrency_limit.map_or(5, |value| value.get()) <= limit.get()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
account.concurrency_limit = Some(limit);
|
||||
self.lowered_limits
|
||||
.lock()
|
||||
.expect("lowered limits")
|
||||
.push((account.id.clone(), limit.get()));
|
||||
Ok(Some(AccountUpdateResult {
|
||||
config_revision: revision(2),
|
||||
account_id: account_id.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -834,18 +973,30 @@ struct StaticSettingsStore;
|
||||
impl SettingsStore for StaticSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 30,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
account_auto_freeze_enabled: true,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
updated_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
@@ -1248,6 +1399,8 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
|
||||
.update(
|
||||
&context("update-request"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1285,6 +1438,8 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
|
||||
.update(
|
||||
&context("update-failure"),
|
||||
UpdateAccount {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
@@ -1327,12 +1482,13 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
enabled: Some(false),
|
||||
concurrency_limit: Some(None),
|
||||
weight: Some(gateway_core::account::AccountWeight::DEFAULT),
|
||||
group_ids: Some(Vec::new()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -1700,6 +1856,218 @@ async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_reads_raw_snapshot_and_limits_usage_to_observation_time() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let now = Utc::now();
|
||||
let observed = now - TimeDelta::hours(1);
|
||||
let reset = now + TimeDelta::days(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(observed),
|
||||
limit_reached: true,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "week".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "周额度".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(7 * 86_400),
|
||||
used_percent: Some(20.0),
|
||||
reset_at: Some(reset),
|
||||
limit_reached: true,
|
||||
// Provider 自带的统计不保证与快照同一时间,预测必须重新采样。
|
||||
local_usage: Some(quota_local_usage("acct_test", 999_999)),
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
});
|
||||
let mut account = account_record("openai");
|
||||
account.created_at = now - TimeDelta::days(60);
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
store.set_quota_window_usage(vec![AccountUsageWindowResult {
|
||||
account_id: "acct_test".to_owned(),
|
||||
key: "week".to_owned(),
|
||||
usage: quota_local_usage("acct_test", 1_000),
|
||||
}]);
|
||||
store.quota_forecast_history.lock().unwrap().usage = QuotaForecastUsage {
|
||||
request_count: 10,
|
||||
tokens: 1_000,
|
||||
known_cost_count: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let report = accounts_service(provider.clone(), store.clone())
|
||||
.await
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.expect("quota forecast");
|
||||
assert_eq!(report.account_id, "acct_test");
|
||||
assert_eq!(report.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(
|
||||
report.forecasts[0].source.as_ref().unwrap().observed_at,
|
||||
Some(observed)
|
||||
);
|
||||
let queries = store.quota_window_queries();
|
||||
assert_eq!(queries.len(), 1);
|
||||
assert_eq!(queries[0].range.start, reset - TimeDelta::days(7));
|
||||
assert_eq!(queries[0].range.end, observed);
|
||||
let requests = provider.quota_requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(!requests[0].refresh);
|
||||
assert!(requests[0].rolling_usage.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_does_not_query_usage_without_a_current_snapshot() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let report = accounts_service(provider.clone(), store.clone())
|
||||
.await
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.expect("empty forecast");
|
||||
assert!(
|
||||
report
|
||||
.forecasts
|
||||
.iter()
|
||||
.all(|item| item.estimated_tokens.is_none())
|
||||
);
|
||||
assert!(store.quota_window_queries().is_empty());
|
||||
assert!(!provider.quota_requests.lock().unwrap()[0].refresh);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_changed_bucket() {
|
||||
use gateway_admin::model::quota_forecast_sampling::QuotaForecastHistoryPoint;
|
||||
let now = Utc::now();
|
||||
let observed = now - TimeDelta::minutes(1);
|
||||
let reset = now + TimeDelta::days(1);
|
||||
let added = now - TimeDelta::hours(5);
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: Some("pro".to_owned()),
|
||||
observed_at: Some(observed),
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "week".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "周额度".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(604_800),
|
||||
used_percent: Some(40.0),
|
||||
reset_at: Some(reset),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
..empty_quota()
|
||||
});
|
||||
let mut account = account_record("openai");
|
||||
account.created_at = added;
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
let make_point = |hours, percent, tokens, delta| {
|
||||
QuotaForecastHistoryPoint {
|
||||
started_at: now - TimeDelta::hours(hours) - TimeDelta::seconds(10),
|
||||
completed_at: now - TimeDelta::hours(hours),
|
||||
usage: QuotaForecastUsage { request_count: tokens / 100, tokens, ..Default::default() },
|
||||
provider_observation: ProviderDocument::new(OpaqueProviderData::new(serde_json::json!({
|
||||
"percent": percent, "reset": (reset + TimeDelta::seconds(delta)).to_rfc3339(), "plan": "pro",
|
||||
}).as_object().unwrap().clone())),
|
||||
}
|
||||
};
|
||||
*store.quota_forecast_history.lock().unwrap() = QuotaForecastHistory {
|
||||
points: vec![
|
||||
make_point(3, 20.0, 1_000, 1),
|
||||
make_point(2, 30.0, 1_500, -1),
|
||||
],
|
||||
usage: QuotaForecastUsage {
|
||||
request_count: 20,
|
||||
tokens: 2_000,
|
||||
..Default::default()
|
||||
},
|
||||
pending_request_count: 1,
|
||||
};
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let result = services
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].unavailable_reason.is_none());
|
||||
assert_eq!(result.forecasts[0].estimated_tokens, Some(5_000));
|
||||
assert_eq!(result.forecasts[0].remaining_tokens, Some(3_000));
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added);
|
||||
store
|
||||
.quota_forecast_history
|
||||
.lock()
|
||||
.unwrap()
|
||||
.points
|
||||
.push(make_point(1, 35.0, 1_700, 3_600));
|
||||
let result = services
|
||||
.accounts()
|
||||
.quota_forecast(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.forecasts[0].estimated_tokens.is_none());
|
||||
assert!(
|
||||
result.forecasts[0]
|
||||
.unavailable_reason
|
||||
.unwrap()
|
||||
.contains("不连续")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let mut account = account_record("openai");
|
||||
account.authentication_kind = "api_key".to_owned();
|
||||
account.created_at = Utc::now() - TimeDelta::days(60);
|
||||
let added_at = account.created_at;
|
||||
let store = FakeAccountStore::with_account(account, events());
|
||||
store.set_quota_window_usage(vec![AccountUsageWindowResult {
|
||||
account_id: "acct_test".to_owned(),
|
||||
key: "account-lifetime".to_owned(),
|
||||
usage: quota_local_usage("acct_test", 4_330_000),
|
||||
}]);
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(page.items[0].quota.windows.is_empty());
|
||||
assert_eq!(
|
||||
page.items[0].usage.as_ref().unwrap().total_tokens,
|
||||
Some(4_330_000)
|
||||
);
|
||||
let queries = store.quota_window_queries();
|
||||
assert_eq!(queries.len(), 1);
|
||||
assert_eq!(queries[0].range.start, added_at);
|
||||
assert!(queries[0].range.end > added_at + TimeDelta::days(59));
|
||||
let detail = services
|
||||
.accounts()
|
||||
.quota(&ProviderAccountId::new("acct_test").unwrap(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail.usage, page.items[0].usage);
|
||||
assert!(detail.quota.windows.is_empty());
|
||||
assert_eq!(store.quota_window_queries()[0].range.start, added_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
@@ -1754,10 +2122,8 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
.as_ref()
|
||||
.expect("quota window local usage");
|
||||
assert_eq!(usage.total_tokens, Some(4_330_000));
|
||||
assert_eq!(
|
||||
item.usage.as_ref().and_then(|usage| usage.total_tokens),
|
||||
Some(4_330_000),
|
||||
);
|
||||
// 短期额度条保留自己的本地用量,但不作为周/月统计面板的回退。
|
||||
assert!(item.usage.is_none());
|
||||
|
||||
let queries = store.quota_window_queries();
|
||||
assert_eq!(queries.len(), 1);
|
||||
@@ -1767,6 +2133,98 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
assert_eq!(queries[0].range.end, reset_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthly_usage() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = accounts_service(provider.clone(), store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").expect("account id");
|
||||
let reset_at = Utc::now() + TimeDelta::days(1);
|
||||
let short = ProviderQuotaWindow {
|
||||
key: "short".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "5小时限额".to_owned(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(18_000),
|
||||
used_percent: Some(50.0),
|
||||
reset_at: Some(reset_at),
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
};
|
||||
let week = ProviderQuotaWindow {
|
||||
key: "week".to_owned(),
|
||||
label: "周额度".to_owned(),
|
||||
window_seconds: Some(7 * 86_400),
|
||||
..short.clone()
|
||||
};
|
||||
let month = ProviderQuotaWindow {
|
||||
key: "month".to_owned(),
|
||||
group: "monthly".to_owned(),
|
||||
label: "月额度".to_owned(),
|
||||
window_seconds: Some(30 * 86_400),
|
||||
..short.clone()
|
||||
};
|
||||
for (windows, selected_key, tokens, duration) in [
|
||||
(vec![short.clone(), month.clone(), week], "week", 700, 7),
|
||||
(vec![short, month], "month", 3000, 30),
|
||||
] {
|
||||
provider.set_quota(ProviderQuota {
|
||||
windows,
|
||||
..empty_quota()
|
||||
});
|
||||
store.set_quota_window_usage(
|
||||
[("short", 50), ("week", 700), ("month", 3000)]
|
||||
.into_iter()
|
||||
.map(|(key, total)| AccountUsageWindowResult {
|
||||
account_id: account_id.to_string(),
|
||||
key: key.to_owned(),
|
||||
usage: quota_local_usage(account_id.as_str(), total),
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let page = services
|
||||
.accounts()
|
||||
.list(AccountListQuery {
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
|
||||
provider_kind: None,
|
||||
group_filter: None,
|
||||
search: None,
|
||||
status: None,
|
||||
sort: None,
|
||||
})
|
||||
.await
|
||||
.expect("list accounts");
|
||||
let refreshed = services
|
||||
.accounts()
|
||||
.quota(&account_id, true)
|
||||
.await
|
||||
.expect("refresh quota");
|
||||
let expected = quota_local_usage(account_id.as_str(), tokens);
|
||||
for item in [&page.items[0], &refreshed] {
|
||||
let usage = item.usage.as_ref().expect("selected window usage");
|
||||
assert_eq!(usage.total_tokens, expected.total_tokens);
|
||||
assert_eq!(
|
||||
item.quota
|
||||
.usage_window()
|
||||
.map(|(window, _)| window.key.as_str()),
|
||||
Some(selected_key)
|
||||
);
|
||||
}
|
||||
let queries = store.quota_window_queries();
|
||||
let selected = queries
|
||||
.iter()
|
||||
.find(|query| query.key == selected_key)
|
||||
.expect("selected query");
|
||||
assert_eq!(selected.range.start, reset_at - TimeDelta::days(duration));
|
||||
assert_eq!(selected.range.end, reset_at);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
@@ -2062,6 +2520,8 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
id: "acct_test".to_owned(),
|
||||
provider_kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
@@ -2104,6 +2564,7 @@ fn prepared_create_with_id(
|
||||
) -> PreparedCredentialCreate {
|
||||
let now = Utc::now();
|
||||
PreparedCredentialCreate {
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
|
||||
provider_kind,
|
||||
@@ -2133,6 +2594,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_profile_statistics() -> ProviderProfileStatistics {
|
||||
ProviderProfileStatistics {
|
||||
display_name: Some("Preview user".to_owned()),
|
||||
username: None,
|
||||
image_url: None,
|
||||
has_stats_error: false,
|
||||
summary: ProviderProfileStatisticsSummary {
|
||||
total_text_tokens: Some(123),
|
||||
peak_tokens: None,
|
||||
longest_task_duration_ms: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage: None,
|
||||
activity_insights: ProviderProfileActivityInsights {
|
||||
fast_mode_percent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_effort_percent: None,
|
||||
skills_explored: None,
|
||||
total_skills_used: None,
|
||||
total_threads: None,
|
||||
invocations: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn personal_info_subscription() -> ProviderSubscription {
|
||||
ProviderSubscription {
|
||||
starts_at: None,
|
||||
expires_at: Utc::now(),
|
||||
will_renew: Some(true),
|
||||
billing_period: Some("monthly".to_owned()),
|
||||
billing_currency: Some("USD".to_owned()),
|
||||
observed_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2)));
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let info = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
services.accounts().personal_info(&account_id),
|
||||
)
|
||||
.await
|
||||
.expect("两项查询应并发执行")
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, Some(subscription.clone()));
|
||||
}
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
"store.load_account",
|
||||
"provider.profile_statistics",
|
||||
"provider.subscription",
|
||||
"store.load_account",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() {
|
||||
for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.subscription_result.lock().unwrap() = subscription;
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(info.profile.unwrap(), empty_profile_statistics());
|
||||
assert_eq!(info.subscription, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_keep_subscription_when_profile_fails() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
*provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway);
|
||||
let subscription = personal_info_subscription();
|
||||
*provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone()));
|
||||
let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await;
|
||||
let info = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
info.profile.unwrap_err().kind(),
|
||||
gateway_admin::model::AdminErrorKind::BadGateway
|
||||
);
|
||||
assert_eq!(info.subscription, Some(subscription));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_reject_missing_account_before_querying_provider() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.set_accounts(Vec::new());
|
||||
let services = accounts_service(provider, store).await;
|
||||
let error = services
|
||||
.accounts()
|
||||
.personal_info(&ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound);
|
||||
assert_eq!(recorded(&events), ["store.load_account"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_info_should_discard_results_when_account_changes_during_query() {
|
||||
for change in 0..5 {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(3));
|
||||
*provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
let account_id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let mutate = async {
|
||||
barrier.wait().await;
|
||||
let mut accounts = store.accounts.lock().unwrap().clone();
|
||||
match change {
|
||||
0 => accounts[0].credential_revision = revision(99),
|
||||
1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()),
|
||||
2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()),
|
||||
3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(),
|
||||
_ => accounts.clear(),
|
||||
}
|
||||
store.set_accounts(accounts);
|
||||
barrier.wait().await;
|
||||
};
|
||||
let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async {
|
||||
tokio::join!(services.accounts().personal_info(&account_id), mutate)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if change == 4 {
|
||||
gateway_admin::model::AdminErrorKind::NotFound
|
||||
} else {
|
||||
gateway_admin::model::AdminErrorKind::Conflict
|
||||
};
|
||||
assert_eq!(result.unwrap_err().kind(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
async fn accounts_service(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
@@ -2234,6 +2856,8 @@ fn unsupported() -> ProviderAdminError {
|
||||
|
||||
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
|
||||
gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: Some("团队备用".to_owned()),
|
||||
model_access: Default::default(),
|
||||
enabled: false,
|
||||
concurrency_limit: Some(
|
||||
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
|
||||
|
||||
@@ -5,7 +5,7 @@ use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AdminSession, LoginCommand},
|
||||
auth::{AdminAuditEvent, AuthSession, LoginCommand},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
@@ -13,8 +13,11 @@ use gateway_admin::{
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryAuthStore {
|
||||
retry_after: Mutex<Option<std::time::Duration>>,
|
||||
unavailable: std::sync::atomic::AtomicBool,
|
||||
reject_delete: Mutex<Option<String>>,
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
sessions: Mutex<BTreeMap<String, AuthSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
}
|
||||
|
||||
@@ -41,7 +44,10 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.unavailable.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return Err(super::unavailable("session"));
|
||||
}
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
@@ -50,11 +56,7 @@ impl AuthStore for MemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
@@ -62,10 +64,30 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
if self.reject_delete.lock().unwrap().as_deref() == Some(session_id) {
|
||||
return Err(super::unavailable("delete session"));
|
||||
}
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(*self.retry_after.lock().unwrap())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audits.lock().expect("audits").push(event);
|
||||
Ok(())
|
||||
@@ -79,19 +101,24 @@ async fn successful_login_should_create_expiring_session_and_audit() {
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login");
|
||||
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(store.audits.lock().expect("audits").len(), 1);
|
||||
}
|
||||
@@ -113,10 +140,14 @@ async fn repeated_default_initialization_should_not_replace_password() {
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
@@ -134,20 +165,134 @@ async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking()
|
||||
let before = Utc::now();
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.login(
|
||||
LoginCommand::Admin {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
},
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("login with huge session TTL");
|
||||
|
||||
assert!(result.expires_at > before);
|
||||
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(result.session.expires_at > before);
|
||||
assert!(result.session.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_sessions_are_removed_and_store_outages_are_not_treated_as_logout() {
|
||||
use gateway_admin::model::{AdminErrorKind, auth::SessionSubject};
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
store.sessions.lock().unwrap().insert(
|
||||
"expired".to_owned(),
|
||||
AuthSession {
|
||||
subject: SessionSubject::Admin {
|
||||
admin_user_id: "admin".to_owned(),
|
||||
},
|
||||
expires_at: Utc::now() - TimeDelta::seconds(1),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("expired"))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
store
|
||||
.unavailable
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some("existing"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_login_types_share_the_login_limit_before_credentials_are_checked() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
*store.retry_after.lock().unwrap() = Some(std::time::Duration::from_secs(37));
|
||||
let services = super::AdminHarness::new().auth(store).build().await;
|
||||
for command in [
|
||||
LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "wrong-password".to_owned(),
|
||||
},
|
||||
LoginCommand::Key {
|
||||
api_key: "unknown-key".to_owned(),
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(command, std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_rotation_discards_the_new_session_and_leaves_the_old_session_retryable() {
|
||||
use gateway_admin::model::auth::LoginError;
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
let command = || LoginCommand::Admin {
|
||||
username: None,
|
||||
password: "strong-test-password".to_owned(),
|
||||
};
|
||||
let original = services
|
||||
.auth()
|
||||
.login(command(), std::net::Ipv4Addr::LOCALHOST.into(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!format!("{original:?}").contains(&original.session_id));
|
||||
*store.reject_delete.lock().unwrap() = Some(original.session_id.clone());
|
||||
assert_eq!(
|
||||
services
|
||||
.auth()
|
||||
.login(
|
||||
command(),
|
||||
std::net::Ipv4Addr::LOCALHOST.into(),
|
||||
Some(&original.session_id)
|
||||
)
|
||||
.await
|
||||
.unwrap_err(),
|
||||
LoginError::Unavailable
|
||||
);
|
||||
assert_eq!(store.sessions.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.session(Some(&original.session_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(services.auth().logout(&original.session_id).await.is_err());
|
||||
*store.reject_delete.lock().unwrap() = None;
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
services.auth().logout(&original.session_id).await.unwrap();
|
||||
assert!(store.sessions.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
time::Duration as StdDuration,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::{
|
||||
model::auth::{AdminAuditEvent, AuthSession, LoginCommand, LoginError, SessionSubject},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
policy::ClientApiKeyId,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn client_login_command_debug_should_redact_api_key() {
|
||||
let command = LoginCommand::Key {
|
||||
api_key: "cpr_should_never_be_logged".to_owned(),
|
||||
};
|
||||
|
||||
let rendered = format!("{command:?}");
|
||||
|
||||
assert!(rendered.contains("[REDACTED]"));
|
||||
assert!(!rendered.contains("cpr_should_never_be_logged"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_should_create_key_bound_session_with_fixed_ttl() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(key.clone()));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.client_session_ttl_minutes(30)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
|
||||
assert!(login.session_id.starts_with("session_"));
|
||||
assert!(!login.session_id.contains("valid-client-key"));
|
||||
assert_eq!(verifier.calls(), 1);
|
||||
assert_eq!(
|
||||
store.session(&login.session_id).map(|value| value.subject),
|
||||
Some(SessionSubject::Key {
|
||||
client_key_id: key.clone()
|
||||
})
|
||||
);
|
||||
let remaining = login.session.expires_at - Utc::now();
|
||||
assert!(remaining > Duration::minutes(29));
|
||||
assert!(remaining <= Duration::minutes(30));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_and_removed_keys_should_share_the_same_login_error() {
|
||||
let rejected_store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
let rejected = super::AdminHarness::new()
|
||||
.auth(rejected_store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::rejecting()))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("unknown-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let removed = super::AdminHarness::new()
|
||||
.auth(Arc::new(MemoryKeyAuthStore::new(None)))
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("removed-key").expect("key id"),
|
||||
)))
|
||||
.build()
|
||||
.await
|
||||
.auth()
|
||||
.login(
|
||||
login_command("disabled-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(rejected, Err(LoginError::InvalidCredentials));
|
||||
assert_eq!(removed, Err(LoginError::InvalidCredentials));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_should_reject_before_verifying_the_candidate_key() {
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(client_key("key-42"))));
|
||||
store.set_retry_after(Some(StdDuration::from_secs(37)));
|
||||
let verifier = Arc::new(FixtureVerifier::accepting(
|
||||
ClientApiKeyId::new("key-42").expect("key id"),
|
||||
));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(verifier.clone())
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(LoginError::TooManyAttempts {
|
||||
retry_after_seconds: 37,
|
||||
})
|
||||
);
|
||||
assert_eq!(verifier.calls(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restored_session_should_be_revoked_when_its_key_is_no_longer_active() {
|
||||
let key = client_key("key-42");
|
||||
let store = Arc::new(MemoryKeyAuthStore::new(Some(key.clone())));
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.client_key_verifier(Arc::new(FixtureVerifier::accepting(key.clone())))
|
||||
.build()
|
||||
.await;
|
||||
let login = services
|
||||
.auth()
|
||||
.login(
|
||||
login_command("valid-client-key"),
|
||||
Ipv4Addr::LOCALHOST.into(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("client login");
|
||||
store.replace_key(None);
|
||||
|
||||
let session = services
|
||||
.auth()
|
||||
.session(Some(&login.session_id))
|
||||
.await
|
||||
.expect("session status");
|
||||
|
||||
assert!(session.is_none());
|
||||
assert!(store.session(&login.session_id).is_none());
|
||||
}
|
||||
|
||||
fn login_command(api_key: &str) -> LoginCommand {
|
||||
LoginCommand::Key {
|
||||
api_key: api_key.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn client_key(id: &str) -> ClientApiKeyId {
|
||||
ClientApiKeyId::new(id).expect("client key id")
|
||||
}
|
||||
|
||||
struct FixtureVerifier {
|
||||
result: Result<ClientApiKeyId, ClientAuthenticationError>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FixtureVerifier {
|
||||
fn accepting(id: ClientApiKeyId) -> Self {
|
||||
Self {
|
||||
result: Ok(id),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejecting() -> Self {
|
||||
Self {
|
||||
result: Err(ClientAuthenticationError::InvalidKey),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyVerifier for FixtureVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryKeyAuthStore {
|
||||
key: Mutex<Option<ClientApiKeyId>>,
|
||||
sessions: Mutex<HashMap<String, AuthSession>>,
|
||||
retry_after: Mutex<Option<StdDuration>>,
|
||||
}
|
||||
|
||||
impl MemoryKeyAuthStore {
|
||||
fn new(key: Option<ClientApiKeyId>) -> Self {
|
||||
Self {
|
||||
key: Mutex::new(key),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
retry_after: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_key(&self, key: Option<ClientApiKeyId>) {
|
||||
*self.key.lock().expect("client key") = key;
|
||||
}
|
||||
|
||||
fn set_retry_after(&self, retry_after: Option<StdDuration>) {
|
||||
*self.retry_after.lock().expect("retry after") = retry_after;
|
||||
}
|
||||
|
||||
fn session(&self, session_id: &str) -> Option<AuthSession> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.get(session_id)
|
||||
.cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for MemoryKeyAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn create_password_hash_if_absent(&self, _: &str, _: &str) -> AdminStoreResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn load_admin_api_key(
|
||||
&self,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn client_key_enabled(&self, id: &ClientApiKeyId) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.key
|
||||
.lock()
|
||||
.expect("client key")
|
||||
.as_ref()
|
||||
.is_some_and(|key| key == id))
|
||||
}
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self.session(session_id))
|
||||
}
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AuthSession) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.insert(session_id.to_owned(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
.expect("client sessions")
|
||||
.remove(session_id))
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: StdDuration,
|
||||
) -> AdminStoreResult<Option<StdDuration>> {
|
||||
Ok(*self.retry_after.lock().expect("retry after"))
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use chrono::Utc;
|
||||
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationActor, MutationContext, Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
|
||||
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
|
||||
UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
|
||||
};
|
||||
|
||||
struct UnusedClientKeyStore;
|
||||
#[derive(Default)]
|
||||
struct TestClientKeyStore {
|
||||
plaintexts: Mutex<Vec<String>>,
|
||||
create_error: Option<AdminStoreErrorKind>,
|
||||
update_error: Option<AdminStoreErrorKind>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnusedClientKeyStore {
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
assert_eq!(query.page_size.get(), u16::MAX);
|
||||
Ok(ClientKeyPage {
|
||||
@@ -36,10 +52,32 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
key: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
if let Some(kind) = self.create_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test create failure",
|
||||
));
|
||||
}
|
||||
self.plaintexts.lock().unwrap().push(key.plaintext);
|
||||
let record = ClientKeyRecord {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
groups: Vec::new(),
|
||||
provider_kinds: Vec::new(),
|
||||
prefix: String::new(),
|
||||
enabled: true,
|
||||
limits: key.limits,
|
||||
budget: Default::default(),
|
||||
last_used_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
Ok((Revision::new(2).unwrap(), record))
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
@@ -47,6 +85,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
if let Some(kind) = self.update_error {
|
||||
return Err(AdminStoreError::new(
|
||||
kind,
|
||||
"client key",
|
||||
"test update failure",
|
||||
));
|
||||
}
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
@@ -70,7 +115,7 @@ impl ClientKeyStore for UnusedClientKeyStore {
|
||||
#[tokio::test]
|
||||
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let sort = ClientKeySort {
|
||||
@@ -98,7 +143,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
#[tokio::test]
|
||||
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.client_keys(Arc::new(TestClientKeyStore::default()))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
@@ -118,6 +163,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
|
||||
let store = Arc::new(TestClientKeyStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(store.clone())
|
||||
.build()
|
||||
.await;
|
||||
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(value.as_deref()))
|
||||
.await
|
||||
.unwrap();
|
||||
let plaintext = created.secret.expose_for_response();
|
||||
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
|
||||
if let Some(value) = value {
|
||||
assert_eq!(plaintext, value);
|
||||
} else {
|
||||
assert!(plaintext.starts_with("sk_"));
|
||||
assert_eq!(plaintext.len(), 46);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::Conflict),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let key = "legacy-duplicate-must-stay-private";
|
||||
let error = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(Some(key)))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
|
||||
assert!(!format!("{error:?}").contains(key));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(Arc::new(TestClientKeyStore {
|
||||
create_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
update_error: Some(AdminStoreErrorKind::DuplicateName),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let created = services
|
||||
.client_keys()
|
||||
.create(&mutation_context(), create_command(None))
|
||||
.await
|
||||
.unwrap_err();
|
||||
let updated = services
|
||||
.client_keys()
|
||||
.update(
|
||||
&mutation_context(),
|
||||
UpdateClientKey {
|
||||
id: ClientApiKeyId::new("key_existing").unwrap(),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
daily_limit_usd: None,
|
||||
weekly_limit_usd: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
for error in [created, updated] {
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert_eq!(error.message(), "名称已存在");
|
||||
}
|
||||
}
|
||||
|
||||
fn create_command(key: Option<&str>) -> CreateClientKey {
|
||||
CreateClientKey {
|
||||
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
|
||||
name: "Migration".to_owned(),
|
||||
label: None,
|
||||
group_ids: Vec::new(),
|
||||
limits: RateLimits::unlimited(),
|
||||
budget: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mutation_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "custom-key-test".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! 容量熔断冻结恢复编排回归:到期探测、失败顺延与自适应并发下调。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration as TimeDelta, Utc};
|
||||
use gateway_admin::freeze_recovery::{FreezeRecoveryDeps, FreezeRecoveryTask};
|
||||
use gateway_admin::model::MutationContext;
|
||||
use gateway_admin::model::accounts::{AccountFreeze, AccountRuntimeSnapshot};
|
||||
use gateway_admin::model::settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings,
|
||||
};
|
||||
use gateway_admin::ports::store::{
|
||||
AccountRuntimeStore, AccountStore, AdminStoreError, AdminStoreResult, SettingsStore,
|
||||
};
|
||||
use gateway_core::engine::probe::{
|
||||
AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult,
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{ScheduledTask as _, WorkerCycleContext, WorkerId, WorkerKind};
|
||||
|
||||
use super::AdminHarness;
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, events, revision};
|
||||
|
||||
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 5,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 90,
|
||||
account_auto_freeze_enabled: enabled,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: probe_enabled,
|
||||
account_auto_freeze_probe_model: Some("gpt-5.5".to_owned()),
|
||||
account_auto_freeze_adaptive_concurrency: adaptive,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
struct FreezeSettingsStore {
|
||||
settings: RuntimeSettings,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for FreezeSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(self.settings.clone())
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(store_unavailable())
|
||||
}
|
||||
}
|
||||
|
||||
fn store_unavailable() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"freeze recovery test",
|
||||
"unused",
|
||||
)
|
||||
}
|
||||
|
||||
/// 记录解冻/顺延调用的运行态 fake;冻结与峰值证据由测试预置。
|
||||
struct FreezeRuntimeStore {
|
||||
freezes: BTreeMap<String, AccountFreeze>,
|
||||
peaks: BTreeMap<String, u32>,
|
||||
cleared: Mutex<Vec<String>>,
|
||||
extended: Mutex<Vec<(String, DateTime<Utc>)>>,
|
||||
}
|
||||
|
||||
impl FreezeRuntimeStore {
|
||||
fn new(freezes: BTreeMap<String, AccountFreeze>, peaks: BTreeMap<String, u32>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
freezes,
|
||||
peaks,
|
||||
cleared: Mutex::new(Vec::new()),
|
||||
extended: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cleared(&self) -> Vec<String> {
|
||||
self.cleared.lock().expect("cleared lock").clone()
|
||||
}
|
||||
|
||||
fn extended(&self) -> Vec<(String, DateTime<Utc>)> {
|
||||
self.extended.lock().expect("extended lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountRuntimeStore for FreezeRuntimeStore {
|
||||
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn account_runtime(
|
||||
&self,
|
||||
_account_ids: &[String],
|
||||
) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn active_freezes(&self) -> AdminStoreResult<BTreeMap<String, AccountFreeze>> {
|
||||
Ok(self.freezes.clone())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(account_ids
|
||||
.iter()
|
||||
.filter_map(|id| self.peaks.get(id).map(|peak| (id.clone(), *peak)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected: &AccountFreeze,
|
||||
postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
assert_eq!(self.freezes.get(account_id), Some(expected));
|
||||
if let Some(until) = postpone_until {
|
||||
self.extended
|
||||
.lock()
|
||||
.expect("extended")
|
||||
.push((account_id.to_owned(), until));
|
||||
} else {
|
||||
self.cleared
|
||||
.lock()
|
||||
.expect("cleared")
|
||||
.push(account_id.to_owned());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
struct SuccessfulProbe;
|
||||
|
||||
impl AccountProbe for SuccessfulProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Ok(AccountProbeResult {
|
||||
text: vec!["OK".to_owned()],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingProbe;
|
||||
|
||||
impl AccountProbe for FailingProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> futures::future::BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(AccountProbeError::new(
|
||||
gateway_core::error::GatewayError::new(
|
||||
gateway_core::error::GatewayErrorKind::RateLimited,
|
||||
"still overloaded",
|
||||
),
|
||||
gateway_core::engine::probe::AccountProbeErrorSource::Upstream,
|
||||
Some(gateway_core::upstream::UpstreamSendState::NotSent),
|
||||
None,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn recovery_task(
|
||||
settings: RuntimeSettings,
|
||||
runtime: Arc<FreezeRuntimeStore>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
|
||||
recovery_task_with_store(settings, runtime, probe).await
|
||||
}
|
||||
|
||||
async fn recovery_task_with_store(
|
||||
settings: RuntimeSettings,
|
||||
runtime: Arc<FreezeRuntimeStore>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> (FreezeRecoveryTask, Arc<FakeAccountStore>) {
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
let services = AdminHarness::new()
|
||||
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
|
||||
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
|
||||
.settings(Arc::new(FreezeSettingsStore {
|
||||
settings: settings.clone(),
|
||||
}))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.probe(probe)
|
||||
.build()
|
||||
.await;
|
||||
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
|
||||
accounts: services.accounts_handle(),
|
||||
store: store.clone(),
|
||||
runtime,
|
||||
settings: Arc::new(FreezeSettingsStore {
|
||||
settings: settings.clone(),
|
||||
}),
|
||||
});
|
||||
(task, store)
|
||||
}
|
||||
|
||||
async fn run_cycle(task: &FreezeRecoveryTask) {
|
||||
let worker = WorkerId::try_new(WorkerKind::AccountFreezeRecovery, "test").expect("worker id");
|
||||
let context = WorkerCycleContext::new(worker, None, CancellationToken::new());
|
||||
task.run_cycle(context)
|
||||
.await
|
||||
.expect("freeze recovery cycle");
|
||||
}
|
||||
|
||||
fn freeze_until(until: DateTime<Utc>) -> BTreeMap<String, AccountFreeze> {
|
||||
BTreeMap::from([(
|
||||
"acct_test".to_owned(),
|
||||
AccountFreeze {
|
||||
credential_revision: revision(1),
|
||||
until,
|
||||
generation: "freeze-generation".to_owned(),
|
||||
requires_probe: true,
|
||||
},
|
||||
)])
|
||||
}
|
||||
|
||||
fn freeze_due() -> BTreeMap<String, AccountFreeze> {
|
||||
freeze_until(Utc::now() - TimeDelta::seconds(1))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_success_clears_freeze() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn probe_failure_postpones_freeze() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(FailingProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
let extended = runtime.extended();
|
||||
assert_eq!(extended.len(), 1);
|
||||
let (account_id, until) = &extended[0];
|
||||
assert_eq!(account_id, "acct_test");
|
||||
let remaining = until.timestamp_millis() - Utc::now().timestamp_millis();
|
||||
assert!(
|
||||
remaining > 7_000_000 && remaining <= 7_200_000,
|
||||
"postponed freeze should be about 2 hours, got {remaining}ms"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_policy_releases_due_freeze_without_probing() {
|
||||
let runtime = FreezeRuntimeStore::new(freeze_due(), BTreeMap::new());
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(false, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert_eq!(runtime.cleared(), vec!["acct_test".to_owned()]);
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_concurrency_lowers_limit_to_observed_peak() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
|
||||
);
|
||||
let (task, store) = recovery_task(
|
||||
runtime_settings(true, false, true),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
// 峰值 4 × 0.8 = 3(不低于下限 2),低于全局默认 5,应下调到 3。
|
||||
assert_eq!(
|
||||
*store.lowered_limits.lock().expect("lowered limits"),
|
||||
vec![("acct_test".to_owned(), 3)]
|
||||
);
|
||||
assert!(store.update_commands().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
assert!(runtime.cleared().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_concurrency_never_raises_limit() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::from([("acct_test".to_owned(), 40_u32)]),
|
||||
);
|
||||
let (task, store) = recovery_task(
|
||||
runtime_settings(true, false, true),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
// 峰值 40 的目标 32 高于全局默认 5;只降不升,不下发任何更新。
|
||||
assert!(store.update_commands().is_empty());
|
||||
}
|
||||
|
||||
/// 到期时间还早(超过探测提前量)的冻结不发起探测。
|
||||
#[tokio::test]
|
||||
async fn probe_skips_freezes_far_from_expiry() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_until(Utc::now() + TimeDelta::hours(1)),
|
||||
BTreeMap::new(),
|
||||
);
|
||||
let (task, _store) = recovery_task(
|
||||
runtime_settings(true, true, false),
|
||||
Arc::clone(&runtime),
|
||||
Arc::new(SuccessfulProbe),
|
||||
)
|
||||
.await;
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
}
|
||||
|
||||
/// 停用账号不参与探测恢复;并发下调同样跳过。
|
||||
#[tokio::test]
|
||||
async fn disabled_accounts_are_skipped() {
|
||||
let runtime = FreezeRuntimeStore::new(
|
||||
freeze_due(),
|
||||
BTreeMap::from([("acct_test".to_owned(), 4_u32)]),
|
||||
);
|
||||
let mut record = account_record("openai");
|
||||
record.enabled = false;
|
||||
let store = FakeAccountStore::with_account(record, events());
|
||||
let services = AdminHarness::new()
|
||||
.accounts(Arc::clone(&store) as Arc<dyn AccountStore>)
|
||||
.account_runtime(Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>)
|
||||
.settings(Arc::new(FreezeSettingsStore {
|
||||
settings: runtime_settings(true, true, true),
|
||||
}))
|
||||
.provider(FakeProviderAdmin::new("openai", events()))
|
||||
.probe(Arc::new(SuccessfulProbe))
|
||||
.build()
|
||||
.await;
|
||||
let task = FreezeRecoveryTask::new(FreezeRecoveryDeps {
|
||||
accounts: services.accounts_handle(),
|
||||
store: store.clone(),
|
||||
runtime: Arc::clone(&runtime) as Arc<dyn AccountRuntimeStore>,
|
||||
settings: Arc::new(FreezeSettingsStore {
|
||||
settings: runtime_settings(true, true, true),
|
||||
}),
|
||||
});
|
||||
|
||||
run_cycle(&task).await;
|
||||
|
||||
assert!(runtime.cleared().is_empty());
|
||||
assert!(runtime.extended().is_empty());
|
||||
assert!(store.update_commands().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use gateway_admin::{
|
||||
AdminBundle, AdminServices,
|
||||
model::{
|
||||
AdminErrorKind, MutationActor, MutationContext,
|
||||
import_tasks::{ImportTaskInput, SubmitImportTask},
|
||||
provider_credentials::ImportCredentials,
|
||||
},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
routing::ProviderKind,
|
||||
task::{WorkerContribution, WorkerKind, WorkerRunnable},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{
|
||||
AdminHarness,
|
||||
accounts::{
|
||||
EventLog, FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
|
||||
},
|
||||
};
|
||||
|
||||
fn command(id: Uuid, count: usize) -> SubmitImportTask {
|
||||
let context = context("task-submit");
|
||||
SubmitImportTask {
|
||||
submission_id: id,
|
||||
fingerprint: [1; 32],
|
||||
context: context.clone(),
|
||||
items: (0..count)
|
||||
.map(|_| ImportTaskInput {
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
command: ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context.clone(),
|
||||
document: document(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn harness() -> (AdminBundle, Arc<FakeProviderAdmin>, EventLog) {
|
||||
let log = events();
|
||||
let provider = FakeProviderAdmin::new("openai", log.clone());
|
||||
let bundle = AdminHarness::new()
|
||||
.provider(provider.clone())
|
||||
.accounts(FakeAccountStore::new("openai", log.clone()))
|
||||
.build_bundle()
|
||||
.await;
|
||||
(bundle, provider, log)
|
||||
}
|
||||
|
||||
fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandle<()>) {
|
||||
let registration = bundle
|
||||
.take_worker_contributions()
|
||||
.into_iter()
|
||||
.find_map(|contribution| match contribution {
|
||||
WorkerContribution::Registration(registration)
|
||||
if registration.id.kind() == WorkerKind::AccountImport =>
|
||||
{
|
||||
Some(registration)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("import registration");
|
||||
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
|
||||
panic!("import daemon")
|
||||
};
|
||||
let cancellation = CancellationToken::new();
|
||||
let token = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
task.run(token).await.expect("import daemon exits cleanly");
|
||||
});
|
||||
(cancellation, handle)
|
||||
}
|
||||
|
||||
async fn wait_started(log: &EventLog, count: usize) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while recorded(log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count()
|
||||
< count
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("imports started");
|
||||
}
|
||||
|
||||
async fn wait_finished(services: &AdminServices, id: Uuid) {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.finished_at
|
||||
.is_none()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("imports finished");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_should_return_the_same_task_but_reject_changed_content() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let id = Uuid::now_v7();
|
||||
let first = services.import_tasks().submit(command(id, 2)).unwrap();
|
||||
let retry = services.import_tasks().submit(command(id, 2)).unwrap();
|
||||
assert_eq!(first.task_id, retry.task_id);
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.list(&context("reopened-page"))
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let mut changed = command(id, 2);
|
||||
changed.fingerprint = [2; 32];
|
||||
assert_eq!(
|
||||
services.import_tasks().submit(changed).unwrap_err().kind(),
|
||||
AdminErrorKind::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reads_and_stop_should_be_isolated_by_owner() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap();
|
||||
let other = MutationContext {
|
||||
actor: MutationActor::AdminSession {
|
||||
admin_user_id: "another-admin".to_owned(),
|
||||
},
|
||||
request_id: "other-session".to_owned(),
|
||||
};
|
||||
assert!(services.import_tasks().list(&other).is_empty());
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.detail(&other, task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.stop(&other, task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stopping_should_skip_pending_items_and_allow_three_in_flight_to_commit() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 8))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
let stopped = services
|
||||
.import_tasks()
|
||||
.stop(&context("stop"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(stopped.summary.counts.running, 3);
|
||||
assert_eq!(stopped.summary.counts.skipped, 5);
|
||||
gate.add_permits(3);
|
||||
wait_finished(&services, task.task_id).await;
|
||||
let finished = services
|
||||
.import_tasks()
|
||||
.detail(&context("new-page"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(finished.summary.counts.imported_accounts, 3);
|
||||
assert_eq!(
|
||||
recorded(&log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrency_limit_should_be_shared_across_tasks() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let first = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 6))
|
||||
.unwrap();
|
||||
let second = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 6))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
let summaries = services.import_tasks().list(&context("poll"));
|
||||
assert_eq!(
|
||||
summaries
|
||||
.iter()
|
||||
.map(|task| task.counts.running)
|
||||
.sum::<usize>(),
|
||||
3
|
||||
);
|
||||
assert!(summaries.iter().all(|task| task.counts.running > 0));
|
||||
gate.add_permits(12);
|
||||
wait_finished(&services, first.task_id).await;
|
||||
wait_finished(&services, second.task_id).await;
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_and_unknown_items_should_not_abort_the_batch_or_retry_credentials() {
|
||||
for (kind, failed, unknown) in [
|
||||
(ProviderAdminErrorKind::Invalid, 1, 0),
|
||||
(ProviderAdminErrorKind::Ambiguous, 0, 1),
|
||||
] {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
provider.fail_next(kind);
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 4))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_finished(&services, task.task_id).await;
|
||||
let result = services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
result.summary.counts.succeeded,
|
||||
result.summary.counts.failed,
|
||||
result.summary.counts.unknown
|
||||
),
|
||||
(3, failed, unknown)
|
||||
);
|
||||
assert_eq!(
|
||||
recorded(&log)
|
||||
.iter()
|
||||
.filter(|event| **event == "provider.prepare_import")
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
cancel.cancel();
|
||||
worker.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_should_drain_running_items_and_reject_new_work() {
|
||||
let (mut bundle, provider, log) = harness().await;
|
||||
let gate = provider.block_imports();
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 7))
|
||||
.unwrap();
|
||||
let (cancel, worker) = start(&mut bundle);
|
||||
wait_started(&log, 3).await;
|
||||
cancel.cancel();
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Unavailable
|
||||
);
|
||||
assert!(!worker.is_finished());
|
||||
gate.add_permits(3);
|
||||
worker.await.unwrap();
|
||||
let result = services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
result.summary.counts.succeeded,
|
||||
result.summary.counts.skipped
|
||||
),
|
||||
(3, 4)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn completed_records_should_expire_and_new_process_should_start_empty() {
|
||||
let (bundle, _, _) = harness().await;
|
||||
let services = bundle.services();
|
||||
let task = services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 2))
|
||||
.unwrap();
|
||||
services
|
||||
.import_tasks()
|
||||
.stop(&context("stop"), task.task_id)
|
||||
.unwrap();
|
||||
tokio::time::advance(Duration::from_secs(3601)).await;
|
||||
assert!(services.import_tasks().list(&context("poll")).is_empty());
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.detail(&context("poll"), task.task_id)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::NotFound
|
||||
);
|
||||
let (fresh, _, _) = harness().await;
|
||||
assert!(
|
||||
fresh
|
||||
.services()
|
||||
.import_tasks()
|
||||
.list(&context("poll"))
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_should_reject_excess_work_before_starting_any_imports() {
|
||||
let (bundle, _, log) = harness().await;
|
||||
let services = bundle.services();
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 201))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
for _ in 0..8 {
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
services
|
||||
.import_tasks()
|
||||
.submit(command(Uuid::now_v7(), 1))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::RateLimited
|
||||
);
|
||||
assert!(recorded(&log).is_empty());
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
mod account_groups;
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod auth_key;
|
||||
mod backup;
|
||||
mod client_keys;
|
||||
mod freeze_recovery;
|
||||
mod import_tasks;
|
||||
mod observability;
|
||||
mod openai;
|
||||
mod proxies;
|
||||
@@ -19,7 +22,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
AdminConfig, AdminServices, ClientConfig, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
@@ -31,7 +34,7 @@ use gateway_admin::{
|
||||
AccountUsage, AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_distribution::CodexDesktopWindowsDownloads,
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
@@ -45,12 +48,11 @@ use gateway_admin::{
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
|
||||
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
|
||||
ProviderModels, ProviderQuota,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PrepareCredentialImport,
|
||||
PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit,
|
||||
PreparedCredentialImport, PreparedCredentialRotation, ProviderExport,
|
||||
ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
},
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
@@ -72,16 +74,21 @@ use gateway_admin::{
|
||||
};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
engine::{
|
||||
execution::{ClientAuthenticationError, ClientKeyVerifier},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::ClientApiKeyId,
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) struct AdminHarness {
|
||||
default_password: String,
|
||||
session_ttl_minutes: u64,
|
||||
client_session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
@@ -94,6 +101,7 @@ pub(super) struct AdminHarness {
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
}
|
||||
|
||||
impl AdminHarness {
|
||||
@@ -102,6 +110,7 @@ impl AdminHarness {
|
||||
Self {
|
||||
default_password: "strong-test-password".to_owned(),
|
||||
session_ttl_minutes: 60,
|
||||
client_session_ttl_minutes: 1_440,
|
||||
accounts: unavailable.clone(),
|
||||
proxies: Arc::new(proxies::TestProxies::default()),
|
||||
account_runtime: unavailable.clone(),
|
||||
@@ -117,6 +126,7 @@ impl AdminHarness {
|
||||
],
|
||||
probe: Arc::new(UnavailableProbe),
|
||||
system: Arc::new(UnavailableSystem),
|
||||
client_key_verifier: Arc::new(UnavailableClientKeyVerifier),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +165,16 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn client_session_ttl_minutes(mut self, minutes: u64) -> Self {
|
||||
self.client_session_ttl_minutes = minutes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn client_key_verifier(mut self, verifier: Arc<dyn ClientKeyVerifier>) -> Self {
|
||||
self.client_key_verifier = verifier;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
|
||||
self.observability = store;
|
||||
self
|
||||
@@ -196,12 +216,19 @@ impl AdminHarness {
|
||||
}
|
||||
|
||||
pub(super) async fn build(self) -> AdminServices {
|
||||
self.build_bundle().await.services()
|
||||
}
|
||||
|
||||
pub(super) async fn build_bundle(self) -> gateway_admin::AdminBundle {
|
||||
gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
session_ttl_minutes: self.session_ttl_minutes,
|
||||
default_username: "admin".to_owned(),
|
||||
default_password: InitialAdminPassword::new(self.default_password),
|
||||
},
|
||||
ClientConfig {
|
||||
session_ttl_minutes: self.client_session_ttl_minutes,
|
||||
},
|
||||
AdminStorePorts::new(
|
||||
AdminAccountStorePorts::new(
|
||||
self.accounts,
|
||||
@@ -215,15 +242,18 @@ impl AdminHarness {
|
||||
self.settings,
|
||||
self.backup,
|
||||
),
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
(self.probe, Arc::new(proxies::TestProxies::default())),
|
||||
Arc::new(NoopClientDistribution),
|
||||
self.system,
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
providers: self.providers,
|
||||
snapshot: Arc::new(NoopSnapshot),
|
||||
account_probe: self.probe,
|
||||
proxy_probe: Arc::new(proxies::TestProxies::default()),
|
||||
client_distribution: Arc::new(NoopClientDistribution),
|
||||
system: self.system,
|
||||
client_key_verifier: self.client_key_verifier,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("initialize admin test harness")
|
||||
.services()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,18 +299,35 @@ impl AuthStore for BootstrapAuthStore {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn store_session(&self, _: &str, _: &AdminSession) -> AdminStoreResult<()> {
|
||||
async fn store_session(&self, _: &str, _: &AuthSession) -> AdminStoreResult<()> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AuthSession>> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn client_key_enabled(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn consume_login_attempt(
|
||||
&self,
|
||||
_: std::net::IpAddr,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: std::time::Duration,
|
||||
) -> AdminStoreResult<Option<std::time::Duration>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -288,6 +335,14 @@ impl AuthStore for BootstrapAuthStore {
|
||||
|
||||
struct UnavailableStore;
|
||||
|
||||
struct UnavailableClientKeyVerifier;
|
||||
|
||||
impl ClientKeyVerifier for UnavailableClientKeyVerifier {
|
||||
fn verify_client_key(&self, _: &str) -> Result<ClientApiKeyId, ClientAuthenticationError> {
|
||||
Err(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableAccountGroupStore;
|
||||
|
||||
#[async_trait]
|
||||
@@ -372,12 +427,11 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("account quota window usage"))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
async fn load_quota_forecast_history(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage> {
|
||||
Err(unavailable("credentials"))
|
||||
_: &AccountUsageWindowQuery,
|
||||
) -> AdminStoreResult<gateway_admin::model::quota_forecast_sampling::QuotaForecastHistory> {
|
||||
Err(unavailable("quota forecast history"))
|
||||
}
|
||||
|
||||
async fn credential_details(
|
||||
@@ -436,6 +490,15 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("account enabled"))
|
||||
}
|
||||
|
||||
async fn lower_concurrency_limit(
|
||||
&self,
|
||||
_: &gateway_core::account::ProviderAccountId,
|
||||
_: gateway_core::account::AccountConcurrencyLimit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountUpdateResult>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
@@ -478,10 +541,39 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
async fn account_runtime(&self, _: &[String]) -> AdminStoreResult<AccountRuntimeSnapshot> {
|
||||
Ok(AccountRuntimeSnapshot::default())
|
||||
}
|
||||
|
||||
async fn active_freezes(
|
||||
&self,
|
||||
) -> AdminStoreResult<BTreeMap<String, gateway_admin::model::accounts::AccountFreeze>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn capacity_peaks(
|
||||
&self,
|
||||
_account_ids: &[String],
|
||||
) -> AdminStoreResult<BTreeMap<String, u32>> {
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
|
||||
async fn finish_freeze(
|
||||
&self,
|
||||
_account_id: &str,
|
||||
_expected: &gateway_admin::model::accounts::AccountFreeze,
|
||||
_postpone_until: Option<DateTime<Utc>>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeyRecord>> {
|
||||
Err(unavailable("client key"))
|
||||
}
|
||||
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
Err(unavailable("client key list"))
|
||||
}
|
||||
|
||||
@@ -753,12 +753,17 @@ struct FixtureSettingsStore;
|
||||
impl SettingsStore for FixtureSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
@@ -766,6 +771,13 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
updated_at: Utc::now(),
|
||||
account_auto_freeze_enabled: true,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
.await
|
||||
.expect("import credential");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
@@ -142,7 +142,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.await
|
||||
.expect("import credentials");
|
||||
|
||||
await_quota_requests(&provider, 2).await;
|
||||
provider.wait_for_quota_requests(2).await;
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
@@ -187,7 +187,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.await
|
||||
.expect("committed import remains successful");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
@@ -224,7 +224,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
.await
|
||||
.expect("complete authorization");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
@@ -280,7 +280,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
|
||||
.await
|
||||
.expect("quota observation must not fail authorization");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(result.account_id.as_str(), "acct_prepared");
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
@@ -322,7 +322,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
.await
|
||||
.expect("released claim must permit retry");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
@@ -406,7 +406,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
|
||||
assert_eq!(result.credential_revision, Some(revision(3)));
|
||||
|
||||
@@ -434,16 +434,6 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn await_quota_requests(provider: &FakeProviderAdmin, expected: usize) {
|
||||
for _ in 0..100 {
|
||||
if provider.quota_requests().len() == expected {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert_eq!(provider.quota_requests().len(), expected);
|
||||
}
|
||||
|
||||
fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
CredentialDeletion {
|
||||
context: context("request-openai"),
|
||||
|
||||
@@ -61,17 +61,29 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 0,
|
||||
refresh_concurrency: 1,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
account_auto_freeze_enabled: true,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -4,7 +4,9 @@ use gateway_core::account::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::provider_credentials::{ImportCredentials, ProviderQuotaRequest},
|
||||
model::provider_credentials::{
|
||||
CompleteAuthorization, ImportCredentials, ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
@@ -28,11 +30,13 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
.await
|
||||
.expect("import xAI credential");
|
||||
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.prepare_import",
|
||||
"store.commit_import",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota"
|
||||
]
|
||||
);
|
||||
@@ -70,6 +74,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.await
|
||||
.expect("import credentials");
|
||||
|
||||
provider.wait_for_quota_requests(2).await;
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[
|
||||
@@ -106,10 +111,120 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.await
|
||||
.expect("committed import remains successful");
|
||||
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
assert_eq!(result.credential_ids.len(), 1);
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_authorization_should_observe_quota_without_affecting_commit() {
|
||||
for quota_failure in [None, Some(ProviderAdminErrorKind::Unavailable)] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
if let Some(kind) = quota_failure {
|
||||
provider.fail_next_quota(kind);
|
||||
}
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
services
|
||||
.xai()
|
||||
.start_authorization(StartAuthorization {
|
||||
outbound_proxy: None,
|
||||
context: context("oauth-xai-create"),
|
||||
name: "new xAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
})
|
||||
.await
|
||||
.expect("start authorization");
|
||||
let result = services
|
||||
.xai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
settings: None,
|
||||
context: context("oauth-xai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("quota observation must not fail authorization");
|
||||
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.start_authorization",
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[ProviderQuotaRequest {
|
||||
account_id: result.account_id,
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["oauth-xai-create"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_import_should_return_while_initial_quota_is_pending() {
|
||||
for kind in ["openai", "xai"] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new(kind, events.clone());
|
||||
let quota_release = provider.pause_next_quota();
|
||||
let store = FakeAccountStore::new(kind, events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
let command = ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-slow-quota"),
|
||||
document: document(),
|
||||
};
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
match kind {
|
||||
"openai" => services.openai().import_document(command).await,
|
||||
_ => services.xai().import_document(command).await,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("committed import must not wait for quota")
|
||||
.expect("import credentials");
|
||||
provider.wait_for_quota_requests(1).await;
|
||||
assert_eq!(result.credential_ids.len(), 1);
|
||||
quota_release.send(()).expect("finish quota observation");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_store_failure_should_not_observe_quota_or_invalidate_provider_facts() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
store.fail_next_commit();
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-xai-store-failure"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect_err("store commit fails");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["provider.prepare_import", "store.commit_import"]
|
||||
);
|
||||
assert!(provider.quota_requests().is_empty());
|
||||
}
|
||||
|
||||
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
|
||||
@@ -16,6 +16,7 @@ axum = { version = "0.8.9", default-features = false, features = ["json", "query
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
flate2 = "1.1.9"
|
||||
futures = "0.3.32"
|
||||
gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
@@ -23,12 +24,14 @@ gateway-protocol = { path = "../gateway-protocol" }
|
||||
secrecy = "0.10"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
|
||||
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
zstd = "=0.13.3"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Account group HTTP wire and fixed routes.
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
@@ -22,8 +24,8 @@ use gateway_core::routing::AccountGroupId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta, WireValidationError, wire::map_admin_service_error,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
@@ -221,7 +223,7 @@ impl From<AccountGroupMutation> for AccountGroupMutationData {
|
||||
/// Construct all fixed account-group management routes.
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/account-groups", get(list::<S>))
|
||||
@@ -238,7 +240,7 @@ async fn list<S>(
|
||||
AdminQuery(query): AdminQuery<ListAccountGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -258,7 +260,7 @@ async fn create<S>(
|
||||
AdminJson(request): AdminJson<CreateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
@@ -284,7 +286,7 @@ async fn update<S>(
|
||||
AdminJson(request): AdminJson<UpdateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
@@ -311,7 +313,7 @@ async fn enable<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, true).await
|
||||
}
|
||||
@@ -322,7 +324,7 @@ async fn disable<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, false).await
|
||||
}
|
||||
@@ -334,7 +336,7 @@ async fn set_enabled<S>(
|
||||
enabled: bool,
|
||||
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
@@ -358,7 +360,7 @@ async fn delete<S>(
|
||||
AdminJson(request): AdminJson<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
fn validate_account_notes(notes: Option<&str>) -> Result<(), WireValidationError> {
|
||||
if notes.is_some_and(|notes| {
|
||||
notes.chars().count() > 500
|
||||
|| notes
|
||||
.chars()
|
||||
.any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t'))
|
||||
}) {
|
||||
return Err(WireValidationError::new("notes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AccountProvider {
|
||||
OpenAi,
|
||||
@@ -18,19 +30,22 @@ impl AccountProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入统一设置,复用编辑账号的调度和分组约束。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
/// 导入统一设置,复用编辑账号的备注、调度和分组约束。
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportSettingsRequest {
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountImportSettingsRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
validate_account_notes(self.notes.as_deref())?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
@@ -41,16 +56,18 @@ impl AccountImportSettingsRequest {
|
||||
self,
|
||||
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
|
||||
Ok(gateway_admin::model::accounts::AccountImportSettings {
|
||||
notes: self.notes,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
@@ -200,16 +217,19 @@ pub struct UpdateAccountRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
pub notes: Option<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl UpdateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
validate_account_notes(self.notes.as_deref())?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
@@ -224,9 +244,11 @@ impl UpdateAccountRequest {
|
||||
self.outbound_proxy_url,
|
||||
)?,
|
||||
account_id: self.account_id,
|
||||
notes: self.notes,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
model_access: self.model_access,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
@@ -297,9 +319,13 @@ impl AccountDeletionRequest {
|
||||
pub struct RotateAccountRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub access_token: String,
|
||||
pub access_token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub id_token: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub transport: Option<String>,
|
||||
pub settings: Option<UpdateAccountRequest>,
|
||||
}
|
||||
|
||||
impl RotateAccountRequest {
|
||||
@@ -308,11 +334,40 @@ impl RotateAccountRequest {
|
||||
return Err(WireValidationError::new("provider"));
|
||||
}
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
validate_oauth_material(
|
||||
&self.access_token,
|
||||
self.refresh_token.as_deref(),
|
||||
self.id_token.as_deref(),
|
||||
)
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
if settings.account_id != self.account_id {
|
||||
return Err(WireValidationError::new("settings.accountId"));
|
||||
}
|
||||
}
|
||||
if let Some(base_url) = &self.base_url {
|
||||
if self.access_token.is_some()
|
||||
|| self.refresh_token.is_some()
|
||||
|| self.id_token.is_some()
|
||||
|| base_url.is_empty()
|
||||
|| base_url.len() > 2048
|
||||
|| !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket"))
|
||||
|| self.api_key.as_ref().is_some_and(|key| {
|
||||
key.is_empty()
|
||||
|| key.len() > 16 * 1024
|
||||
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
})
|
||||
{
|
||||
return Err(WireValidationError::new("credential"));
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
if self.api_key.is_some() || self.transport.is_some() {
|
||||
return Err(WireValidationError::new("credential"));
|
||||
}
|
||||
validate_oauth_material(
|
||||
self.access_token
|
||||
.as_deref()
|
||||
.ok_or_else(|| WireValidationError::new("accessToken"))?,
|
||||
self.refresh_token.as_deref(),
|
||||
self.id_token.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
@@ -321,16 +376,34 @@ impl RotateAccountRequest {
|
||||
) -> Result<RotateCredential, WireValidationError> {
|
||||
self.validate()?;
|
||||
let mut material = Map::new();
|
||||
material.insert("access_token".to_owned(), Value::String(self.access_token));
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
self.refresh_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"id_token".to_owned(),
|
||||
self.id_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
if let Some(base_url) = self.base_url {
|
||||
material.insert("base_url".to_owned(), Value::String(base_url));
|
||||
material.insert(
|
||||
"transport".to_owned(),
|
||||
self.transport.map_or(Value::Null, Value::String),
|
||||
);
|
||||
if let Some(key) = self.api_key {
|
||||
material.insert("api_key".to_owned(), Value::String(key));
|
||||
}
|
||||
} else {
|
||||
material.insert(
|
||||
"access_token".to_owned(),
|
||||
self.access_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
self.refresh_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"id_token".to_owned(),
|
||||
self.id_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
}
|
||||
Ok(RotateCredential {
|
||||
settings: self
|
||||
.settings
|
||||
.map(UpdateAccountRequest::into_command)
|
||||
.transpose()?,
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! 账号管理路由与 HTTP handler 编排。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 构造统一账号管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.merge(super::import_tasks::router::<S>())
|
||||
.route("/api/admin/accounts", get(list_accounts::<S>))
|
||||
.route("/api/admin/accounts/detail", get(account_detail::<S>))
|
||||
.route("/api/admin/accounts/export", get(export_accounts::<S>))
|
||||
@@ -23,8 +26,12 @@ where
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/profile-statistics",
|
||||
get(account_profile_statistics::<S>),
|
||||
"/api/admin/accounts/personal-info",
|
||||
get(account_personal_info::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/quota-forecast",
|
||||
get(account_quota_forecast::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-avatar",
|
||||
@@ -63,7 +70,7 @@ async fn batch_update_accounts<S>(
|
||||
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -84,7 +91,7 @@ async fn list_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = query.validate().map_err(map_wire_error)?;
|
||||
let page = command.page;
|
||||
@@ -105,7 +112,7 @@ async fn account_detail<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -114,8 +121,15 @@ where
|
||||
.quota(&account_id, false)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
let configuration = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.account_configuration(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountDetailData {
|
||||
account: account_view(result, Utc::now()),
|
||||
credential_configuration: configuration.map(provider_document_value),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
@@ -126,7 +140,7 @@ async fn export_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<AccountExportQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let ids = query.into_ids().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -149,7 +163,7 @@ async fn import_accounts<S>(
|
||||
AdminJson(request): AdminJson<AccountImportRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -177,7 +191,7 @@ async fn start_account_authorization<S>(
|
||||
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -211,7 +225,7 @@ async fn complete_account_authorization<S>(
|
||||
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -245,7 +259,7 @@ async fn rotate_account<S>(
|
||||
AdminJson(request): AdminJson<RotateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -268,7 +282,7 @@ async fn update_account<S>(
|
||||
AdminJson(request): AdminJson<UpdateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -289,7 +303,7 @@ async fn delete_accounts<S>(
|
||||
AdminJson(request): AdminJson<AccountDeletionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
@@ -311,7 +325,7 @@ async fn refresh_account<S>(
|
||||
AdminJson(request): AdminJson<AccountRefreshRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -330,7 +344,7 @@ async fn recover_account<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -349,7 +363,7 @@ async fn account_quota<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -364,24 +378,45 @@ where
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_profile_statistics<S>(
|
||||
async fn account_quota_forecast<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_statistics(&account_id)
|
||||
.quota_forecast(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
|
||||
AdminEnvelope::ok(AccountQuotaForecastData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_personal_info<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.personal_info(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountPersonalInfoData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -391,7 +426,7 @@ async fn account_profile_avatar<S>(
|
||||
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let avatar = state
|
||||
@@ -454,7 +489,7 @@ async fn refresh_account_quota<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -475,7 +510,7 @@ async fn account_reset_credits<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -496,7 +531,7 @@ async fn consume_account_reset_credit<S>(
|
||||
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -517,7 +552,7 @@ async fn account_models<S>(
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -536,7 +571,7 @@ async fn refresh_account_models<S>(
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -555,7 +590,7 @@ async fn test_account_connection<S>(
|
||||
AdminQuery(query): AdminQuery<AccountTestQuery>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
|
||||
let stream = state
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
//! 后台导入任务的有界请求与安全结果投影。
|
||||
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use gateway_admin::model::import_tasks::{
|
||||
ImportTaskDetail, ImportTaskInput, ImportTaskSummary, MAX_IMPORT_TASK_ITEMS, SubmitImportTask,
|
||||
};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use super::*;
|
||||
use crate::auth::SessionState;
|
||||
|
||||
pub(super) fn router<S>() -> Router<S>
|
||||
where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/admin/accounts/import-tasks",
|
||||
get(list::<S>).post(submit::<S>),
|
||||
)
|
||||
.route("/api/admin/accounts/import-tasks/detail", get(detail::<S>))
|
||||
.route("/api/admin/accounts/import-tasks/stop", post(stop::<S>))
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SubmitRequest {
|
||||
submission_id: String,
|
||||
items: Vec<AccountImportRequest>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct TaskId {
|
||||
task_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SummaryView {
|
||||
task_id: String,
|
||||
created_at: String,
|
||||
finished_at: Option<String>,
|
||||
stop_requested: bool,
|
||||
total: usize,
|
||||
counts: CountsView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CountsView {
|
||||
pending: usize,
|
||||
running: usize,
|
||||
succeeded: usize,
|
||||
failed: usize,
|
||||
unknown: usize,
|
||||
skipped: usize,
|
||||
imported_accounts: usize,
|
||||
}
|
||||
|
||||
impl From<ImportTaskSummary> for SummaryView {
|
||||
fn from(task: ImportTaskSummary) -> Self {
|
||||
let counts = task.counts;
|
||||
Self {
|
||||
task_id: task.task_id.to_string(),
|
||||
created_at: task.created_at.to_rfc3339(),
|
||||
finished_at: task.finished_at.map(|at| at.to_rfc3339()),
|
||||
stop_requested: task.stop_requested,
|
||||
total: task.total,
|
||||
counts: CountsView {
|
||||
pending: counts.pending,
|
||||
running: counts.running,
|
||||
succeeded: counts.succeeded,
|
||||
failed: counts.failed,
|
||||
unknown: counts.unknown,
|
||||
skipped: counts.skipped,
|
||||
imported_accounts: counts.imported_accounts,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DetailView {
|
||||
#[serde(flatten)]
|
||||
summary: SummaryView,
|
||||
items: Vec<ItemView>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ItemView {
|
||||
index: usize,
|
||||
provider: String,
|
||||
status: &'static str,
|
||||
account_ids: Vec<String>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ImportTaskDetail> for DetailView {
|
||||
fn from(task: ImportTaskDetail) -> Self {
|
||||
Self {
|
||||
summary: task.summary.into(),
|
||||
items: task
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| ItemView {
|
||||
index: item.index,
|
||||
provider: item.provider.to_string(),
|
||||
status: item.status.as_str(),
|
||||
account_ids: item
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect(),
|
||||
message: item.message,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ListView {
|
||||
items: Vec<SummaryView>,
|
||||
}
|
||||
|
||||
async fn submit<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<SubmitRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let submission_id = Uuid::parse_str(&request.submission_id)
|
||||
.map_err(|_| map_wire_error(WireValidationError::new("submissionId")))?;
|
||||
if submission_id.is_nil() {
|
||||
return Err(map_wire_error(WireValidationError::new("submissionId")));
|
||||
}
|
||||
if request.items.is_empty() || request.items.len() > MAX_IMPORT_TASK_ITEMS {
|
||||
return Err(map_wire_error(WireValidationError::new("items")));
|
||||
}
|
||||
let encoded = serde_json::to_vec(&request.items)
|
||||
.map_err(|_| map_wire_error(WireValidationError::new("items")))?;
|
||||
let fingerprint = Sha256::digest(&encoded).into();
|
||||
drop(encoded);
|
||||
let context = auth.context().mutation_context();
|
||||
let items = request
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let (provider, command) = item.into_command(context.clone())?;
|
||||
let provider = ProviderKind::new(match provider {
|
||||
AccountProvider::OpenAi => "openai",
|
||||
AccountProvider::Xai => "xai",
|
||||
})
|
||||
.map_err(|_| WireValidationError::new("provider"))?;
|
||||
Ok(ImportTaskInput { provider, command })
|
||||
})
|
||||
.collect::<Result<Vec<_>, WireValidationError>>()
|
||||
.map_err(map_wire_error)?;
|
||||
let task = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
.submit(SubmitImportTask {
|
||||
submission_id,
|
||||
fingerprint,
|
||||
context,
|
||||
items,
|
||||
})
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::ACCEPTED,
|
||||
AdminEnvelope::ok(SummaryView::from(task)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list<S>(auth: AdminAuth, State(state): State<S>) -> impl IntoResponse
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let items = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
.list(&auth.context().mutation_context())
|
||||
.into_iter()
|
||||
.map(SummaryView::from)
|
||||
.collect();
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(ListView { items }))
|
||||
}
|
||||
|
||||
async fn detail<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<TaskId>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let task = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
.detail(
|
||||
&auth.context().mutation_context(),
|
||||
parse_task_id(&query.task_id)?,
|
||||
)
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DetailView::from(task)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn stop<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<TaskId>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let task = state
|
||||
.admin_services()
|
||||
.import_tasks()
|
||||
.stop(
|
||||
&auth.context().mutation_context(),
|
||||
parse_task_id(&request.task_id)?,
|
||||
)
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DetailView::from(task)),
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_task_id(value: &str) -> Result<Uuid, AdminError> {
|
||||
Uuid::parse_str(value).map_err(|_| map_wire_error(WireValidationError::new("taskId")))
|
||||
}
|
||||
@@ -25,14 +25,15 @@ use gateway_admin::model::{
|
||||
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
|
||||
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
|
||||
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
|
||||
ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
|
||||
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
|
||||
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
|
||||
StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
@@ -45,8 +46,8 @@ use uuid::{Uuid, Version};
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta, WireValidationError,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
WireValidationError,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
@@ -65,6 +66,7 @@ const MAX_AVATAR_VERSION_BYTES: usize = 32;
|
||||
|
||||
mod credentials;
|
||||
mod handlers;
|
||||
mod import_tasks;
|
||||
mod presenter;
|
||||
mod wire;
|
||||
|
||||
@@ -74,8 +76,8 @@ pub(super) use presenter::quota_window_view;
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
|
||||
provider_document_value, require_account_id, validate_wire_group_ids,
|
||||
AccountProvider, parse_account_weight, parse_concurrency_limit, provider_document_value,
|
||||
require_account_id, validate_wire_group_ids,
|
||||
};
|
||||
use presenter::*;
|
||||
use wire::BatchUpdatedAccountsData;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Admin 领域结果到安全 HTTP wire 的展示投影。
|
||||
|
||||
use super::*;
|
||||
use gateway_admin::model::quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport};
|
||||
|
||||
pub(super) fn account_page_data(
|
||||
result: AccountDirectoryPage,
|
||||
@@ -65,14 +66,20 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
quota,
|
||||
} = item;
|
||||
let status = projection.status.as_str().to_owned();
|
||||
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
|
||||
let cooldown = projection.cooldown;
|
||||
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
|
||||
let added_at = china_rfc3339(&account.created_at);
|
||||
let updated_at = china_rfc3339(&account.updated_at);
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
|
||||
let usage_period = quota.usage_window().map(|(_, period)| period);
|
||||
let mut usage = account_usage_view(usage, usage_period, now);
|
||||
if account.authentication_kind == "api_key" {
|
||||
usage.window_label_display = "通用额度".to_owned();
|
||||
}
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now);
|
||||
AccountView {
|
||||
id: account.id.clone(),
|
||||
name: account.name,
|
||||
notes: account.notes,
|
||||
provider: account.provider_kind.to_string(),
|
||||
groups: account
|
||||
.groups
|
||||
@@ -101,6 +108,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
@@ -117,13 +125,71 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
updated_at,
|
||||
updated_at_display: china_datetime(&account.updated_at),
|
||||
quota,
|
||||
usage: account_usage_view(usage, now),
|
||||
usage,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountQuotaForecastReport> for AccountQuotaForecastData {
|
||||
fn from(report: AccountQuotaForecastReport) -> Self {
|
||||
Self {
|
||||
account_id: report.account_id,
|
||||
generated_at: china_rfc3339(&report.generated_at),
|
||||
forecasts: report
|
||||
.forecasts
|
||||
.into_iter()
|
||||
.map(quota_forecast_view)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_forecast_view(forecast: AccountQuotaForecast) -> AccountQuotaForecastView {
|
||||
AccountQuotaForecastView {
|
||||
period: match forecast.period {
|
||||
AccountUsagePeriod::Weekly => "weekly",
|
||||
AccountUsagePeriod::Monthly => "monthly",
|
||||
},
|
||||
target_days: forecast.target_seconds as f64 / 86_400.0,
|
||||
extrapolated: forecast.extrapolated,
|
||||
source: forecast.source.map(|source| QuotaForecastSourceView {
|
||||
label: source.label,
|
||||
used_percent: source.used_percent,
|
||||
used_percent_display: source
|
||||
.used_percent
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
observed_at: source.observed_at.map(|value| china_rfc3339(&value)),
|
||||
observed_at_display: source
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
reset_at: china_rfc3339(&source.reset_at),
|
||||
tokens_display: display_optional_tokens(source.tokens),
|
||||
usd_display: forecast_usd_display(source.usd),
|
||||
}),
|
||||
unavailable_reason: forecast.unavailable_reason,
|
||||
low_sample: forecast.low_sample,
|
||||
incomplete_cost: forecast.incomplete_cost,
|
||||
incomplete_tokens: forecast.incomplete_tokens,
|
||||
estimated_tokens: forecast.estimated_tokens,
|
||||
estimated_tokens_display: display_optional_tokens(forecast.estimated_tokens),
|
||||
estimated_usd: forecast.estimated_usd,
|
||||
estimated_usd_display: forecast_usd_display(forecast.estimated_usd),
|
||||
remaining_tokens: forecast.remaining_tokens,
|
||||
remaining_tokens_display: display_optional_tokens(forecast.remaining_tokens),
|
||||
remaining_usd: forecast.remaining_usd,
|
||||
remaining_usd_display: forecast_usd_display(forecast.remaining_usd),
|
||||
}
|
||||
}
|
||||
|
||||
fn forecast_usd_display(value: Option<f64>) -> String {
|
||||
value.map_or_else(
|
||||
|| "—".to_owned(),
|
||||
|value| format_decimal_currency(&format!("{value:.10}"), "USD"),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn account_quota_view(
|
||||
mut quota: ProviderQuota,
|
||||
rate_limited_until: Option<DateTime<Utc>>,
|
||||
cooldown: Option<gateway_core::account::AccountCooldown>,
|
||||
now: DateTime<Utc>,
|
||||
) -> (AccountQuotaView, Option<String>) {
|
||||
quota.apply_limit_reached_display();
|
||||
@@ -134,12 +200,22 @@ pub(super) fn account_quota_view(
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
|
||||
let windows = quota.windows.into_iter().map(quota_window_view).collect();
|
||||
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
|
||||
let rate_limited_until = cooldown.map(|value| china_datetime(&value.until.into()));
|
||||
let rate_limit_reason = cooldown.map(|value| {
|
||||
if value.kind.is_capacity_freeze() {
|
||||
"capacity_freeze".to_owned()
|
||||
} else {
|
||||
"upstream_rate_limit".to_owned()
|
||||
}
|
||||
});
|
||||
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
|
||||
(
|
||||
AccountQuotaView {
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
rate_limit_reason,
|
||||
recovery_probe_required,
|
||||
windows,
|
||||
},
|
||||
refresh_token_expires_at,
|
||||
@@ -211,6 +287,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
|
||||
|
||||
pub(super) fn account_usage_view(
|
||||
usage: Option<AccountUsage>,
|
||||
period: Option<AccountUsagePeriod>,
|
||||
now: DateTime<Utc>,
|
||||
) -> AccountUsageView {
|
||||
let Some(usage) = usage else {
|
||||
@@ -228,6 +305,12 @@ pub(super) fn account_usage_view(
|
||||
"known"
|
||||
};
|
||||
AccountUsageView {
|
||||
window_label_display: match period {
|
||||
Some(AccountUsagePeriod::Weekly) => "周额度窗口",
|
||||
Some(AccountUsagePeriod::Monthly) => "月额度窗口",
|
||||
None => "周/月额度窗口",
|
||||
}
|
||||
.to_owned(),
|
||||
request_count: Some(usage.request_count),
|
||||
request_count_display: format_number(usage.request_count),
|
||||
input_tokens: usage.input_tokens,
|
||||
@@ -347,6 +430,7 @@ pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
|
||||
|
||||
pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
AccountUsageView {
|
||||
window_label_display: "周/月额度窗口".to_owned(),
|
||||
request_count: None,
|
||||
request_count_display: "—".to_owned(),
|
||||
input_tokens: None,
|
||||
|
||||
@@ -64,11 +64,12 @@ pub struct BatchUpdateAccountsRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_nullable_limit")]
|
||||
pub concurrency_limit: Option<Option<u64>>,
|
||||
pub weight: Option<u64>,
|
||||
pub model_access: Option<gateway_core::account::AccountModelAccess>,
|
||||
pub group_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -91,6 +92,12 @@ impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_nullable_limit<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<u64>>, D::Error> {
|
||||
Option::<u64>::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
impl BatchUpdateAccountsRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.account_ids.is_empty()
|
||||
@@ -103,9 +110,23 @@ impl BatchUpdateAccountsRequest {
|
||||
{
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
if let Some(group_ids) = &self.group_ids {
|
||||
validate_wire_group_ids(group_ids)?;
|
||||
}
|
||||
self.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
&& self.group_ids.is_none()
|
||||
&& self.model_access.is_none()
|
||||
&& self.outbound_proxy_id.is_none()
|
||||
&& self.outbound_proxy_url.is_none()
|
||||
{
|
||||
return Err(WireValidationError::new("accountSettings"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -115,9 +136,17 @@ impl BatchUpdateAccountsRequest {
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
concurrency_limit: self
|
||||
.concurrency_limit
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?,
|
||||
weight: self.weight.map(parse_account_weight).transpose()?,
|
||||
model_access: self.model_access,
|
||||
group_ids: self
|
||||
.group_ids
|
||||
.as_deref()
|
||||
.map(validate_wire_group_ids)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,6 +269,7 @@ pub struct AccountView {
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub notes: Option<String>,
|
||||
pub provider: String,
|
||||
pub groups: Vec<AccountGroupRefView>,
|
||||
pub resource_ref: String,
|
||||
@@ -260,6 +290,7 @@ pub struct AccountView {
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<u32>,
|
||||
pub weight: u16,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub access_token_expires_at: Option<String>,
|
||||
pub access_token_expires_at_display: Option<String>,
|
||||
pub refresh_token_expires_at: Option<String>,
|
||||
@@ -272,6 +303,49 @@ pub struct AccountView {
|
||||
pub usage: AccountUsageView,
|
||||
}
|
||||
|
||||
/// 容量估算仅供管理端展示;金额不是订阅账单或可消费余额。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaForecastData {
|
||||
pub account_id: String,
|
||||
pub generated_at: String,
|
||||
pub forecasts: Vec<AccountQuotaForecastView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaForecastView {
|
||||
pub period: &'static str,
|
||||
pub target_days: f64,
|
||||
pub extrapolated: bool,
|
||||
pub source: Option<QuotaForecastSourceView>,
|
||||
pub unavailable_reason: Option<&'static str>,
|
||||
pub low_sample: bool,
|
||||
pub incomplete_cost: bool,
|
||||
pub incomplete_tokens: bool,
|
||||
pub estimated_tokens: Option<u64>,
|
||||
pub estimated_tokens_display: String,
|
||||
pub estimated_usd: Option<f64>,
|
||||
pub estimated_usd_display: String,
|
||||
pub remaining_tokens: Option<u64>,
|
||||
pub remaining_tokens_display: String,
|
||||
pub remaining_usd: Option<f64>,
|
||||
pub remaining_usd_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuotaForecastSourceView {
|
||||
pub label: String,
|
||||
pub used_percent: Option<f64>,
|
||||
pub used_percent_display: String,
|
||||
pub observed_at: Option<String>,
|
||||
pub observed_at_display: String,
|
||||
pub reset_at: String,
|
||||
pub tokens_display: String,
|
||||
pub usd_display: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountGroupRefView {
|
||||
@@ -287,8 +361,10 @@ pub struct AccountGroupRefView {
|
||||
pub struct AccountQuotaView {
|
||||
pub refreshed_at_display: String,
|
||||
pub limit_reached: bool,
|
||||
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
|
||||
/// 冷却到期或可开始恢复探测的时间;非限流中为 `null`。
|
||||
pub rate_limited_until: Option<String>,
|
||||
pub rate_limit_reason: Option<String>,
|
||||
pub recovery_probe_required: bool,
|
||||
pub windows: Vec<AccountQuotaWindowView>,
|
||||
}
|
||||
|
||||
@@ -316,6 +392,7 @@ pub struct AccountQuotaWindowView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountUsageView {
|
||||
pub window_label_display: String,
|
||||
pub request_count: Option<u64>,
|
||||
pub request_count_display: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -609,6 +686,62 @@ pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDetailData {
|
||||
pub account: AccountView,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credential_configuration: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 个人资料、累计统计与订阅的统一响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPersonalInfoData {
|
||||
pub profile: Option<AccountProfileStatisticsData>,
|
||||
pub profile_error: Option<String>,
|
||||
pub subscription: Option<AccountSubscriptionData>,
|
||||
}
|
||||
|
||||
impl From<AccountPersonalInfo> for AccountPersonalInfoData {
|
||||
fn from(info: AccountPersonalInfo) -> Self {
|
||||
let (profile, profile_error) = match info.profile {
|
||||
Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None),
|
||||
Err(error) => (None, Some(error.message().to_owned())),
|
||||
};
|
||||
Self {
|
||||
profile,
|
||||
profile_error,
|
||||
subscription: info.subscription.map(AccountSubscriptionData::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按需读取的订阅安全字段,不暴露原始上游响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSubscriptionData {
|
||||
pub starts_at: Option<String>,
|
||||
pub expires_at: String,
|
||||
pub will_renew: Option<bool>,
|
||||
pub billing_period: Option<String>,
|
||||
pub billing_currency: Option<String>,
|
||||
pub observed_at: String,
|
||||
}
|
||||
|
||||
impl From<ProviderSubscription> for AccountSubscriptionData {
|
||||
fn from(subscription: ProviderSubscription) -> Self {
|
||||
Self {
|
||||
starts_at: subscription.starts_at.map(|value| value.to_rfc3339()),
|
||||
expires_at: subscription.expires_at.to_rfc3339(),
|
||||
will_renew: subscription.will_renew,
|
||||
billing_period: subscription.billing_period,
|
||||
billing_currency: subscription.billing_currency,
|
||||
observed_at: subscription.observed_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料统计响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
//! 管理员登录、会话与管理请求鉴权接线。
|
||||
|
||||
use std::fmt;
|
||||
//! 管理端权限校验与请求审计上下文。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{FromRequestParts, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
extract::FromRequestParts,
|
||||
http::{HeaderMap, request::Parts},
|
||||
};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use gateway_admin::model::auth::{AdminPrincipal, AdminRequestContext};
|
||||
use tower_http::request_id::RequestId;
|
||||
|
||||
use super::{AdminEnvelope, AdminError, AdminJson, AdminResponse};
|
||||
use crate::{auth::SessionState, session_cookie};
|
||||
|
||||
use super::{AdminError, wire::map_admin_service_error};
|
||||
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
|
||||
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
|
||||
|
||||
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
|
||||
pub trait AdminSessionState {
|
||||
fn admin_services(&self) -> &AdminServices;
|
||||
}
|
||||
|
||||
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
|
||||
pub struct AdminAuth {
|
||||
@@ -41,7 +27,7 @@ impl AdminAuth {
|
||||
|
||||
impl<S> FromRequestParts<S> for AdminAuth
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
type Rejection = AdminError;
|
||||
|
||||
@@ -74,23 +60,23 @@ fn admin_request_id(parts: &Parts) -> Option<String> {
|
||||
|
||||
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
match state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
|
||||
.resolve_admin_user_id(session_cookie::value(headers).as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(admin_user_id)) => Ok(admin_user_id),
|
||||
Ok(None) => Err(AdminError::admin_session_required()),
|
||||
Err(_) => Err(AdminError::internal()),
|
||||
Ok(None) => Err(AdminError::session_required()),
|
||||
Err(error) => Err(map_admin_service_error(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
if let Some(api_key) = admin_api_key_header(headers) {
|
||||
return match state
|
||||
@@ -101,7 +87,7 @@ where
|
||||
{
|
||||
Ok(true) => Ok(AdminPrincipal::ApiKey),
|
||||
Ok(false) => Err(AdminError::invalid_admin_api_key()),
|
||||
Err(_) => Err(AdminError::internal()),
|
||||
Err(error) => Err(map_admin_service_error(error)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,171 +100,3 @@ fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
|
||||
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
/// 管理员登录请求;密码不得进入 Debug。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminLoginRequest {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl AdminLoginRequest {
|
||||
/// 取出认证用例所需字段。
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Option<String>, String) {
|
||||
(self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AdminLoginRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminLoginData {
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl AdminLoginData {
|
||||
#[must_use]
|
||||
pub fn new(expires_at: String) -> Self {
|
||||
Self { expires_at }
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员会话状态响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSessionStatusData {
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
impl AdminSessionStatusData {
|
||||
#[must_use]
|
||||
pub const fn new(authenticated: bool) -> Self {
|
||||
Self { authenticated }
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出成功响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct AdminLogoutData {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
impl AdminLogoutData {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
message: "Logged out successfully",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdminLogoutData {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 管理员认证路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/auth/login", post(login::<S>))
|
||||
.route("/api/admin/auth/status", get(session_status::<S>))
|
||||
.route("/api/admin/auth/logout", post(logout::<S>))
|
||||
}
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
AdminJson(payload): AdminJson<AdminLoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (username, password) = payload.into_parts();
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(LoginCommand { username, password })
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
|
||||
)
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
|
||||
session.session_id
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn session_status<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let authenticated = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.validate_session(admin_session_cookie(&headers).as_deref())
|
||||
.await
|
||||
.map_err(|_| AdminError::internal())?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
if let Some(session_id) = admin_session_cookie(&headers) {
|
||||
let _ = state.admin_services().auth().logout(&session_id).await;
|
||||
}
|
||||
let mut response =
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
|
||||
.into_response();
|
||||
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
let cookie = headers.get("cookie")?.to_str().ok()?;
|
||||
cookie.split(';').find_map(|part| {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
|
||||
LoginError::Unavailable => AdminError::internal(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! 备份设置、计划、手动创建、记录、下载与删除路由。
|
||||
|
||||
//!
|
||||
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
@@ -19,7 +22,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminPageData, AdminQuery, AdminResponse,
|
||||
AdminSessionState, PageMeta, wire::map_admin_service_error,
|
||||
PageMeta, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
|
||||
@@ -200,7 +203,7 @@ impl From<ConnectionTestResult> for ConnectionTestView {
|
||||
/// 构造固定 GET/POST 备份路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings/backups", get(backup_settings::<S>))
|
||||
@@ -239,7 +242,7 @@ async fn backup_settings<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let settings = state
|
||||
.admin_services()
|
||||
@@ -259,7 +262,7 @@ async fn update_backup_storage<S>(
|
||||
AdminJson(request): AdminJson<UpdateBackupStorageRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupStorageCommand {
|
||||
endpoint: request.endpoint,
|
||||
@@ -287,7 +290,7 @@ async fn test_backup_storage<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -307,7 +310,7 @@ async fn update_backup_schedule<S>(
|
||||
AdminJson(request): AdminJson<UpdateBackupScheduleRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupScheduleCommand {
|
||||
schedule_enabled: request.schedule_enabled,
|
||||
@@ -334,7 +337,7 @@ async fn backup_records<S>(
|
||||
AdminQuery(query): AdminQuery<BackupRecordsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("pageSize 不合法"))?;
|
||||
@@ -389,7 +392,7 @@ async fn create_backup<S>(
|
||||
body: Option<AdminJson<CreateBackupRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let expires_at = body
|
||||
.and_then(|body| body.0.expires_in_days)
|
||||
@@ -420,7 +423,7 @@ async fn download_backup_url<S>(
|
||||
AdminJson(request): AdminJson<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -440,7 +443,7 @@ async fn delete_backup<S>(
|
||||
AdminJson(request): AdminJson<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let record = state
|
||||
.admin_services()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Client API Key 管理 wire contract。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -13,7 +15,7 @@ use gateway_admin::model::client_keys::{
|
||||
use gateway_core::{
|
||||
engine::budget::ClientBudgetLimits,
|
||||
metering::Decimal,
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,7 +29,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -70,9 +72,7 @@ impl ListClientKeysQuery {
|
||||
}
|
||||
let search = self.search.map(|search| search.trim().to_owned());
|
||||
if search.as_deref().is_some_and(|search| {
|
||||
search.len() > MAX_SEARCH_BYTES
|
||||
|| search.chars().any(char::is_control)
|
||||
|| contains_client_key_material(search)
|
||||
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(WireValidationError::new("search"));
|
||||
}
|
||||
@@ -143,9 +143,10 @@ impl ClientKeySort {
|
||||
}
|
||||
|
||||
/// 创建 Client Key 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[derive(Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
custom_key: Option<String>,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
group_ids: Vec<String>,
|
||||
@@ -155,6 +156,16 @@ pub struct CreateClientKeyRequest {
|
||||
weekly_limit_usd: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreateClientKeyRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreateClientKeyRequest")
|
||||
.field("name", &self.name)
|
||||
.field("custom_key", &"[REDACTED]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
@@ -164,6 +175,12 @@ impl CreateClientKeyRequest {
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
custom_key: self
|
||||
.custom_key
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(PlaintextClientApiKey::new)
|
||||
.transpose()
|
||||
.map_err(|_| WireValidationError::new("customKey"))?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
group_ids,
|
||||
@@ -661,19 +678,10 @@ fn validate_optional_text(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contains_client_key_material(value: &str) -> bool {
|
||||
value.as_bytes().windows(46).any(|window| {
|
||||
&window[..3] == b"sk_"
|
||||
&& window[3..]
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/client-keys", get(list_client_keys::<S>))
|
||||
@@ -706,7 +714,7 @@ async fn list_client_keys<S>(
|
||||
AdminQuery(query): AdminQuery<ListClientKeysQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -724,7 +732,7 @@ async fn create_client_key<S>(
|
||||
AdminJson(payload): AdminJson<CreateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -745,7 +753,7 @@ async fn reveal_client_key<S>(
|
||||
AdminQuery(query): AdminQuery<ClientKeyIdQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = query.into_domain_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -771,7 +779,7 @@ async fn update_client_key<S>(
|
||||
AdminJson(payload): AdminJson<UpdateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -789,7 +797,7 @@ async fn disable_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -810,7 +818,7 @@ async fn enable_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
@@ -831,7 +839,7 @@ async fn delete_client_key<S>(
|
||||
AdminJson(payload): AdminJson<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 管理端 HTTP adapter、wire contract 与固定路由。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderValue, header},
|
||||
@@ -21,7 +23,7 @@ pub mod settings;
|
||||
pub mod system;
|
||||
pub mod wire;
|
||||
|
||||
pub use auth::{AdminAuth, AdminSessionState};
|
||||
pub use auth::AdminAuth;
|
||||
pub use extract::{AdminJson, AdminQuery};
|
||||
pub use wire::{
|
||||
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
|
||||
@@ -31,13 +33,12 @@ pub use wire::{
|
||||
/// 构造完整且固定的 `/api/admin` 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.merge(account_groups::router::<S>())
|
||||
.merge(proxies::router::<S>())
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(backups::router::<S>())
|
||||
.merge(client_keys::router::<S>())
|
||||
.merge(observability::router::<S>())
|
||||
|
||||
@@ -10,8 +10,8 @@ use serde_json::Value;
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, AdminSessionState,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminQuery, AdminResponse, WireValidationError,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
mod presenter;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! 固定 `/api/admin` 观测路由与 handler。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/dashboard/summary", get(dashboard_summary::<S>))
|
||||
@@ -35,7 +37,7 @@ pub(crate) async fn dashboard_summary<S>(
|
||||
AdminQuery(query): AdminQuery<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
|
||||
@@ -59,7 +61,7 @@ pub(crate) async fn dashboard_trend<S>(
|
||||
AdminQuery(query): AdminQuery<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
@@ -82,7 +84,7 @@ pub(crate) async fn usage_records<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = usage_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -101,7 +103,7 @@ pub(crate) async fn usage_record_detail<S>(
|
||||
AdminQuery(query): AdminQuery<DetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
query.validate().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -122,7 +124,7 @@ pub(crate) async fn usage_records_summary<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
@@ -145,7 +147,7 @@ pub(crate) async fn usage_insights_overview<S>(
|
||||
AdminQuery(query): AdminQuery<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
@@ -168,7 +170,7 @@ pub(crate) async fn usage_insights_diagnostics<S>(
|
||||
AdminQuery(query): AdminQuery<DiagnosticsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let dimension = query.dimension().map_err(map_wire_error)?;
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
@@ -198,7 +200,7 @@ pub(crate) async fn ops_errors<S>(
|
||||
AdminQuery(query): AdminQuery<OpsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = ops_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
@@ -15,8 +17,7 @@ use gateway_admin::model::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, PageMeta,
|
||||
accounts::{AccountGroupRefView, AccountProxyUpdate},
|
||||
};
|
||||
|
||||
@@ -47,6 +48,7 @@ struct RemoveAccountRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CreateRequest {
|
||||
location: Option<gateway_core::account::RequestLocation>,
|
||||
name: String,
|
||||
proxy_url: AccountProxyUpdate,
|
||||
}
|
||||
@@ -54,12 +56,23 @@ struct CreateRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpdateRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_location_update")]
|
||||
location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
id: String,
|
||||
revision: u64,
|
||||
name: String,
|
||||
proxy_url: Option<AccountProxyUpdate>,
|
||||
}
|
||||
|
||||
fn deserialize_location_update<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<Option<gateway_core::account::RequestLocation>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::deserialize(deserializer).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct IdRequest {
|
||||
@@ -110,6 +123,7 @@ struct ProxyAccountView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyView {
|
||||
location: Option<gateway_core::account::RequestLocation>,
|
||||
id: String,
|
||||
name: String,
|
||||
endpoint: String,
|
||||
@@ -126,6 +140,7 @@ impl From<ProxyRecord> for ProxyView {
|
||||
fn from(record: ProxyRecord) -> Self {
|
||||
let endpoint = record.proxy.endpoint();
|
||||
Self {
|
||||
location: record.location,
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
has_authentication: record.proxy.expose_url() != endpoint,
|
||||
@@ -170,7 +185,7 @@ impl From<ProxyMutation> for MutationView {
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/proxies", get(list::<S>))
|
||||
@@ -203,7 +218,7 @@ async fn list<S>(
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -237,7 +252,7 @@ async fn list_accounts<S>(
|
||||
AdminQuery(query): AdminQuery<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -295,7 +310,7 @@ async fn create<S>(
|
||||
AdminJson(request): AdminJson<CreateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -306,6 +321,7 @@ where
|
||||
.proxies()
|
||||
.create(
|
||||
NewProxy {
|
||||
location: request.location,
|
||||
name: request.name,
|
||||
proxy,
|
||||
},
|
||||
@@ -325,7 +341,7 @@ async fn remove_account<S>(
|
||||
AdminJson(request): AdminJson<RemoveAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let revision = state
|
||||
.admin_services()
|
||||
@@ -349,7 +365,7 @@ async fn update<S>(
|
||||
AdminJson(request): AdminJson<UpdateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -364,6 +380,7 @@ where
|
||||
.proxies()
|
||||
.update(
|
||||
UpdateProxy {
|
||||
location: request.location,
|
||||
id: request.id,
|
||||
revision: revision(request.revision)?,
|
||||
name: request.name,
|
||||
@@ -385,7 +402,7 @@ async fn delete<S>(
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -409,7 +426,7 @@ async fn probe<S>(
|
||||
AdminJson(request): AdminJson<ProbeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
@@ -433,7 +450,7 @@ async fn test<S>(
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use axum::{
|
||||
@@ -21,7 +23,7 @@ use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -32,17 +34,29 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u64,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -50,23 +64,49 @@ pub struct RuntimeSettingsView {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
pub account_auto_freeze_enabled: bool,
|
||||
pub account_auto_freeze_threshold: u64,
|
||||
pub account_auto_freeze_window_seconds: u64,
|
||||
pub account_auto_freeze_duration_seconds: u64,
|
||||
pub account_auto_freeze_probe_enabled: bool,
|
||||
pub account_auto_freeze_probe_model: Option<String>,
|
||||
pub account_auto_freeze_adaptive_concurrency: bool,
|
||||
}
|
||||
|
||||
impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
self.request_location
|
||||
.validate()
|
||||
.map_err(|_| WireValidationError::new("requestLocation"))?;
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.max_waiting_per_key, "maxWaitingPerKey"),
|
||||
(self.max_waiting_per_account, "maxWaitingPerAccount"),
|
||||
] {
|
||||
if value > 1_000 {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
}
|
||||
if !(1..=120).contains(&self.concurrency_wait_timeout_seconds) {
|
||||
return Err(WireValidationError::new("concurrencyWaitTimeoutSeconds"));
|
||||
}
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
(self.refresh_concurrency, "refreshConcurrency"),
|
||||
@@ -91,12 +131,46 @@ impl UpdateRuntimeSettingsRequest {
|
||||
self.min_codex_cli_version.as_deref(),
|
||||
"minCodexCliVersion",
|
||||
)?;
|
||||
for (value, field) in [
|
||||
(
|
||||
self.account_auto_freeze_threshold,
|
||||
"accountAutoFreezeThreshold",
|
||||
),
|
||||
(
|
||||
self.account_auto_freeze_window_seconds,
|
||||
"accountAutoFreezeWindowSeconds",
|
||||
),
|
||||
(
|
||||
self.account_auto_freeze_duration_seconds,
|
||||
"accountAutoFreezeDurationSeconds",
|
||||
),
|
||||
] {
|
||||
require_positive_i64(value, field)?;
|
||||
}
|
||||
if !(2..=1_000).contains(&self.account_auto_freeze_threshold) {
|
||||
return Err(WireValidationError::new("accountAutoFreezeThreshold"));
|
||||
}
|
||||
if !(60..=3_600).contains(&self.account_auto_freeze_window_seconds) {
|
||||
return Err(WireValidationError::new("accountAutoFreezeWindowSeconds"));
|
||||
}
|
||||
if !(300..=604_800).contains(&self.account_auto_freeze_duration_seconds) {
|
||||
return Err(WireValidationError::new("accountAutoFreezeDurationSeconds"));
|
||||
}
|
||||
validate_optional_probe_model(
|
||||
self.account_auto_freeze_probe_model.as_deref(),
|
||||
"accountAutoFreezeProbeModel",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ReplaceRuntimeSettings {
|
||||
request_location_enabled: self.request_location_enabled,
|
||||
request_location: self
|
||||
.request_location
|
||||
.normalized()
|
||||
.map_err(|_| WireValidationError::new("requestLocation"))?,
|
||||
model_mappings: domain_model_mappings(self.model_mappings)?,
|
||||
refresh_margin_seconds: self.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(self.refresh_concurrency)
|
||||
@@ -104,6 +178,9 @@ impl UpdateRuntimeSettingsRequest {
|
||||
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
|
||||
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
|
||||
request_interval_ms: self.request_interval_ms,
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
min_codex_desktop_version: self.min_codex_desktop_version,
|
||||
@@ -114,6 +191,14 @@ impl UpdateRuntimeSettingsRequest {
|
||||
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
|
||||
audit_retention_days: u32::try_from(self.audit_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
|
||||
account_auto_freeze_enabled: self.account_auto_freeze_enabled,
|
||||
account_auto_freeze_threshold: u32::try_from(self.account_auto_freeze_threshold)
|
||||
.map_err(|_| WireValidationError::new("settingsFreezeThresholdOverflow"))?,
|
||||
account_auto_freeze_window_seconds: self.account_auto_freeze_window_seconds,
|
||||
account_auto_freeze_duration_seconds: self.account_auto_freeze_duration_seconds,
|
||||
account_auto_freeze_probe_enabled: self.account_auto_freeze_probe_enabled,
|
||||
account_auto_freeze_probe_model: self.account_auto_freeze_probe_model,
|
||||
account_auto_freeze_adaptive_concurrency: self.account_auto_freeze_adaptive_concurrency,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,17 +206,30 @@ impl UpdateRuntimeSettingsRequest {
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: u64::from(settings.refresh_concurrency),
|
||||
max_concurrent_per_account: u64::from(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,
|
||||
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
usage_retention_days: u64::from(settings.usage_retention_days),
|
||||
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
|
||||
audit_retention_days: u64::from(settings.audit_retention_days),
|
||||
account_auto_freeze_enabled: settings.account_auto_freeze_enabled,
|
||||
account_auto_freeze_threshold: u64::from(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,
|
||||
updated_at: settings.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -228,7 +326,7 @@ impl Default for DeletedAdminApiKey {
|
||||
/// 构造固定 GET/POST 设置路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
@@ -257,7 +355,7 @@ async fn codex_desktop_windows_downloads<S>(
|
||||
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let downloads = state
|
||||
.admin_services()
|
||||
@@ -275,7 +373,7 @@ async fn settings<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -295,7 +393,7 @@ async fn update_settings<S>(
|
||||
AdminJson(request): AdminJson<UpdateRuntimeSettingsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
@@ -315,7 +413,7 @@ async fn admin_api_key_status<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let exists = state
|
||||
.admin_services()
|
||||
@@ -334,7 +432,7 @@ async fn regenerate_admin_api_key<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -355,7 +453,7 @@ async fn delete_admin_api_key<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
state
|
||||
.admin_services()
|
||||
@@ -427,6 +525,22 @@ fn validate_optional_client_version(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 探测模型为可选自由文本:非空、去首尾空白后不变、无控制字符且不超过 128 字节。
|
||||
fn validate_optional_probe_model(
|
||||
value: Option<&str>,
|
||||
field: &'static str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if value.is_some_and(|value| {
|
||||
value.is_empty()
|
||||
|| value.len() > 128
|
||||
|| value != value.trim()
|
||||
|| value.bytes().any(|byte| byte.is_ascii_control())
|
||||
}) {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
|
||||
@@ -434,6 +548,12 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
|
||||
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
|
||||
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
|
||||
"requestLocation" => "请求位置不合法,请检查国家代码、地区和城市".to_owned(),
|
||||
"settingsFreezeThresholdOverflow" => "accountAutoFreezeThreshold 不合法".to_owned(),
|
||||
"accountAutoFreezeThreshold" => "账号自动冻结阈值应为 2~1000 的整数".to_owned(),
|
||||
"accountAutoFreezeWindowSeconds" => "账号自动冻结统计窗口应为 60~3600 秒".to_owned(),
|
||||
"accountAutoFreezeDurationSeconds" => "账号自动冻结时长应为 300~604800 秒".to_owned(),
|
||||
"accountAutoFreezeProbeModel" => "探测模型格式不合法".to_owned(),
|
||||
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
|
||||
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
|
||||
field => format!("{field} 字段不合法"),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! 系统管理接口的查询与请求 wire contract。
|
||||
|
||||
//!
|
||||
//! 这里不依赖更新服务或进程控制;应用层通过窄端口提供系统操作事实。
|
||||
|
||||
use crate::auth::SessionState;
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
@@ -23,7 +26,7 @@ use gateway_admin::model::system::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -214,7 +217,7 @@ struct SystemUpdateEventView {
|
||||
/// 构造固定 GET/POST 系统管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/system/version", get(version::<S>))
|
||||
@@ -234,7 +237,7 @@ async fn version<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let version = state
|
||||
.admin_services()
|
||||
@@ -254,7 +257,7 @@ async fn update_detail<S>(
|
||||
AdminQuery(query): AdminQuery<UpdateDetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let detail = state
|
||||
.admin_services()
|
||||
@@ -273,7 +276,7 @@ async fn update_event_stream<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let stream = state
|
||||
.admin_services()
|
||||
@@ -293,7 +296,7 @@ async fn perform_update<S>(
|
||||
payload: Option<AdminJson<UpdateRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -328,7 +331,7 @@ async fn update_status<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let status = state
|
||||
.admin_services()
|
||||
@@ -347,7 +350,7 @@ async fn rollback<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
@@ -378,7 +381,7 @@ async fn restart<S>(
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
|
||||
@@ -96,17 +96,19 @@ impl AdminErrorCode {
|
||||
pub const INVALID_TIME_RANGE: Self = Self(40002);
|
||||
/// 模型来源不合法。
|
||||
pub const INVALID_MODEL_SOURCE: Self = Self(40003);
|
||||
/// 缺少管理员会话。
|
||||
/// 缺少登录会话。
|
||||
pub const SESSION_REQUIRED: Self = Self(40101);
|
||||
/// 管理员登录凭据错误。
|
||||
/// 登录凭据错误。
|
||||
pub const INVALID_CREDENTIALS: Self = Self(40102);
|
||||
/// 管理 API Key 错误。
|
||||
pub const INVALID_API_KEY: Self = Self(40103);
|
||||
/// 当前会话无权访问。
|
||||
pub const FORBIDDEN: Self = Self(40301);
|
||||
/// 资源不存在。
|
||||
pub const NOT_FOUND: Self = Self(40401);
|
||||
/// 配置 revision 或资源状态冲突。
|
||||
pub const CONFLICT: Self = Self(40901);
|
||||
/// 管理员登录尝试过多。
|
||||
/// 登录尝试过多。
|
||||
pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901);
|
||||
/// 设置持久化失败。
|
||||
pub const SETTINGS_PERSIST: Self = Self(50000);
|
||||
@@ -165,6 +167,7 @@ impl AdminErrorBody {
|
||||
pub struct AdminError {
|
||||
status: StatusCode,
|
||||
body: AdminErrorBody,
|
||||
retry_after_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -202,12 +205,12 @@ const INVALID_TIME_RANGE: AdminErrorSpec = AdminErrorSpec::new(
|
||||
const SESSION_REQUIRED: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::SESSION_REQUIRED,
|
||||
"需要管理员登录",
|
||||
"需要登录",
|
||||
);
|
||||
const INVALID_CREDENTIALS: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::INVALID_CREDENTIALS,
|
||||
"管理员用户名或密码错误",
|
||||
"登录凭据错误",
|
||||
);
|
||||
const INVALID_API_KEY: AdminErrorSpec = AdminErrorSpec::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -255,6 +258,7 @@ impl AdminError {
|
||||
Self {
|
||||
status,
|
||||
body: AdminErrorBody::new(code, message),
|
||||
retry_after_seconds: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,11 +290,11 @@ impl AdminError {
|
||||
Self::new(BAD_REQUEST.status, BAD_REQUEST.code, message)
|
||||
}
|
||||
|
||||
pub fn admin_session_required() -> Self {
|
||||
pub fn session_required() -> Self {
|
||||
Self::from_spec(SESSION_REQUIRED)
|
||||
}
|
||||
|
||||
pub fn invalid_admin_credentials() -> Self {
|
||||
pub fn invalid_credentials() -> Self {
|
||||
Self::from_spec(INVALID_CREDENTIALS)
|
||||
}
|
||||
|
||||
@@ -302,6 +306,10 @@ impl AdminError {
|
||||
Self::from_spec(TOO_MANY_LOGIN_ATTEMPTS)
|
||||
}
|
||||
|
||||
pub fn forbidden() -> Self {
|
||||
Self::new(StatusCode::FORBIDDEN, AdminErrorCode::FORBIDDEN, "无权访问")
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, AdminErrorCode::CONFLICT, message)
|
||||
}
|
||||
@@ -325,15 +333,22 @@ impl AdminError {
|
||||
pub fn service_unavailable() -> Self {
|
||||
Self::from_spec(SERVICE_UNAVAILABLE)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_after(mut self, seconds: u64) -> Self {
|
||||
self.retry_after_seconds = Some(seconds.max(1));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
|
||||
pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
use gateway_admin::model::AdminErrorKind;
|
||||
|
||||
match error.kind() {
|
||||
let mut response = match error.kind() {
|
||||
AdminErrorKind::Invalid => AdminError::bad_request(error.message()),
|
||||
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
|
||||
AdminErrorKind::Forbidden => AdminError::forbidden(),
|
||||
AdminErrorKind::Unauthorized => AdminError::session_required(),
|
||||
AdminErrorKind::NotFound => AdminError::not_found(error.message()),
|
||||
AdminErrorKind::Conflict => AdminError::conflict(error.message()),
|
||||
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
|
||||
@@ -341,12 +356,32 @@ pub(crate) fn map_admin_service_error(error: gateway_admin::model::AdminError) -
|
||||
AdminErrorKind::UpstreamResultUnknown => AdminError::upstream_result_unknown(),
|
||||
AdminErrorKind::Unavailable => AdminError::service_unavailable(),
|
||||
AdminErrorKind::Internal => AdminError::internal(),
|
||||
};
|
||||
// Admin owner 已负责公开文案,不能在 HTTP 边界再次丢掉安全的业务原因。
|
||||
// 认证与未知内部异常仍使用固定提示,不放行底层诊断或任意 500 消息。
|
||||
if matches!(
|
||||
error.kind(),
|
||||
AdminErrorKind::BadGateway
|
||||
| AdminErrorKind::UpstreamResultUnknown
|
||||
| AdminErrorKind::Unavailable
|
||||
) && !error.message().trim().is_empty()
|
||||
{
|
||||
response.body.message = error.message().to_owned();
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.body)).into_response()
|
||||
let mut response = (self.status, Json(self.body)).into_response();
|
||||
if let Some(seconds) = self.retry_after_seconds
|
||||
&& let Ok(value) = axum::http::HeaderValue::from_str(&seconds.to_string())
|
||||
{
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(axum::http::header::RETRY_AFTER, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! 控制面统一登录、会话恢复与退出;身份由认证用例返回。
|
||||
|
||||
use std::{fmt, net::SocketAddr};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get, post},
|
||||
};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::auth::{AuthSession, LoginCommand, LoginError, SessionSubject},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
admin::{AdminEnvelope, AdminError, AdminJson, AdminResponse, wire::map_admin_service_error},
|
||||
session_cookie,
|
||||
};
|
||||
|
||||
/// 控制面 HTTP adapter 消费同一组用例;权限由各入口服务端校验。
|
||||
pub trait SessionState {
|
||||
fn admin_services(&self) -> &AdminServices;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "mode", rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub enum LoginRequest {
|
||||
Admin {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
},
|
||||
Key {
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for LoginRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Admin { username, .. } => formatter
|
||||
.debug_struct("AdminLogin")
|
||||
.field("username", username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish(),
|
||||
Self::Key { .. } => formatter
|
||||
.debug_struct("KeyLogin")
|
||||
.field("api_key", &"[REDACTED]")
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LoginRequest> for LoginCommand {
|
||||
fn from(request: LoginRequest) -> Self {
|
||||
match request {
|
||||
LoginRequest::Admin { username, password } => Self::Admin { username, password },
|
||||
LoginRequest::Key { api_key } => Self::Key { api_key },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionData {
|
||||
role: &'static str,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl From<&AuthSession> for SessionData {
|
||||
fn from(session: &AuthSession) -> Self {
|
||||
Self {
|
||||
role: match session.subject {
|
||||
SessionSubject::Admin { .. } => "admin",
|
||||
SessionSubject::Key { .. } => "key",
|
||||
},
|
||||
expires_at: session.expires_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SessionStatusData {
|
||||
authenticated: bool,
|
||||
session: Option<SessionData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LogoutData {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) fn router<S>() -> Router<S>
|
||||
where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/auth/login", post(login::<S>))
|
||||
.route("/api/auth/status", get(session_status::<S>))
|
||||
.route("/api/auth/logout", post(logout::<S>))
|
||||
.route("/api/auth", any(not_found))
|
||||
.route("/api/auth/{*path}", any(not_found))
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
AdminJson(payload): AdminJson<LoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(
|
||||
payload.into(),
|
||||
peer.ip(),
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let max_age = result
|
||||
.session
|
||||
.expires_at
|
||||
.signed_duration_since(chrono::Utc::now())
|
||||
.num_seconds()
|
||||
.max(1);
|
||||
let expires = result
|
||||
.session
|
||||
.expires_at
|
||||
.format("%a, %d %b %Y %H:%M:%S GMT");
|
||||
let cookie = format!(
|
||||
"{}={}; {}; Max-Age={max_age}; Expires={expires}",
|
||||
session_cookie::NAME,
|
||||
result.session_id,
|
||||
session_cookie::attributes(&headers)
|
||||
);
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SessionData::from(&result.session)),
|
||||
)
|
||||
.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn session_status<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.session(session_cookie::value(&headers).as_deref())
|
||||
.await
|
||||
.map_err(map_admin_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SessionStatusData {
|
||||
authenticated: session.is_some(),
|
||||
session: session.as_ref().map(SessionData::from),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
if let Some(session_id) = session_cookie::value(&headers) {
|
||||
state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.logout(&session_id)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?;
|
||||
}
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogoutData {
|
||||
message: "Logged out successfully",
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{}=; {}; Max-Age=0",
|
||||
session_cookie::NAME,
|
||||
session_cookie::attributes(&headers)
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie).map_err(|_| AdminError::internal())?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_credentials(),
|
||||
LoginError::TooManyAttempts {
|
||||
retry_after_seconds,
|
||||
} => AdminError::too_many_login_attempts().with_retry_after(retry_after_seconds),
|
||||
LoginError::Unavailable => AdminError::service_unavailable(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn method_not_allowed() -> AdminError {
|
||||
AdminError::method_not_allowed()
|
||||
}
|
||||
async fn not_found() -> AdminError {
|
||||
AdminError::not_found("认证接口不存在")
|
||||
}
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! 当前 Key 的单页用量 API;只暴露页面需要的只读字段。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{any, get},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
admin::{AdminEnvelope, AdminError, AdminQuery, AdminResponse, wire::map_admin_service_error},
|
||||
auth::SessionState,
|
||||
session_cookie,
|
||||
};
|
||||
|
||||
mod presenter;
|
||||
mod query;
|
||||
|
||||
pub(crate) fn router<S>() -> Router<S>
|
||||
where
|
||||
S: SessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/key-usage/overview", get(overview::<S>))
|
||||
.route("/api/key-usage/records", get(records::<S>))
|
||||
.route("/api/key-usage", any(not_found))
|
||||
.route("/api/key-usage/{*path}", any(not_found))
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn overview<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(query): AdminQuery<query::OverviewQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let overview = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.overview(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::overview(overview)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn records<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
AdminQuery(query): AdminQuery<query::RecordsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let records = state
|
||||
.admin_services()
|
||||
.key_usage()
|
||||
.records(
|
||||
session_cookie::value(&headers).as_deref(),
|
||||
query.into_domain()?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_admin_service_error)?
|
||||
.ok_or_else(AdminError::session_required)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(presenter::records(records)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn not_found() -> AdminError {
|
||||
AdminError::not_found("密钥用量接口不存在")
|
||||
}
|
||||
async fn method_not_allowed() -> AdminError {
|
||||
AdminError::method_not_allowed()
|
||||
}
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! 只投影当前 Key 可见的用量字段,不序列化账号、凭据、上游标识或诊断正文。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
key_usage::{KeyUsageOverview, KeyUsageRecords},
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
|
||||
},
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::admin::observability::{
|
||||
BillingView, HealthTimelineView, PageData, TokenDetailsView, billing_view,
|
||||
health_timeline_view, usage_list_token_details,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct OverviewView {
|
||||
as_of: DateTime<Utc>,
|
||||
start_time: DateTime<Utc>,
|
||||
end_time: DateTime<Utc>,
|
||||
key: KeyView,
|
||||
summary: MetricsView,
|
||||
trend: Vec<TrendPointView>,
|
||||
health_timeline: HealthTimelineView,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeyView {
|
||||
name: String,
|
||||
prefix: String,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
daily_limit_usd: String,
|
||||
daily_used_usd: String,
|
||||
daily_resets_at: Option<DateTime<Utc>>,
|
||||
weekly_limit_usd: String,
|
||||
weekly_used_usd: String,
|
||||
weekly_resets_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MetricsView {
|
||||
requests: u64,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cached_tokens: u64,
|
||||
cache_write_tokens: u64,
|
||||
reasoning_tokens: u64,
|
||||
total_tokens: u64,
|
||||
cost_usd: Option<String>,
|
||||
cost_incomplete: bool,
|
||||
}
|
||||
|
||||
fn metrics(value: &RequestMetrics, costs: &[CurrencyCost], coverage: &CostCoverage) -> MetricsView {
|
||||
MetricsView {
|
||||
requests: value.request_count,
|
||||
input_tokens: value.input_tokens,
|
||||
output_tokens: value.output_tokens,
|
||||
cached_tokens: value.cached_tokens,
|
||||
cache_write_tokens: value.cache_write_tokens,
|
||||
reasoning_tokens: value.reasoning_tokens,
|
||||
total_tokens: value.total_tokens,
|
||||
cost_usd: costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
|
||||
.map(|cost| cost.amount.as_str().to_owned())
|
||||
.or_else(|| (value.request_count == 0).then(|| "0".to_owned())),
|
||||
cost_incomplete: coverage.partial_count > 0 || coverage.unavailable_count > 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TrendPointView {
|
||||
time: DateTime<Utc>,
|
||||
bucket_seconds: u32,
|
||||
#[serde(flatten)]
|
||||
metrics: MetricsView,
|
||||
}
|
||||
|
||||
pub(super) fn overview(value: KeyUsageOverview) -> OverviewView {
|
||||
let key = value.key;
|
||||
OverviewView {
|
||||
as_of: Utc::now(),
|
||||
start_time: value.overview.range.start,
|
||||
end_time: value.overview.range.end,
|
||||
key: KeyView {
|
||||
name: key.name,
|
||||
prefix: key.prefix,
|
||||
max_concurrency: key.limits.max_concurrency,
|
||||
requests_per_minute: key.limits.requests_per_minute,
|
||||
daily_limit_usd: key.budget.limits.daily_usd.canonical(),
|
||||
daily_used_usd: key.budget.daily_used_usd.canonical(),
|
||||
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
|
||||
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
|
||||
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
|
||||
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
|
||||
},
|
||||
summary: metrics(
|
||||
&value.overview.requests,
|
||||
&value.overview.attempts.costs,
|
||||
&value.overview.attempts.cost_coverage,
|
||||
),
|
||||
trend: value
|
||||
.trend
|
||||
.into_iter()
|
||||
.map(|point| TrendPointView {
|
||||
time: point.bucket_start,
|
||||
bucket_seconds: match point.granularity {
|
||||
Granularity::FifteenMinutes => 900,
|
||||
Granularity::Hour => 3600,
|
||||
Granularity::Day => 86400,
|
||||
},
|
||||
metrics: metrics(&point.metrics, &point.costs, &point.cost_coverage),
|
||||
})
|
||||
.collect(),
|
||||
health_timeline: health_timeline_view(value.health_timeline),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct RecordView {
|
||||
id: String,
|
||||
created_at: DateTime<Utc>,
|
||||
model: Option<String>,
|
||||
route: Option<String>,
|
||||
reasoning_effort: Option<String>,
|
||||
client_transport: Option<String>,
|
||||
upstream_transport: Option<String>,
|
||||
token_details: Option<TokenDetailsView>,
|
||||
billing: Option<BillingView>,
|
||||
latency_ms: Option<u64>,
|
||||
first_token_latency_ms: Option<u64>,
|
||||
latency_details: OutputTimingView,
|
||||
client_ip: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
status: &'static str,
|
||||
status_code: Option<u16>,
|
||||
}
|
||||
|
||||
// Key 只查看自身请求的输出时间,不包含账号容量、调度等待等管理侧观测。
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_text_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn success_record(value: UsageListRecord) -> RecordView {
|
||||
let token_details = Some(usage_list_token_details(&value));
|
||||
let billing = billing_view(value.billing.as_ref());
|
||||
RecordView {
|
||||
id: value.id,
|
||||
created_at: value.started_at,
|
||||
model: value.requested_model_id,
|
||||
route: Some(value.endpoint),
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: Some(value.client_transport),
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details,
|
||||
billing,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: value.first_token_ms,
|
||||
latency_details: OutputTimingView {
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
},
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "success",
|
||||
// 成功记录不保存 HTTP 状态,不能用 200 伪造缺失的原始事实。
|
||||
status_code: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_record(value: OpsError) -> RecordView {
|
||||
RecordView {
|
||||
id: value.event_id,
|
||||
created_at: value.occurred_at,
|
||||
model: value.requested_model_id,
|
||||
route: value.endpoint,
|
||||
reasoning_effort: value.reasoning_effort,
|
||||
client_transport: value.client_transport,
|
||||
upstream_transport: value.upstream_transport,
|
||||
token_details: None,
|
||||
billing: None,
|
||||
latency_ms: value.latency_ms,
|
||||
first_token_latency_ms: None,
|
||||
latency_details: OutputTimingView::default(),
|
||||
client_ip: value.client_ip,
|
||||
user_agent: value.user_agent,
|
||||
status: "error",
|
||||
status_code: value.client_status_code,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn records(value: KeyUsageRecords) -> PageData<RecordView> {
|
||||
match value {
|
||||
KeyUsageRecords::Success(page) => PageData {
|
||||
items: page.items.into_iter().map(success_record).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
KeyUsageRecords::Error(page) => PageData {
|
||||
items: page.items.into_iter().map(error_record).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 查询不接受 Key、账号或 Provider 范围;禁止未知字段穿透权限边界。
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, Duration};
|
||||
use gateway_admin::model::{
|
||||
PageSize,
|
||||
key_usage::{KeyUsageQuery, KeyUsageRecordKind, KeyUsageRecordsQuery},
|
||||
observability::TimeRange,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::admin::AdminError;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct OverviewQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl OverviewQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageQuery, AdminError> {
|
||||
let parse = |value: &str| {
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| value.to_utc())
|
||||
.map_err(|_| {
|
||||
AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"时间必须使用 RFC3339 格式",
|
||||
)
|
||||
})
|
||||
};
|
||||
let range = TimeRange::new(parse(&self.start_time)?, parse(&self.end_time)?)
|
||||
.map_err(|_| AdminError::invalid_request(StatusCode::BAD_REQUEST, "时间范围不合法"))?;
|
||||
if range.end - range.start > Duration::days(31) {
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"一次最多查询 31 天的用量",
|
||||
));
|
||||
}
|
||||
let model = self
|
||||
.model
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty());
|
||||
if model
|
||||
.as_ref()
|
||||
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"模型筛选内容不合法",
|
||||
));
|
||||
}
|
||||
Ok(KeyUsageQuery { range, model })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
enum RecordKind {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct RecordsQuery {
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
model: Option<String>,
|
||||
current_page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
#[serde(default)]
|
||||
kind: RecordKind,
|
||||
}
|
||||
|
||||
impl RecordsQuery {
|
||||
pub(super) fn into_domain(self) -> Result<KeyUsageRecordsQuery, AdminError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
let page_size = self.page_size.unwrap_or(20);
|
||||
if current_page == 0 || !(1..=100).contains(&page_size) {
|
||||
return Err(AdminError::invalid_request(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"页码必须大于 0,每页数量为 1–100",
|
||||
));
|
||||
}
|
||||
Ok(KeyUsageRecordsQuery {
|
||||
usage: OverviewQuery {
|
||||
start_time: self.start_time,
|
||||
end_time: self.end_time,
|
||||
model: self.model,
|
||||
}
|
||||
.into_domain()?,
|
||||
kind: match self.kind {
|
||||
RecordKind::Success => KeyUsageRecordKind::Success,
|
||||
RecordKind::Error => KeyUsageRecordKind::Error,
|
||||
},
|
||||
current_page,
|
||||
page_size: PageSize::new(page_size).map_err(|_| {
|
||||
AdminError::invalid_request(StatusCode::BAD_REQUEST, "每页数量不合法")
|
||||
})?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,14 @@ use crate::health::HealthStatus;
|
||||
use crate::openai::service::OpenAiService;
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
mod health;
|
||||
mod key_usage;
|
||||
pub mod openai;
|
||||
mod session_cookie;
|
||||
|
||||
/// API-owned HTTP 与静态资源配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApiConfig {
|
||||
pub asset_directory: PathBuf,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
@@ -59,12 +61,17 @@ impl ApiConfig {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
}
|
||||
if self.asset_directory.as_os_str().is_empty() {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
self.validate()?;
|
||||
if self.asset_directory.is_relative() {
|
||||
self.asset_directory = source_dir.join(&self.asset_directory);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate(&mut self) -> Result<(), ApiConfigError> {
|
||||
if self.asset_directory.as_os_str().is_empty() {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
if self.request_timeout_seconds == Some(0) {
|
||||
return Err(ApiConfigError::InvalidRequestTimeout);
|
||||
}
|
||||
@@ -121,9 +128,8 @@ pub fn initialize(
|
||||
worker_health: Arc<dyn WorkerHealthSource>,
|
||||
lifecycle: Arc<dyn ConnectionLifecycle>,
|
||||
) -> Result<ApiBundle, ApiError> {
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(ApiError::Config)?;
|
||||
// 配置加载已解析环境变量与相对路径,初始化只校验,避免覆盖最终目录。
|
||||
config.validate().map_err(ApiError::Config)?;
|
||||
let request_id_header = HeaderName::from_str(&config.request_id_header)
|
||||
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
|
||||
let state = ApiState {
|
||||
@@ -136,6 +142,8 @@ pub fn initialize(
|
||||
.route("/healthz", get(health::healthz))
|
||||
.merge(openai::router::router())
|
||||
.merge(admin::router::<ApiState>())
|
||||
.merge(auth::router::<ApiState>())
|
||||
.merge(key_usage::router::<ApiState>())
|
||||
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
|
||||
if !config.cors_allowed_origins.is_empty() {
|
||||
let origins = config
|
||||
@@ -225,7 +233,7 @@ impl ApiState {
|
||||
}
|
||||
}
|
||||
|
||||
impl admin::AdminSessionState for ApiState {
|
||||
impl auth::SessionState for ApiState {
|
||||
fn admin_services(&self) -> &AdminServices {
|
||||
&self.admin
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
|
||||
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
|
||||
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
|
||||
let raw = headers
|
||||
.get(AUTHORIZATION)
|
||||
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
|
||||
if token.is_empty() {
|
||||
return Err(ClientApiKeyAuthError::MalformedAuthorization);
|
||||
}
|
||||
if !token.starts_with("sk_") {
|
||||
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
|
||||
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
|
||||
}
|
||||
Ok(token)
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::engine::execution::StartedExecution;
|
||||
use gateway_core::engine::execution::{ExecutionSession, StartedExecution};
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
|
||||
use super::{
|
||||
@@ -15,7 +15,12 @@ use super::{
|
||||
|
||||
/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。
|
||||
pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response {
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
let response = collect_raw_json_session(started.session).await;
|
||||
super::with_model_request_id(response, &started.request_id)
|
||||
}
|
||||
|
||||
async fn collect_raw_json_session(session: Box<dyn ExecutionSession>) -> Response {
|
||||
let mut execution = PendingExecution::new(session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
|
||||
@@ -190,7 +190,15 @@ pub fn engine_error_response(error: &EngineError) -> Response {
|
||||
}
|
||||
return response;
|
||||
}
|
||||
gateway_error_response(&gateway_error_from_engine(error))
|
||||
let mut response = gateway_error_response(&gateway_error_from_engine(error));
|
||||
// 正文未完整取得时不能透传残缺响应,但已确认的上游关联 ID 仍应交付。
|
||||
if let EngineError::Provider(error) = error
|
||||
&& let Some(request_id) = error.upstream_request_id()
|
||||
&& let Ok(value) = HeaderValue::from_str(request_id.as_str())
|
||||
{
|
||||
response.headers_mut().insert("x-request-id", value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Gateway 错误稳定映射,供 HTTP、SSE 和 WebSocket 共用。
|
||||
@@ -239,6 +247,16 @@ pub const fn gateway_error_contract(
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
),
|
||||
GatewayErrorKind::ConcurrencyQueueFull => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"concurrency_queue_full",
|
||||
),
|
||||
GatewayErrorKind::ConcurrencyQueueTimeout => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"concurrency_queue_timeout",
|
||||
),
|
||||
GatewayErrorKind::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
|
||||
@@ -9,3 +9,28 @@ pub mod responses;
|
||||
pub mod router;
|
||||
pub mod search;
|
||||
pub(crate) mod service;
|
||||
|
||||
/// 客户端可读的 ID 必须能按现有请求记录检索;不以无关入口 ID 补位。
|
||||
fn with_model_request_id(
|
||||
mut response: axum::response::Response,
|
||||
request_id: &gateway_core::engine::ModelRequestId,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
let Ok(gateway_id) = HeaderValue::from_str(request_id.as_str()) else {
|
||||
return response;
|
||||
};
|
||||
let headers = response.headers_mut();
|
||||
let usable = |value: &HeaderValue| value.to_str().is_ok_and(|id| !id.trim().is_empty());
|
||||
if !headers.get("x-request-id").is_some_and(usable) {
|
||||
// 只有别名时沿用同一个上游值,避免外层 middleware 再填入入口 ID。
|
||||
let client_id = headers
|
||||
.get("x-oai-request-id")
|
||||
.filter(|value| usable(value))
|
||||
.unwrap_or(&gateway_id)
|
||||
.clone();
|
||||
headers.insert("x-request-id", client_id);
|
||||
}
|
||||
headers.insert("x-gateway-request-id", gateway_id);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use gateway_core::routing::{PublicModelDescriptor, PublicModelId, PublicModelProfile};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::ApiState;
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::model_not_found_response,
|
||||
error::{model_not_found_response, openai_error_response},
|
||||
};
|
||||
|
||||
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
|
||||
@@ -37,19 +37,53 @@ pub(crate) async fn models(
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
if query
|
||||
if let Some(version) = query
|
||||
.client_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| !version.trim().is_empty())
|
||||
.filter(|version| !version.trim().is_empty())
|
||||
{
|
||||
let models = service
|
||||
.public_model_profiles(&client)
|
||||
if version.len() > 64
|
||||
|| !version
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".-+".contains(&byte))
|
||||
{
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid client_version",
|
||||
"invalid_request_error",
|
||||
"invalid_client_version",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let catalog = match service.client_model_catalog(&client, version).await {
|
||||
Ok(catalog) => catalog,
|
||||
Err(_) => return catalog_unavailable_response(),
|
||||
};
|
||||
let models = catalog
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
.map(|(index, entry)| match entry {
|
||||
PublicModelDescriptor::Adapted(profile) => Ok(codex_model_json(profile, index)),
|
||||
PublicModelDescriptor::Native { model, payload } => {
|
||||
if payload.protocol() != "codex" {
|
||||
return Err(());
|
||||
}
|
||||
let mut value: Value =
|
||||
serde_json::from_slice(payload.body()).map_err(|_| ())?;
|
||||
let object = value.as_object_mut().ok_or(())?;
|
||||
// 别名只改变请求标识,能力、提示词、顺序等仍由上游目录决定。
|
||||
object.insert("slug".to_owned(), Value::String(model.as_str().to_owned()));
|
||||
Ok(value)
|
||||
}
|
||||
})
|
||||
.collect::<Result<Vec<_>, ()>>();
|
||||
let Ok(models) = models else {
|
||||
return catalog_unavailable_response();
|
||||
};
|
||||
return (
|
||||
StatusCode::OK,
|
||||
// 目录经过账号选择/别名/多 Provider 聚合,不能借用上游 ETag 表示改写后的正文。
|
||||
[("cache-control", "private, no-store")],
|
||||
Json(json!({
|
||||
"models": models,
|
||||
})),
|
||||
@@ -72,6 +106,16 @@ pub(crate) async fn models(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn catalog_unavailable_response() -> Response {
|
||||
openai_error_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Model catalog is temporarily unavailable",
|
||||
"server_error",
|
||||
"model_catalog_unavailable",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/{model_id}`。
|
||||
pub(crate) async fn model_detail(
|
||||
State(state): State<ApiState>,
|
||||
@@ -124,6 +168,7 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
.iter()
|
||||
.any(|effort| effort != "none");
|
||||
let context_window = presentation.context_window_tokens();
|
||||
let max_context_window = presentation.max_context_window_tokens();
|
||||
let input_modalities = if presentation.image_input() {
|
||||
vec!["text", "image"]
|
||||
} else {
|
||||
@@ -173,7 +218,7 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
"supports_parallel_tool_calls": presentation.parallel_tool_calls(),
|
||||
"supports_image_detail_original": presentation.image_detail_original(),
|
||||
"context_window": context_window,
|
||||
"max_context_window": context_window,
|
||||
"max_context_window": max_context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": input_modalities,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user