mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5205c07d60 | ||
|
|
60eb08cb96 | ||
|
|
fd4a49e64f | ||
|
|
be2ee7f3b8 | ||
|
|
757abdb4d1 | ||
|
|
cb5e0ba4c0 | ||
|
|
d2b418f8cc | ||
|
|
2390783900 | ||
|
|
c75f0e7fd9 | ||
|
|
d5e9360ed7 | ||
|
|
3fcb8e5f2d | ||
|
|
8cdaf8b2e6 | ||
|
|
68f05a4481 | ||
|
|
bb4d178b5d | ||
|
|
73487fd98b | ||
|
|
66fd09eba6 | ||
|
|
bd152dec00 | ||
|
|
7090dca502 | ||
|
|
029f3124bf | ||
|
|
840707e5a1 | ||
|
|
704b652f86 | ||
|
|
2b98839c29 |
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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) |
|
||||
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
|
||||
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.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. 结合上表读取本次需要的文档,先找可复用的同类实现,再修改所属模块。
|
||||
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
|
||||
5. 按 `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,55 @@
|
||||
---
|
||||
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),问题反馈使用当前的
|
||||
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
|
||||
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
|
||||
|
||||
## 确定目标和动作
|
||||
|
||||
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
|
||||
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
|
||||
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
|
||||
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
|
||||
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
|
||||
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
|
||||
|
||||
## 证据与去重
|
||||
|
||||
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
|
||||
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
|
||||
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
|
||||
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
|
||||
不因发现重复而擅自评论、重开或关闭已有 Issue。
|
||||
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
|
||||
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
|
||||
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,75 @@
|
||||
---
|
||||
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 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
|
||||
|
||||
## 创建 PR
|
||||
|
||||
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
|
||||
不用提交日志拼接正文,也不假定仓库一定有模板。
|
||||
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
|
||||
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
|
||||
3. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
|
||||
更新现有标题或正文须在本次整理/更新请求范围内。
|
||||
4. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
|
||||
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
|
||||
5. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题和最终行为,
|
||||
再给必要的设计取舍、验证与遗留事项;按规模压缩,不强填无关章节。
|
||||
6. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
|
||||
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
|
||||
7. 创建后核对链接、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. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
3. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
|
||||
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
|
||||
4. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
|
||||
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
|
||||
5. 依照贡献文档用中文输出必须修复项、可选建议和验证缺口;没有的类别可省略。
|
||||
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
|
||||
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
|
||||
6. 结束前核对 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,79 @@
|
||||
---
|
||||
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/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
|
||||
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
|
||||
本任务需要的提交按贡献文档保留英文 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,111 @@
|
||||
name: 问题反馈
|
||||
description: 提交可复现的问题,帮助定位和修复
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、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: 填写具体版本号;从源码运行时填写提交号。
|
||||
placeholder: 版本号或提交号
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: 部署方式
|
||||
options:
|
||||
- Docker Compose
|
||||
- 其他容器部署
|
||||
- 二进制运行
|
||||
- 源码运行
|
||||
- 其他方式(请在环境信息中说明)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
|
||||
placeholder: |
|
||||
操作系统与版本:
|
||||
浏览器与版本(页面问题):
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: account-client
|
||||
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: |
|
||||
接口问题请提供脱敏后的 cURL 或最小请求体、HTTP 状态码、响应片段,以及请求 ID / 发生时间。
|
||||
如需详细报文,可按部署文档开启 `host.logging.request_dump`,仅摘取定位所需片段并脱敏,不要直接上传完整转储。
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或录屏
|
||||
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 补充信息
|
||||
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
|
||||
@@ -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.');
|
||||
@@ -33,7 +33,6 @@ Thumbs.db
|
||||
# Local tools and worktrees
|
||||
/.codegraph/
|
||||
/.codex-ws-audit/
|
||||
/.agents/
|
||||
/.reasonix/
|
||||
/.claude
|
||||
/.worktrees/
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# 项目协作约定
|
||||
|
||||
开发和审查先阅读 [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 -->
|
||||
|
||||
## 工作方式
|
||||
|
||||
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
|
||||
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
|
||||
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
|
||||
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
|
||||
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
|
||||
|
||||
## Code Review Rules
|
||||
|
||||
### 审查范围
|
||||
|
||||
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
|
||||
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
|
||||
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
|
||||
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
|
||||
|
||||
### 代码与界面一致性
|
||||
|
||||
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
|
||||
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
|
||||
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
|
||||
|
||||
### 审查输出
|
||||
|
||||
- 用中文报告本次变更新引入或加重的、可定位的问题,包含文件位置、触发场景或规范依据、影响和最小修复方向。
|
||||
- 区分必须修复的问题、可选建议和验证缺口。个人偏好标为建议,不作为必须修复项;没有问题时不要凑数。
|
||||
- 严重程度按实际影响判断,不为绕过工具的优先级过滤而升级普通规范问题。说明审查和验证的实际覆盖范围,AI 未报告问题不等于维护者批准。
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# 贡献与审查
|
||||
|
||||
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
|
||||
|
||||
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
|
||||
|
||||
## 提交 Issue
|
||||
|
||||
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
|
||||
|
||||
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
|
||||
|
||||
## 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 填写所有维度的报告。
|
||||
|
||||
| 维度 | 检查重点 |
|
||||
| --- | --- |
|
||||
| 设计与架构 | 职责归属、依赖方向、公开接口是否合理;是否融入已有体系,有无绕过边界的旁路 |
|
||||
| 正确性与兼容性 | 正常、异常和边界输入;并发、取消、重试与副作用;是否改变既有 API、协议或数据合同 |
|
||||
| 安全与数据 | 权限、输入校验、敏感信息、状态隔离和持久化一致性;日志与诊断是否符合脱敏边界 |
|
||||
| 简洁性与复用 | 重复业务规则、冗余状态、无效代码和过早抽象;能否用更直接的实现解决当前问题 |
|
||||
| 性能与资源 | 实际数据规模下的查询、请求、内存与连接成本;资源释放、背压和异常时的生命周期 |
|
||||
| 可读性与风格 | 命名是否准确,控制流是否易懂,格式是否一致;注释解释约束与原因而非复述代码 |
|
||||
| 测试与验证 | 是否覆盖真实触发条件和结果,能否发现回归;证据是否对应本次变更,有无跳过或缺失项 |
|
||||
| 界面与交互 | 共用组件和主题、信息层级、状态反馈、响应式、键盘操作及可访问性;是否有实际页面证据 |
|
||||
| 文档与升级 | 行为、配置、API 和部署说明是否同步;数据迁移和兼容影响是否交代清楚 |
|
||||
|
||||
审查意见区分三类:
|
||||
|
||||
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
|
||||
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
|
||||
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
|
||||
|
||||
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
|
||||
|
||||
## 项目约定
|
||||
|
||||
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 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 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
|
||||
|
||||
## 界面验证
|
||||
|
||||
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
|
||||
|
||||
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
|
||||
|
||||
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
|
||||
|
||||
## 验证
|
||||
|
||||
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
|
||||
|
||||
| 范围 | 现有检查 | 仍需提供的证据 |
|
||||
| --- | --- | --- |
|
||||
| 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 服务或更改分支设置。
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;支持独立出站代理及 sub2api OpenAI 账号代理导入。
|
||||
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
|
||||
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
|
||||
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
|
||||
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
|
||||
@@ -123,6 +123,7 @@ curl http://127.0.0.1:8080/v1/models \
|
||||
- [系统架构](docs/architecture.md)
|
||||
- [管理端主题](docs/theme.md)
|
||||
- [数据库迁移](backend/migrations/README.md)
|
||||
- [贡献与审查](CONTRIBUTING.md)
|
||||
|
||||
## 社区
|
||||
|
||||
|
||||
Generated
+4
@@ -744,6 +744,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1433,6 +1434,7 @@ dependencies = [
|
||||
"cron",
|
||||
"futures",
|
||||
"gateway-core",
|
||||
"heck",
|
||||
"hex",
|
||||
"rand_core 0.6.4",
|
||||
"secrecy",
|
||||
@@ -1456,6 +1458,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures",
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
@@ -1471,6 +1474,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -28,6 +28,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
serde_json = "1"
|
||||
syn = { version = "3.0.3", features = ["full", "visit"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio-rustls = { version = "=0.26.4", default-features = false }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -78,7 +78,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
(
|
||||
core.account_probe(),
|
||||
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
|
||||
),
|
||||
host.client_distribution_resolver(),
|
||||
host.system_operations(),
|
||||
)
|
||||
|
||||
@@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
&[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"proxy_probe",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
|
||||
@@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
use tokio_rustls::{
|
||||
TlsAcceptor,
|
||||
rustls::{
|
||||
ServerConfig,
|
||||
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
|
||||
},
|
||||
};
|
||||
|
||||
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
|
||||
let Ok(directory) = std::env::var(CHILD_ENV) else {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
generate_proxy_test_certificates(directory.path());
|
||||
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
|
||||
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap());
|
||||
child
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, directory.path())
|
||||
.env_remove("CODEX_CA_CERTIFICATE")
|
||||
.env_remove("SSL_CERT_FILE")
|
||||
.env(ca_env, directory.path().join("ca.pem"));
|
||||
if ca_env == "CODEX_CA_CERTIFICATE" {
|
||||
child.env(
|
||||
"SSL_CERT_FILE",
|
||||
directory.path().join("missing-fallback.pem"),
|
||||
);
|
||||
}
|
||||
let output = child.output().unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{ca_env}: {}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
return;
|
||||
};
|
||||
provider_openai::ensure_rustls_provider();
|
||||
let directory = std::path::Path::new(&directory);
|
||||
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
|
||||
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
|
||||
let acceptor = TlsAcceptor::from(Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate], key)
|
||||
.unwrap(),
|
||||
));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut stream = acceptor.accept(socket).await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
assert!(request.len() < 8192);
|
||||
request.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
|
||||
let body = "{\"ip\":\"203.0.113.8\"}";
|
||||
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
});
|
||||
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
|
||||
let result = probe.test(&proxy).await;
|
||||
assert!(result.success, "{}", result.message);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn generate_proxy_test_certificates(directory: &std::path::Path) {
|
||||
let openssl = |args: &[&str]| {
|
||||
let output = Command::new("openssl")
|
||||
.args(args)
|
||||
.current_dir(directory)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
openssl(&[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-out",
|
||||
"ca.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-subj",
|
||||
"/CN=Proxy Test CA",
|
||||
]);
|
||||
openssl(&[
|
||||
"req",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
]);
|
||||
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
|
||||
openssl(&[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.pem",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.pem",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"extensions",
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_load_complete_terminal_example() {
|
||||
parse_config(&valid_config()).expect("terminal config example");
|
||||
|
||||
@@ -19,6 +19,7 @@ bytes = "1.11.1"
|
||||
chrono = "0.4.45"
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
@@ -25,7 +25,7 @@ pub use use_case::{
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_distribution::ClientDistributionService,
|
||||
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
|
||||
settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
@@ -149,6 +149,7 @@ pub enum AdminConfigError {
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
proxies: Arc<dyn ProxiesService>,
|
||||
auth: Arc<dyn AuthService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
@@ -163,6 +164,11 @@ pub struct AdminServices {
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> &dyn ProxiesService {
|
||||
self.proxies.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> &dyn AuthService {
|
||||
self.auth.as_ref()
|
||||
@@ -247,10 +253,11 @@ pub async fn initialize(
|
||||
store: AdminStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
|
||||
client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
let (probe, proxy_probe) = probes;
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
@@ -290,6 +297,12 @@ pub async fn initialize(
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let services = AdminServices {
|
||||
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
|
||||
store.proxies(),
|
||||
proxy_probe,
|
||||
snapshot.clone(),
|
||||
registry.clone(),
|
||||
)),
|
||||
auth,
|
||||
accounts,
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
@@ -316,11 +329,13 @@ pub async fn initialize(
|
||||
openai: Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
xai: Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
store.proxies(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
backups,
|
||||
|
||||
@@ -221,7 +221,7 @@ pub struct UpdateAccount {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
@@ -239,7 +239,7 @@ pub struct BatchUpdateAccounts {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ pub struct CredentialDetails {
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub context: MutationContext,
|
||||
pub document: ProviderDocument,
|
||||
@@ -132,6 +133,7 @@ pub struct CredentialImportResult {
|
||||
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
|
||||
pub struct PrepareCredentialImport {
|
||||
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -175,6 +177,7 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
@@ -255,6 +258,7 @@ pub struct PendingAuthorizationMutation {
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
@@ -269,6 +273,7 @@ impl PendingAuthorizationMutation {
|
||||
target,
|
||||
owner_binding,
|
||||
outbound_proxy: None,
|
||||
outbound_proxy_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +304,16 @@ impl PendingAuthorizationMutation {
|
||||
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_outbound_proxy_id(mut self, id: Option<String>) -> Self {
|
||||
self.outbound_proxy_id = id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn outbound_proxy_id(&self) -> Option<&str> {
|
||||
self.outbound_proxy_id.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
@@ -338,6 +353,9 @@ impl PendingAuthorizationMutation {
|
||||
Value::String(proxy.expose_url().to_owned()),
|
||||
);
|
||||
}
|
||||
if let Some(id) = &self.outbound_proxy_id {
|
||||
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
|
||||
}
|
||||
document
|
||||
}
|
||||
|
||||
@@ -382,7 +400,8 @@ impl PendingAuthorizationMutation {
|
||||
started_request_id: document.started_request_id,
|
||||
},
|
||||
)
|
||||
.with_outbound_proxy(proxy))
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(document.outbound_proxy_id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +414,8 @@ struct StoredAuthorizationMutationV1 {
|
||||
started_request_id: String,
|
||||
#[serde(default)]
|
||||
outbound_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
outbound_proxy_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -418,7 +439,7 @@ pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
@@ -750,6 +771,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 {
|
||||
@@ -900,23 +928,34 @@ impl ProviderQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
|
||||
///
|
||||
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
|
||||
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
|
||||
/// 同一优先级保持 Provider 投影顺序。
|
||||
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
|
||||
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
|
||||
#[must_use]
|
||||
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
|
||||
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, window)| {
|
||||
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
&& window.window_seconds.is_some()
|
||||
&& window.local_usage.is_some()
|
||||
.filter_map(|(index, window)| {
|
||||
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
|| window.reset_at.is_none()
|
||||
|| window.local_usage.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((index, window, period))
|
||||
})
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.and_then(|(_, window)| window.local_usage.as_ref())
|
||||
.min_by_key(|(index, _, period)| (*period, *index))
|
||||
.map(|(_, window, period)| (window, period))
|
||||
}
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountProxySelection {
|
||||
Direct,
|
||||
Url(OutboundProxy),
|
||||
Saved(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImportProxyBinding {
|
||||
pub id: String,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyAccountRef {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub provider_kind: String,
|
||||
pub authentication_kind: String,
|
||||
pub plan_type: Option<String>,
|
||||
pub plan_type_display: Option<String>,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 按代理查询关联账号,分页与搜索均在存储层执行。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyAccountListQuery {
|
||||
pub proxy_id: String,
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyAccountPage {
|
||||
pub items: Vec<ProxyAccountRef>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProxyTestResult {
|
||||
pub success: bool,
|
||||
pub latency_ms: u64,
|
||||
pub exit_ip: Option<std::net::IpAddr>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
pub revision: Revision,
|
||||
pub account_count: u64,
|
||||
pub last_test_at: Option<DateTime<Utc>>,
|
||||
pub last_test: Option<ProxyTestResult>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyPage {
|
||||
pub items: Vec<ProxyRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProxy {
|
||||
pub name: String,
|
||||
pub proxy: OutboundProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateProxy {
|
||||
pub id: String,
|
||||
pub revision: Revision,
|
||||
pub name: String,
|
||||
pub proxy: Option<OutboundProxy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: ProxyRecord,
|
||||
}
|
||||
@@ -3,5 +3,6 @@
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod provider;
|
||||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -8,6 +8,7 @@ use gateway_core::{
|
||||
operation::Operation,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use heck::ToUpperCamelCase;
|
||||
|
||||
use crate::model::observability::{
|
||||
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
|
||||
@@ -251,7 +252,20 @@ impl ProviderAdminRegistry {
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的套餐展示投影,不修改持久化的原始套餐值。
|
||||
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
|
||||
pub(crate) fn resolve_account_plan(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
plan_type: &mut Option<String>,
|
||||
quota: Option<&ProviderQuota>,
|
||||
) -> Option<String> {
|
||||
if let Some(quota) = quota {
|
||||
quota.fill_missing_plan_type(plan_type);
|
||||
}
|
||||
self.plan_type_display(provider_kind, plan_type.as_deref())
|
||||
}
|
||||
|
||||
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
|
||||
pub(crate) fn plan_type_display(
|
||||
&self,
|
||||
provider_kind: &str,
|
||||
@@ -261,10 +275,12 @@ impl ProviderAdminRegistry {
|
||||
let provider = ProviderKind::new(provider_kind.to_owned())
|
||||
.ok()
|
||||
.and_then(|kind| self.providers.get(&kind));
|
||||
Some(provider.map_or_else(
|
||||
let display = provider.map_or_else(
|
||||
|| plan_type.to_owned(),
|
||||
|provider| provider.plan_type_display(plan_type),
|
||||
))
|
||||
);
|
||||
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
|
||||
Some(display.replace('+', " Plus ").to_upper_camel_case())
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
proxies::{
|
||||
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
|
||||
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyStore: Send + Sync {
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
|
||||
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage>;
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
|
||||
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
async fn record_test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord>;
|
||||
}
|
||||
|
||||
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
|
||||
pub trait ProxyImportGuard: Send + Sync {}
|
||||
|
||||
pub struct ProxyImportReservation {
|
||||
pub binding: ImportProxyBinding,
|
||||
pub guard: Box<dyn ProxyImportGuard>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyProbe: Send + Sync {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
|
||||
}
|
||||
@@ -382,6 +382,7 @@ pub struct AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
}
|
||||
|
||||
impl AdminAccountStorePorts {
|
||||
@@ -390,11 +391,13 @@ impl AdminAccountStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
runtime: Arc<dyn AccountRuntimeStore>,
|
||||
groups: Arc<dyn AccountGroupStore>,
|
||||
proxies: Arc<dyn super::proxy::ProxyStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
runtime,
|
||||
groups,
|
||||
proxies,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,6 +450,11 @@ impl AdminStorePorts {
|
||||
self.accounts.groups.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
|
||||
self.accounts.proxies.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> Arc<dyn AuthStore> {
|
||||
self.auth.clone()
|
||||
|
||||
@@ -279,12 +279,14 @@ impl DefaultAccountsService {
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
quota.fill_missing_plan_type(&mut stored.account.plan_type);
|
||||
let usage = quota
|
||||
.usage_window()
|
||||
.and_then(|(window, _)| window.local_usage.clone());
|
||||
Ok(AccountDirectoryItem {
|
||||
plan_type_display: self.providers.plan_type_display(
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
stored.account.provider_kind.as_str(),
|
||||
stored.account.plan_type.as_deref(),
|
||||
&mut stored.account.plan_type,
|
||||
Some("a),
|
||||
),
|
||||
projection: stored.projection,
|
||||
usage,
|
||||
@@ -372,12 +374,14 @@ impl AccountsService for DefaultAccountsService {
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(mut item, quota)| {
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
quota.fill_missing_plan_type(&mut item.account.plan_type);
|
||||
let usage = quota
|
||||
.usage_window()
|
||||
.and_then(|(window, _)| window.local_usage.clone());
|
||||
AccountDirectoryItem {
|
||||
plan_type_display: self.providers.plan_type_display(
|
||||
plan_type_display: self.providers.resolve_account_plan(
|
||||
item.account.provider_kind.as_str(),
|
||||
item.account.plan_type.as_deref(),
|
||||
&mut item.account.plan_type,
|
||||
Some("a),
|
||||
),
|
||||
usage,
|
||||
account: item.account,
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod xai;
|
||||
@@ -109,13 +110,27 @@ async fn required_credential(
|
||||
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
|
||||
}
|
||||
|
||||
async fn import_proxy_binding(
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
id: Option<&str>,
|
||||
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
|
||||
let Some(id) = id else { return Ok(None) };
|
||||
let reservation = proxies
|
||||
.reserve_import(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
Ok(Some(reservation))
|
||||
}
|
||||
|
||||
async fn pending_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
proxies: &dyn crate::ports::proxy::ProxyStore,
|
||||
provider_kind: &ProviderKind,
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let mut proxy = command.outbound_proxy.clone();
|
||||
let proxy;
|
||||
let mut proxy_id = None;
|
||||
let target = match &command.reauthorization {
|
||||
Some(account_id) => {
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
@@ -126,9 +141,27 @@ async fn pending_authorization(
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
},
|
||||
None => {
|
||||
use crate::model::proxies::AccountProxySelection;
|
||||
proxy = match &command.outbound_proxy {
|
||||
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
|
||||
Some(AccountProxySelection::Saved(id)) => {
|
||||
let record = proxies
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if !record.last_test.is_some_and(|test| test.success) {
|
||||
return Err(AdminError::conflict("请先测试代理连接"));
|
||||
}
|
||||
proxy_id = Some(record.id);
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
};
|
||||
AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
provider_kind.clone(),
|
||||
@@ -137,7 +170,8 @@ async fn pending_authorization(
|
||||
&command.context,
|
||||
),
|
||||
)
|
||||
.with_outbound_proxy(proxy))
|
||||
.with_outbound_proxy(proxy)
|
||||
.with_outbound_proxy_id(proxy_id))
|
||||
}
|
||||
|
||||
fn validate_prepared_import(
|
||||
|
||||
@@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync {
|
||||
pub(crate) struct DefaultOpenAiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -66,11 +67,13 @@ impl DefaultOpenAiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let outbound_proxy = proxy_reservation
|
||||
.as_ref()
|
||||
.map(|reservation| reservation.binding.clone());
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: outbound_proxy
|
||||
.as_ref()
|
||||
.map(|binding| binding.proxy.clone()),
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
@@ -144,6 +158,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
outbound_proxy,
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
@@ -151,6 +166,7 @@ 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;
|
||||
@@ -166,6 +182,7 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"OpenAI credential",
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::{StreamExt, stream};
|
||||
use gateway_core::{
|
||||
account::{OutboundProxy, ProviderAccountId},
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext, Revision,
|
||||
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
|
||||
proxies::*,
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
store::AdminStoreErrorKind,
|
||||
},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxiesService: Send + Sync {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> Result<ProxyAccountPage, AdminError>;
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError>;
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultProxiesService {
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
test_slots: Semaphore,
|
||||
}
|
||||
|
||||
impl DefaultProxiesService {
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ProxyStore>,
|
||||
probe: Arc<dyn ProxyProbe>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
probe,
|
||||
snapshot,
|
||||
providers,
|
||||
test_slots: Semaphore::new(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxiesService for DefaultProxiesService {
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理 ID 不合法"));
|
||||
}
|
||||
let account_id = ProviderAccountId::new(account_id.to_owned())
|
||||
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
|
||||
let revision = self
|
||||
.store
|
||||
.remove_account(proxy_id, &account_id, context)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.kind() == AdminStoreErrorKind::Conflict {
|
||||
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
|
||||
} else {
|
||||
map_store_error(error, "proxy account")
|
||||
}
|
||||
})?;
|
||||
publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> Result<ProxyAccountPage, AdminError> {
|
||||
if query.proxy_id.is_empty()
|
||||
|| query.proxy_id.len() > 128
|
||||
|| query.proxy_id.chars().any(char::is_control)
|
||||
|| query.page == 0
|
||||
|| query.search.len() > 256
|
||||
|| query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("关联账号查询参数不合法"));
|
||||
}
|
||||
let mut page = self
|
||||
.store
|
||||
.list_accounts(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
page.items = stream::iter(page.items)
|
||||
.map(|mut account| async {
|
||||
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
|
||||
let mut cached_quota = None;
|
||||
if explicit_plan_type(account.plan_type.as_deref()).is_none()
|
||||
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
|
||||
&& let Ok(provider) = self.providers.require(&kind)
|
||||
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
|
||||
&& let Ok(quota) = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
cached_quota = Some(quota);
|
||||
}
|
||||
account.plan_type_display = self.providers.resolve_account_plan(
|
||||
&account.provider_kind,
|
||||
&mut account.plan_type,
|
||||
cached_quota.as_ref(),
|
||||
);
|
||||
account
|
||||
})
|
||||
.buffered(8)
|
||||
.collect()
|
||||
.await;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
self.store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
mut command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
let result = self
|
||||
.store
|
||||
.create(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
mut command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyMutation, AdminError> {
|
||||
command.name = validate_name(&command.name)?;
|
||||
let result = self
|
||||
.store
|
||||
.update(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let result = self
|
||||
.store
|
||||
.delete(id, revision, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
Ok(self.probe.test(proxy).await)
|
||||
}
|
||||
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
let record = self
|
||||
.store
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if record.revision != revision {
|
||||
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
|
||||
}
|
||||
let result = self.probe.test(&record.proxy).await;
|
||||
self.store
|
||||
.record_test(id, revision, result, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_name(value: &str) -> Result<String, AdminError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理名称需要 1 至 100 个字符"));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync {
|
||||
pub(crate) struct DefaultXaiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
@@ -57,11 +58,13 @@ impl DefaultXaiService {
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
proxies,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
@@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let proxy_reservation = super::import_proxy_binding(
|
||||
self.proxies.as_ref(),
|
||||
command.outbound_proxy_id.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let outbound_proxy = proxy_reservation
|
||||
.as_ref()
|
||||
.map(|reservation| reservation.binding.clone());
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: outbound_proxy
|
||||
.as_ref()
|
||||
.map(|binding| binding.proxy.clone()),
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
@@ -97,6 +111,7 @@ impl XaiService for DefaultXaiService {
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
outbound_proxy,
|
||||
prepared,
|
||||
settings: command.settings,
|
||||
},
|
||||
@@ -104,6 +119,7 @@ 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 {
|
||||
@@ -125,6 +141,7 @@ impl XaiService for DefaultXaiService {
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.proxies.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"xAI credential",
|
||||
|
||||
@@ -157,7 +157,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 +194,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]
|
||||
|
||||
@@ -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 {
|
||||
@@ -97,7 +220,21 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
|
||||
PendingAuthorizationMutation::from_storage_v1(Value::Object(
|
||||
expected.to_storage_v1()
|
||||
)),
|
||||
Ok(expected),
|
||||
Ok(expected.clone()),
|
||||
);
|
||||
let with_proxy = expected
|
||||
.with_outbound_proxy(Some(
|
||||
gateway_core::account::OutboundProxy::parse(
|
||||
"http://user:secret@proxy.example:8080",
|
||||
)
|
||||
.unwrap(),
|
||||
))
|
||||
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
|
||||
assert_eq!(
|
||||
PendingAuthorizationMutation::from_storage_v1(Value::Object(
|
||||
with_proxy.to_storage_v1()
|
||||
)),
|
||||
Ok(with_proxy)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1391,7 +1391,7 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
),
|
||||
(
|
||||
Some(" self_serve_business_prolite "),
|
||||
Some("openai display: self_serve_business_prolite"),
|
||||
Some("OpenaiDisplaySelfServeBusinessProlite"),
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1409,16 +1409,27 @@ async fn accounts_list_should_return_complete_directory_semantics() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
|
||||
for (stored_plan, quota_plan, expected) in [
|
||||
(None, Some("free"), Some("free")),
|
||||
(Some(" "), Some("free"), Some("free")),
|
||||
(Some("unknown"), Some("free"), Some("free")),
|
||||
for (stored_plan, quota_plan, expected, expected_display) in [
|
||||
(None, Some("free"), Some("free"), Some("OpenaiDisplayFree")),
|
||||
(
|
||||
Some(" "),
|
||||
Some("free"),
|
||||
Some("free"),
|
||||
Some("OpenaiDisplayFree"),
|
||||
),
|
||||
(
|
||||
Some("unknown"),
|
||||
Some("free"),
|
||||
Some("free"),
|
||||
Some("OpenaiDisplayFree"),
|
||||
),
|
||||
(
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("team"),
|
||||
Some("self_serve_business_prolite"),
|
||||
Some("OpenaiDisplaySelfServeBusinessProlite"),
|
||||
),
|
||||
(None, None, None),
|
||||
(None, None, None, None),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
@@ -1444,10 +1455,7 @@ async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_s
|
||||
.expect("account list");
|
||||
let account = page.items.first().expect("account item");
|
||||
assert_eq!(account.account.plan_type.as_deref(), expected);
|
||||
assert_eq!(
|
||||
account.plan_type_display,
|
||||
expected.map(|plan| format!("openai display: {plan}"))
|
||||
);
|
||||
assert_eq!(account.plan_type_display.as_deref(), expected_display);
|
||||
for refresh in [false, true] {
|
||||
let detail = services
|
||||
.accounts()
|
||||
@@ -1746,10 +1754,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);
|
||||
@@ -1759,6 +1765,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());
|
||||
|
||||
@@ -5,6 +5,7 @@ mod backup;
|
||||
mod client_keys;
|
||||
mod observability;
|
||||
mod openai;
|
||||
mod proxies;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod xai;
|
||||
@@ -82,6 +83,7 @@ pub(super) struct AdminHarness {
|
||||
default_password: String,
|
||||
session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
account_runtime: Arc<dyn AccountRuntimeStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
@@ -101,6 +103,7 @@ impl AdminHarness {
|
||||
default_password: "strong-test-password".to_owned(),
|
||||
session_ttl_minutes: 60,
|
||||
accounts: unavailable.clone(),
|
||||
proxies: Arc::new(proxies::TestProxies::default()),
|
||||
account_runtime: unavailable.clone(),
|
||||
account_groups: Arc::new(UnavailableAccountGroupStore),
|
||||
auth: Arc::new(BootstrapAuthStore::default()),
|
||||
@@ -179,6 +182,14 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn proxies(
|
||||
mut self,
|
||||
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
|
||||
) -> Self {
|
||||
self.proxies = proxies;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
|
||||
self.system = system;
|
||||
self
|
||||
@@ -196,6 +207,7 @@ impl AdminHarness {
|
||||
self.accounts,
|
||||
self.account_runtime,
|
||||
self.account_groups,
|
||||
self.proxies,
|
||||
),
|
||||
self.auth,
|
||||
self.client_keys,
|
||||
@@ -205,7 +217,7 @@ impl AdminHarness {
|
||||
),
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
self.probe,
|
||||
(self.probe, Arc::new(proxies::TestProxies::default())),
|
||||
Arc::new(NoopClientDistribution),
|
||||
self.system,
|
||||
)
|
||||
|
||||
@@ -50,6 +50,7 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("import-openai"),
|
||||
document: document(),
|
||||
@@ -107,6 +108,7 @@ async fn openai_import_should_expose_only_explicit_public_errors_without_committ
|
||||
let error = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-openai-pat-failure"),
|
||||
document: document(),
|
||||
@@ -132,6 +134,7 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-openai-batch"),
|
||||
document: document(),
|
||||
@@ -176,6 +179,7 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-openai-quota-failure"),
|
||||
document: document(),
|
||||
@@ -348,6 +352,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-openai-error"),
|
||||
document: document(),
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::{
|
||||
model::{MutationContext, Revision, proxies::*},
|
||||
ports::{
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
store::AdminStoreResult,
|
||||
},
|
||||
};
|
||||
use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct TestProxies {
|
||||
pub events: Option<super::accounts::EventLog>,
|
||||
pub accounts: Option<Vec<ProxyAccountRef>>,
|
||||
}
|
||||
|
||||
struct ImportGuard(super::accounts::EventLog);
|
||||
|
||||
impl gateway_admin::ports::proxy::ProxyImportGuard for ImportGuard {}
|
||||
|
||||
impl Drop for ImportGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.lock().unwrap().push("proxy.release");
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyStore for TestProxies {
|
||||
async fn remove_account(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
|
||||
async fn reserve_import(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
|
||||
let events = self
|
||||
.events
|
||||
.as_ref()
|
||||
.ok_or_else(|| super::unavailable("proxy"))?;
|
||||
events.lock().unwrap().push("proxy.reserve");
|
||||
Ok(gateway_admin::ports::proxy::ProxyImportReservation {
|
||||
binding: ImportProxyBinding {
|
||||
id: id.to_owned(),
|
||||
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
|
||||
},
|
||||
guard: Box::new(ImportGuard(events.clone())),
|
||||
})
|
||||
}
|
||||
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage> {
|
||||
let items = self
|
||||
.accounts
|
||||
.clone()
|
||||
.ok_or_else(|| super::unavailable("proxy"))?;
|
||||
Ok(ProxyAccountPage {
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord> {
|
||||
Err(super::unavailable("proxy"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for TestProxies {
|
||||
async fn test(&self, _: &OutboundProxy) -> ProxyTestResult {
|
||||
panic!("unexpected proxy probe")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
|
||||
use super::accounts::{FakeProviderAdmin, events};
|
||||
use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota};
|
||||
use std::sync::Arc;
|
||||
|
||||
for (stored, cached, expected) in [
|
||||
(None, Some("free"), Some("free")),
|
||||
(Some("unknown"), Some("free"), Some("free")),
|
||||
(Some("plus"), Some("free"), Some("plus")),
|
||||
(None, None, None),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
plan_type: cached.map(str::to_owned),
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
});
|
||||
let services = super::AdminHarness::new()
|
||||
.provider(provider.clone())
|
||||
.proxies(Arc::new(TestProxies {
|
||||
accounts: Some(vec![ProxyAccountRef {
|
||||
id: "acct_plan".to_owned(),
|
||||
name: "套餐测试".to_owned(),
|
||||
email: None,
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
plan_type: stored.map(str::to_owned),
|
||||
plan_type_display: None,
|
||||
groups: vec![],
|
||||
enabled: true,
|
||||
}]),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let result = services
|
||||
.proxies()
|
||||
.list_accounts(ProxyAccountListQuery {
|
||||
proxy_id: "proxy_plan".to_owned(),
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).unwrap(),
|
||||
search: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.items[0].plan_type.as_deref(), expected);
|
||||
assert_eq!(
|
||||
result.items[0].plan_type_display.as_deref(),
|
||||
match expected {
|
||||
Some("free") => Some("OpenaiDisplayFree"),
|
||||
Some("plus") => Some("OpenaiDisplayPlus"),
|
||||
_ => None,
|
||||
}
|
||||
);
|
||||
let requests = provider.quota_requests();
|
||||
assert_eq!(requests.len(), usize::from(stored != Some("plus")));
|
||||
assert!(requests.iter().all(|request| !request.refresh));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() {
|
||||
use super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
|
||||
};
|
||||
use gateway_admin::model::provider_credentials::ImportCredentials;
|
||||
use gateway_admin::ports::provider::ProviderAdminErrorKind;
|
||||
use std::sync::Arc;
|
||||
|
||||
for kind in ["openai", "xai"] {
|
||||
for failure in [None, Some("prepare"), Some("commit")] {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new(kind, events.clone());
|
||||
let store = FakeAccountStore::new(kind, events.clone());
|
||||
if failure == Some("prepare") {
|
||||
provider.fail_next(ProviderAdminErrorKind::Unavailable);
|
||||
}
|
||||
if failure == Some("commit") {
|
||||
store.fail_next_commit();
|
||||
}
|
||||
let services = super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.proxies(Arc::new(TestProxies {
|
||||
events: Some(events.clone()),
|
||||
..Default::default()
|
||||
}))
|
||||
.build()
|
||||
.await;
|
||||
let command = ImportCredentials {
|
||||
outbound_proxy_id: Some("proxy_import".to_owned()),
|
||||
settings: None,
|
||||
context: context("reserved-import"),
|
||||
document: document(),
|
||||
};
|
||||
let result = if kind == "openai" {
|
||||
services.openai().import_document(command).await
|
||||
} else {
|
||||
services.xai().import_document(command).await
|
||||
};
|
||||
assert_eq!(result.is_err(), failure.is_some());
|
||||
let events = recorded(&events);
|
||||
let expected = if failure == Some("prepare") {
|
||||
vec!["proxy.reserve", "provider.prepare_import", "proxy.release"]
|
||||
} else {
|
||||
vec![
|
||||
"proxy.reserve",
|
||||
"provider.prepare_import",
|
||||
"store.commit_import",
|
||||
"proxy.release",
|
||||
]
|
||||
};
|
||||
assert_eq!(&events[..expected.len()], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: Some(super::accounts::import_settings()),
|
||||
context: context("import-xai"),
|
||||
document: document(),
|
||||
@@ -61,6 +62,7 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-xai-batch"),
|
||||
document: document(),
|
||||
@@ -96,6 +98,7 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let result = services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
outbound_proxy_id: None,
|
||||
settings: None,
|
||||
context: context("import-xai-quota-failure"),
|
||||
document: document(),
|
||||
|
||||
@@ -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" }
|
||||
@@ -29,6 +30,7 @@ tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeo
|
||||
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"
|
||||
|
||||
@@ -53,6 +53,7 @@ impl AccountImportSettingsRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub settings: Option<AccountImportSettingsRequest>,
|
||||
pub provider: String,
|
||||
pub data: Value,
|
||||
@@ -60,6 +61,9 @@ pub struct AccountImportRequest {
|
||||
|
||||
impl AccountImportRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if let Some(id) = &self.outbound_proxy_id {
|
||||
require_wire_id(id, "outboundProxyId")?;
|
||||
}
|
||||
if let Some(settings) = &self.settings {
|
||||
settings.validate()?;
|
||||
}
|
||||
@@ -82,6 +86,7 @@ impl AccountImportRequest {
|
||||
Ok((
|
||||
provider,
|
||||
ImportCredentials {
|
||||
outbound_proxy_id: self.outbound_proxy_id,
|
||||
settings: self
|
||||
.settings
|
||||
.map(AccountImportSettingsRequest::into_settings)
|
||||
@@ -96,6 +101,7 @@ impl AccountImportRequest {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartAccountAuthorizationRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
@@ -129,7 +135,10 @@ impl StartAccountAuthorizationRequest {
|
||||
context,
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
outbound_proxy: self.outbound_proxy_url.and_then(|value| value.0),
|
||||
outbound_proxy: super::wire::proxy_selection(
|
||||
self.outbound_proxy_id,
|
||||
self.outbound_proxy_url,
|
||||
)?,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -188,6 +197,7 @@ impl CompleteAccountAuthorizationRequest {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
@@ -209,7 +219,10 @@ impl UpdateAccountRequest {
|
||||
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(UpdateAccount {
|
||||
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
|
||||
outbound_proxy: super::wire::proxy_selection(
|
||||
self.outbound_proxy_id,
|
||||
self.outbound_proxy_url,
|
||||
)?,
|
||||
account_id: self.account_id,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
|
||||
@@ -26,13 +26,13 @@ use gateway_admin::model::{
|
||||
},
|
||||
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,
|
||||
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
|
||||
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
|
||||
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
|
||||
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
|
||||
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
|
||||
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
|
||||
@@ -69,6 +69,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
|
||||
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 usage_period = quota.usage_window().map(|(_, period)| period);
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
|
||||
AccountView {
|
||||
id: account.id.clone(),
|
||||
@@ -117,7 +118,7 @@ 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: account_usage_view(usage, usage_period, now),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +212,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 +230,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 +355,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,
|
||||
|
||||
@@ -17,6 +17,33 @@ impl<'de> Deserialize<'de> for AccountProxyUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn proxy_selection(
|
||||
id: Option<String>,
|
||||
url: Option<AccountProxyUpdate>,
|
||||
) -> Result<Option<gateway_admin::model::proxies::AccountProxySelection>, WireValidationError> {
|
||||
use gateway_admin::model::proxies::AccountProxySelection;
|
||||
match (id, url) {
|
||||
(Some(_), Some(_)) => Err(WireValidationError::new("outboundProxyId")),
|
||||
(Some(id), None) if id.is_empty() => Ok(Some(AccountProxySelection::Direct)),
|
||||
(Some(id), None) => {
|
||||
if id.len() > 128
|
||||
|| !id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(WireValidationError::new("outboundProxyId"));
|
||||
}
|
||||
Ok(Some(AccountProxySelection::Saved(id)))
|
||||
}
|
||||
(None, Some(value)) => Ok(Some(
|
||||
value
|
||||
.0
|
||||
.map_or(AccountProxySelection::Direct, AccountProxySelection::Url),
|
||||
)),
|
||||
(None, None) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号列表查询参数。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -34,6 +61,7 @@ pub struct ListQuery {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
@@ -84,7 +112,7 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
outbound_proxy: self.outbound_proxy_url.map(|value| value.0),
|
||||
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)?,
|
||||
@@ -288,6 +316,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>,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod client_keys;
|
||||
mod extract;
|
||||
pub mod observability;
|
||||
pub mod presenter;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod wire;
|
||||
@@ -34,6 +35,7 @@ where
|
||||
{
|
||||
Router::new()
|
||||
.merge(account_groups::router::<S>())
|
||||
.merge(proxies::router::<S>())
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(backups::router::<S>())
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use gateway_admin::model::{
|
||||
PageSize, Revision,
|
||||
proxies::{
|
||||
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
|
||||
ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta,
|
||||
accounts::{AccountGroupRefView, AccountProxyUpdate},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ListQuery {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AccountsQuery {
|
||||
proxy_id: String,
|
||||
page: Option<u32>,
|
||||
page_size: Option<u16>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct RemoveAccountRequest {
|
||||
proxy_id: String,
|
||||
account_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CreateRequest {
|
||||
name: String,
|
||||
proxy_url: AccountProxyUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpdateRequest {
|
||||
id: String,
|
||||
revision: u64,
|
||||
name: String,
|
||||
proxy_url: Option<AccountProxyUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct IdRequest {
|
||||
id: String,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ProbeRequest {
|
||||
proxy_url: AccountProxyUpdate,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyTestView {
|
||||
success: bool,
|
||||
latency_ms: u64,
|
||||
exit_ip: Option<String>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl From<ProxyTestResult> for ProxyTestView {
|
||||
fn from(result: ProxyTestResult) -> Self {
|
||||
Self {
|
||||
success: result.success,
|
||||
latency_ms: result.latency_ms,
|
||||
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
|
||||
message: result.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyAccountView {
|
||||
id: String,
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
provider: String,
|
||||
authentication_kind: String,
|
||||
plan_type: Option<String>,
|
||||
plan_type_display: Option<String>,
|
||||
groups: Vec<AccountGroupRefView>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyView {
|
||||
id: String,
|
||||
name: String,
|
||||
endpoint: String,
|
||||
has_authentication: bool,
|
||||
revision: u64,
|
||||
account_count: u64,
|
||||
last_test_at: Option<String>,
|
||||
last_test: Option<ProxyTestView>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
impl From<ProxyRecord> for ProxyView {
|
||||
fn from(record: ProxyRecord) -> Self {
|
||||
let endpoint = record.proxy.endpoint();
|
||||
Self {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
has_authentication: record.proxy.expose_url() != endpoint,
|
||||
endpoint,
|
||||
revision: record.revision.get(),
|
||||
account_count: record.account_count,
|
||||
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
|
||||
last_test: record.last_test.map(Into::into),
|
||||
created_at: record.created_at.to_rfc3339(),
|
||||
updated_at: record.updated_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProxyPageView {
|
||||
items: Vec<ProxyView>,
|
||||
page: PageMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProxyAccountPageView {
|
||||
items: Vec<ProxyAccountView>,
|
||||
page: PageMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MutationView {
|
||||
record: ProxyView,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<ProxyMutation> for MutationView {
|
||||
fn from(value: ProxyMutation) -> Self {
|
||||
Self {
|
||||
record: value.record.into(),
|
||||
config_revision: value.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/proxies", get(list::<S>))
|
||||
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
|
||||
.route(
|
||||
"/api/admin/proxies/accounts/remove",
|
||||
post(remove_account::<S>),
|
||||
)
|
||||
.route("/api/admin/proxies/create", post(create::<S>))
|
||||
.route("/api/admin/proxies/update", post(update::<S>))
|
||||
.route("/api/admin/proxies/delete", post(delete::<S>))
|
||||
.route("/api/admin/proxies/test", post(test::<S>))
|
||||
.route("/api/admin/proxies/probe", post(probe::<S>))
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, AdminError> {
|
||||
if value > i64::MAX as u64 {
|
||||
return Err(AdminError::bad_request("代理版本不合法"));
|
||||
}
|
||||
Revision::new(value).map_err(|_| AdminError::bad_request("代理版本不合法"))
|
||||
}
|
||||
|
||||
fn map_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
super::wire::map_admin_service_error(error)
|
||||
}
|
||||
|
||||
async fn list<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.list(ProxyListQuery {
|
||||
page: query.page.unwrap_or(1),
|
||||
page_size: PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
|
||||
search: query.search.unwrap_or_default().trim().to_owned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
let total_pages = result.total.div_ceil(u64::from(result.page_size));
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyPageView {
|
||||
items: result.items.into_iter().map(Into::into).collect(),
|
||||
page: PageMeta::new(
|
||||
result.page,
|
||||
u32::from(result.page_size),
|
||||
result.total,
|
||||
u32::try_from(total_pages).unwrap_or(u32::MAX),
|
||||
),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_accounts<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.list_accounts(ProxyAccountListQuery {
|
||||
proxy_id: query.proxy_id,
|
||||
page: query.page.unwrap_or(1),
|
||||
page_size: PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
|
||||
search: query.search.unwrap_or_default().trim().to_owned(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
let total_pages = result.total.div_ceil(u64::from(result.page_size));
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyAccountPageView {
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|account| ProxyAccountView {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
email: account.email,
|
||||
provider: account.provider_kind,
|
||||
authentication_kind: account.authentication_kind,
|
||||
plan_type: account.plan_type,
|
||||
plan_type_display: account.plan_type_display,
|
||||
groups: account
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|group| AccountGroupRefView {
|
||||
id: group.id.to_string(),
|
||||
name: group.name,
|
||||
color: group.color.as_str().to_owned(),
|
||||
enabled: group.enabled,
|
||||
})
|
||||
.collect(),
|
||||
enabled: account.enabled,
|
||||
})
|
||||
.collect(),
|
||||
page: PageMeta::new(
|
||||
result.page,
|
||||
u32::from(result.page_size),
|
||||
result.total,
|
||||
u32::try_from(total_pages).unwrap_or(u32::MAX),
|
||||
),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<CreateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.0
|
||||
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.create(
|
||||
NewProxy {
|
||||
name: request.name,
|
||||
proxy,
|
||||
},
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(MutationView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn remove_account<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<RemoveAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let revision = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.remove_account(
|
||||
&request.proxy_id,
|
||||
&request.account_id,
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<UpdateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.map(|value| {
|
||||
value
|
||||
.0
|
||||
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))
|
||||
})
|
||||
.transpose()?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.update(
|
||||
UpdateProxy {
|
||||
id: request.id,
|
||||
revision: revision(request.revision)?,
|
||||
name: request.name,
|
||||
proxy,
|
||||
},
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(MutationView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.delete(
|
||||
&request.id,
|
||||
revision(request.revision)?,
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(serde_json::json!({"configRevision": result.get()})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn probe<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<ProbeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.0
|
||||
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.probe(&proxy)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyTestView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn test<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<IdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.test(
|
||||
&request.id,
|
||||
revision(request.revision)?,
|
||||
&auth.context().mutation_context(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyView::from(result)),
|
||||
))
|
||||
}
|
||||
@@ -40,6 +40,15 @@ pub enum RequestDecodeError {
|
||||
/// 请求不是合法 JSON。
|
||||
#[error("request body must be valid JSON")]
|
||||
MalformedJson,
|
||||
/// 请求体解压后超过允许上限。
|
||||
#[error("decompressed request body is too large")]
|
||||
DecompressedBodyTooLarge,
|
||||
/// 请求使用了网关不支持的 `Content-Encoding`。
|
||||
#[error("request content-encoding `{encoding}` is not supported")]
|
||||
UnsupportedContentEncoding {
|
||||
/// Content-Encoding 标识。
|
||||
encoding: String,
|
||||
},
|
||||
/// 顶层不是 object。
|
||||
#[error("request body must be a JSON object")]
|
||||
ExpectedObject,
|
||||
@@ -99,6 +108,16 @@ impl RequestDecodeError {
|
||||
"Request body must be valid JSON.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::DecompressedBodyTooLarge => (
|
||||
"request_too_large",
|
||||
"Decompressed request body exceeds the allowed size.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::UnsupportedContentEncoding { encoding } => (
|
||||
"unsupported_content_encoding",
|
||||
format!("Content-Encoding `{encoding}` is not supported."),
|
||||
None,
|
||||
),
|
||||
Self::ExpectedObject => (
|
||||
"invalid_request_body",
|
||||
"Request body must be a JSON object.".to_owned(),
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。
|
||||
|
||||
use std::{fmt, net::IpAddr};
|
||||
use std::{borrow::Cow, fmt, net::IpAddr};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
|
||||
use gateway_protocol::openai::{
|
||||
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
|
||||
is_transport_managed_request_header,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -322,12 +323,87 @@ impl fmt::Debug for DecodedResponsesRequest {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。
|
||||
/// 编码不支持、压缩正文损坏或超限、JSON 非法,或路由字段无效时返回安全错误。
|
||||
pub fn decode_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
let body = decompress_request_body(body, headers)?;
|
||||
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
/// 下游请求体解压后的最大字节数;防御解压炸弹。
|
||||
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
/// 按 `Content-Encoding` 解压下游请求体。
|
||||
///
|
||||
/// 未压缩与 `identity` 借用原始正文;压缩正文在读取过程中限制展开大小,
|
||||
/// 避免先完整分配再检查。只接受单一编码,重复头和叠加编码不能只解释第一项。
|
||||
fn decompress_request_body<'a>(
|
||||
body: &'a [u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
|
||||
let encoding = headers
|
||||
.get_all(axum::http::header::CONTENT_ENCODING)
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.to_str()
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.join(",");
|
||||
match encoding.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "identity" => Ok(Cow::Borrowed(body)),
|
||||
"gzip" => {
|
||||
let decoder = flate2::read::MultiGzDecoder::new(body);
|
||||
read_bounded(decoder).map(Cow::Owned)
|
||||
}
|
||||
"deflate" => {
|
||||
let decoder = flate2::read::ZlibDecoder::new(body);
|
||||
read_bounded(decoder).map(Cow::Owned)
|
||||
}
|
||||
"zstd" => {
|
||||
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
|
||||
decoder
|
||||
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
read_bounded(decoder).map(Cow::Owned)
|
||||
}
|
||||
other => Err(RequestDecodeError::UnsupportedContentEncoding {
|
||||
encoding: other.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
|
||||
let mut decoded = Vec::new();
|
||||
let mut chunk = [0; 8192];
|
||||
loop {
|
||||
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
|
||||
let read_limit = chunk.len().min(remaining + 1);
|
||||
let read = reader
|
||||
.read(&mut chunk[..read_limit])
|
||||
.map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
if read == 0 {
|
||||
return Ok(decoded);
|
||||
}
|
||||
if read > remaining {
|
||||
return Err(RequestDecodeError::DecompressedBodyTooLarge);
|
||||
}
|
||||
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
|
||||
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
|
||||
let required = decoded.len() + read;
|
||||
if required > decoded.capacity() {
|
||||
let capacity = (decoded.capacity() * 2)
|
||||
.max(required)
|
||||
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
|
||||
decoded.reserve_exact(capacity - decoded.len());
|
||||
}
|
||||
decoded.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decode_request_inner(
|
||||
@@ -458,7 +534,7 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
if connection_headers
|
||||
.iter()
|
||||
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|
||||
|| name.starts_with("sec-websocket-")
|
||||
|| is_transport_managed_request_header(name)
|
||||
|| name.starts_with("x-grok-")
|
||||
|| name.starts_with("x-xai-")
|
||||
{
|
||||
@@ -467,30 +543,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
|
||||
!matches!(
|
||||
name,
|
||||
// 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "host"
|
||||
| "content-length"
|
||||
// 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。
|
||||
| "forwarded"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-proto"
|
||||
| "x-forwarded-port"
|
||||
| "x-real-ip"
|
||||
| "true-client-ip"
|
||||
| "cf-connecting-ip"
|
||||
| "x-request-id"
|
||||
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
|
||||
| "authorization"
|
||||
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
|
||||
"authorization"
|
||||
| "x-api-key"
|
||||
// Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。
|
||||
| "x-openai-actor-authorization"
|
||||
|
||||
@@ -340,6 +340,7 @@ mod response {
|
||||
#[test]
|
||||
fn account_usage_view_should_keep_unobserved_numbers_null() {
|
||||
let view = AccountUsageView {
|
||||
window_label_display: "周/月额度窗口".to_owned(),
|
||||
request_count: None,
|
||||
request_count_display: "-".to_owned(),
|
||||
input_tokens: None,
|
||||
@@ -374,6 +375,7 @@ mod response {
|
||||
models: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(view).expect("serialize account usage");
|
||||
assert_eq!(value["windowLabelDisplay"], "周/月额度窗口");
|
||||
assert!(value["inputTokens"].is_null());
|
||||
assert!(value["totalTokens"].is_null());
|
||||
assert!(value["reasoningTokens"].is_null());
|
||||
|
||||
@@ -82,6 +82,7 @@ mod auth;
|
||||
mod client_keys;
|
||||
mod errors;
|
||||
mod observability;
|
||||
mod proxies;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod wire;
|
||||
@@ -124,7 +125,12 @@ impl AdminTestFixture {
|
||||
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
|
||||
});
|
||||
let stores = AdminStorePorts::new(
|
||||
AdminAccountStorePorts::new(unused.clone(), unused.clone(), account_groups.clone()),
|
||||
AdminAccountStorePorts::new(
|
||||
unused.clone(),
|
||||
unused.clone(),
|
||||
account_groups.clone(),
|
||||
Arc::new(proxies::MemoryProxies::default()),
|
||||
),
|
||||
auth.clone(),
|
||||
client_keys.clone(),
|
||||
unused,
|
||||
@@ -144,7 +150,7 @@ impl AdminTestFixture {
|
||||
stores,
|
||||
providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
Arc::new(NoopProbe),
|
||||
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
|
||||
Arc::new(StaticClientDistribution),
|
||||
system,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::{
|
||||
model::{MutationContext, Revision, proxies::*},
|
||||
ports::{
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
|
||||
},
|
||||
};
|
||||
use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
|
||||
|
||||
fn missing() -> AdminStoreError {
|
||||
AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "missing proxy")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyStore for MemoryProxies {
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Conflict,
|
||||
"proxy account",
|
||||
"changed binding",
|
||||
));
|
||||
}
|
||||
Ok(Revision::new(4).unwrap())
|
||||
}
|
||||
|
||||
async fn reserve_import(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
|
||||
Err(missing())
|
||||
}
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect();
|
||||
Ok(ProxyPage {
|
||||
total: u64::try_from(items.len()).unwrap(),
|
||||
items,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
|
||||
self.0.lock().unwrap().clone().ok_or_else(missing)
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage> {
|
||||
if !self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.id == query.proxy_id)
|
||||
{
|
||||
return Err(missing());
|
||||
}
|
||||
Ok(ProxyAccountPage {
|
||||
items: vec![ProxyAccountRef {
|
||||
id: "acct_linked".to_owned(),
|
||||
name: "工作账号".to_owned(),
|
||||
email: Some("work@example.invalid".to_owned()),
|
||||
provider_kind: "openai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
plan_type: Some("plus".to_owned()),
|
||||
plan_type_display: None,
|
||||
groups: vec![],
|
||||
enabled: true,
|
||||
}],
|
||||
total: 21,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
let record = ProxyRecord {
|
||||
id: "proxy_test".to_owned(),
|
||||
name: command.name,
|
||||
proxy: command.proxy,
|
||||
revision: Revision::new(1).unwrap(),
|
||||
account_count: 0,
|
||||
last_test: None,
|
||||
last_test_at: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*self.0.lock().unwrap() = Some(record.clone());
|
||||
Ok(ProxyMutation {
|
||||
config_revision: record.revision,
|
||||
record,
|
||||
})
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
let mut stored = self.0.lock().unwrap();
|
||||
let record = stored.as_mut().ok_or_else(missing)?;
|
||||
record.name = command.name;
|
||||
if let Some(proxy) = command.proxy {
|
||||
record.proxy = proxy;
|
||||
}
|
||||
record.revision = Revision::new(record.revision.get() + 1).unwrap();
|
||||
Ok(ProxyMutation {
|
||||
config_revision: record.revision,
|
||||
record: record.clone(),
|
||||
})
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
self.0.lock().unwrap().take().ok_or_else(missing)?;
|
||||
Ok(Revision::new(3).unwrap())
|
||||
}
|
||||
async fn record_test(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Revision,
|
||||
result: ProxyTestResult,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord> {
|
||||
let mut stored = self.0.lock().unwrap();
|
||||
let record = stored.as_mut().ok_or_else(missing)?;
|
||||
record.last_test = Some(result);
|
||||
record.last_test_at = Some(Utc::now());
|
||||
Ok(record.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct SuccessfulProbe;
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for SuccessfulProbe {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
|
||||
assert_eq!(
|
||||
proxy.expose_url(),
|
||||
"http://test-user:private-password@proxy.example:8080/"
|
||||
);
|
||||
ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: 15,
|
||||
exit_ip: Some("203.0.113.2".parse().unwrap()),
|
||||
message: "Connected".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request(
|
||||
fixture: &AdminTestFixture,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
authenticated: bool,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut builder = Request::builder()
|
||||
.header("x-request-id", "req_proxy_tests")
|
||||
.uri(path)
|
||||
.method(if body.is_some() { "POST" } else { "GET" });
|
||||
if authenticated {
|
||||
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
|
||||
}
|
||||
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
|
||||
let response = gateway_api::admin::proxies::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
builder
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(!text.contains("private-password"));
|
||||
assert!(!text.contains("test-user"));
|
||||
(status, serde_json::from_slice(&body).unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_credentials() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let (status, created) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/create",
|
||||
Some(json!({
|
||||
"name": " Office ", "proxyUrl": "http://test-user:private-password@proxy.example:8080"
|
||||
})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created["data"]["record"]["name"], "Office");
|
||||
assert_eq!(created["data"]["record"]["hasAuthentication"], true);
|
||||
assert_eq!(
|
||||
created["data"]["record"]["endpoint"],
|
||||
"http://proxy.example:8080/"
|
||||
);
|
||||
assert!(created["data"]["record"].get("proxyUrl").is_none());
|
||||
assert_eq!(created["data"]["record"]["accountCount"], 0);
|
||||
assert!(created["data"]["record"].get("accounts").is_none());
|
||||
|
||||
let (status, linked) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work",
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
linked["data"]["page"],
|
||||
json!({"page":2,"pageSize":20,"total":21,"totalPages":2})
|
||||
);
|
||||
assert_eq!(
|
||||
linked["data"]["items"],
|
||||
json!([{
|
||||
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
|
||||
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
|
||||
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
|
||||
}])
|
||||
);
|
||||
|
||||
let (status, tested) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/test",
|
||||
Some(json!({"id": "proxy_test", "revision": 1})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2");
|
||||
let (_, listed) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies?page=1&pageSize=20",
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listed["data"]["items"][0], tested["data"]);
|
||||
|
||||
let (status, _) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/update",
|
||||
Some(json!({"id": "proxy_test", "revision": 1, "name": "Renamed"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (status, _) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/test",
|
||||
Some(json!({"id": "proxy_test", "revision": 1})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
let (status, _) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/test",
|
||||
Some(json!({"id": "proxy_test", "revision": 2})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (status, _) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/delete",
|
||||
Some(json!({"id": "proxy_test", "revision": 2})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["page"]["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let draft = json!({
|
||||
"proxyUrl": "http://test-user:private-password@proxy.example:8080"
|
||||
});
|
||||
let (status, probed) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/probe",
|
||||
Some(draft.clone()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
probed["data"],
|
||||
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
|
||||
);
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["page"]["total"], 0);
|
||||
|
||||
let (_, created) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/create",
|
||||
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (body, expected) in [
|
||||
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
|
||||
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(&fixture, "/api/admin/proxies/probe", Some(body), true)
|
||||
.await
|
||||
.0,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_routes_require_auth_and_reject_invalid_input() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
assert_eq!(
|
||||
request(&fixture, "/api/admin/proxies", None, false).await.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts?proxyId=proxy_test",
|
||||
None,
|
||||
false
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
for query in [
|
||||
"",
|
||||
"proxyId=",
|
||||
"proxyId=proxy_test&page=0",
|
||||
"proxyId=proxy_test&pageSize=0",
|
||||
"proxyId=proxy_test&pageSize=201",
|
||||
"proxyId=proxy_test&search=%00",
|
||||
"proxyId=proxy_test&unknown=1",
|
||||
] {
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
&format!("/api/admin/proxies/accounts?{query}"),
|
||||
None,
|
||||
true
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts?proxyId=missing",
|
||||
None,
|
||||
true
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
for action in [
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"test",
|
||||
"probe",
|
||||
"accounts/remove",
|
||||
] {
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
&format!("/api/admin/proxies/{action}"),
|
||||
Some(json!({})),
|
||||
false
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
for query in ["page=0", "pageSize=0", "pageSize=201", "unknown=1"] {
|
||||
assert_eq!(
|
||||
request(&fixture, &format!("/api/admin/proxies?{query}"), None, true)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
json!({"name":" ","proxyUrl":"http://proxy.example:8080"}),
|
||||
json!({"name":"Invalid","proxyUrl":""}),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(&fixture, "/api/admin/proxies/create", Some(body), true)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
let (status, _) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/create",
|
||||
Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_account_removal_validates_binding_and_input() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (body, expected) in [
|
||||
(
|
||||
json!({"proxyId":"", "accountId":"acct_linked"}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"proxyId":"proxy_test", "accountId":""}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"proxyId":"proxy_test", "accountId":"invalid"}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts/remove",
|
||||
Some(body),
|
||||
true
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
expected
|
||||
);
|
||||
}
|
||||
let (status, result) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts/remove",
|
||||
Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["data"], json!({"configRevision":4}));
|
||||
let (status, result) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/accounts/remove",
|
||||
Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::CONFLICT);
|
||||
assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试");
|
||||
}
|
||||
@@ -50,6 +50,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/admin/observability/routes.rs",
|
||||
"src/admin/observability/wire.rs",
|
||||
"src/admin/presenter.rs",
|
||||
"src/admin/proxies.rs",
|
||||
"src/admin/settings.rs",
|
||||
"src/admin/system.rs",
|
||||
"src/admin/wire.rs",
|
||||
@@ -96,6 +97,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/admin/observability/mod.rs",
|
||||
"tests/admin/observability/query.rs",
|
||||
"tests/admin/observability/response.rs",
|
||||
"tests/admin/proxies.rs",
|
||||
"tests/admin/settings.rs",
|
||||
"tests/admin/system.rs",
|
||||
"tests/admin/wire.rs",
|
||||
@@ -110,6 +112,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/openai/endpoint.rs",
|
||||
"tests/openai/responses/http.rs",
|
||||
"tests/openai/responses/mod.rs",
|
||||
"tests/openai/responses/request.rs",
|
||||
"tests/openai/responses/websocket/connection.rs",
|
||||
"tests/openai/responses/websocket/mod.rs",
|
||||
"tests/openai/responses/websocket/protocol.rs",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
@@ -11,7 +12,7 @@ use axum::{
|
||||
extract::connect_info::ConnectInfo,
|
||||
http::{
|
||||
HeaderMap, HeaderValue, Request, StatusCode,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
|
||||
},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
@@ -451,9 +452,26 @@ async fn captured_client_context(
|
||||
async fn captured_http_request(
|
||||
provider_name: &str,
|
||||
body: Value,
|
||||
mut headers: HeaderMap,
|
||||
headers: HeaderMap,
|
||||
peer_address: Option<SocketAddr>,
|
||||
) -> CapturedClientContext {
|
||||
let (response, observed) = http_request_with_body(
|
||||
provider_name,
|
||||
body.to_string().into(),
|
||||
headers,
|
||||
peer_address,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
observed.expect("captured request context")
|
||||
}
|
||||
|
||||
async fn http_request_with_body(
|
||||
provider_name: &str,
|
||||
body: Bytes,
|
||||
mut headers: HeaderMap,
|
||||
peer_address: Option<SocketAddr>,
|
||||
) -> (axum::response::Response, Option<CapturedClientContext>) {
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
"Bearer sk_context_test".parse().expect("authorization"),
|
||||
@@ -464,7 +482,7 @@ async fn captured_http_request(
|
||||
client: authenticated_client_for_provider("sk_context_test", provider_name),
|
||||
});
|
||||
let mut request = Request::post("/v1/responses")
|
||||
.body(Body::from(body.to_string()))
|
||||
.body(Body::from(body))
|
||||
.expect("context request");
|
||||
*request.headers_mut() = headers;
|
||||
if let Some(peer_address) = peer_address {
|
||||
@@ -475,12 +493,75 @@ async fn captured_http_request(
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("context response");
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
observed
|
||||
.lock()
|
||||
.expect("context capture lock")
|
||||
.clone()
|
||||
.expect("captured request context")
|
||||
let observed = observed.lock().expect("context capture lock").clone();
|
||||
(response, observed)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() {
|
||||
let body = json!({
|
||||
"model": "model-a", "input": "hello", "previous_response_id": "resp_previous",
|
||||
"prompt_cache_key": "cache-key", "client_metadata": {"source": "test"}
|
||||
});
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers.insert("session_id", HeaderValue::from_static("session-test"));
|
||||
let expected = captured_http_request("openai", body.clone(), headers.clone(), None).await;
|
||||
for encoding in ["gzip", "deflate", "zstd"] {
|
||||
let compressed = super::request::encode_body(encoding, body.to_string().as_bytes());
|
||||
let mut headers = headers.clone();
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
|
||||
headers.insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&compressed.len().to_string()).expect("compressed length"),
|
||||
);
|
||||
let (response, observed) =
|
||||
http_request_with_body("openai", compressed.into(), headers, None).await;
|
||||
// 捕获执行器在解码成功后主动返回 500;正文和业务头应与未压缩请求一致,
|
||||
// 压缩编码及长度不能泄漏到已解压正文的上游协议上下文。
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(observed.as_ref(), Some(&expected), "{encoding}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_compressed_http_requests_should_fail_before_execution() {
|
||||
for (encoding, body, code, message) in [
|
||||
(
|
||||
"gzip",
|
||||
Bytes::from_static(b"broken gzip"),
|
||||
"invalid_json",
|
||||
"Request body must be valid JSON.",
|
||||
),
|
||||
(
|
||||
"br",
|
||||
Bytes::from_static(b"{}"),
|
||||
"unsupported_content_encoding",
|
||||
"Content-Encoding `br` is not supported.",
|
||||
),
|
||||
(
|
||||
"zstd",
|
||||
super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1))
|
||||
.into(),
|
||||
"request_too_large",
|
||||
"Decompressed request body exceeds the allowed size.",
|
||||
),
|
||||
] {
|
||||
let headers = HeaderMap::from_iter([
|
||||
(CONTENT_ENCODING, HeaderValue::from_static(encoding)),
|
||||
(CONTENT_TYPE, HeaderValue::from_static("application/json")),
|
||||
]);
|
||||
let (response, observed) = http_request_with_body("openai", body, headers, None).await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{encoding}");
|
||||
assert!(observed.is_none(), "invalid body must not start execution");
|
||||
let body = to_bytes(response.into_body(), 4096)
|
||||
.await
|
||||
.expect("error body");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&body).expect("error JSON"),
|
||||
json!({"error": {"type": "invalid_request_error", "code": code, "message": message}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn captured_http_compaction(provider_name: &str) -> CapturedClientContext {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod http;
|
||||
mod request;
|
||||
mod websocket;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
@@ -369,6 +370,47 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() {
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in [
|
||||
"cf-visitor",
|
||||
"cf-connecting-ip",
|
||||
"cf-connecting-ipv6",
|
||||
"cf-pseudo-ipv4",
|
||||
"cf-ray",
|
||||
"cf-ipcountry",
|
||||
"cf-warp-tag-id",
|
||||
"cf-worker",
|
||||
"cf-ew-via",
|
||||
"cdn-loop",
|
||||
"via",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-prefix",
|
||||
"accept-encoding",
|
||||
] {
|
||||
headers.insert(name, HeaderValue::from_static("downstream-only"));
|
||||
}
|
||||
headers.insert(
|
||||
"CF-Visitor",
|
||||
HeaderValue::from_static(r#"{"scheme":"https"}"#),
|
||||
);
|
||||
headers.insert("accept-encoding", HeaderValue::from_static("br, gzip"));
|
||||
// 正文未压缩;入口现在消费 Content-Encoding,但仍不得向上游透传。
|
||||
headers.insert("content-encoding", HeaderValue::from_static("identity"));
|
||||
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
|
||||
|
||||
let decoded =
|
||||
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
|
||||
.expect("decode request behind a reverse proxy");
|
||||
|
||||
assert_eq!(
|
||||
openai_protocol_context(&decoded).get("opaque_request_headers"),
|
||||
Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
|
||||
let secret = "nested-private-value";
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use std::io::{Read, Write, copy, repeat};
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_ENCODING};
|
||||
use gateway_api::openai::responses::{RequestDecodeError, decode_request_with_headers};
|
||||
use serde_json::json;
|
||||
|
||||
use super::openai_wire_body;
|
||||
|
||||
const ENCODINGS: [&str; 3] = ["gzip", "deflate", "zstd"];
|
||||
const REQUEST: &[u8] = br#"{"model":"gpt-test","input":"hello","future_field":{"value":1}}"#;
|
||||
const DECOMPRESSED_LIMIT: u64 = 64 * 1024 * 1024;
|
||||
|
||||
pub(super) fn encode_body(encoding: &str, mut data: impl Read) -> Vec<u8> {
|
||||
match encoding {
|
||||
"gzip" => {
|
||||
let mut encoder =
|
||||
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
copy(&mut data, &mut encoder).expect("gzip encode");
|
||||
encoder.finish().expect("gzip finish")
|
||||
}
|
||||
"deflate" => {
|
||||
let mut encoder =
|
||||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
copy(&mut data, &mut encoder).expect("deflate encode");
|
||||
encoder.finish().expect("deflate finish")
|
||||
}
|
||||
"zstd" => zstd::stream::encode_all(data, 3).expect("zstd encode"),
|
||||
_ => panic!("unsupported fixture encoding"),
|
||||
}
|
||||
}
|
||||
|
||||
fn headers(encoding: &str) -> HeaderMap {
|
||||
HeaderMap::from_iter([(
|
||||
CONTENT_ENCODING,
|
||||
HeaderValue::from_str(encoding).expect("encoding header"),
|
||||
)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_preserve_compressed_request_fields() {
|
||||
let expected = serde_json::from_slice::<serde_json::Value>(REQUEST).expect("fixture JSON");
|
||||
for encoding in ENCODINGS {
|
||||
let compressed = encode_body(encoding, REQUEST);
|
||||
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
|
||||
assert_eq!(
|
||||
openai_wire_body(&decoded),
|
||||
expected.as_object().expect("object")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() {
|
||||
for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] {
|
||||
assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok());
|
||||
}
|
||||
let compressed = encode_body("gzip", REQUEST);
|
||||
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_reject_unknown_and_stacked_encodings() {
|
||||
let mut repeated = headers("identity");
|
||||
repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
|
||||
for request_headers in [headers("br"), headers("gzip, zstd"), repeated] {
|
||||
let error = decode_request_with_headers(REQUEST, &request_headers)
|
||||
.expect_err("unsupported encoding");
|
||||
assert!(matches!(
|
||||
error,
|
||||
RequestDecodeError::UnsupportedContentEncoding { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
error.protocol_body().into_value()["error"]["code"],
|
||||
"unsupported_content_encoding"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
|
||||
for encoding in ENCODINGS {
|
||||
let mut truncated = encode_body(encoding, REQUEST);
|
||||
truncated.truncate(truncated.len() - 4);
|
||||
for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] {
|
||||
let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding);
|
||||
assert_eq!(
|
||||
error.protocol_body().into_value(),
|
||||
json!({"error": {
|
||||
"type": "invalid_request_error", "code": "invalid_json",
|
||||
"message": "Request body must be valid JSON."
|
||||
}})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_read_all_gzip_members_and_zstd_frames() {
|
||||
for encoding in ["gzip", "zstd"] {
|
||||
let mut compressed = encode_body(encoding, &REQUEST[..12]);
|
||||
compressed.extend(encode_body(encoding, &REQUEST[12..]));
|
||||
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
|
||||
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_not_ignore_invalid_trailing_members() {
|
||||
for encoding in ["gzip", "zstd"] {
|
||||
let mut compressed = encode_body(encoding, REQUEST);
|
||||
compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice()));
|
||||
assert_eq!(
|
||||
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding),
|
||||
RequestDecodeError::MalformedJson
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
|
||||
let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).expect("zstd encoder");
|
||||
encoder.window_log(27).expect("128 MiB window");
|
||||
encoder.write_all(REQUEST).expect("write JSON");
|
||||
// 提前输出帧头,避免编码器根据最终小正文缩减窗口。
|
||||
encoder.flush().expect("flush frame header");
|
||||
let compressed = encoder.finish().expect("finish frame");
|
||||
assert_eq!(
|
||||
zstd::stream::decode_all(compressed.as_slice()).expect("valid zstd frame"),
|
||||
REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
|
||||
RequestDecodeError::MalformedJson
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_accept_the_exact_decompressed_limit() {
|
||||
let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64);
|
||||
let compressed = encode_body("zstd", REQUEST.chain(padding));
|
||||
let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit");
|
||||
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_reject_bodies_above_the_decompressed_limit() {
|
||||
for encoding in ENCODINGS {
|
||||
let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
|
||||
let error =
|
||||
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
|
||||
assert_eq!(
|
||||
error,
|
||||
RequestDecodeError::DecompressedBodyTooLarge,
|
||||
"{encoding}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
|
||||
for encoding in ["zstd", "gzip"] {
|
||||
// 尾部故意损坏:若先完整解压再检查长度,就会错误地返回 invalid_json,
|
||||
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
|
||||
let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
|
||||
compressed.extend_from_slice(b"invalid trailing frame");
|
||||
let error =
|
||||
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
|
||||
assert_eq!(
|
||||
error,
|
||||
RequestDecodeError::DecompressedBodyTooLarge,
|
||||
"{encoding}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use gateway_api::openai::responses::{OpenAiRequestHeaders, decode_response_create_with_context};
|
||||
use gateway_core::operation::Operation;
|
||||
use serde_json::json;
|
||||
|
||||
use super::decode_response_create;
|
||||
|
||||
#[test]
|
||||
fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
|
||||
let mut headers = HeaderMap::new();
|
||||
for (name, value) in [
|
||||
("CF-Visitor", r#"{"scheme":"https"}"#),
|
||||
("cdn-loop", "cloudflare; loops=1"),
|
||||
("cf-warp-tag-id", "downstream-warp"),
|
||||
("cf-ipcountry", "US"),
|
||||
("cf-worker", "worker.example"),
|
||||
("cf-ew-via", "15"),
|
||||
("cf-future-proxy-field", "downstream-only"),
|
||||
] {
|
||||
headers.insert(name, HeaderValue::from_static(value));
|
||||
}
|
||||
headers.append("x-openai-future-mode", HeaderValue::from_static("first"));
|
||||
headers.append("x-openai-future-mode", HeaderValue::from_static("second"));
|
||||
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
|
||||
|
||||
// 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。
|
||||
for input in ["hello", "continue"] {
|
||||
let decoded = decode_response_create_with_context(
|
||||
&json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(),
|
||||
&request_headers,
|
||||
)
|
||||
.expect("decode response.create behind Cloudflare");
|
||||
let Operation::Generate(request) = decoded.operation() else {
|
||||
panic!("Responses must map to Generate");
|
||||
};
|
||||
assert_eq!(
|
||||
request
|
||||
.protocol_payload()
|
||||
.context()
|
||||
.get("opaque_request_headers"),
|
||||
Some(&json!([
|
||||
["x-openai-future-mode", STANDARD.encode(b"first")],
|
||||
["x-openai-future-mode", STANDARD.encode(b"second")],
|
||||
])),
|
||||
);
|
||||
assert_eq!(request.protocol_payload().body()["input"], input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_official_codex_response_create_fixture_decodes_unchanged() {
|
||||
// Audited against openai/codex main 94cbbddafc1776d5e377bca1b05932c697e82238.
|
||||
|
||||
@@ -17,7 +17,7 @@ futures = "0.3.32"
|
||||
gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
hex = "0.4.3"
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
|
||||
scraper = "0.27.0"
|
||||
semver = "1.0.27"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod client_distribution;
|
||||
pub mod config;
|
||||
mod logging;
|
||||
pub mod proxy_probe;
|
||||
pub mod serve;
|
||||
pub mod system_update;
|
||||
pub mod workers;
|
||||
@@ -81,6 +82,17 @@ impl HostBundle {
|
||||
self.client_distribution.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn proxy_probe<E>(
|
||||
&self,
|
||||
build_client: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Arc<dyn gateway_admin::ports::proxy::ProxyProbe> {
|
||||
Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
|
||||
self.connections.clone()
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试。
|
||||
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::{model::proxies::ProxyTestResult, ports::proxy::ProxyProbe};
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub struct HttpProxyProbe {
|
||||
endpoint: String,
|
||||
build_client: Arc<ProxyClientBuilder>,
|
||||
}
|
||||
|
||||
type ProxyClientBuilder =
|
||||
dyn Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, &'static str> + Send + Sync;
|
||||
|
||||
impl Default for HttpProxyProbe {
|
||||
fn default() -> Self {
|
||||
Self::new("https://api.ipify.org?format=json")
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpProxyProbe {
|
||||
#[must_use]
|
||||
pub fn new(endpoint: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint: endpoint.into(),
|
||||
build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 由组合根注入与 Provider 请求一致的证书信任策略。
|
||||
#[must_use]
|
||||
pub fn with_client_builder<E>(
|
||||
mut self,
|
||||
build: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E> + Send + Sync + 'static,
|
||||
) -> Self {
|
||||
self.build_client = Arc::new(move |builder| {
|
||||
build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置")
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
async fn exit_ip(&self, proxy: &OutboundProxy) -> Result<IpAddr, &'static str> {
|
||||
let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?;
|
||||
let builder = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.proxy(proxy)
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.timeout(Duration::from_secs(12))
|
||||
.redirect(reqwest::redirect::Policy::none());
|
||||
let client = (self.build_client)(builder)?;
|
||||
let mut response = client.get(&self.endpoint).send().await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"代理连接超时"
|
||||
} else {
|
||||
"代理连接失败,请检查地址、认证和网络"
|
||||
}
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(
|
||||
if response.status() == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
|
||||
"代理认证失败"
|
||||
} else {
|
||||
"出口检测服务返回错误状态"
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| "出口检测响应读取失败")?
|
||||
{
|
||||
if body.len() + chunk.len() > 1024 {
|
||||
return Err("出口检测响应过大");
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Response {
|
||||
ip: IpAddr,
|
||||
}
|
||||
serde_json::from_slice::<Response>(&body)
|
||||
.map(|response| response.ip)
|
||||
.map_err(|_| "出口检测响应不合法")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for HttpProxyProbe {
|
||||
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
|
||||
let started = Instant::now();
|
||||
let result = tokio::time::timeout(Duration::from_secs(15), self.exit_ip(proxy)).await;
|
||||
let result = result.unwrap_or(Err("代理连接超时"));
|
||||
ProxyTestResult {
|
||||
success: result.is_ok(),
|
||||
latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
|
||||
exit_ip: result.as_ref().ok().copied(),
|
||||
message: result.map_or_else(str::to_owned, |_| "连接成功".to_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod client_distribution;
|
||||
mod config;
|
||||
mod logging;
|
||||
mod proxy_probe;
|
||||
mod serve;
|
||||
mod system_update;
|
||||
mod workers;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use gateway_admin::ports::proxy::ProxyProbe;
|
||||
use gateway_core::account::OutboundProxy;
|
||||
use gateway_host::proxy_probe::HttpProxyProbe;
|
||||
use serde_json::json;
|
||||
use wiremock::{
|
||||
Mock, MockServer, ResponseTemplate,
|
||||
matchers::{any, header},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_sends_authentication_through_explicit_proxy() {
|
||||
let proxy_server = MockServer::start().await;
|
||||
Mock::given(header("proxy-authorization", "Basic dXNlcjpwYXNzd29yZA=="))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"})))
|
||||
.expect(1)
|
||||
.mount(&proxy_server)
|
||||
.await;
|
||||
let proxy =
|
||||
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())).unwrap();
|
||||
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.test(&proxy)
|
||||
.await;
|
||||
assert!(result.success);
|
||||
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_responses() {
|
||||
for response in [
|
||||
ResponseTemplate::new(407),
|
||||
ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1/"),
|
||||
ResponseTemplate::new(200).set_body_json(json!({"ip": "not-an-ip"})),
|
||||
ResponseTemplate::new(200).set_body_string("a".repeat(1025)),
|
||||
] {
|
||||
let proxy_server = MockServer::start().await;
|
||||
Mock::given(any())
|
||||
.respond_with(response)
|
||||
.expect(1)
|
||||
.mount(&proxy_server)
|
||||
.await;
|
||||
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
|
||||
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap())
|
||||
.await;
|
||||
assert!(!result.success);
|
||||
assert!(result.exit_ip.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_proxy_never_falls_back_to_direct_connection() {
|
||||
let target = MockServer::start().await;
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip":"203.0.113.8"})))
|
||||
.expect(0)
|
||||
.mount(&target)
|
||||
.await;
|
||||
let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap();
|
||||
drop(unused);
|
||||
let result = HttpProxyProbe::new(target.uri()).test(&proxy).await;
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_certificate_configuration_should_not_fall_back_or_expose_details() {
|
||||
let target = MockServer::start().await;
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&target)
|
||||
.await;
|
||||
let result = HttpProxyProbe::new(target.uri())
|
||||
.with_client_builder(|_| Err("private-certificate-path"))
|
||||
.test(&OutboundProxy::parse(&target.uri()).unwrap())
|
||||
.await;
|
||||
assert!(!result.success);
|
||||
assert!(!result.message.contains("private-certificate-path"));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! OpenAI 请求头的业务协议与传输边界。
|
||||
|
||||
/// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传。
|
||||
///
|
||||
/// API 入站和 Provider 编码共用此分类;`Connection` 动态声明的逐跳头由入站额外剥离。
|
||||
/// 只用于请求方向,不影响上游响应中的代理诊断信息。
|
||||
#[must_use]
|
||||
pub fn is_transport_managed_request_header(name: &str) -> bool {
|
||||
// 代理命名空间描述的是下游链路;包括未知扩展也不能冒充上游连接事实。
|
||||
name.starts_with("cf-")
|
||||
|| name.starts_with("x-forwarded-")
|
||||
|| name.starts_with("sec-websocket-")
|
||||
|| matches!(
|
||||
name,
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "host"
|
||||
| "content-length"
|
||||
// 请求实体与响应压缩能力属于各段 transport,不能继承下游协商。
|
||||
| "content-encoding"
|
||||
| "accept-encoding"
|
||||
| "forwarded"
|
||||
| "via"
|
||||
| "cdn-loop"
|
||||
| "x-real-ip"
|
||||
| "true-client-ip"
|
||||
| "x-request-id"
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
|
||||
pub mod codex;
|
||||
/// OpenAI/Codex 事件语义、用量与限流字段编解码。
|
||||
pub mod events;
|
||||
mod headers;
|
||||
/// Server-Sent Events 帧的解析与编码。
|
||||
pub mod sse;
|
||||
|
||||
@@ -20,3 +21,4 @@ pub use codex::{
|
||||
CodexResponsesRequestSemantics, codex_responses_request_semantics,
|
||||
codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id,
|
||||
};
|
||||
pub use headers::is_transport_managed_request_header;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
use gateway_protocol::openai::is_transport_managed_request_header;
|
||||
|
||||
#[test]
|
||||
fn transport_headers_should_include_proxy_namespaces_and_compression() {
|
||||
for name in [
|
||||
"cf-visitor",
|
||||
"cf-connecting-ip",
|
||||
"cf-connecting-ipv6",
|
||||
"cf-pseudo-ipv4",
|
||||
"cf-ray",
|
||||
"cf-ipcountry",
|
||||
"cf-warp-tag-id",
|
||||
"cf-worker",
|
||||
"cf-ew-via",
|
||||
"cf-future-proxy-field",
|
||||
"cdn-loop",
|
||||
"via",
|
||||
"forwarded",
|
||||
"x-forwarded-for",
|
||||
"x-forwarded-host",
|
||||
"x-forwarded-proto",
|
||||
"x-forwarded-port",
|
||||
"x-forwarded-prefix",
|
||||
"x-forwarded-future-field",
|
||||
"x-real-ip",
|
||||
"true-client-ip",
|
||||
"x-request-id",
|
||||
"accept-encoding",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"host",
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-connection",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-extensions",
|
||||
] {
|
||||
assert!(is_transport_managed_request_header(name), "missing {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() {
|
||||
for name in [
|
||||
"x-openai-future-mode",
|
||||
"x-custom-extension",
|
||||
"x-client-request-id",
|
||||
"session-id",
|
||||
"thread-id",
|
||||
"x-codex-turn-state",
|
||||
"x-codex-beta-features",
|
||||
"openai-beta",
|
||||
"accept",
|
||||
"content-type",
|
||||
"x-cf-business-field",
|
||||
] {
|
||||
assert!(
|
||||
!is_transport_managed_request_header(name),
|
||||
"unexpected {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod codex;
|
||||
mod events;
|
||||
mod headers;
|
||||
mod sse;
|
||||
|
||||
@@ -106,6 +106,7 @@ pub async fn initialize(mut config: StoreConfig) -> StoreResult<StoreBundle> {
|
||||
)),
|
||||
admin_account_runtime,
|
||||
Arc::new(postgres::PgAccountGroupRepository::new(pool.clone())),
|
||||
Arc::new(postgres::PgProxyRepository::new(pool.clone())),
|
||||
),
|
||||
Arc::new(AdminAuthStoreAdapter {
|
||||
security: postgres::PgAdminSecurityAuditRepository::new(pool.clone()),
|
||||
|
||||
@@ -22,6 +22,7 @@ mod execution_buffer;
|
||||
mod observability;
|
||||
mod ops_events;
|
||||
mod provider_accounts;
|
||||
mod proxies;
|
||||
mod retention;
|
||||
mod runtime_settings;
|
||||
mod snapshot;
|
||||
@@ -38,6 +39,7 @@ pub use execution_buffer::*;
|
||||
pub use observability::*;
|
||||
pub use ops_events::*;
|
||||
pub use provider_accounts::*;
|
||||
pub use proxies::PgProxyRepository;
|
||||
pub use retention::*;
|
||||
pub use runtime_settings::*;
|
||||
pub use snapshot::*;
|
||||
|
||||
@@ -216,6 +216,7 @@ impl PgAdminAccountStore {
|
||||
settings: Option<AccountImportSettings>,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
let provider_kind = prepared.provider_kind.as_str().to_owned();
|
||||
let accounts = prepared
|
||||
@@ -233,6 +234,7 @@ impl PgAdminAccountStore {
|
||||
.accounts
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings,
|
||||
outbound_proxy,
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: provider_kind.clone(),
|
||||
},
|
||||
@@ -627,6 +629,7 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command.settings,
|
||||
context,
|
||||
"import_document",
|
||||
command.outbound_proxy,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -650,6 +653,16 @@ impl AccountStore for PgAdminAccountStore {
|
||||
command.settings,
|
||||
context,
|
||||
"authorize",
|
||||
command
|
||||
.pending
|
||||
.outbound_proxy_id()
|
||||
.zip(command.pending.outbound_proxy())
|
||||
.map(
|
||||
|(id, proxy)| gateway_admin::model::proxies::ImportProxyBinding {
|
||||
id: id.to_owned(),
|
||||
proxy: proxy.clone(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let [account_id]: [CoreProviderAccountId; 1] =
|
||||
|
||||
@@ -133,15 +133,28 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
} else {
|
||||
CredentialState::Unknown
|
||||
};
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin provider account insert"))?;
|
||||
let proxy_id = match account.outbound_proxy.as_ref() {
|
||||
Some(proxy) => Some(
|
||||
super::super::proxies::ensure_proxy_for_url(&mut transaction, proxy, None)
|
||||
.await?
|
||||
.0,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
sqlx::query(
|
||||
"insert into provider_accounts (
|
||||
outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
|
||||
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
|
||||
concurrency_limit, weight, credential_state, provider_quota_json,
|
||||
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
|
||||
) values (
|
||||
$18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
)",
|
||||
)
|
||||
@@ -163,9 +176,14 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
.bind(credential_state.as_str())
|
||||
.bind(account.credential_observed_at)
|
||||
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
|
||||
.execute(&self.pool)
|
||||
.bind(proxy_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("insert provider account"))?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("commit provider account insert"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -443,6 +461,22 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
|
||||
.map_err(|_| postgres_unavailable("begin provider account admin import"))?;
|
||||
let result = async {
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
if let Some(binding) = &command.outbound_proxy {
|
||||
let (_, current) = super::super::proxies::resolve_proxy_selection(
|
||||
&mut transaction,
|
||||
&gateway_admin::model::proxies::AccountProxySelection::Saved(
|
||||
binding.id.clone(),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
if current.as_ref() != Some(&binding.proxy) {
|
||||
return Err(StoreError::Conflict {
|
||||
entity: "outbound proxy",
|
||||
id: binding.id.clone(),
|
||||
kind: ConflictKind::StaleRevision,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut account_ids = Vec::with_capacity(command.accounts.len());
|
||||
for account in &command.accounts {
|
||||
account_ids
|
||||
@@ -701,15 +735,23 @@ pub(crate) async fn upsert_provider_account_in_transaction(
|
||||
} else {
|
||||
CredentialState::Unknown
|
||||
};
|
||||
let proxy_id = match account.outbound_proxy.as_ref() {
|
||||
Some(proxy) => Some(
|
||||
super::super::proxies::ensure_proxy_for_url(transaction, proxy, None)
|
||||
.await?
|
||||
.0,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let imported_id = sqlx::query_scalar::<_, String>(
|
||||
"insert into provider_accounts (
|
||||
outbound_proxy_url, id, provider_kind, name, email, upstream_user_id,
|
||||
outbound_proxy_url, outbound_proxy_id, id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled,
|
||||
concurrency_limit, weight, credential_state, provider_quota_json,
|
||||
credential_observed_at, quota_access_observed_at, quota_observed_at, created_at, updated_at
|
||||
) values (
|
||||
$18, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$18, $19, $1, $2, $3, $4, $5, $6, $7, $8, $9, 1, $10, $11, $12, $13,
|
||||
$14, $15, $16, null, $17, null, null, now(), greatest(now(), $17)
|
||||
)
|
||||
on conflict (
|
||||
@@ -723,6 +765,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
|
||||
authentication_kind = excluded.authentication_kind,
|
||||
provider_credentials_json = excluded.provider_credentials_json,
|
||||
outbound_proxy_url = coalesce(excluded.outbound_proxy_url, provider_accounts.outbound_proxy_url),
|
||||
outbound_proxy_id = coalesce(excluded.outbound_proxy_id, provider_accounts.outbound_proxy_id),
|
||||
credential_revision = provider_accounts.credential_revision + 1,
|
||||
has_refresh_token = excluded.has_refresh_token,
|
||||
access_token_expires_at = excluded.access_token_expires_at,
|
||||
@@ -759,6 +802,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
|
||||
.bind(credential_state.as_str())
|
||||
.bind(account.credential_observed_at)
|
||||
.bind(account.outbound_proxy.as_ref().map(|proxy| proxy.expose_url()))
|
||||
.bind(proxy_id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -865,12 +909,19 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
|
||||
enabled: bool,
|
||||
concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
weight: AccountWeight,
|
||||
outbound_proxy: Option<&Option<gateway_core::account::OutboundProxy>>,
|
||||
outbound_proxy: Option<&gateway_admin::model::proxies::AccountProxySelection>,
|
||||
) -> StoreResult<()> {
|
||||
let (proxy_id, proxy) = match outbound_proxy {
|
||||
Some(selection) => {
|
||||
super::super::proxies::resolve_proxy_selection(transaction, selection).await?
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let updated = sqlx::query_scalar::<_, String>(
|
||||
"update provider_accounts
|
||||
set enabled = $2, concurrency_limit = $3, weight = $4, updated_at = greatest(now(), updated_at),
|
||||
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end
|
||||
outbound_proxy_url = case when $5 then $6 else outbound_proxy_url end,
|
||||
outbound_proxy_id = case when $5 then $7 else outbound_proxy_id end
|
||||
where id = any($1::text[])
|
||||
returning id",
|
||||
)
|
||||
@@ -879,11 +930,8 @@ pub(crate) async fn update_provider_accounts_scheduling_in_transaction(
|
||||
.bind(concurrency_limit.map(|limit| i64::from(limit.get())))
|
||||
.bind(i16::try_from(weight.get()).map_err(|_| invalid("invalid weight"))?)
|
||||
.bind(outbound_proxy.is_some())
|
||||
.bind(
|
||||
outbound_proxy
|
||||
.and_then(Option::as_ref)
|
||||
.map(|proxy| proxy.expose_url()),
|
||||
)
|
||||
.bind(proxy.as_ref().map(gateway_core::account::OutboundProxy::expose_url))
|
||||
.bind(proxy_id)
|
||||
.fetch_all(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("set provider accounts state in admin transaction"))?
|
||||
|
||||
@@ -233,6 +233,7 @@ impl NewProviderAccount {
|
||||
#[derive(Clone)]
|
||||
pub struct ImportProviderAccounts {
|
||||
pub settings: Option<AccountImportSettings>,
|
||||
pub outbound_proxy: Option<gateway_admin::model::proxies::ImportProxyBinding>,
|
||||
pub scope: ProviderAccountAdminScope,
|
||||
pub accounts: Vec<NewProviderAccount>,
|
||||
pub audit: AdminAuditEvent,
|
||||
@@ -313,7 +314,7 @@ impl fmt::Debug for RotateProviderAccount {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BatchUpdateProviderAccountsAdmin {
|
||||
pub outbound_proxy: Option<Option<gateway_core::account::OutboundProxy>>,
|
||||
pub outbound_proxy: Option<gateway_admin::model::proxies::AccountProxySelection>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
//! 命名代理持有认证信息,账号保留解析后的 URL 供 Provider 传输使用。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationContext, Revision as AdminRevision,
|
||||
account_groups::{AccountGroupColor, AccountGroupRef},
|
||||
proxies::*,
|
||||
},
|
||||
ports::{
|
||||
proxy::{ProxyImportGuard, ProxyImportReservation, ProxyStore},
|
||||
store::{AdminStoreError, AdminStoreResult},
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
account::{OutboundProxy, ProviderAccountId},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder, Row as _, Transaction, postgres::PgRow};
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||
|
||||
use super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction};
|
||||
use crate::{
|
||||
ConflictKind, Revision, StoreError, StoreResult, admin_revision, admin_store_error,
|
||||
mutation_audit, postgres_unavailable,
|
||||
};
|
||||
|
||||
const ENTITY: &str = "outbound proxy";
|
||||
const SELECT: &str = "select p.*,
|
||||
(select count(*) from provider_accounts a where a.outbound_proxy_id = p.id) as account_count
|
||||
from outbound_proxies p";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PgProxyRepository {
|
||||
pool: PgPool,
|
||||
import_slots: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl PgProxyRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
import_slots: Arc::new(Semaphore::new(4)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PgProxyImportGuard {
|
||||
// 独立会话持有咨询锁,避免空闲事务超时,也不给提交事务占用的连接池制造死锁。
|
||||
// 每个仓储最多增加四个会话;取消请求或进程退出时,关闭连接会自动释放锁。
|
||||
_connection: PgConnection,
|
||||
_slot: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
impl ProxyImportGuard for PgProxyImportGuard {}
|
||||
|
||||
async fn exclude_active_imports(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
id: &str,
|
||||
) -> StoreResult<()> {
|
||||
let acquired: bool =
|
||||
sqlx::query_scalar("select pg_try_advisory_xact_lock(hashtextextended($1, 739219))")
|
||||
.bind(id)
|
||||
.fetch_one(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| unavailable())?;
|
||||
if !acquired {
|
||||
return Err(conflict(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_error(error: StoreError) -> AdminStoreError {
|
||||
admin_store_error(ENTITY, error)
|
||||
}
|
||||
fn unavailable() -> StoreError {
|
||||
postgres_unavailable("outbound proxy operation")
|
||||
}
|
||||
fn conflict(id: &str) -> StoreError {
|
||||
StoreError::Conflict {
|
||||
entity: ENTITY,
|
||||
id: id.to_owned(),
|
||||
kind: ConflictKind::InvalidTransition,
|
||||
}
|
||||
}
|
||||
fn invalid() -> StoreError {
|
||||
StoreError::InvalidData {
|
||||
entity: ENTITY,
|
||||
message: "invalid proxy record".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(row: PgRow) -> StoreResult<ProxyRecord> {
|
||||
let success: Option<bool> = row.try_get("last_test_success").map_err(|_| invalid())?;
|
||||
let ip: Option<String> = row.try_get("last_test_ip").map_err(|_| invalid())?;
|
||||
let latency: Option<i64> = row.try_get("last_test_latency_ms").map_err(|_| invalid())?;
|
||||
Ok(ProxyRecord {
|
||||
id: row.try_get("id").map_err(|_| invalid())?,
|
||||
name: row.try_get("name").map_err(|_| invalid())?,
|
||||
proxy: OutboundProxy::parse(
|
||||
&row.try_get::<String, _>("proxy_url")
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
revision: AdminRevision::new(
|
||||
u64::try_from(row.try_get::<i64, _>("revision").map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
account_count: u64::try_from(
|
||||
row.try_get::<i64, _>("account_count")
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
last_test_at: row.try_get("last_test_at").map_err(|_| invalid())?,
|
||||
last_test: success
|
||||
.map(|success| -> StoreResult<_> {
|
||||
Ok(ProxyTestResult {
|
||||
success,
|
||||
latency_ms: u64::try_from(latency.ok_or_else(invalid)?)
|
||||
.map_err(|_| invalid())?,
|
||||
exit_ip: ip.map(|ip| ip.parse().map_err(|_| invalid())).transpose()?,
|
||||
message: row
|
||||
.try_get::<Option<String>, _>("last_test_message")
|
||||
.map_err(|_| invalid())?
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.transpose()?,
|
||||
created_at: row.try_get("created_at").map_err(|_| invalid())?,
|
||||
updated_at: row.try_get("updated_at").map_err(|_| invalid())?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn lock_url(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
proxy: &OutboundProxy,
|
||||
) -> StoreResult<()> {
|
||||
sqlx::query("select pg_advisory_xact_lock(hashtextextended($1, 739218))")
|
||||
.bind(proxy.expose_url())
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| unavailable())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 导入和旧版 URL 写入在账号事务内登记到共享代理目录。
|
||||
pub(crate) async fn ensure_proxy_for_url(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
proxy: &OutboundProxy,
|
||||
name: Option<&str>,
|
||||
) -> StoreResult<(String, bool)> {
|
||||
lock_url(transaction, proxy).await?;
|
||||
if let Some(id) = sqlx::query_scalar::<_, String>("select id from outbound_proxies where proxy_url = $1 order by created_at, id limit 1 for share")
|
||||
.bind(proxy.expose_url()).fetch_optional(&mut **transaction).await.map_err(|_| unavailable())? {
|
||||
return Ok((id, false));
|
||||
}
|
||||
let id = format!("proxy_{}", uuid::Uuid::now_v7().simple());
|
||||
let generated_name = format!("Imported proxy {}", &id[id.len() - 8..]);
|
||||
sqlx::query("insert into outbound_proxies (id, name, proxy_url) values ($1, $2, $3)")
|
||||
.bind(&id)
|
||||
.bind(name.unwrap_or(&generated_name))
|
||||
.bind(proxy.expose_url())
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| unavailable())?;
|
||||
Ok((id, true))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_proxy_selection(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
selection: &AccountProxySelection,
|
||||
) -> StoreResult<(Option<String>, Option<OutboundProxy>)> {
|
||||
match selection {
|
||||
AccountProxySelection::Direct => Ok((None, None)),
|
||||
AccountProxySelection::Url(proxy) => {
|
||||
let (id, _) = ensure_proxy_for_url(transaction, proxy, None).await?;
|
||||
Ok((Some(id), Some(proxy.clone())))
|
||||
}
|
||||
AccountProxySelection::Saved(id) => {
|
||||
let (value, tested) = sqlx::query_as::<_, (String, Option<bool>)>(
|
||||
"select proxy_url, last_test_success from outbound_proxies where id = $1 for share",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
.map_err(|_| unavailable())?
|
||||
.ok_or_else(|| StoreError::NotFound {
|
||||
entity: ENTITY,
|
||||
id: id.clone(),
|
||||
})?;
|
||||
if tested != Some(true) {
|
||||
return Err(conflict(id));
|
||||
}
|
||||
Ok((
|
||||
Some(id.clone()),
|
||||
Some(OutboundProxy::parse(&value).map_err(|_| invalid())?),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn audit(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
id: &str,
|
||||
fields: &[&str],
|
||||
revision: Revision,
|
||||
) -> StoreResult<()> {
|
||||
append_admin_audit_event_in_transaction(
|
||||
transaction,
|
||||
mutation_audit(
|
||||
context,
|
||||
action,
|
||||
"outbound_proxy",
|
||||
id,
|
||||
fields.iter().map(|value| (*value).to_owned()).collect(),
|
||||
),
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyStore for PgProxyRepository {
|
||||
async fn remove_account(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
account_id: &ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminRevision> {
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
// 在同一条更新中校验绑定,避免旧弹窗清除账号后来选择的其他代理。
|
||||
let updated = sqlx::query(
|
||||
"update provider_accounts
|
||||
set outbound_proxy_id = null, outbound_proxy_url = null,
|
||||
updated_at = greatest(now(), updated_at)
|
||||
where id = $1 and outbound_proxy_id = $2",
|
||||
)
|
||||
.bind(account_id.as_str())
|
||||
.bind(proxy_id)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
if updated.rows_affected() != 1 {
|
||||
return Err(store_error(conflict(proxy_id)));
|
||||
}
|
||||
append_admin_audit_event_in_transaction(
|
||||
&mut transaction,
|
||||
mutation_audit(
|
||||
context,
|
||||
"update",
|
||||
"provider_account",
|
||||
account_id.as_str(),
|
||||
vec!["outbound_proxy".to_owned()],
|
||||
),
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
admin_revision(revision)
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
query: ProxyAccountListQuery,
|
||||
) -> AdminStoreResult<ProxyAccountPage> {
|
||||
if query.page == 0 {
|
||||
return Err(store_error(invalid()));
|
||||
}
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
// 数量和当前页共用只读快照,避免绑定变化使同一次响应的分页事实不一致。
|
||||
sqlx::query("set transaction isolation level repeatable read, read only")
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"select count(a.id) from outbound_proxies p
|
||||
left join provider_accounts a on a.outbound_proxy_id = p.id
|
||||
and (strpos(lower(a.name), lower($2)) > 0
|
||||
or strpos(lower(coalesce(a.email, '')), lower($2)) > 0)
|
||||
where p.id = $1 group by p.id",
|
||||
)
|
||||
.bind(&query.proxy_id)
|
||||
.bind(&query.search)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?
|
||||
.ok_or_else(|| {
|
||||
store_error(StoreError::NotFound {
|
||||
entity: ENTITY,
|
||||
id: query.proxy_id.clone(),
|
||||
})
|
||||
})?;
|
||||
let mut items = sqlx::query(
|
||||
"select id, name, email, provider_kind, authentication_kind, plan_type, enabled from provider_accounts
|
||||
where outbound_proxy_id = $1 and (strpos(lower(name), lower($2)) > 0
|
||||
or strpos(lower(coalesce(email, '')), lower($2)) > 0)
|
||||
order by name, id limit $3 offset $4",
|
||||
)
|
||||
.bind(&query.proxy_id)
|
||||
.bind(&query.search)
|
||||
.bind(i64::from(query.page_size.get()))
|
||||
.bind(i64::from(query.page - 1) * i64::from(query.page_size.get()))
|
||||
.fetch_all(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
Ok(ProxyAccountRef {
|
||||
id: row.try_get("id").map_err(|_| invalid())?,
|
||||
name: row.try_get("name").map_err(|_| invalid())?,
|
||||
email: row.try_get("email").map_err(|_| invalid())?,
|
||||
provider_kind: row.try_get("provider_kind").map_err(|_| invalid())?,
|
||||
authentication_kind: row.try_get("authentication_kind").map_err(|_| invalid())?,
|
||||
plan_type: row.try_get("plan_type").map_err(|_| invalid())?,
|
||||
plan_type_display: None,
|
||||
groups: Vec::new(),
|
||||
enabled: row.try_get("enabled").map_err(|_| invalid())?,
|
||||
})
|
||||
})
|
||||
.collect::<StoreResult<Vec<_>>>()
|
||||
.map_err(store_error)?;
|
||||
if !items.is_empty() {
|
||||
// 仅批量加载当前页的分组,并沿用同一快照,避免逐账号查询。
|
||||
let account_ids: Vec<&str> = items.iter().map(|account| account.id.as_str()).collect();
|
||||
let rows = sqlx::query_as::<_, (String, String, String, String, bool)>(
|
||||
"select m.provider_account_id, g.id, g.name, g.color, g.enabled
|
||||
from account_group_accounts m join account_groups g on g.id = m.account_group_id
|
||||
where m.provider_account_id = any($1::text[])
|
||||
order by m.provider_account_id, g.id",
|
||||
)
|
||||
.bind(&account_ids)
|
||||
.fetch_all(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let mut groups = BTreeMap::<String, Vec<AccountGroupRef>>::new();
|
||||
for (account_id, group_id, name, color, enabled) in rows {
|
||||
groups.entry(account_id).or_default().push(AccountGroupRef {
|
||||
id: AccountGroupId::new(group_id).map_err(|_| store_error(invalid()))?,
|
||||
name,
|
||||
color: AccountGroupColor::parse(&color)
|
||||
.ok_or_else(|| store_error(invalid()))?,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
for account in &mut items {
|
||||
account.groups = groups.remove(&account.id).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
Ok(ProxyAccountPage {
|
||||
items,
|
||||
total: u64::try_from(total).map_err(|_| store_error(invalid()))?,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation> {
|
||||
let slot = self
|
||||
.import_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| store_error(conflict(id)))?;
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?
|
||||
.detach();
|
||||
let acquired: bool =
|
||||
sqlx::query_scalar("select pg_try_advisory_lock_shared(hashtextextended($1, 739219))")
|
||||
.bind(id)
|
||||
.fetch_one(&mut connection)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
if !acquired {
|
||||
return Err(store_error(conflict(id)));
|
||||
}
|
||||
let record = self.get(id).await?;
|
||||
if !record.last_test.is_some_and(|test| test.success) {
|
||||
return Err(store_error(conflict(id)));
|
||||
}
|
||||
Ok(ProxyImportReservation {
|
||||
binding: ImportProxyBinding {
|
||||
id: record.id,
|
||||
proxy: record.proxy,
|
||||
},
|
||||
guard: Box::new(PgProxyImportGuard {
|
||||
_connection: connection,
|
||||
_slot: slot,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
if query.page == 0 {
|
||||
return Err(store_error(invalid()));
|
||||
}
|
||||
let total: i64 = sqlx::query_scalar(
|
||||
"select count(*) from outbound_proxies where strpos(lower(name), lower($1)) > 0",
|
||||
)
|
||||
.bind(&query.search)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let mut builder = QueryBuilder::<Postgres>::new(SELECT);
|
||||
builder
|
||||
.push(" where strpos(lower(p.name), lower(")
|
||||
.push_bind(&query.search)
|
||||
.push(")) > 0 order by p.created_at desc, p.id limit ")
|
||||
.push_bind(i64::from(query.page_size.get()))
|
||||
.push(" offset ")
|
||||
.push_bind(i64::from(query.page - 1) * i64::from(query.page_size.get()));
|
||||
let items = builder
|
||||
.build()
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?
|
||||
.into_iter()
|
||||
.map(record)
|
||||
.collect::<StoreResult<Vec<_>>>()
|
||||
.map_err(store_error)?;
|
||||
Ok(ProxyPage {
|
||||
items,
|
||||
total: u64::try_from(total).map_err(|_| store_error(invalid()))?,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
|
||||
let mut builder = QueryBuilder::<Postgres>::new(SELECT);
|
||||
builder.push(" where p.id = ").push_bind(id);
|
||||
let row = builder
|
||||
.build()
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?
|
||||
.ok_or_else(|| {
|
||||
store_error(StoreError::NotFound {
|
||||
entity: ENTITY,
|
||||
id: id.to_owned(),
|
||||
})
|
||||
})?;
|
||||
record(row).map_err(store_error)
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
command: NewProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let (id, created) =
|
||||
ensure_proxy_for_url(&mut transaction, &command.proxy, Some(&command.name))
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
if !created {
|
||||
return Err(store_error(conflict(&id)));
|
||||
}
|
||||
audit(
|
||||
&mut transaction,
|
||||
context,
|
||||
"create",
|
||||
&id,
|
||||
&["name", "proxy_url"],
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
Ok(ProxyMutation {
|
||||
config_revision: admin_revision(revision)?,
|
||||
record: self.get(&id).await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
command: UpdateProxy,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyMutation> {
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
exclude_active_imports(&mut transaction, &command.id)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
if let Some(proxy) = &command.proxy {
|
||||
lock_url(&mut transaction, proxy)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let duplicate: bool = sqlx::query_scalar(
|
||||
"select exists(select 1 from outbound_proxies where proxy_url = $1 and id <> $2)",
|
||||
)
|
||||
.bind(proxy.expose_url())
|
||||
.bind(&command.id)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
if duplicate {
|
||||
return Err(store_error(conflict(&command.id)));
|
||||
}
|
||||
}
|
||||
let changed = sqlx::query(
|
||||
"update outbound_proxies set name = $3, proxy_url = coalesce($4, proxy_url), revision = revision + 1, updated_at = now(),
|
||||
last_test_at = case when $4 is not null and $4 <> proxy_url then null else last_test_at end,
|
||||
last_test_success = case when $4 is not null and $4 <> proxy_url then null else last_test_success end,
|
||||
last_test_latency_ms = case when $4 is not null and $4 <> proxy_url then null else last_test_latency_ms end,
|
||||
last_test_ip = case when $4 is not null and $4 <> proxy_url then null else last_test_ip end,
|
||||
last_test_message = case when $4 is not null and $4 <> proxy_url then null else last_test_message end
|
||||
where id = $1 and revision = $2")
|
||||
.bind(&command.id).bind(i64::try_from(command.revision.get()).map_err(|_| store_error(invalid()))?)
|
||||
.bind(&command.name).bind(command.proxy.as_ref().map(OutboundProxy::expose_url))
|
||||
.execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
|
||||
if changed.rows_affected() != 1 {
|
||||
return Err(store_error(conflict(&command.id)));
|
||||
}
|
||||
sqlx::query("update provider_accounts a set outbound_proxy_url = p.proxy_url, updated_at = greatest(now(), a.updated_at) from outbound_proxies p where p.id = $1 and a.outbound_proxy_id = p.id and a.outbound_proxy_url is distinct from p.proxy_url")
|
||||
.bind(&command.id).execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
|
||||
audit(
|
||||
&mut transaction,
|
||||
context,
|
||||
"update",
|
||||
&command.id,
|
||||
&["name", "proxy_url"],
|
||||
revision,
|
||||
)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
Ok(ProxyMutation {
|
||||
config_revision: admin_revision(revision)?,
|
||||
record: self.get(&command.id).await?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: AdminRevision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminRevision> {
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
exclude_active_imports(&mut transaction, id)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let config_revision = bump_config_revision_in_transaction(&mut transaction)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let result = sqlx::query("delete from outbound_proxies where id = $1 and revision = $2")
|
||||
.bind(id)
|
||||
.bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?)
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
// PostgreSQL 18 为 RESTRICT 返回不同于普通外键违规的错误码。
|
||||
if error.as_database_error().is_some_and(|error| {
|
||||
error.is_foreign_key_violation() || error.code().as_deref() == Some("23001")
|
||||
}) {
|
||||
store_error(conflict(id))
|
||||
} else {
|
||||
store_error(unavailable())
|
||||
}
|
||||
})?;
|
||||
if result.rows_affected() != 1 {
|
||||
return Err(store_error(conflict(id)));
|
||||
}
|
||||
audit(
|
||||
&mut transaction,
|
||||
context,
|
||||
"delete",
|
||||
id,
|
||||
&[],
|
||||
config_revision,
|
||||
)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
admin_revision(config_revision)
|
||||
}
|
||||
|
||||
async fn record_test(
|
||||
&self,
|
||||
id: &str,
|
||||
revision: AdminRevision,
|
||||
result: ProxyTestResult,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<ProxyRecord> {
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
exclude_active_imports(&mut transaction, id)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
let updated = sqlx::query("update outbound_proxies set last_test_at = now(), last_test_success = $3, last_test_latency_ms = $4, last_test_ip = $5, last_test_message = $6 where id = $1 and revision = $2")
|
||||
.bind(id).bind(i64::try_from(revision.get()).map_err(|_| store_error(invalid()))?)
|
||||
.bind(result.success).bind(i64::try_from(result.latency_ms).map_err(|_| store_error(invalid()))?)
|
||||
.bind(result.exit_ip.map(|ip| ip.to_string())).bind(result.message)
|
||||
.execute(&mut *transaction).await.map_err(|_| store_error(unavailable()))?;
|
||||
if updated.rows_affected() != 1 {
|
||||
return Err(store_error(conflict(id)));
|
||||
}
|
||||
let current: i64 =
|
||||
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
let current = Revision::new(u64::try_from(current).map_err(|_| store_error(invalid()))?)
|
||||
.map_err(store_error)?;
|
||||
audit(
|
||||
&mut transaction,
|
||||
context,
|
||||
"test",
|
||||
id,
|
||||
&["last_test"],
|
||||
current,
|
||||
)
|
||||
.await
|
||||
.map_err(store_error)?;
|
||||
transaction
|
||||
.commit()
|
||||
.await
|
||||
.map_err(|_| store_error(unavailable()))?;
|
||||
self.get(id).await
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod health;
|
||||
mod observability;
|
||||
mod ops_events;
|
||||
mod provider_accounts;
|
||||
mod proxies;
|
||||
mod query_budget;
|
||||
mod retention;
|
||||
mod runtime_settings;
|
||||
@@ -235,6 +236,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
|
||||
"client_key_charge_events",
|
||||
"model_requests",
|
||||
"ops_events",
|
||||
"outbound_proxies",
|
||||
"provider_accounts",
|
||||
"runtime_settings",
|
||||
]
|
||||
|
||||
@@ -1224,7 +1224,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
|
||||
store
|
||||
.update_account(
|
||||
UpdateAccount {
|
||||
outbound_proxy: Some(Some(
|
||||
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url(
|
||||
gateway_core::account::OutboundProxy::parse(
|
||||
"socks5h://next:new-secret@127.0.0.1:1080",
|
||||
)
|
||||
@@ -1240,7 +1240,7 @@ async fn account_proxy_edits_preserve_credentials_and_clear_egress_without_audit
|
||||
store
|
||||
.update_account(
|
||||
UpdateAccount {
|
||||
outbound_proxy: Some(None),
|
||||
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Direct),
|
||||
..command
|
||||
},
|
||||
&context,
|
||||
@@ -1546,6 +1546,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_admin_upsert", "user-admin-upsert")],
|
||||
audit: audit("audit_admin_upsert_create", "import", "acct_admin_upsert"),
|
||||
@@ -1569,6 +1570,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![updated],
|
||||
audit: audit("audit_admin_upsert_update", "import", "acct_admin_upsert"),
|
||||
@@ -1604,6 +1606,7 @@ async fn admin_import_updates_the_same_verified_identity_without_rebinding_it()
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope,
|
||||
accounts: vec![account("acct_admin_upsert", "another-user")],
|
||||
audit: audit("audit_admin_upsert_rebind", "import", "acct_admin_upsert"),
|
||||
@@ -1693,6 +1696,111 @@ async fn authorization_create_returns_existing_account_id_when_identity_is_upser
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorization_import_rejects_a_saved_proxy_changed_during_oauth() {
|
||||
use gateway_admin::{
|
||||
model::proxies::{NewProxy, ProxyTestResult, UpdateProxy},
|
||||
ports::proxy::ProxyStore,
|
||||
};
|
||||
let Some(database) = TestDatabase::create("oauth_proxy_guard").await else {
|
||||
return;
|
||||
};
|
||||
let proxies = gateway_store::postgres::PgProxyRepository::new(database.pool.clone());
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "oauth-proxy".to_owned(),
|
||||
};
|
||||
let original = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:8080").unwrap();
|
||||
let saved = proxies
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "OAuth".to_owned(),
|
||||
proxy: original.clone(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
let success = ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: 1,
|
||||
exit_ip: Some("203.0.113.5".parse().unwrap()),
|
||||
message: "Connected".to_owned(),
|
||||
};
|
||||
proxies
|
||||
.record_test(&saved.id, saved.revision, success.clone(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let replacement = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:9090").unwrap();
|
||||
let edited = proxies
|
||||
.update(
|
||||
UpdateProxy {
|
||||
id: saved.id.clone(),
|
||||
revision: saved.revision,
|
||||
name: saved.name,
|
||||
proxy: Some(replacement.clone()),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
proxies
|
||||
.record_test(&edited.id, edited.revision, success, &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let mut candidate = account("acct_oauth_proxy", "oauth-user");
|
||||
candidate.outbound_proxy = Some(original);
|
||||
let command = |candidate: NewProviderAccount| ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: Some(gateway_admin::model::proxies::ImportProxyBinding {
|
||||
id: saved.id.clone(),
|
||||
proxy: candidate.outbound_proxy.clone().unwrap(),
|
||||
}),
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: "openai".to_owned(),
|
||||
},
|
||||
accounts: vec![candidate],
|
||||
audit: audit("audit_oauth_proxy", "authorize", "acct_oauth_proxy"),
|
||||
};
|
||||
assert!(
|
||||
repository
|
||||
.import_provider_accounts(command(candidate.clone()))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account_count(&database.pool, "acct_oauth_proxy").await, 0);
|
||||
candidate.outbound_proxy = Some(replacement);
|
||||
repository
|
||||
.import_provider_accounts(command(candidate))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
proxies
|
||||
.list_accounts(gateway_admin::model::proxies::ProxyAccountListQuery {
|
||||
proxy_id: saved.id.clone(),
|
||||
page: 1,
|
||||
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
|
||||
search: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.items[0]
|
||||
.id,
|
||||
"acct_oauth_proxy"
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
let Some(database) = TestDatabase::create("provider_account_core_refresh").await else {
|
||||
@@ -1884,6 +1992,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_a", "user-admin-a"),
|
||||
@@ -1912,6 +2021,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![
|
||||
account("acct_admin_transient", "user-admin-transient"),
|
||||
@@ -2101,6 +2211,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
|
||||
let imported = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_grouped_import", "user-grouped-import")],
|
||||
audit: audit("audit_grouped_import", "import", "acct_grouped_import"),
|
||||
@@ -2135,6 +2246,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_reimport_candidate", "user-grouped-import")],
|
||||
audit: audit("audit_grouped_reimport", "import", "acct_grouped_import"),
|
||||
@@ -2203,6 +2315,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![account("acct_rotation_quota", "user-rotation-quota")],
|
||||
audit: audit(
|
||||
@@ -2345,6 +2458,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
|
||||
repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: None,
|
||||
scope: scope.clone(),
|
||||
accounts: vec![disabled],
|
||||
audit: audit(
|
||||
@@ -2398,7 +2512,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
|
||||
pub(super) fn account(id: &str, upstream_user_id: &str) -> NewProviderAccount {
|
||||
NewProviderAccount {
|
||||
outbound_proxy: None,
|
||||
id: id.to_owned(),
|
||||
@@ -2458,7 +2572,7 @@ fn plaintext_credential(marker: &str) -> PlaintextCredential {
|
||||
)
|
||||
}
|
||||
|
||||
fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent {
|
||||
pub(super) fn audit(id: &str, action: &str, entity_ref: &str) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: id.to_owned(),
|
||||
actor_kind: AdminAuditActorKind::System,
|
||||
@@ -2604,7 +2718,7 @@ async fn proxy_edit_preserves_an_inflight_token_refresh() {
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
outbound_proxy: Some(Some(
|
||||
outbound_proxy: Some(gateway_admin::model::proxies::AccountProxySelection::Url(
|
||||
gateway_core::account::OutboundProxy::parse("http://127.0.0.1:18080").unwrap(),
|
||||
)),
|
||||
},
|
||||
@@ -2663,6 +2777,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
|
||||
let result = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: Some(settings.clone()),
|
||||
outbound_proxy: None,
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: "openai".to_owned(),
|
||||
},
|
||||
@@ -2693,6 +2808,7 @@ async fn account_import_settings_apply_atomically_to_new_and_existing_identities
|
||||
.expect("existing account");
|
||||
let failed = repository
|
||||
.import_provider_accounts(ImportProviderAccounts {
|
||||
outbound_proxy: None,
|
||||
settings: Some(AccountImportSettings {
|
||||
group_ids: vec![
|
||||
AccountGroupId::new("grp_00000000000000000000000000000092")
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, PageSize,
|
||||
accounts::{BatchUpdateAccounts, UpdateAccount},
|
||||
proxies::*,
|
||||
},
|
||||
ports::{
|
||||
proxy::ProxyStore,
|
||||
store::{AccountStore, AdminStoreErrorKind},
|
||||
},
|
||||
};
|
||||
use gateway_core::account::{AccountWeight, OutboundProxy, ProviderAccountId};
|
||||
use gateway_store::postgres::{
|
||||
PgProviderAccountRepository, PgProxyRepository, ProviderAccountRepository,
|
||||
};
|
||||
|
||||
use super::{TestDatabase, admin_account_store, provider_accounts::account};
|
||||
|
||||
fn context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "managed-proxy-test".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn success() -> ProxyTestResult {
|
||||
ProxyTestResult {
|
||||
success: true,
|
||||
latency_ms: 10,
|
||||
exit_ip: Some("203.0.113.5".parse().unwrap()),
|
||||
message: "Connected".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(account_id: &str, selection: AccountProxySelection) -> UpdateAccount {
|
||||
UpdateAccount {
|
||||
account_id: account_id.to_owned(),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
outbound_proxy: Some(selection),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_account_removal_preserves_settings_and_rejects_changed_bindings() {
|
||||
let Some(database) = TestDatabase::create("proxy_account_remove").await else {
|
||||
return;
|
||||
};
|
||||
let store = PgProxyRepository::new(database.pool.clone());
|
||||
let context = context();
|
||||
let saved = store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "解绑测试".to_owned(),
|
||||
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:17890").unwrap(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
sqlx::query(
|
||||
"insert into provider_accounts
|
||||
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
|
||||
has_refresh_token, enabled, concurrency_limit, weight, plan_type,
|
||||
credential_observed_at, created_at, updated_at, outbound_proxy_id, outbound_proxy_url)
|
||||
values ('acct_remove', 'openai', '解绑账号', 'remove@example.invalid', 'oauth',
|
||||
'{\"access_token\":\"preserve-test-secret\"}'::jsonb, false, false, 3, 7, 'plus',
|
||||
now(), now(), now(), $1, $2)",
|
||||
)
|
||||
.bind(&saved.id)
|
||||
.bind(saved.proxy.expose_url())
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
|
||||
values ('grp_00000000000000000000000000000001', '保留分组', '#123456FF', true, now(), now())",
|
||||
).execute(&database.pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
|
||||
values ('grp_00000000000000000000000000000001', 'acct_remove', now())",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let account_id = ProviderAccountId::new("acct_remove").unwrap();
|
||||
let snapshot = "select to_jsonb(a) - 'updated_at' - 'outbound_proxy_id' - 'outbound_proxy_url'
|
||||
from provider_accounts a where id = 'acct_remove'";
|
||||
let before: serde_json::Value = sqlx::query_scalar(snapshot)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let revision: i64 =
|
||||
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.remove_account("proxy_previous", &account_id, &context)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminStoreErrorKind::Conflict
|
||||
);
|
||||
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 1);
|
||||
let unchanged: (Option<String>, Option<String>) = sqlx::query_as(
|
||||
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
|
||||
).fetch_one(&database.pool).await.unwrap();
|
||||
assert_eq!(
|
||||
unchanged,
|
||||
(
|
||||
Some(saved.id.clone()),
|
||||
Some(saved.proxy.expose_url().to_owned())
|
||||
)
|
||||
);
|
||||
let current_revision: i64 =
|
||||
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(current_revision, revision);
|
||||
|
||||
let committed = store
|
||||
.remove_account(&saved.id, &account_id, &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(committed.get(), u64::try_from(revision + 1).unwrap());
|
||||
let after: serde_json::Value = sqlx::query_scalar(snapshot)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after, before);
|
||||
let direct: (Option<String>, Option<String>) = sqlx::query_as(
|
||||
"select outbound_proxy_id, outbound_proxy_url from provider_accounts where id = 'acct_remove'",
|
||||
).fetch_one(&database.pool).await.unwrap();
|
||||
assert_eq!(direct, (None, None));
|
||||
let group_count: i64 = sqlx::query_scalar(
|
||||
"select count(*) from account_group_accounts where provider_account_id = 'acct_remove'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(group_count, 1);
|
||||
assert_eq!(store.get(&saved.id).await.unwrap().account_count, 0);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_accounts(ProxyAccountListQuery {
|
||||
proxy_id: saved.id.clone(),
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).unwrap(),
|
||||
search: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.total,
|
||||
0
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.remove_account(&saved.id, &account_id, &context)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminStoreErrorKind::Conflict
|
||||
);
|
||||
let current_audits: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(current_audits, audit_count + 1);
|
||||
let current_revision: i64 =
|
||||
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(u64::try_from(current_revision).unwrap(), committed.get());
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_accounts_paginate_thousands_of_accounts_and_search_without_loading_the_catalog() {
|
||||
let Some(database) = TestDatabase::create("proxy_account_pagination").await else {
|
||||
return;
|
||||
};
|
||||
let store = PgProxyRepository::new(database.pool.clone());
|
||||
let context = context();
|
||||
let saved = store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "分页测试".to_owned(),
|
||||
proxy: OutboundProxy::parse("http://127.0.0.1:17890").unwrap(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
let empty = store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "无关联账号".to_owned(),
|
||||
proxy: OutboundProxy::parse("http://127.0.0.1:17891").unwrap(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
// 相同名称验证稳定排序;账号仅写入隔离测试数据库,不接触真实账号池。
|
||||
sqlx::query(
|
||||
"insert into provider_accounts
|
||||
(id, provider_kind, name, email, authentication_kind, provider_credentials_json,
|
||||
has_refresh_token, enabled, credential_observed_at, created_at, updated_at,
|
||||
outbound_proxy_id, outbound_proxy_url)
|
||||
select 'acct_page_' || lpad(n::text, 4, '0'),
|
||||
case when n % 2 = 0 then 'xai' else 'openai' end, '共享账号',
|
||||
'page_' || lpad(n::text, 4, '0') || '@example.invalid', 'oauth',
|
||||
'{\"access_token\":\"page-test-secret\"}'::jsonb, false, n % 2 = 0,
|
||||
now(), now(), now(), $1, $2
|
||||
from generate_series(1, 1005) as n",
|
||||
)
|
||||
.bind(&saved.id)
|
||||
.bind(saved.proxy.expose_url())
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("update provider_accounts set plan_type = 'plus' where id = 'acct_page_0001'")
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_groups (id, name, color, enabled, created_at, updated_at)
|
||||
values ('grp_00000000000000000000000000000001', '工作组', '#123456FF', false, now(), now()),
|
||||
('grp_00000000000000000000000000000002', '次页分组', '#654321FF', true, now(), now())",
|
||||
).execute(&database.pool).await.unwrap();
|
||||
sqlx::query(
|
||||
"insert into account_group_accounts (account_group_id, provider_account_id, created_at)
|
||||
values ('grp_00000000000000000000000000000001', 'acct_page_0001', now()),
|
||||
('grp_00000000000000000000000000000002', 'acct_page_0021', now())",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
PgProviderAccountRepository::new(database.pool.clone())
|
||||
.insert_provider_account(account("acct_unrelated", "unrelated-user"))
|
||||
.await
|
||||
.unwrap();
|
||||
let query = |page, search: &str| ProxyAccountListQuery {
|
||||
proxy_id: saved.id.clone(),
|
||||
page,
|
||||
page_size: PageSize::new(20).unwrap(),
|
||||
search: search.to_owned(),
|
||||
};
|
||||
let first = store.list_accounts(query(1, "")).await.unwrap();
|
||||
assert_eq!((first.total, first.items.len()), (1005, 20));
|
||||
assert_eq!(first.items[0].id, "acct_page_0001");
|
||||
assert_eq!(
|
||||
first.items[0].email.as_deref(),
|
||||
Some("page_0001@example.invalid")
|
||||
);
|
||||
assert_eq!(first.items[0].provider_kind, "openai");
|
||||
assert_eq!(first.items[0].authentication_kind, "oauth");
|
||||
assert_eq!(first.items[0].plan_type.as_deref(), Some("plus"));
|
||||
assert_eq!(first.items[0].groups.len(), 1);
|
||||
assert_eq!(first.items[0].groups[0].name, "工作组");
|
||||
assert_eq!(first.items[0].groups[0].color.as_str(), "#123456FF");
|
||||
assert!(!first.items[0].groups[0].enabled);
|
||||
assert!(first.items[1].groups.is_empty());
|
||||
assert!(!first.items[0].enabled);
|
||||
let second = store.list_accounts(query(2, "")).await.unwrap();
|
||||
assert_eq!(second.items[0].id, "acct_page_0021");
|
||||
assert_eq!(second.items[0].groups[0].name, "次页分组");
|
||||
assert!(
|
||||
first
|
||||
.items
|
||||
.iter()
|
||||
.all(|left| second.items.iter().all(|right| left.id != right.id))
|
||||
);
|
||||
let last = store.list_accounts(query(51, "")).await.unwrap();
|
||||
assert_eq!((last.total, last.items.len()), (1005, 5));
|
||||
assert_eq!(last.items[4].id, "acct_page_1005");
|
||||
let filtered = store.list_accounts(query(1, "PAGE_004")).await.unwrap();
|
||||
assert_eq!((filtered.total, filtered.items.len()), (10, 10));
|
||||
assert_eq!(filtered.items[0].id, "acct_page_0040");
|
||||
assert_eq!(store.list_accounts(query(1, "%")).await.unwrap().total, 0);
|
||||
assert!(
|
||||
store
|
||||
.list_accounts(query(52, ""))
|
||||
.await
|
||||
.unwrap()
|
||||
.items
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_accounts(ProxyAccountListQuery {
|
||||
proxy_id: empty.id,
|
||||
..query(1, "")
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.total,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_accounts(ProxyAccountListQuery {
|
||||
proxy_id: "missing".to_owned(),
|
||||
..query(1, "")
|
||||
})
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminStoreErrorKind::NotFound
|
||||
);
|
||||
let catalog = store
|
||||
.list(ProxyListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).unwrap(),
|
||||
search: "分页测试".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(catalog.items[0].account_count, 1005);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_reservation_blocks_proxy_mutations_until_rotated_credentials_are_committed() {
|
||||
use gateway_store::postgres::{
|
||||
ImportProviderAccounts, ProviderAccountAdminRepository, ProviderAccountAdminScope,
|
||||
};
|
||||
let Some(database) = TestDatabase::create("proxy_import_reservation").await else {
|
||||
return;
|
||||
};
|
||||
let store = PgProxyRepository::new(database.pool.clone());
|
||||
let other_process = PgProxyRepository::new(database.pool.clone());
|
||||
let context = context();
|
||||
let saved = store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "导入出口".to_owned(),
|
||||
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
assert!(store.reserve_import(&saved.id).await.is_err());
|
||||
store
|
||||
.record_test(&saved.id, saved.revision, success(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let reservation = store.reserve_import(&saved.id).await.unwrap();
|
||||
let replacement = UpdateProxy {
|
||||
id: saved.id.clone(),
|
||||
revision: saved.revision,
|
||||
name: saved.name,
|
||||
proxy: Some(OutboundProxy::parse("http://127.0.0.1:9090").unwrap()),
|
||||
};
|
||||
assert!(
|
||||
other_process
|
||||
.update(replacement.clone(), &context)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
other_process
|
||||
.delete(&saved.id, saved.revision, &context)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
other_process
|
||||
.record_test(
|
||||
&saved.id,
|
||||
saved.revision,
|
||||
ProxyTestResult {
|
||||
success: false,
|
||||
..success()
|
||||
},
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// 模拟上游已轮换的新凭据;持有保护时,另一个连接仍能完成导入事务。
|
||||
let mut candidate = account("acct_reserved_import", "reserved-import-user");
|
||||
candidate.outbound_proxy = Some(reservation.binding.proxy.clone());
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let audit =
|
||||
super::provider_accounts::audit("audit_reserved_import", "import", "acct_reserved_import");
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
repository.import_provider_accounts(ImportProviderAccounts {
|
||||
settings: None,
|
||||
outbound_proxy: Some(reservation.binding.clone()),
|
||||
scope: ProviderAccountAdminScope {
|
||||
provider_kind: "openai".to_owned(),
|
||||
},
|
||||
accounts: vec![candidate],
|
||||
audit,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
drop(reservation);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if other_process
|
||||
.update(replacement.clone(), &context)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
repository
|
||||
.load_provider_account("acct_reserved_import")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_proxies_persist_bind_update_all_accounts_and_protect_stale_tests() {
|
||||
let Some(database) = TestDatabase::create("managed_proxies").await else {
|
||||
return;
|
||||
};
|
||||
let accounts = PgProviderAccountRepository::new(database.pool.clone());
|
||||
for id in ["acct_one", "acct_two"] {
|
||||
accounts
|
||||
.insert_provider_account(account(id, id))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let store = PgProxyRepository::new(database.pool.clone());
|
||||
let admin = admin_account_store(&database.pool);
|
||||
let context = context();
|
||||
let old_proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap();
|
||||
let created = store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "Office".to_owned(),
|
||||
proxy: old_proxy.clone(),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
assert_eq!(store.get(&created.id).await.unwrap().proxy, old_proxy);
|
||||
assert!(
|
||||
store
|
||||
.create(
|
||||
NewProxy {
|
||||
name: "Duplicate".to_owned(),
|
||||
proxy: old_proxy.clone()
|
||||
},
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let selection = AccountProxySelection::Saved(created.id.clone());
|
||||
assert!(
|
||||
admin
|
||||
.update_account(update("acct_one", selection.clone()), &context)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let read = || async {
|
||||
sqlx::query_as::<_, (String, Option<String>, Option<String>, i64)>("select id, outbound_proxy_id, outbound_proxy_url, credential_revision from provider_accounts order by id")
|
||||
.fetch_all(&database.pool).await.unwrap()
|
||||
};
|
||||
assert!(
|
||||
read()
|
||||
.await
|
||||
.iter()
|
||||
.all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1)
|
||||
);
|
||||
let tested = store
|
||||
.record_test(&created.id, created.revision, success(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(tested.last_test_at.is_some());
|
||||
for id in ["acct_one", "acct_two"] {
|
||||
admin
|
||||
.update_account(update(id, selection.clone()), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(store.get(&created.id).await.unwrap().account_count, 2);
|
||||
assert_eq!(
|
||||
store
|
||||
.delete(&created.id, created.revision, &context)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminStoreErrorKind::Conflict
|
||||
);
|
||||
assert!(
|
||||
read()
|
||||
.await
|
||||
.iter()
|
||||
.all(|row| row.1.as_deref() == Some(created.id.as_str())
|
||||
&& row.2.as_deref() == Some(old_proxy.expose_url())
|
||||
&& row.3 == 1)
|
||||
);
|
||||
|
||||
let renamed = store
|
||||
.update(
|
||||
UpdateProxy {
|
||||
id: created.id.clone(),
|
||||
revision: created.revision,
|
||||
name: "Renamed".to_owned(),
|
||||
proxy: None,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
assert_eq!(renamed.proxy, old_proxy);
|
||||
assert_eq!(renamed.last_test, Some(success()));
|
||||
assert!(read().await.iter().all(|row| row.3 == 1));
|
||||
let new_proxy = OutboundProxy::parse("socks5h://next:new-secret@127.0.0.1:1080").unwrap();
|
||||
let edited = store
|
||||
.update(
|
||||
UpdateProxy {
|
||||
id: created.id.clone(),
|
||||
revision: renamed.revision,
|
||||
name: renamed.name,
|
||||
proxy: Some(new_proxy.clone()),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.record;
|
||||
assert!(edited.last_test.is_none());
|
||||
assert!(edited.last_test_at.is_none());
|
||||
assert!(
|
||||
read()
|
||||
.await
|
||||
.iter()
|
||||
.all(|row| row.2.as_deref() == Some(new_proxy.expose_url()) && row.3 == 1)
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.record_test(&created.id, created.revision, success(), &context)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.get(&created.id).await.unwrap().last_test.is_none());
|
||||
store
|
||||
.record_test(&created.id, edited.revision, success(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for id in ["acct_one", "acct_two"] {
|
||||
admin
|
||||
.update_account(update(id, AccountProxySelection::Direct), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
read()
|
||||
.await
|
||||
.iter()
|
||||
.all(|row| row.1.is_none() && row.2.is_none() && row.3 == 1)
|
||||
);
|
||||
store
|
||||
.delete(&created.id, edited.revision, &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store.get(&created.id).await.is_err());
|
||||
let audits: Vec<serde_json::Value> =
|
||||
sqlx::query_scalar("select to_jsonb(a) from admin_audit_events a")
|
||||
.fetch_all(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!serde_json::to_string(&audits).unwrap().contains("secret"));
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_urls_join_one_catalog_entry_and_invalid_batch_rolls_back() {
|
||||
let Some(database) = TestDatabase::create("proxy_catalog_legacy").await else {
|
||||
return;
|
||||
};
|
||||
let accounts = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let proxy = OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap();
|
||||
for id in ["acct_one", "acct_two"] {
|
||||
let mut seed = account(id, id);
|
||||
seed.outbound_proxy = Some(proxy.clone());
|
||||
accounts.insert_provider_account(seed).await.unwrap();
|
||||
}
|
||||
let store = PgProxyRepository::new(database.pool.clone());
|
||||
let page = store
|
||||
.list(ProxyListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(20).unwrap(),
|
||||
search: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(page.total, 1);
|
||||
assert_eq!(page.items[0].account_count, 2);
|
||||
let admin = admin_account_store(&database.pool);
|
||||
assert!(
|
||||
admin
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
account_ids: vec!["acct_one".to_owned(), "acct_missing".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: AccountWeight::DEFAULT,
|
||||
group_ids: vec![],
|
||||
outbound_proxy: Some(AccountProxySelection::Url(
|
||||
OutboundProxy::parse("http://127.0.0.1:9090").unwrap()
|
||||
)),
|
||||
},
|
||||
&context()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_as::<_, (bool, String)>(
|
||||
"select enabled, outbound_proxy_url from provider_accounts where id = 'acct_one'"
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
(true, proxy.expose_url().to_owned())
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migration_backfills_shared_proxies_without_changing_credentials() {
|
||||
let Some(database) = TestDatabase::create("proxy_backfill").await else {
|
||||
return;
|
||||
};
|
||||
let accounts = PgProviderAccountRepository::new(database.pool.clone());
|
||||
for id in ["acct_one", "acct_two", "acct_direct"] {
|
||||
accounts
|
||||
.insert_provider_account(account(id, id))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
sqlx::raw_sql("alter table provider_accounts drop column outbound_proxy_id; drop table outbound_proxies;
|
||||
update provider_accounts set outbound_proxy_url = 'http://user:secret@127.0.0.1:8080/' where id <> 'acct_direct';")
|
||||
.execute(&database.pool).await.unwrap();
|
||||
sqlx::raw_sql(include_str!(
|
||||
"../../../../migrations/0005_managed_outbound_proxies.sql"
|
||||
))
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("select count(*) from outbound_proxies")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(sqlx::query_scalar::<_, i64>("select count(*) from provider_accounts a join outbound_proxies p on a.outbound_proxy_id = p.id where a.outbound_proxy_url = p.proxy_url and a.credential_revision = 1").fetch_one(&database.pool).await.unwrap(), 2);
|
||||
assert!(
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"select outbound_proxy_id from provider_accounts where id = 'acct_direct'"
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
database.close().await;
|
||||
}
|
||||
@@ -291,9 +291,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
let prepared = self
|
||||
.credentials
|
||||
.prepare_import_document(Value::Object(
|
||||
command.document.into_provider_data().into_inner(),
|
||||
))
|
||||
.prepare_import_document_with_proxy(
|
||||
Value::Object(command.document.into_provider_data().into_inner()),
|
||||
command.default_outbound_proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
log_import_failure("prepare_document", credential_admin_error_code(error));
|
||||
|
||||
@@ -824,6 +824,14 @@ impl CodexCredentialAdminService {
|
||||
pub async fn prepare_import_document(
|
||||
&self,
|
||||
payload: Value,
|
||||
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
|
||||
self.prepare_import_document_with_proxy(payload, None).await
|
||||
}
|
||||
|
||||
pub async fn prepare_import_document_with_proxy(
|
||||
&self,
|
||||
payload: Value,
|
||||
default_proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<PreparedCodexAccountImport, CodexCredentialAdminError> {
|
||||
if serde_json::to_vec(&payload)
|
||||
.map_err(|_| CodexCredentialAdminError::InvalidInput)?
|
||||
@@ -832,7 +840,7 @@ impl CodexCredentialAdminService {
|
||||
{
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
let candidates = parse_import_document(&payload)?;
|
||||
let candidates = parse_import_document(&payload, default_proxy)?;
|
||||
if candidates.is_empty() || candidates.len() > MAX_BATCH {
|
||||
return Err(CodexCredentialAdminError::InvalidInput);
|
||||
}
|
||||
@@ -1041,6 +1049,7 @@ fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFai
|
||||
|
||||
fn parse_import_document(
|
||||
payload: &Value,
|
||||
default_proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
|
||||
let payload = payload
|
||||
.get("data")
|
||||
@@ -1052,7 +1061,14 @@ fn parse_import_document(
|
||||
continue;
|
||||
}
|
||||
let mut account = parse_oauth_import_account(value)?;
|
||||
account.outbound_proxy = import_proxy(payload, value)?;
|
||||
account.outbound_proxy = if ["outboundProxyUrl", "outbound_proxy_url", "proxy_key"]
|
||||
.iter()
|
||||
.any(|field| value.get(field).is_some())
|
||||
{
|
||||
import_proxy(payload, value)?
|
||||
} else {
|
||||
default_proxy.cloned()
|
||||
};
|
||||
accounts.push(account);
|
||||
}
|
||||
Ok(accounts)
|
||||
|
||||
@@ -36,7 +36,7 @@ pub use provider::{
|
||||
pub mod credential;
|
||||
pub mod transport;
|
||||
|
||||
pub use transport::tls::ensure_rustls_provider;
|
||||
pub use transport::tls::{build_reqwest_client_with_custom_ca, ensure_rustls_provider};
|
||||
pub use transport::{
|
||||
CodexCanonicalDecoder, CodexCanonicalError, CodexRequestEncodeError, OpenAiBillingUsage,
|
||||
encode_generate_request, openai_billing_breakdown,
|
||||
|
||||
@@ -820,6 +820,12 @@ pub(super) async fn read_error_response_body(
|
||||
|
||||
pub(super) fn websocket_upstream_request(request: &CodexResponsesRequest) -> CodexResponsesRequest {
|
||||
let mut request = request.clone();
|
||||
// 上游通过流式事件执行,下游仍按客户端原来的偏好返回响应。
|
||||
if !request.stream() {
|
||||
request
|
||||
.body_mut()
|
||||
.insert("stream".to_owned(), Value::Bool(true));
|
||||
}
|
||||
project_websocket_client_metadata(&mut request);
|
||||
request
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::io;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
|
||||
use gateway_protocol::openai::{
|
||||
WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY, is_transport_managed_request_header,
|
||||
};
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::Serialize as _;
|
||||
use serde_json::{Map, Value};
|
||||
@@ -619,32 +621,12 @@ fn decode_passthrough_headers(context: &Map<String, Value>) -> HeaderMap {
|
||||
}
|
||||
|
||||
fn provider_managed_header(name: &str) -> bool {
|
||||
name.starts_with("sec-websocket-")
|
||||
is_transport_managed_request_header(name)
|
||||
|| name.starts_with("x-grok-")
|
||||
|| name.starts_with("x-xai-")
|
||||
|| matches!(
|
||||
name,
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "host"
|
||||
| "content-length"
|
||||
| "forwarded"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-proto"
|
||||
| "x-forwarded-port"
|
||||
| "x-real-ip"
|
||||
| "true-client-ip"
|
||||
| "cf-connecting-ip"
|
||||
| "x-request-id"
|
||||
| "authorization"
|
||||
"authorization"
|
||||
| "x-api-key"
|
||||
| "x-openai-actor-authorization"
|
||||
| "cookie"
|
||||
|
||||
@@ -984,6 +984,7 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
|
||||
let admin = bundle.admin_provider();
|
||||
let import_error = admin
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: None,
|
||||
document: ProviderDocument::new(OpaqueProviderData::new(Map::new())),
|
||||
})
|
||||
.await
|
||||
@@ -1025,6 +1026,7 @@ async fn initialized_provider_reports_a_safe_pat_format_error_before_network_acc
|
||||
let error = bundle
|
||||
.admin_provider()
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: None,
|
||||
document: ProviderDocument::new(OpaqueProviderData::new(Map::from_iter([(
|
||||
"accessToken".to_owned(),
|
||||
json!("at-sensitive-token with whitespace"),
|
||||
|
||||
@@ -56,6 +56,54 @@ async fn sub2api_import_resolves_distinct_proxy_bindings_and_encodes_credentials
|
||||
assert!(!format!("{prepared:?}").contains("p:a/ss"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_default_proxy_preserves_explicit_account_exits() {
|
||||
let service = CodexCredentialAdminService::new(
|
||||
Arc::new(UnusedRefresher),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
runtime_policy(),
|
||||
);
|
||||
let default_proxy =
|
||||
gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap();
|
||||
for (field, value, expected) in [
|
||||
(
|
||||
None,
|
||||
serde_json::Value::Null,
|
||||
Some("http://default.example:8080/"),
|
||||
),
|
||||
(
|
||||
Some("outboundProxyUrl"),
|
||||
serde_json::json!("socks5h://override.example:1080"),
|
||||
Some("socks5h://override.example:1080"),
|
||||
),
|
||||
(Some("outbound_proxy_url"), serde_json::json!(""), None),
|
||||
(Some("outboundProxyUrl"), serde_json::Value::Null, None),
|
||||
(Some("proxy_key"), serde_json::Value::Null, None),
|
||||
] {
|
||||
let mut account = serde_json::json!({"access_token": test_jwt(serde_json::json!({
|
||||
"https://api.openai.com/auth": {"chatgpt_user_id": "default-proxy-user"}
|
||||
}))});
|
||||
if let Some(field) = field {
|
||||
account[field] = value;
|
||||
}
|
||||
let prepared = service
|
||||
.prepare_import_document_with_proxy(
|
||||
serde_json::json!({"data": {"accounts": [account]}}),
|
||||
Some(&default_proxy),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepared.accounts()[0]
|
||||
.account
|
||||
.outbound_proxy()
|
||||
.map(|proxy| proxy.endpoint())
|
||||
.as_deref(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub2api_invalid_proxy_bindings_are_rejected_before_any_token_refresh() {
|
||||
let service = CodexCredentialAdminService::new(
|
||||
@@ -746,7 +794,7 @@ async fn oauth_import_rejects_legacy_bare_token_field() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn pat_import_uses_account_proxy_without_direct_fallback() {
|
||||
for status in [200, 503] {
|
||||
for (status, use_default) in [(200, false), (503, false), (200, true), (503, true)] {
|
||||
let origin = MockServer::start().await;
|
||||
let proxy = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -756,11 +804,13 @@ async fn pat_import_uses_account_proxy_without_direct_fallback() {
|
||||
.expect(1)
|
||||
.mount(&proxy)
|
||||
.await;
|
||||
let default_proxy = gateway_core::account::OutboundProxy::parse(&proxy.uri()).unwrap();
|
||||
let mut document = serde_json::json!({"accessToken": "at-proxy-token"});
|
||||
if !use_default {
|
||||
document["outboundProxyUrl"] = serde_json::json!(proxy.uri());
|
||||
}
|
||||
let imported = pat_service(&origin)
|
||||
.prepare_import_document(serde_json::json!({
|
||||
"accessToken": "at-proxy-token",
|
||||
"outboundProxyUrl": proxy.uri(),
|
||||
}))
|
||||
.prepare_import_document_with_proxy(document, use_default.then_some(&default_proxy))
|
||||
.await;
|
||||
if status == 200 {
|
||||
let prepared = imported.expect("PAT validated through account proxy");
|
||||
|
||||
@@ -7,16 +7,27 @@ use tokio_tungstenite::tungstenite::http::HeaderMap as WsHeaderMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
const DOWNSTREAM_TRANSPORT_HEADERS: &[(&str, &str)] = &[
|
||||
("CF-Visitor", r#"{"scheme":"https"}"#),
|
||||
("cf-connecting-ip", "192.0.2.1"),
|
||||
("cf-connecting-ipv6", "2001:db8::1"),
|
||||
("cf-pseudo-ipv4", "240.0.0.1"),
|
||||
("cf-ray", "downstream-ray"),
|
||||
("cf-ipcountry", "US"),
|
||||
("cf-warp-tag-id", "downstream-warp"),
|
||||
("cf-worker", "worker.example"),
|
||||
("cf-ew-via", "15"),
|
||||
("cdn-loop", "cloudflare; loops=1"),
|
||||
("via", "1.1 downstream-proxy"),
|
||||
("forwarded", "for=192.0.2.1;proto=https"),
|
||||
("x-forwarded-for", "192.0.2.1"),
|
||||
("x-forwarded-prefix", "/gateway"),
|
||||
("accept-encoding", "br, gzip"),
|
||||
("content-encoding", "downstream-encoding"),
|
||||
];
|
||||
|
||||
fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({"model": "gpt-test", "input": "hello"})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone(),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([
|
||||
let mut context = Map::from_iter([
|
||||
(
|
||||
"opaque_request_headers".to_owned(),
|
||||
json!([
|
||||
@@ -61,7 +72,25 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
|
||||
]),
|
||||
),
|
||||
("turn_state".to_owned(), json!("typed-turn-state")),
|
||||
]));
|
||||
]);
|
||||
context
|
||||
.get_mut("opaque_request_headers")
|
||||
.and_then(Value::as_array_mut)
|
||||
.expect("opaque headers")
|
||||
.extend(
|
||||
DOWNSTREAM_TRANSPORT_HEADERS
|
||||
.iter()
|
||||
.map(|(name, value)| json!([name, STANDARD.encode(value.as_bytes())])),
|
||||
);
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
json!({"model": "gpt-test", "input": "hello"})
|
||||
.as_object()
|
||||
.expect("request object")
|
||||
.clone(),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(context);
|
||||
let mut request = encode_generate_request(
|
||||
&GenerateRequest::from_protocol_payload(payload),
|
||||
"gpt-routed",
|
||||
@@ -352,7 +381,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_identity() {
|
||||
async fn backend_http_should_preserve_business_headers_without_downstream_transport_headers() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind opaque HTTP server");
|
||||
@@ -394,6 +423,17 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
|
||||
.expect("opaque HTTP response");
|
||||
let raw = server.await.expect("opaque HTTP server task");
|
||||
|
||||
for &(name, _) in DOWNSTREAM_TRANSPORT_HEADERS {
|
||||
// HTTP 正文由 transport 重编码为 zstd,不能沿用下游 Content-Encoding。
|
||||
if name == "content-encoding" {
|
||||
assert_eq!(raw_header_values(&raw, name), vec![b"zstd".to_vec()]);
|
||||
} else {
|
||||
assert!(
|
||||
raw_header_values(&raw, name).is_empty(),
|
||||
"unexpected {name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
raw_header_values(&raw, "x-openai-future-mode"),
|
||||
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
|
||||
@@ -450,7 +490,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values() {
|
||||
async fn backend_websocket_should_preserve_business_headers_without_downstream_transport_headers() {
|
||||
let received = Arc::new(Mutex::new(WsHeaderMap::new()));
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -520,6 +560,17 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for (name, _) in DOWNSTREAM_TRANSPORT_HEADERS {
|
||||
assert!(values(name).is_empty(), "unexpected {name}");
|
||||
}
|
||||
assert!(
|
||||
received
|
||||
.get("sec-websocket-extensions")
|
||||
.expect("upstream WebSocket compression negotiation")
|
||||
.to_str()
|
||||
.expect("extension value")
|
||||
.contains("permessage-deflate")
|
||||
);
|
||||
assert_eq!(
|
||||
values("x-openai-future-mode"),
|
||||
vec![b"future-ascii".to_vec()]
|
||||
|
||||
@@ -193,6 +193,74 @@ fn websocket_connection_should_prepare_response_create_payload_text() {
|
||||
assert!(payload.get("store").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_websocket_should_stream_upstream_for_non_streaming_client_request() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let Some(Ok(Message::Text(payload))) = websocket.next().await else {
|
||||
panic!("client should send response.create");
|
||||
};
|
||||
let payload: Value = serde_json::from_str(payload.as_str()).unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_non_streaming",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {"input_tokens": 3, "output_tokens": 1, "total_tokens": 4}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
payload
|
||||
});
|
||||
let request = CodexResponsesRequest::from_body(
|
||||
json!({
|
||||
"model": "gpt-test",
|
||||
"instructions": "collect this response",
|
||||
"input": [],
|
||||
"stream": false,
|
||||
"previous_response_id": "resp_previous"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
);
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{address}"),
|
||||
test_wire_profile(),
|
||||
);
|
||||
let response = timeout(
|
||||
Duration::from_secs(5),
|
||||
backend.create_response(
|
||||
&request,
|
||||
request_context("req_non_streaming_ws", Some("acct-non-streaming")),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("upstream WebSocket response");
|
||||
let payload = server.await.unwrap();
|
||||
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
|
||||
assert!(response.body.contains("resp_non_streaming"));
|
||||
assert_eq!(payload["stream"], true);
|
||||
assert_eq!(payload["previous_response_id"], "resp_previous");
|
||||
assert!(
|
||||
!request.stream(),
|
||||
"downstream delivery remains non-streaming"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_connection_should_prepare_capture_payload_with_canonical_field_order() {
|
||||
let mut request = codex_request_with_prompt_cache_key(
|
||||
|
||||
@@ -429,8 +429,11 @@ impl ProviderAdmin for XaiAdminProvider {
|
||||
command.document.into_provider_data().into_inner(),
|
||||
))
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let document = GrokOAuthImportDocument::parse_json(&document)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let document = GrokOAuthImportDocument::parse_json_with_proxy(
|
||||
&document,
|
||||
command.default_outbound_proxy.as_ref(),
|
||||
)
|
||||
.map_err(|_| provider_error(ProviderAdminErrorKind::Invalid))?;
|
||||
let mut subjects = BTreeSet::new();
|
||||
let mut credentials = Vec::new();
|
||||
// 逐条目独立成败:verify 会真实轮换 RT,后续条目的任何失败都不得
|
||||
|
||||
@@ -162,6 +162,13 @@ impl GrokOAuthImportDocument {
|
||||
/// 独立代理 URL 随账号导入;其他展示 metadata 不参与认证。
|
||||
/// 实际 token 仍须通过官方 refresh/user-info 验证;API Key 不能混入 OAuth 条目。
|
||||
pub fn parse_json(document: &[u8]) -> Result<Self, GrokOAuthImportError> {
|
||||
Self::parse_json_with_proxy(document, None)
|
||||
}
|
||||
|
||||
pub fn parse_json_with_proxy(
|
||||
document: &[u8],
|
||||
default_proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<Self, GrokOAuthImportError> {
|
||||
if document.is_empty() || document.len() > MAX_IMPORT_DOCUMENT_BYTES {
|
||||
return Err(GrokOAuthImportError::InvalidField("document"));
|
||||
}
|
||||
@@ -174,7 +181,13 @@ impl GrokOAuthImportDocument {
|
||||
let now = Utc::now();
|
||||
let mut entries = Vec::with_capacity(accounts.len());
|
||||
for (index, account) in accounts.into_iter().enumerate() {
|
||||
if let Some(entry) = parse_account_entry(account, index, now)? {
|
||||
if let Some(mut entry) = parse_account_entry(account, index, now)? {
|
||||
if !["outboundProxyUrl", "outbound_proxy_url", "proxy_key"]
|
||||
.iter()
|
||||
.any(|field| account.get(field).is_some())
|
||||
{
|
||||
entry.outbound_proxy = default_proxy.cloned();
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,7 @@ async fn xai_admin_provider_rejects_unprepared_mutations_before_store_commit() {
|
||||
|
||||
let import_error = admin
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
default_outbound_proxy: None,
|
||||
document: ProviderDocument::new(OpaqueProviderData::new(Map::new())),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -204,6 +204,45 @@ fn strict_oauth_account_document_should_parse_without_exposing_credentials() {
|
||||
let _candidate = entry.into_candidate();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_default_proxy_preserves_explicit_account_exits() {
|
||||
let default_proxy =
|
||||
gateway_core::account::OutboundProxy::parse("http://default.example:8080").unwrap();
|
||||
for (field, value, expected) in [
|
||||
(
|
||||
None,
|
||||
serde_json::Value::Null,
|
||||
Some("http://default.example:8080/"),
|
||||
),
|
||||
(
|
||||
Some("outboundProxyUrl"),
|
||||
serde_json::json!("socks5h://override.example:1080"),
|
||||
Some("socks5h://override.example:1080"),
|
||||
),
|
||||
(Some("outbound_proxy_url"), serde_json::json!(""), None),
|
||||
(Some("outboundProxyUrl"), serde_json::Value::Null, None),
|
||||
(Some("proxy_key"), serde_json::Value::Null, None),
|
||||
] {
|
||||
let mut document: serde_json::Value =
|
||||
serde_json::from_slice(&oauth_account_document()).unwrap();
|
||||
if let Some(field) = field {
|
||||
document["accounts"][0][field] = value;
|
||||
}
|
||||
let parsed = GrokOAuthImportDocument::parse_json_with_proxy(
|
||||
&serde_json::to_vec(&document).unwrap(),
|
||||
Some(&default_proxy),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parsed.into_entries()[0]
|
||||
.outbound_proxy()
|
||||
.map(|proxy| proxy.endpoint())
|
||||
.as_deref(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_account_document_should_reject_api_key_credential_field() {
|
||||
let mut document: serde_json::Value =
|
||||
|
||||
@@ -2,3 +2,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
|
||||
78c427c17d8b5759405f1629a80ceb624012cb6b31371b762078b000266d86c6 0002_client_key_budgets.sql
|
||||
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
|
||||
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
|
||||
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
create table outbound_proxies (
|
||||
id text primary key,
|
||||
name text not null check (char_length(name) between 1 and 100),
|
||||
proxy_url text not null,
|
||||
revision bigint not null default 1 check (revision > 0),
|
||||
last_test_at timestamptz,
|
||||
last_test_success boolean,
|
||||
last_test_latency_ms bigint,
|
||||
last_test_ip text,
|
||||
last_test_message text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table provider_accounts add column outbound_proxy_id text
|
||||
references outbound_proxies(id) on delete restrict;
|
||||
create index provider_accounts_outbound_proxy_id_idx on provider_accounts(outbound_proxy_id);
|
||||
|
||||
insert into outbound_proxies (id, name, proxy_url)
|
||||
select 'proxy_' || gen_random_uuid()::text,
|
||||
'Imported proxy ' || row_number() over (order by proxy_url), proxy_url
|
||||
from (select distinct outbound_proxy_url as proxy_url from provider_accounts
|
||||
where outbound_proxy_url is not null) existing;
|
||||
|
||||
update provider_accounts a set outbound_proxy_id = p.id
|
||||
from outbound_proxies p where p.proxy_url = a.outbound_proxy_url;
|
||||
+85
-13
@@ -113,8 +113,8 @@ body 不进入这个通用信封。稳定业务码如下:
|
||||
|
||||
## 3. OpenAI 数据面与模型目录
|
||||
|
||||
Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame 不设置网关私有长度上限;
|
||||
协议可接受性由上游决定。
|
||||
除下述 Responses 入站解压保护外,Responses、Images 和 standalone Search HTTP body、
|
||||
WebSocket message 和 frame 不设置网关私有长度上限;协议可接受性由上游决定。
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -129,6 +129,20 @@ Responses、Images 和 standalone Search HTTP body、WebSocket message 和 frame
|
||||
Codex 的 review 等子代理请求仍使用 `/v1/responses`,并通过 `x-openai-subagent` 请求头携带子代理类型;
|
||||
网关不提供独立的子代理请求路径。
|
||||
|
||||
`POST /v1/responses` 在鉴权后按 `Content-Encoding` 解压,再解析 JSON;支持单一 `gzip`、
|
||||
`deflate`(zlib 封装)和 `zstd`,缺省、空值或 `identity` 直接使用原始正文。gzip 多成员与 zstd
|
||||
多帧连续解码,整体展开结果最多 64 MiB,超限在继续展开前返回 `400 request_too_large`;zstd
|
||||
回溯窗口同样最多 64 MiB,不能满足该限制的帧按解码失败处理。这个限制保护入站解压资源,不是
|
||||
模型上下文或 Token 上限,也不新增未压缩正文的长度限制。
|
||||
不支持的编码、逗号分隔的叠加编码和重复 `Content-Encoding` 头返回
|
||||
`400 unsupported_content_encoding`;压缩正文损坏、截断或解压后不是合法 JSON 返回
|
||||
`400 invalid_json`。本地错误不包含原始正文或解压库细节。WebSocket 文本帧不经过这条解压路径。
|
||||
|
||||
Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwarded-*`、`forwarded`、`via`、
|
||||
`cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力
|
||||
由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。
|
||||
此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。
|
||||
|
||||
Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧
|
||||
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
|
||||
这对齐 Codex 客户端 `stream_request` 持锁至本轮结束的串行行为,不表示支持额外控制消息类型。
|
||||
@@ -171,12 +185,12 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
| `GET` | `/api/admin/accounts` | `page`、`pageSize`、`provider`、`groupId`、`search`、`status`、排序字段 | 分页查询账号与汇总 |
|
||||
| `GET` | `/api/admin/accounts/detail` | `accountId` | 查询账号详情、额度和本地用量 |
|
||||
| `GET` | `/api/admin/accounts/export` | `accountIds`、`confirm=export_sensitive_accounts` | 显式导出最多 200 个账号的敏感 Provider 文档 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings? }` | 导入或按上游身份更新账号,可同时应用调度与分组设置 |
|
||||
| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings?, outboundProxyId? }` | 导入或按上游身份更新账号,可同时应用调度、分组设置与默认代理 |
|
||||
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
|
||||
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
|
||||
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
|
||||
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
|
||||
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 |
|
||||
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
|
||||
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled, concurrencyLimit, weight, groupIds, outboundProxyId?, outboundProxyUrl? }` | 一次事务统一更新所选账号的调度字段、完整分组集合与可选代理 |
|
||||
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
|
||||
| `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 |
|
||||
| `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 |
|
||||
@@ -186,7 +200,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
|
||||
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
|
||||
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
|
||||
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
|
||||
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
|
||||
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 |
|
||||
|
||||
账号列表支持以下稳定值:
|
||||
@@ -197,18 +211,69 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。
|
||||
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`;
|
||||
- `sortDirection`: `asc`、`desc`。
|
||||
|
||||
账号视图和 Dashboard 账号概览中的 `planType` 保留原始套餐值;`planTypeDisplay` 是后端按 Provider 生成的展示名称,
|
||||
前端直接展示该字段。OpenAI 的 `self_serve_business_prolite` 等 Team 套餐按官方客户端显示为
|
||||
`Business`;新套餐保留原始名称。账号套餐为空或 `unknown` 时,后端优先用已保存的上游额度响应
|
||||
账号视图和 Dashboard 账号概览中的 `planType` 保留原始套餐值;`planTypeDisplay` 由后端先按 Provider 解析名称,
|
||||
再统一为大驼峰格式,前端直接展示该字段,例如 `Free`、`SuperGrokPro`、`EduPlus`。
|
||||
OpenAI 的 `self_serve_business_prolite` 等 Team 套餐显示为 `Business`;新套餐也使用相同格式。
|
||||
账号套餐为空或 `unknown` 时,后端优先用已保存的上游额度响应
|
||||
中的明确套餐值补全 `planType` 和 `planTypeDisplay`;两处均无套餐信息时才显示“未知套餐”。
|
||||
|
||||
`outboundProxyUrl` 接受 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码。
|
||||
`outboundProxyId` 绑定已保存且最近测试成功的代理;省略或 `null` 保留当前绑定,空字符串清除绑定。
|
||||
`outboundProxyUrl` 兼容 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码;不能与 ID 同时设置。
|
||||
编辑时省略或 `null` 表示保持原配置,空字符串表示清除代理并直连。列表和详情只返回
|
||||
不含认证信息的 `outboundProxyEndpoint`(直连时为 `null`);只有显式敏感导出包含完整 URL。
|
||||
指定代理后,推理、OAuth 服务端交换/刷新及账号辅助请求使用同一出口;代理失败不会退回直连。
|
||||
浏览器打开的第三方 OAuth 授权页仍使用浏览器自身网络。
|
||||
账号出口与连接隔离见 [架构说明](architecture.md#账号出站代理)。
|
||||
|
||||
### 独立代理管理 / Managed Proxies
|
||||
|
||||
所有端点要求管理员身份。所有响应只返回去掉认证信息的 `endpoint`,不会返回完整 URL。
|
||||
All endpoints require admin authentication and redact proxy credentials from responses.
|
||||
|
||||
| 方法 / Method | 路径 / Path | 请求 / Request | 结果 / Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET` | `/api/admin/proxies` | `page`、`pageSize`(1-200)、`search`(名称) | `{ items, page }` |
|
||||
| `GET` | `/api/admin/proxies/accounts` | `proxyId`、`page`、`pageSize`(1-200)、`search`(账号名称或邮箱) | `{ items, page }` |
|
||||
| `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` |
|
||||
| `POST` | `/api/admin/proxies/create` | `{ name, proxyUrl }` | `201 { record, configRevision }` |
|
||||
| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, proxyUrl? }` | `{ record, configRevision }` |
|
||||
| `POST` | `/api/admin/proxies/test` | `{ id, revision }` | 最新代理记录 / Proxy record with test result |
|
||||
| `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` |
|
||||
|
||||
`record` 包含 `id`、`name`、`endpoint`、`hasAuthentication`、`revision`、`accountCount`、
|
||||
`lastTestAt`、`lastTest: { success, latencyMs, exitIp, message }`、`createdAt`、`updatedAt`。
|
||||
未测试时 `lastTestAt` / `lastTest` 为 `null`。连通性失败返回 HTTP 200 和 `lastTest.success=false`;
|
||||
记录版本过期、重复 URL、删除已绑定的代理返回 409,并发测试满载返回 429。
|
||||
|
||||
代理列表只返回关联账号数量。关联账号按需查询,每项包含 `id`、`name`、`email`、`provider`、`enabled`、
|
||||
`authenticationKind`、`planType`、`planTypeDisplay` 和 `groups: [{ id, name, color, enabled }]`,
|
||||
不返回账号凭据。默认每页 20 条,按名称、ID 稳定排序;搜索不区分大小写,匹配名称或邮箱的字面子串。
|
||||
不存在的代理返回 404,未绑定账号或没有匹配结果时返回空页。数量与当前页来自同一个数据库只读快照。
|
||||
|
||||
移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。
|
||||
若账号已不再绑定请求中的代理,则返回 409;成功后在同一事务中更新配置版本与审计,并发布运行时快照。
|
||||
|
||||
更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。
|
||||
Omit `proxyUrl` to preserve credentials. Connection changes invalidate the previous test and update all bound accounts.
|
||||
Tests persist only when the requested revision still matches. Connectivity failures use HTTP 200 with
|
||||
`lastTest.success=false`; stale revisions, duplicate URLs and deleting an in-use proxy return 409.
|
||||
The test concurrency limit returns 429.
|
||||
|
||||
测试固定经代理访问 `https://api.ipify.org?format=json`,超时 15 秒,每进程最多同时测试 4 条。
|
||||
探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`,
|
||||
其次读取 `SSL_CERT_FILE`,并保留系统根证书;证书配置错误不会回退为不验证证书。
|
||||
出口测试通过不表示 Provider 账号权限或额度可用;账号可用性使用账号连接测试。
|
||||
导入请求可以携带顶层 `outboundProxyId`,在令牌交换前解析为默认出口;文件中显式的代理配置优先。
|
||||
文件及 AT/RT 导入从凭据交换到落库期间保护所选代理;此时修改、删除或写入测试结果返回 409,
|
||||
避免已轮换的凭据因代理状态变化而丢失。完成导入或请求取消后自动释放保护。
|
||||
OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配置改变或测试失败的代理。
|
||||
|
||||
Tests reach `https://api.ipify.org?format=json` through the configured proxy, with a 15-second timeout
|
||||
and four concurrent tests per process. Provider access still requires the account connection test.
|
||||
Imports accept a top-level `outboundProxyId` as the default exit before token exchange; explicit per-account
|
||||
settings in the document take precedence. Credential imports reserve their selected proxy until commit;
|
||||
concurrent proxy mutations return 409. OAuth commits still reject a deleted, changed or failed proxy.
|
||||
|
||||
### 账号连接测试 SSE
|
||||
|
||||
`GET /api/admin/accounts/connection-test` 固定探测请求指定的账号,不参与普通账号轮换。成功流沿用
|
||||
@@ -346,9 +411,12 @@ OAuth start 使用:
|
||||
OAuth refresh 的明确永久错误写入。
|
||||
- 正常 Responses 请求会解析上游响应的 rate-limit headers,合并进同一 quota 快照并同步状态。Free、
|
||||
K12 等套餐共用该状态机;套餐只参与账号展示和按套餐隔离的模型目录 cache,不存在 K12 专属额度路径。
|
||||
- 账号展开区的 Token 结构和模型排行使用代表性账号级额度窗口聚合,查询边界严格为
|
||||
`[resetAt - windowSeconds, resetAt)`;额度刷新若返回了更早的重置时间,会按新边界重新聚合。无法取得
|
||||
完整窗口边界或只有模型专属额度时显示无数据,不回退成历史累计。金额原值保持完整精度,USD 展示值
|
||||
- 账号展开区的 Token 结构、模型排行和列表 Token 汇总优先使用账号级周额度窗口,无可统计的周窗口时
|
||||
使用月额度窗口;`usage.windowLabelDisplay` 随选中的窗口返回“周额度窗口”或“月额度窗口”。查询边界
|
||||
严格为 `[resetAt - windowSeconds, resetAt)`,不是自然周/月或最近 7/30 天;额度刷新若返回了更早的
|
||||
重置时间,会按新边界重新聚合。没有边界完整、可归属到账号的周/月窗口时显示无数据,标签为
|
||||
“周/月额度窗口”,不回退到 5 小时、日窗口或历史累计。各额度条与 Dashboard 的百分比选择不受影响。
|
||||
金额原值保持完整精度,USD 展示值
|
||||
小于 1 美元时最多保留四位小数,其余保留两位。
|
||||
- 账号页没有定时静默轮询。手工额度刷新只替换响应中的账号行并同步状态汇总,不触发整页 loading;若
|
||||
新状态不符合当前筛选,该行从当前页移除。请求驱动或后台任务产生的状态变化,需要下一次显式查询账号
|
||||
@@ -467,6 +535,10 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
|
||||
自动结算按网关请求 ID 幂等执行。账本独立于使用统计日志,记录保留至删除 Key,
|
||||
不受 `usageRetentionDays` 影响。
|
||||
|
||||
English: Daily and weekly budgets use automatically recorded costs. Missing usage or interrupted requests
|
||||
do not block a Key, and no manual reconciliation is required. New requests receive `429` once recorded
|
||||
costs reach the daily or weekly limit; requests already admitted can finish above that threshold.
|
||||
|
||||
## 8. 运行设置
|
||||
|
||||
| 方法 | 路由 | 说明 |
|
||||
|
||||
+22
-1
@@ -174,6 +174,9 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
|
||||
|
||||
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone
|
||||
Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。
|
||||
- Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义,
|
||||
API 入站与 OpenAI Provider 编码共同使用。下游链路元数据和压缩协商不跨越该边界,
|
||||
上游认证、请求画像与传输字段仍由 Provider 生成;响应方向的诊断头不受请求过滤规则影响。
|
||||
- xAI 是翻译边界。Provider 把 Grok wire 转换为 Responses wire;上游结构化错误的 message/code/type
|
||||
可以透出,但账号指纹会先脱敏。
|
||||
- response ID 是不透明 UTF-8 bytes,不假设 UUID、固定长度或跨 Provider 可复用。
|
||||
@@ -189,11 +192,29 @@ Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 ca
|
||||
连接池按出口隔离。修改代理推进配置 revision,使后续请求使用新出口;已执行请求可沿原连接完成。
|
||||
代理变更不推进 `credential_revision`,不会使进行中的令牌刷新因凭据版本冲突而丢失结果。
|
||||
|
||||
导入器在认证交换前解析并校验账号出口。sub2api 的代理引用转换为账号上的代理 URL,不创建共享代理资源;
|
||||
代理独立保存和测试,通过 `outboundProxyId` 绑定账号;账号保留解析后的 URL 供 Provider 使用。
|
||||
连接配置改变时在同一事务内同步关联账号并清除旧测试结果,已绑定的代理不可删除。
|
||||
关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL,
|
||||
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。
|
||||
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,复用代理 ID 索引,
|
||||
在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。
|
||||
文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。
|
||||
代理修改、删除和测试结果写入通过同一资源的事务级独占锁检查,导入期间直接返回冲突。
|
||||
锁连接不占用提交所需的连接池槽位,也不持有空闲事务;每进程最多增加四个导入保护连接。
|
||||
请求取消或进程退出时关闭会话并释放锁。探测器由组合根注入 OpenAI 的 HTTP 客户端构建函数,
|
||||
复用其自定义 CA 加载与证书校验规则,Host 不依赖具体 Provider 包。
|
||||
迁移 `0005_managed_outbound_proxies.sql` 将已有 URL 按完整连接信息合并为共享代理,保留认证及账号绑定。
|
||||
|
||||
导入器在认证交换前解析并校验账号出口。sub2api 的代理引用按完整 URL 登记为共享代理并绑定账号;
|
||||
其 SOCKS5 配置按远端 DNS 语义转换为 SOCKS5H。账号导入只迁移账号及出口,不迁移下游 Key、余额或历史用量。
|
||||
代理认证信息仅通过敏感账号导出返回,列表、详情、Debug 和普通审计不得暴露;数据库及备份按凭据保护。
|
||||
输入字段、协议支持与导入校验见 [账号 API](api.md#5-账号)。
|
||||
|
||||
Managed proxies store names, credentials and connectivity results independently. Accounts bind by ID and retain
|
||||
the resolved URL for provider transports. Connection edits update all bound accounts atomically and invalidate
|
||||
old tests; in-use proxies cannot be deleted. Migration 0005 preserves existing credentials and account bindings.
|
||||
Proxy changes advance the runtime configuration revision without invalidating in-flight token refreshes.
|
||||
|
||||
### Codex 原生生图与认证
|
||||
|
||||
管理端导出的 Codex 配置使用代理 Bearer 密钥,并声明服务端托管账号认证。
|
||||
|
||||
@@ -3,7 +3,6 @@ export * from './modules/account-groups'
|
||||
|
||||
// 账号管理
|
||||
export * from './modules/accounts'
|
||||
|
||||
// API Keys 管理
|
||||
export * from './modules/api-keys'
|
||||
|
||||
@@ -16,6 +15,8 @@ export * from './modules/backups'
|
||||
// Dashboard 聚合
|
||||
export * from './modules/dashboard'
|
||||
|
||||
export * from './modules/proxies'
|
||||
|
||||
// 设置管理
|
||||
export * from './modules/settings'
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AccountModelUsage {
|
||||
}
|
||||
|
||||
export interface AccountUsage {
|
||||
windowLabelDisplay: string
|
||||
requestCount: number | null
|
||||
requestCountDisplay: string
|
||||
inputTokens: number | null
|
||||
@@ -291,6 +292,7 @@ interface AccountResetCreditConsumeParam extends AccountIdParam {
|
||||
|
||||
interface AccountUpdateParam {
|
||||
outboundProxyUrl?: string
|
||||
outboundProxyId?: string
|
||||
accountId: string
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
@@ -300,6 +302,7 @@ interface AccountUpdateParam {
|
||||
|
||||
interface AccountBatchUpdateParam {
|
||||
outboundProxyUrl?: string
|
||||
outboundProxyId?: string
|
||||
accountIds: string[]
|
||||
enabled: boolean
|
||||
concurrencyLimit: number | null
|
||||
@@ -320,6 +323,7 @@ interface AccountImportSettings {
|
||||
}
|
||||
|
||||
interface AccountImportParam {
|
||||
outboundProxyId?: string
|
||||
settings?: AccountImportSettings
|
||||
provider: string
|
||||
data: unknown
|
||||
@@ -327,6 +331,7 @@ interface AccountImportParam {
|
||||
|
||||
interface AccountOAuthStartParam {
|
||||
outboundProxyUrl?: string
|
||||
outboundProxyId?: string
|
||||
provider: string
|
||||
name: string
|
||||
accountId?: string
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AccountGroupRef } from './account-groups'
|
||||
import request from '../request'
|
||||
|
||||
export interface OutboundProxyTest {
|
||||
success: boolean
|
||||
latencyMs: number
|
||||
exitIp: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface OutboundProxyRecord {
|
||||
id: string
|
||||
name: string
|
||||
endpoint: string
|
||||
hasAuthentication: boolean
|
||||
revision: number
|
||||
accountCount: number
|
||||
lastTestAt: string | null
|
||||
lastTest: OutboundProxyTest | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface ProxyPage {
|
||||
items: OutboundProxyRecord[]
|
||||
page: { page: number, pageSize: number, total: number, totalPages: number }
|
||||
}
|
||||
|
||||
export interface OutboundProxyAccount {
|
||||
id: string
|
||||
name: string
|
||||
email: string | null
|
||||
provider: string
|
||||
authenticationKind: string
|
||||
planType: string | null
|
||||
planTypeDisplay: string | null
|
||||
groups: AccountGroupRef[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ProxyAccountPage {
|
||||
items: OutboundProxyAccount[]
|
||||
page: ProxyPage['page']
|
||||
}
|
||||
|
||||
export function getProxyAccounts(params: { proxyId: string, page: number, pageSize: number, search?: string }) {
|
||||
return request<ProxyAccountPage>({
|
||||
url: '/api/admin/proxies/accounts',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeProxyAccount(data: { proxyId: string, accountId: string }) {
|
||||
return request<{ configRevision: number }>({
|
||||
url: '/api/admin/proxies/accounts/remove',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
interface ProxyMutation {
|
||||
record: OutboundProxyRecord
|
||||
configRevision: number
|
||||
}
|
||||
|
||||
export function getProxies(params: { page: number, pageSize: number, search?: string }) {
|
||||
return request<ProxyPage>({
|
||||
url: '/api/admin/proxies',
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function createProxy(data: { name: string, proxyUrl: string }) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/create',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProxy(data: { id: string, revision: number, name: string, proxyUrl?: string }) {
|
||||
return request<ProxyMutation>({
|
||||
url: '/api/admin/proxies/update',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProxy(data: { id: string, revision: number }) {
|
||||
return request<{ configRevision: number }>({
|
||||
url: '/api/admin/proxies/delete',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function testProxy(data: { id: string, revision: number }) {
|
||||
return request<OutboundProxyRecord>({
|
||||
url: '/api/admin/proxies/test',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
export function probeProxy(data: { proxyUrl: string }) {
|
||||
return request<OutboundProxyTest>({
|
||||
url: '/api/admin/proxies/probe',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
@@ -33,10 +33,9 @@ export function useModalDrag(
|
||||
if (!target.value)
|
||||
return
|
||||
|
||||
target.value.style.transform
|
||||
= offset.x === 0 && offset.y === 0
|
||||
? ''
|
||||
: `translate3d(${offset.x}px, ${offset.y}px, 0)`
|
||||
// 使用相对定位保留普通绘制,避免变换合成层在表格单元格之间产生像素缝。
|
||||
target.value.style.left = offset.x === 0 ? '' : `${offset.x}px`
|
||||
target.value.style.top = offset.y === 0 ? '' : `${offset.y}px`
|
||||
}
|
||||
|
||||
function removeDragListeners() {
|
||||
|
||||
@@ -12,6 +12,7 @@ type SelectSize = 'sm' | 'md' | 'lg'
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -303,9 +304,17 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
@click="toggleMenu"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<span class="min-w-0 truncate" :class="selectedOption?.description ? 'max-w-1/2 shrink-0' : 'flex-1'">
|
||||
{{ selectedOption?.label ?? placeholder }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedOption?.description"
|
||||
:title="selectedOption.description"
|
||||
class="min-w-0 flex-1 truncate text-xs font-normal"
|
||||
:class="disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
|
||||
>
|
||||
{{ selectedOption.description }}
|
||||
</span>
|
||||
<ChevronDown
|
||||
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
|
||||
:class="
|
||||
@@ -355,7 +364,15 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
@mousedown.prevent
|
||||
@click="chooseOption(option, index)"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<span class="min-w-0 truncate" :class="option.description ? 'max-w-1/2 shrink-0' : 'flex-1'">{{ option.label }}</span>
|
||||
<span
|
||||
v-if="option.description"
|
||||
:title="option.description"
|
||||
class="min-w-0 flex-1 truncate text-xs font-normal"
|
||||
:class="option.disabled ? 'text-cp-text-disabled' : 'text-cp-text-tertiary'"
|
||||
>
|
||||
{{ option.description }}
|
||||
</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-cp-primary-text"
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BaseTableColumn<Row extends TableRow = TableRow> {
|
||||
label?: string
|
||||
kind?: TableColumnKind
|
||||
size?: TableColumnSize
|
||||
fixedWidth?: boolean
|
||||
align?: TableColumnAlign
|
||||
sortable?: boolean | string
|
||||
format?: (value: unknown, row: Row) => unknown
|
||||
@@ -212,11 +213,14 @@ export function columnStyle<Row extends TableRow>(
|
||||
column: ResolvedTableColumn<Row>,
|
||||
columns: ResolvedTableColumn<Row>[],
|
||||
) {
|
||||
const tableWidth = minimumTableWidth(columns)
|
||||
const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
|
||||
const fixedWidth = columns.reduce((total, item) => total + (item.fixedWidth ? item.basisWidth : 0), 0)
|
||||
const flexibleWidth = minimumTableWidth(columns) - fixedWidth
|
||||
const ratio = flexibleWidth > 0 ? column.basisWidth / flexibleWidth : 0
|
||||
|
||||
return {
|
||||
width: `${widthPercent}%`,
|
||||
width: column.fixedWidth
|
||||
? `${column.basisWidth}px`
|
||||
: `${ratio * 100}%`,
|
||||
minWidth: `${column.basisWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { OutboundProxyRecord } from '@/api'
|
||||
import { onMounted, shallowRef } from 'vue'
|
||||
import { getProxies } from '@/api'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
|
||||
export function useProxyCatalog() {
|
||||
const proxies = shallowRef<OutboundProxyRecord[]>([])
|
||||
const loading = shallowRef(false)
|
||||
|
||||
async function loadProxies() {
|
||||
if (loading.value)
|
||||
return
|
||||
loading.value = true
|
||||
try {
|
||||
const first = await getProxies({ page: 1, pageSize: 200 })
|
||||
const items = [...first.items]
|
||||
for (let page = 2; page <= first.page.totalPages; page += 1) {
|
||||
items.push(...(await getProxies({ page, pageSize: 200 })).items)
|
||||
}
|
||||
proxies.value = items
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(errorMessage(error, '代理列表加载失败'))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadProxies())
|
||||
return { proxies, loading, loadProxies }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user