mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cee430947 | ||
|
|
ac98a764ff | ||
|
|
56ef1bffa2 | ||
|
|
e275a6dada | ||
|
|
555269739c | ||
|
|
d03d8408fc | ||
|
|
228eea90c1 | ||
|
|
0d25061ef0 | ||
|
|
e97b1faf0d | ||
|
|
90ecac978a | ||
|
|
4326eb1e4b | ||
|
|
2a8d5d8643 | ||
|
|
b7fa5872a3 | ||
|
|
e907f419ab | ||
|
|
ee7e837d1f | ||
|
|
1240f22580 | ||
|
|
e390417821 | ||
|
|
e3f1299a0c | ||
|
|
4e4cd96478 | ||
|
|
6de1beb1df | ||
|
|
ba3baddbda | ||
|
|
09b22a9550 | ||
|
|
04a48e848d | ||
|
|
1655ecc972 | ||
|
|
4527db7b5d | ||
|
|
2b7a64dae6 | ||
|
|
0c07aac1d6 | ||
|
|
3e33e84a45 | ||
|
|
c73e7126a2 | ||
|
|
1d88c9a4a5 | ||
|
|
26dbc12010 | ||
|
|
2dc6bf0150 | ||
|
|
f7f64ead3b | ||
|
|
37749001db | ||
|
|
272534a3b6 | ||
|
|
b9bcb4e086 | ||
|
|
9039cef3d8 | ||
|
|
a9a77f5f9d | ||
|
|
6f7d92594e | ||
|
|
6859520969 | ||
|
|
21d84af2d8 | ||
|
|
311e4d228f | ||
|
|
d604adfbeb | ||
|
|
46b4665c5b | ||
|
|
1daa0623a0 | ||
|
|
860fb39622 | ||
|
|
f5c5a6e5b7 | ||
|
|
bdfc201795 |
@@ -5,6 +5,13 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
|
||||
# 开发指南
|
||||
|
||||
## 首要写作要求:言简意赅
|
||||
|
||||
所有面向人的文字都按此要求编写和审查,包括界面、通知、后端错误、任务状态、命令行、日志、注释、文档和交付说明
|
||||
|
||||
- 先写读者需要知道的事实或动作。删掉一句不影响理解、判断或操作,就删;标题、正文和通知不重复同一信息
|
||||
- 保留会改变决定的条件、权限、后果和恢复步骤。错误通知只说已知问题,有明确办法才补一句;后端返回的提示在后端改,前端不重复解释。上游原文、用户内容和诊断事实保持原样
|
||||
|
||||
## 先确定领域与必读规范
|
||||
|
||||
按用户意图、受影响行为与对象初判,定位后按实际文件复核,不要求用户说出“前端”“后端”或技能名。**所有命中行共同构成本次必读规范,先读完再确定方案、修改或给出审查结论**;跨领域叠加,不能只选最像当前步骤的一行
|
||||
@@ -13,7 +20,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
|
||||
| 领域或操作,行为影响与文件路径满足其一即可 | 确定方案前读取与执行 |
|
||||
| --- | --- |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),覆盖组件复用、文案、Token、样式、视觉状态和运行证据;局部修改同样执行 |
|
||||
| 调整页面、弹窗、表单、按钮、文案、样式或交互;目标在 `frontend/`、`modules/ui/`、插件前端,或是 `.vue` 文件 | [前端流程](references/frontend.md),局部修改同样按影响范围检查组件复用、文案、Token、样式、视觉状态和运行证据 |
|
||||
| 开发、排查或审查插件接入、宿主 SDK、Runtime、扩展接口或独立网关插件;目标在 `backend/crates/gateway-plugin/`、`modules/plugins/` | 完整读取[插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主维护自身合同、资源与稳定性,插件业务错误在插件侧修复,不在宿主、SDK 或 Runtime 中补偿;独立插件开发、排查、审查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 开发、排查或审查服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)的模块与依赖规则、所属模块及[后端自审](../../../docs/architecture.md#后端自审);Rust 使用 `$rust-best-practices` 和下方后端注释要求,宿主 Rust 同时读取 [Rust 模块组织约定](../../../docs/architecture.md#32-rust-模块组织约定) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
@@ -27,7 +34,7 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
|
||||
纯文档任务按文档分支执行;核实文中的合同与职责时读对应领域条款,不因提及代码路径或语言加载实现、构建与页面验收流程
|
||||
|
||||
命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,读取指定章节的完整内容。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;已有内容只在当前上下文可用且仍适用时复用,不循环重读技能
|
||||
首次命中的流程引用完整读取,并落实其中指定的必读章节;长文档先用 `rg -n '^#{1,4} ' 文件` 定位,分段读取指定章节,避免输出截断。`#章节` 不会自动截取,“按需”仅用于未命中的领域和后续操作资料,不能省略必读规范;读取记录与验证证据的复用按 [任务延续与收尾](../../../AGENTS.md#任务延续与收尾) 执行
|
||||
|
||||
## 参考仓库的优先级
|
||||
|
||||
@@ -38,8 +45,9 @@ description: Codex Proxy RS 开发、修复、排障、代码审查和文档修
|
||||
1. 遵守已提供的 [AGENTS.md](../../../AGENTS.md),按其 CodeGraph 约定定位代码;确认工作区、任务范围与已有改动,找到事实归属、同类实现和调用链,补齐上表命中规范及[项目约定](../../../CONTRIBUTING.md#项目约定)对应条目后再修改
|
||||
2. 以最终差异重新匹配上表,补齐新增领域的规范并检查实际实现,再处理本次引入的职责越界、重复规则、冗余状态与无依据分支;删除或替换功能时一并清理失效的配置、类型、调用、测试与文档,已部署数据和公开合同仍遵守所属迁移及兼容规则;仅审查时报告,不自行修复
|
||||
3. 按最终行为执行[文档检查](references/documentation.md),核对所属正文及相关概述、示例和引用,在本任务内完成必要同步;不以尚无文档差异跳过检查。内部重构等未改变文档所述事实时无需改文档;常规文档只写当前状态,普通开发不自动修改 `release/notes.md`
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,后端命令见[验证命令](../../../docs/architecture.md#验证命令);复用仍适用的验证证据与用户已确定的验收分工,区分通过、失败、跳过与未执行
|
||||
5. 交付前按[首要写作要求](#首要写作要求言简意赅)通读本次新增或修改的文字。交代实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
6. 按授权完成提交与交付;提交前按[提交约定](../../../CONTRIBUTING.md#项目约定)准备提交信息与实际共同作者署名,提交后核对实际提交消息,PR 的 AI 披露不能替代提交署名
|
||||
|
||||
## 测试维护
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 领域必读
|
||||
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节;只改文案或局部样式也执行。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
确定页面方案或审查结论前,读取[前端职责](../../../../docs/architecture.md#34-前端模块职责)、[文案与信息层级](../../../../docs/theme.md#界面文案与信息层级)和[界面验证](../../../../CONTRIBUTING.md#界面验证)的完整章节,局部修改同样读取。明确本次用户操作、原页面、最接近的同类实现及可复用基础组件,沿用既有设计与请求层,按本轮验收分工留存所需截图
|
||||
|
||||
## 按改动补齐规范
|
||||
|
||||
@@ -21,9 +21,15 @@
|
||||
|
||||
普通维护和新增业务页面沿用现有设计;设计技能中的配色、字体、布局探索不覆盖项目规范,主题算法只在涉及时读取
|
||||
|
||||
## 验收范围与收尾
|
||||
|
||||
验证前按[界面验证](../../../../CONTRIBUTING.md#界面验证)明确本次受影响的用户路径、状态和通过条件。局部修改围绕受影响区域及相关操作验收;主题、窗口尺寸和空/加载/错误状态仅在受影响时补充,共用主题或基础组件变更按主题规范扩大范围
|
||||
|
||||
通过后按[验证停止条件](../../../../CONTRIBUTING.md#验证)收尾;只有新改动、失败或未解疑点时补验相关部分。补交截图或 PR 材料时复用仍有效的运行证据
|
||||
|
||||
## 交付前
|
||||
|
||||
对照最终差异和实际页面逐项核对,不以读取过规范代替检查:
|
||||
对照最终差异和实际页面核对适用项,不以读取过规范代替检查:
|
||||
|
||||
- **复用与范围**:是否沿用参考页面和基础组件,已有组件能承载时不手写替代控件;共用缺陷按职责修复,修复后清理本次已失效的页面补丁,不顺带重排无关区域或新增重复入口
|
||||
- **文案与层级**:标点是否合规,标题、状态和帮助是否重复,详细说明与当前决策必需提示的位置是否合适
|
||||
|
||||
@@ -18,8 +18,9 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
|
||||
## 必须保持的边界
|
||||
|
||||
- Codex 兼容性变化按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核对已有依据与验证范围,缺少依据列为发行风险,不以 CI 通过代替
|
||||
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
|
||||
- `release/publish --check <version>` 只读检查发行准备条件;不带 `--check` 会提交、打 tag、推送并触发工作流,实际发布仍需相应授权
|
||||
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
|
||||
- 备版只更新源码的构建版本;通道快照是版本 Release 的 `version.yaml` 附件,验收时核对固定预发行页 `update-index` 指向的公开快照
|
||||
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
|
||||
- `release/notes.md` 只承载当前发行的说明;常规文档修改另按开发入口执行[文档检查](../cpr-dev-guide/references/documentation.md)
|
||||
- 交付版本、提交、Release/工作流链接与实际结果,未完成构建或缺失产物时不称发布成功
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
应用内更新仅允许同一轮实验,规则见[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则)。
|
||||
发版前核对上一实验版是否已包含该更新器;仍禁用在线更新的旧构建需要先手动升级。
|
||||
- GitHub Release 标为 Pre-release,且不设为 Latest;Docker 发布版本标签,不覆盖稳定版 `latest`。
|
||||
正式版和 alpha、beta、rc 均不接收实验版更新;环境变量不能改变发行线。
|
||||
发布任务应将该基线的最高公开实验版写入版本 Release 通道快照的 `published[大版本].exp[基线]`,不推进普通通道。
|
||||
- 复用正式版平台清单、质量检查、完整安装包与多架构镜像,以及部署文件、校验和和现有产物证明。
|
||||
对照本次清单确认平台,不以“实验版”为由仅发布单架构镜像。
|
||||
- 部署说明交代与稳定实例并存的目录、配置、端口和数据库要求;需要 Compose 隔离配置时,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
|
||||
先编辑 `release/notes.md`,保留为未提交改动(已暂存或未暂存均可);其他已跟踪文件的改动应先单独提交。
|
||||
脚本会更新 `release/version.yaml`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
脚本只改 `release/version.yaml` 的顶层 `version`,按[接口弃用规则](../../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#接口弃用)校验并维护 `plugin-api-deprecations.json`,将这些差异与最终工作区中的发布说明一起提交为 `chore(release): vX.Y.Z`。
|
||||
不要提前单独提交说明或把版本文件改成目标版本;脚本要求说明相对 HEAD 有改动,并自行更新版本号。
|
||||
本任务需要的提交按[项目约定](../../../../CONTRIBUTING.md#项目约定)保留英文 Conventional Commits 与相应署名。
|
||||
|
||||
@@ -31,7 +31,7 @@ release/publish "$release_version"
|
||||
```
|
||||
|
||||
该命令会创建上述版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
|
||||
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
只读预检使用 `release/publish --check "$release_version"`。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
|
||||
|
||||
只调整已发布页面的标题或正文时,编辑对应 GitHub Release 的 `name` 或 `body` 并回读核对;
|
||||
保持已有 tag、源码提交、附件和预发行状态,不重新执行发布入口。
|
||||
@@ -43,7 +43,10 @@ release/publish "$release_version"
|
||||
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
|
||||
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
|
||||
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
|
||||
- 构建与打包中间产物保留 7 天,在有效期内可重跑失败任务;产物过期后重跑完整工作流,不移动原 tag。
|
||||
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
|
||||
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
|
||||
- 回读固定预发行页 `update-index`(不是 GitHub Latest),按正文 JSON 的 `current` 下载对应 `v<current>` 的 `version.yaml`。核对快照版本及 `published`:正式版参与同一大版本四个普通通道的比较;RC 参与 RC、Beta、Alpha,Beta 参与 Beta、Alpha,Alpha 只参与自身。实验版只推进 `exp["X.Y.Z"]`。晚完成的发行不得回退较高候选或丢失其他发行线
|
||||
- 只有指针写入失败时,在既有授权内执行 `gh workflow run release.yml --ref main -f tag="v$release_version" -f index_only=true`。恢复任务与发布共用锁,只核对已公开版本的附件和待发布快照摘要,不重新打包、改版本 tag 或写 main。完成后回读指针与附件;待发布记录不能当作成功。索引附件最多 128 KiB
|
||||
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
|
||||
Release 和镜像已生成也不表示用户的运行实例已经升级。
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 基线与版本
|
||||
|
||||
1. 确认类型、分支、upstream、目标仓库、工作区与完整待发布范围,保留无关改动;未跟踪文件也需核对
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
2. 读取 [version.yaml](../../../../release/version.yaml)、相关 tag 和实际 Release,区分源码构建版本和 `update-index` 指向的发行附件中的 `published` 通道表,结合目标通道确定上一发布基线与本次目标提交,再核对二者间的最终 diff,不只看最近几条提交
|
||||
3. 使用用户确定的目标版本,建议版本时说明兼容性、能力或修复依据;同名 tag 先区分重试与新发行,不递增版本掩盖冲突
|
||||
4. 命名与晋级按[版本命名与升级规则](../../../../deploy/README.md#版本命名与升级规则),实验版另读[实验流程](experimental.md#分支与版本)
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
|
||||
进入实际发行准备后,读取目标分支的 [release/publish](../../../../release/publish)、[工作流](../../../../.github/workflows/release.yml)与[平台清单](../../../../release/platforms.yaml),按当前入口执行,不另写脚本或绕过检查
|
||||
|
||||
发布说明准备好、其他代码已提交后,执行 `release/publish --check <version>`,检查版本、说明、工作区、远端分支与 tag、工具链和插件兼容窗口。
|
||||
预检不修改文件、Git 引用或远端状态;远端存在本地未知提交时先同步再检查。它不替代 CI、发行物验证或实际发布授权
|
||||
|
||||
按[验证要求](../../../../CONTRIBUTING.md#验证)核对目标提交的证据,区分通过、失败、跳过与未执行
|
||||
数据库变更同时读取[迁移冻结规则](../../../../backend/migrations/README.md#冻结规则):
|
||||
|
||||
|
||||
@@ -139,7 +139,6 @@ body:
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -108,7 +108,6 @@ body:
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 预期行为
|
||||
description: 说明原本期望得到的结果。
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Build binary
|
||||
description: Build a native release binary with shared dependency caching and timings
|
||||
inputs:
|
||||
rust:
|
||||
description: Pinned Rust toolchain
|
||||
required: true
|
||||
target:
|
||||
description: Rust target triple
|
||||
required: true
|
||||
platform:
|
||||
description: Release platform name, such as linux-amd64
|
||||
required: true
|
||||
binary:
|
||||
description: Executable filename
|
||||
default: codex-proxy-rs
|
||||
rust-image:
|
||||
description: Pinned Linux builder image; empty for native builds
|
||||
default: ''
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ inputs.rust }}
|
||||
targets: ${{ inputs.target }}
|
||||
|
||||
- name: Configure Windows runtime linkage
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
# 发行包静态链接 MSVC 运行库,解压运行时无需安装 VC++ Redistributable
|
||||
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
shared-key: release-${{ inputs.target }}-${{ inputs.rust-image }}
|
||||
cache-bin: false
|
||||
# 只缓存依赖,避免随版本变化的 workspace 产物挤占缓存
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
BUILD_TARGET: ${{ inputs.target }}
|
||||
BUILD_PLATFORM: ${{ inputs.platform }}
|
||||
BUILD_BINARY: ${{ inputs.binary }}
|
||||
RUST_IMAGE: ${{ inputs.rust-image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$RUST_IMAGE" ]; then
|
||||
mkdir -p "$HOME/.cargo"
|
||||
docker run --rm --user "$(id -u):$(id -g)" \
|
||||
-e CARGO_HOME=/cargo -e HOME=/tmp \
|
||||
-e CPR_VERSION -e CPR_GIT_SHA -e CPR_BUILD_TIME -e CPR_BUILD_TYPE \
|
||||
-v "$HOME/.cargo:/cargo" -v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend "$RUST_IMAGE" \
|
||||
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
|
||||
else
|
||||
cargo build --release --locked --timings --target "$BUILD_TARGET" --bin codex-proxy-rs
|
||||
fi
|
||||
"target/$BUILD_TARGET/release/$BUILD_BINARY" --help
|
||||
"target/$BUILD_TARGET/release/$BUILD_BINARY" --version
|
||||
mkdir -p "../dist/binaries/$BUILD_PLATFORM"
|
||||
cp "target/$BUILD_TARGET/release/$BUILD_BINARY" "../dist/binaries/$BUILD_PLATFORM/$BUILD_BINARY"
|
||||
|
||||
- name: Upload build timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: build-timings-${{ inputs.platform }}
|
||||
path: backend/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -43,4 +43,4 @@ runs:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# syntax=docker/dockerfile:1.7@sha256:a57df69d0ea827fb7266491f2813635de6f17269be881f696fbfdf2d83dda33e
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS restore
|
||||
ARG CACHE_STAMP
|
||||
RUN --mount=type=bind,target=/cache \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& tar -xzf /cache/registry.tar.gz -C /usr/local/cargo/registry \
|
||||
&& tar -xzf /cache/git.tar.gz -C /usr/local/cargo/git \
|
||||
&& tar -xzf /cache/target.tar.gz -C /app/backend/target
|
||||
|
||||
FROM busybox:1.37.0-musl@sha256:5cec3fc171c87218698e85a52af7087de727372aae264a787b8112901a5b0092 AS snapshot
|
||||
ARG CACHE_STAMP
|
||||
# 直接压缩 mount 内容,构建层和 runner 只保存归档,避免多份展开的编译产物占满磁盘
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/app/backend/target \
|
||||
test -n "$CACHE_STAMP" \
|
||||
&& mkdir -p /cache \
|
||||
&& tar -czf /cache/registry.tar.gz -C /usr/local/cargo/registry . \
|
||||
&& tar -czf /cache/git.tar.gz -C /usr/local/cargo/git . \
|
||||
&& tar -czf /cache/target.tar.gz -C /app/backend/target .
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Cargo cache mounts
|
||||
description: Restore or export a validated Cargo snapshot for a BuildKit builder
|
||||
inputs:
|
||||
operation:
|
||||
description: Cache operation, restore or snapshot
|
||||
required: true
|
||||
builder:
|
||||
description: Buildx builder name
|
||||
required: true
|
||||
path:
|
||||
description: Cargo snapshot directory on the runner
|
||||
required: true
|
||||
architecture:
|
||||
description: Cargo target directory architecture, amd64 or arm64
|
||||
required: true
|
||||
outputs:
|
||||
usable:
|
||||
description: Whether the restored or exported snapshot contains Cargo dependencies
|
||||
value: ${{ steps.cache.outputs.usable }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: cache
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
OPERATION: ${{ inputs.operation }}
|
||||
BUILDER: ${{ inputs.builder }}
|
||||
CACHE_PATH: ${{ inputs.path }}
|
||||
ARCHITECTURE: ${{ inputs.architecture }}
|
||||
run: bash "$ACTION_PATH/cargo-cache.sh" "$OPERATION" "$BUILDER" "$CACHE_PATH" "$ARCHITECTURE"
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "usage: $0 restore|snapshot BUILDER DIRECTORY amd64|arm64" >&2
|
||||
exit 2
|
||||
fi
|
||||
operation="$1"
|
||||
builder="$2"
|
||||
directory="$(realpath -m "$3")"
|
||||
architecture="$4"
|
||||
case "$operation" in restore|snapshot) ;; *) exit 2 ;; esac
|
||||
case "$architecture" in amd64|arm64) ;; *) exit 2 ;; esac
|
||||
dockerfile="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/Dockerfile"
|
||||
|
||||
usable() {
|
||||
# 流式检查归档中的非空依赖文件,校验时不在 runner 展开编译产物
|
||||
python3 - "$1" "$architecture" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
architecture = sys.argv[2]
|
||||
|
||||
def contains(filename, pattern):
|
||||
with tarfile.open(root / filename, mode="r|gz") as archive:
|
||||
return any(
|
||||
member.isfile() and member.size > 0
|
||||
and re.fullmatch(pattern, member.name.removeprefix("./"))
|
||||
for member in archive
|
||||
)
|
||||
|
||||
try:
|
||||
valid = (
|
||||
(root / "git.tar.gz").stat().st_size > 0
|
||||
and contains("registry.tar.gz", r"cache/[^/]+/[^/]+\.crate")
|
||||
and contains("target.tar.gz", rf"{architecture}/release/deps/[^/]+\.rlib")
|
||||
)
|
||||
except (OSError, tarfile.TarError):
|
||||
valid = False
|
||||
sys.exit(0 if valid else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
command=(docker buildx build --builder "$builder" --file "$dockerfile" --progress plain)
|
||||
# mount 的内容不参与层指纹,用时间戳重跑归档操作;--no-cache 会重置 cache mount
|
||||
command+=(--build-arg "CACHE_STAMP=$(date +%s%N)" --target "$operation")
|
||||
valid=false
|
||||
if [ "$operation" = restore ]; then
|
||||
if usable "$directory"; then
|
||||
"${command[@]}" --output type=cacheonly "$directory"
|
||||
valid=true
|
||||
else
|
||||
echo "No usable Cargo snapshot; cached image layers may still be available"
|
||||
fi
|
||||
else
|
||||
# 导出只发送空上下文,新快照独立校验后替换旧目录,避免残留文件造成误判。
|
||||
mkdir -p "$(dirname "$directory")"
|
||||
temporary="$(mktemp -d "${directory}.XXXXXX")"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
mkdir "$temporary"/{context,snapshot}
|
||||
# tar 流避开 local exporter 的逐文件同步阻塞,只接收 /cache 下的压缩归档
|
||||
# 直接导出打包阶段,避免再创建一层归档副本
|
||||
# https://github.com/moby/buildkit/issues/2950
|
||||
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
|
||||
| tar -xf - -C "$temporary/snapshot" --strip-components=1 cache/
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
valid=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Cargo snapshot for $architecture: usable=$valid"
|
||||
if [ "$valid" = true ]; then du -sh "$directory"; fi
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then printf 'usable=%s\n' "$valid" >> "$GITHUB_OUTPUT"; fi
|
||||
@@ -70,7 +70,6 @@ updates:
|
||||
- package-ecosystem: docker
|
||||
directories:
|
||||
- /deploy
|
||||
- /.github/actions/cargo-cache
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require 'digest'
|
||||
require 'fileutils'
|
||||
require 'json'
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 归档与镜像消费相同二进制和封口清单,不在打包时重新解释插件分发策略
|
||||
module ReleasePackage
|
||||
APP_NAME = 'codex-proxy-rs'
|
||||
APP_NAME = ReleaseMetadata::APP_NAME
|
||||
MANIFEST = 'dist/plugin-release-assets/plugin-release-manifest.json'
|
||||
|
||||
def self.install_manifest(directory)
|
||||
@@ -47,7 +47,7 @@ module ReleasePackage
|
||||
FileUtils.cp_r('dist/frontend/.', "#{root}/web/dist")
|
||||
FileUtils.cp(config, "#{root}/deploy")
|
||||
install_manifest("#{root}/plugins/official")
|
||||
archive = "#{release_dir}/#{APP_NAME}_#{ENV.fetch('VERSION')}_#{platform['os']}_#{platform['arch']}.tar.gz"
|
||||
archive = File.join(release_dir, ReleaseMetadata.archive_name(ENV.fetch('VERSION'), platform))
|
||||
raise "Failed to create #{archive}" unless system('tar', '-czf', archive, '-C', File.dirname(root), APP_NAME)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
require 'base64'
|
||||
require 'digest'
|
||||
require 'uri'
|
||||
require_relative 'release-metadata'
|
||||
|
||||
# 通道快照随版本封存,固定 Release 只记录已公开快照和可恢复的待发布摘要
|
||||
module PublishedChannels
|
||||
POINTER = 'update-index'
|
||||
LIMIT = 128 * 1024
|
||||
|
||||
def self.advance(document, version)
|
||||
version = ReleaseMetadata.version(version)
|
||||
major, = version.split('.', 2)
|
||||
core, stage = version.split('+', 2).first.split('-', 2)
|
||||
channels = (document.fetch('published')[major] ||= {})
|
||||
if stage&.start_with?('exp.')
|
||||
channels = (channels['exp'] ||= {})
|
||||
keys = [core]
|
||||
else
|
||||
stages = %w[stable rc beta alpha]
|
||||
keys = stages.drop(stages.index(stage ? stage.split('.').first : 'stable'))
|
||||
end
|
||||
incoming = ReleaseMetadata.sort_key(version)
|
||||
changed = false
|
||||
keys.each do |key|
|
||||
current = channels[key]
|
||||
if current
|
||||
comparison = incoming <=> ReleaseMetadata.sort_key(current)
|
||||
raise "Conflicting release identities: #{current}, #{version}" if comparison.zero? && current != version
|
||||
next unless comparison.positive?
|
||||
end
|
||||
channels[key] = version
|
||||
changed = true
|
||||
end
|
||||
changed
|
||||
end
|
||||
|
||||
class Publisher
|
||||
def api(endpoint, method: 'GET', data: nil, optional: false)
|
||||
args = ['gh', 'api', endpoint, '--method', method]
|
||||
args.concat(%w[--input -]) if data
|
||||
out, error, status = Open3.capture3(*args, stdin_data: data ? JSON.generate(data) : '')
|
||||
return nil if optional && !status.success? && error.include?('(HTTP 404)')
|
||||
raise "Cannot access #{endpoint}: #{error.strip}" unless status.success?
|
||||
JSON.parse(out)
|
||||
end
|
||||
|
||||
def initialize(repository)
|
||||
@repository = repository
|
||||
@base = "repos/#{repository}"
|
||||
release = api("#{@base}/releases/tags/#{POINTER}", optional: true)
|
||||
if release
|
||||
raise 'Invalid update-index release' unless release.fetch('tag_name') == POINTER &&
|
||||
release.fetch('prerelease') && !release.fetch('draft')
|
||||
@id = release.fetch('id')
|
||||
@state = JSON.parse(release.fetch('body'))
|
||||
@state.fetch('current')
|
||||
else
|
||||
@state = { 'current' => nil }
|
||||
end
|
||||
end
|
||||
|
||||
def save(current:, pending: nil)
|
||||
state = { 'current' => current }
|
||||
state['pending'] = pending if pending
|
||||
body = { body: JSON.generate(state), make_latest: 'false' }
|
||||
if @id
|
||||
api("#{@base}/releases/#{@id}", method: 'PATCH', data: body)
|
||||
else
|
||||
release = api("#{@base}/releases", method: 'POST', data: body.merge(
|
||||
tag_name: POINTER, name: '更新通道索引', target_commitish: 'main', prerelease: true
|
||||
))
|
||||
@id = release.fetch('id')
|
||||
end
|
||||
@state = state
|
||||
end
|
||||
|
||||
def release(version)
|
||||
api("#{@base}/releases/tags/v#{ReleaseMetadata.version(version)}", optional: true)
|
||||
end
|
||||
|
||||
def verify_assets(version, release, index:)
|
||||
tag = "v#{version}"
|
||||
raise "Release is not public or tag differs: #{tag}" unless release && release.fetch('tag_name') == tag &&
|
||||
!release.fetch('draft') && release.fetch('prerelease') == ReleaseMetadata.prerelease?(version)
|
||||
data = api("#{@base}/contents/release/platforms.yaml?ref=#{URI.encode_www_form_component(tag)}")
|
||||
platforms = YAML.safe_load(Base64.decode64(data.fetch('content'))).fetch('platforms')
|
||||
raise "Missing platforms for #{tag}" if platforms.empty?
|
||||
expected = platforms.map { |p| ReleaseMetadata.archive_name(version, p) }
|
||||
expected.concat(%w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json])
|
||||
expected << 'version.yaml' if index
|
||||
assets = release.fetch('assets').select { |a| a.fetch('state') == 'uploaded' && a.fetch('size').positive? }.to_h { |a| [a.fetch('name'), a] }
|
||||
missing = expected - assets.keys
|
||||
raise "Incomplete release #{tag}: #{missing.join(', ')}" unless missing.empty?
|
||||
assets
|
||||
end
|
||||
|
||||
def download(version)
|
||||
content, error, status = Open3.capture3('gh', 'release', 'download', "v#{version}", '--repo', @repository,
|
||||
'--pattern', 'version.yaml', '--output', '-')
|
||||
raise "Cannot download v#{version} snapshot: #{error.strip}" unless status.success?
|
||||
content
|
||||
end
|
||||
|
||||
def read_snapshot(version, asset)
|
||||
raise 'Invalid version.yaml size' unless (1..LIMIT).cover?(asset.fetch('size'))
|
||||
content = download(version)
|
||||
digest = Digest::SHA256.hexdigest(content)
|
||||
raise "Snapshot size or digest differs for v#{version}" unless content.bytesize == asset.fetch('size') && asset.fetch('digest') == "sha256:#{digest}"
|
||||
doc = YAML.safe_load(content)
|
||||
raise 'Invalid channel snapshot' unless doc.fetch('version') == version && doc.fetch('published').is_a?(Hash)
|
||||
[doc, digest]
|
||||
end
|
||||
|
||||
def previous
|
||||
if (current = @state.fetch('current'))
|
||||
assets = verify_assets(current, release(current), index: true)
|
||||
return read_snapshot(current, assets.fetch('version.yaml')).first
|
||||
end
|
||||
# 首次接入时从实际公开的稳定版引导;没有历史 Release 的仓库从空表开始
|
||||
doc = { 'published' => {} }
|
||||
latest = api("#{@base}/releases/latest", optional: true)
|
||||
if latest
|
||||
version = ReleaseMetadata.version(latest.fetch('tag_name'))
|
||||
verify_assets(version, latest, index: false)
|
||||
PublishedChannels.advance(doc, version)
|
||||
end
|
||||
doc
|
||||
end
|
||||
|
||||
# 调用方持有同一仓库的发布锁;快照必须在取锁后生成,不能在并行构建中预先合并
|
||||
def prepare(version, directory)
|
||||
if (pending = @state.dig('pending', 'version')) && (old = release(pending)) && !old.fetch('draft')
|
||||
finish(pending)
|
||||
end
|
||||
existing = release(version)
|
||||
raise "Release v#{version} already public; use index-only recovery" if existing && !existing.fetch('draft')
|
||||
doc = previous
|
||||
doc['version'] = version
|
||||
PublishedChannels.advance(doc, version)
|
||||
content = YAML.dump(doc)
|
||||
raise 'Published index exceeds the client size limit' if content.bytesize > LIMIT
|
||||
digest = Digest::SHA256.hexdigest(content)
|
||||
File.write(File.join(directory, 'version.yaml'), content)
|
||||
File.open(File.join(directory, 'checksums.txt'), 'a') { |file| file.puts "#{digest} version.yaml" }
|
||||
save(current: @state.fetch('current'), pending: { 'version' => version, 'sha256' => digest })
|
||||
end
|
||||
|
||||
def finish(version)
|
||||
assets = verify_assets(version, release(version), index: true)
|
||||
_, digest = read_snapshot(version, assets.fetch('version.yaml'))
|
||||
return if @state.fetch('current') == version
|
||||
unless @state['pending'] == { 'version' => version, 'sha256' => digest }
|
||||
raise "v#{version} is not the reserved snapshot; refusing to overwrite another publication"
|
||||
end
|
||||
save(current: version)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
mode, raw = ARGV
|
||||
version = ReleaseMetadata.version(raw || '')
|
||||
publisher = PublishedChannels::Publisher.new(ENV.fetch('GITHUB_REPOSITORY'))
|
||||
case mode
|
||||
when 'prepare' then publisher.prepare(version, 'dist/release')
|
||||
when 'finish' then publisher.finish(version)
|
||||
else abort 'usage: published-channels.rb prepare|finish VERSION'
|
||||
end
|
||||
end
|
||||
@@ -62,6 +62,10 @@ module ReleaseMetadata
|
||||
entries
|
||||
end
|
||||
|
||||
def self.archive_name(version, platform)
|
||||
"#{APP_NAME}_#{version}_#{platform.fetch('os')}_#{platform.fetch('arch')}.#{platform.fetch('archive')}"
|
||||
end
|
||||
|
||||
def self.outputs(tag)
|
||||
value = version(YAML.load_file('release/version.yaml').fetch('version'))
|
||||
raise "Release tag #{tag} does not match v#{value}" unless tag == "v#{value}"
|
||||
@@ -82,6 +86,7 @@ module ReleaseMetadata
|
||||
'platform_matrix' => JSON.generate('include' => entries),
|
||||
'docker_matrix' => JSON.generate('include' => docker),
|
||||
'non_docker_matrix' => JSON.generate('include' => native),
|
||||
'windows_matrix' => JSON.generate('include' => entries.select { |entry| entry['os'] == 'windows' }),
|
||||
'docker_platforms' => docker.map { |entry| entry['dockerPlatform'] }.join(','),
|
||||
'previous_tag' => previous,
|
||||
'prerelease' => prerelease?(value),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'json'
|
||||
require 'open3'
|
||||
|
||||
# 仅复用同一源码的主分支 CI,缺少证据时完整检查发行提交
|
||||
module ReleaseQuality
|
||||
def self.capture(*command)
|
||||
output, error, status = Open3.capture3(*command)
|
||||
raise "#{command.first} failed: #{error.strip}" unless status.success?
|
||||
output
|
||||
end
|
||||
|
||||
def self.evidence(repository)
|
||||
parents = capture('git', 'rev-list', '--parents', '-n', '1', 'HEAD').split
|
||||
return nil unless parents.length == 2
|
||||
parent = parents.last
|
||||
changes = capture('git', 'diff', '--name-only', '-z', parent, 'HEAD').split("\0")
|
||||
return nil if changes.empty? || (changes - %w[release/version.yaml release/notes.md]).any?
|
||||
|
||||
# 按最新一次运行判断,失败或仍在执行时不回退到旧的成功记录
|
||||
endpoint = "repos/#{repository}/actions/workflows/ci.yml/runs?event=push&branch=main&head_sha=#{parent}&per_page=1"
|
||||
run = JSON.parse(capture('gh', 'api', endpoint)).fetch('workflow_runs').first
|
||||
return nil unless run && run.fetch('head_sha') == parent && run.fetch('conclusion') == 'success'
|
||||
return nil unless run.fetch('head_repository').fetch('full_name') == repository
|
||||
|
||||
rows = capture('gh', 'api', '--paginate', "repos/#{repository}/actions/runs/#{run.fetch('id')}/jobs?per_page=100", '--jq', '.jobs[] | @json')
|
||||
job = rows.lines.map { |line| JSON.parse(line) }.find { |entry| entry.fetch('name') == 'quality / backend' }
|
||||
return nil unless job && job.fetch('conclusion') == 'success'
|
||||
job.fetch('html_url')
|
||||
end
|
||||
end
|
||||
|
||||
if $PROGRAM_NAME == __FILE__
|
||||
begin
|
||||
evidence = ReleaseQuality.evidence(ENV.fetch('GITHUB_REPOSITORY'))
|
||||
rescue StandardError => error
|
||||
warn "CI evidence unavailable; running full release checks: #{error.message}"
|
||||
evidence = nil
|
||||
end
|
||||
puts "run_backend=#{evidence.nil?}"
|
||||
if ENV['GITHUB_STEP_SUMMARY']
|
||||
message = evidence ? "后端质量检查复用版本提交父提交的 [CI 结果](#{evidence});仅版本与发布说明不同" : '未找到可复用的后端 CI 结果,完整检查发行提交'
|
||||
File.open(ENV.fetch('GITHUB_STEP_SUMMARY'), 'a') { |file| file.puts(message) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,115 @@
|
||||
require 'tmpdir'
|
||||
require_relative '../published-channels'
|
||||
|
||||
# 测试只替换 GitHub 读写边界,执行真实的发布状态转换
|
||||
class PublishedChannelsTest
|
||||
def assert_equal(expected, actual)
|
||||
raise "expected #{expected.inspect}, got #{actual.inspect}" unless expected == actual
|
||||
end
|
||||
|
||||
def assert_rejected
|
||||
yield
|
||||
rescue RuntimeError
|
||||
return
|
||||
else
|
||||
raise 'expected publication to be rejected'
|
||||
end
|
||||
|
||||
def initialize
|
||||
@pointer = nil
|
||||
@releases = {}
|
||||
@files = {}
|
||||
@calls = []
|
||||
@platforms = [{ 'os' => 'linux', 'arch' => 'amd64', 'archive' => 'tar.gz' }]
|
||||
end
|
||||
|
||||
def api(endpoint, method: 'GET', data: nil, optional: false)
|
||||
path = endpoint.delete_prefix('repos/test/repo/')
|
||||
@calls << [method, path]
|
||||
case [method, path]
|
||||
when ['GET', 'releases/tags/update-index'] then @pointer
|
||||
when ['POST', 'releases']
|
||||
raise 'duplicate pointer' if @pointer
|
||||
@pointer = data.transform_keys(&:to_s).merge('id' => 12, 'draft' => false)
|
||||
when ['PATCH', 'releases/12']
|
||||
@pointer.merge!(data.transform_keys(&:to_s))
|
||||
when ['GET', 'releases/latest'] then @releases['3.41.2']
|
||||
else
|
||||
return @releases[path.delete_prefix('releases/tags/v')] if method == 'GET' && path.start_with?('releases/tags/v')
|
||||
return { 'content' => Base64.encode64(YAML.dump({ 'platforms' => @platforms })) } if path.start_with?('contents/release/platforms.yaml?ref=v')
|
||||
raise "unexpected #{method} #{path}"
|
||||
end
|
||||
end
|
||||
|
||||
def publish_assets(version, content = nil)
|
||||
names = %w[checksums.txt compose.yaml config.example.yaml plugin-release-manifest.json]
|
||||
names << ReleaseMetadata.archive_name(version, @platforms.first)
|
||||
@files[version] = content if content
|
||||
assets = names.map { |name| { 'name' => name, 'size' => 100, 'state' => 'uploaded' } }
|
||||
assets << { 'name' => 'version.yaml', 'size' => content.bytesize, 'digest' => "sha256:#{Digest::SHA256.hexdigest(content)}", 'state' => 'uploaded' } if content
|
||||
@releases[version] = { 'tag_name' => "v#{version}", 'draft' => false, 'prerelease' => ReleaseMetadata.prerelease?(version), 'assets' => assets }
|
||||
end
|
||||
|
||||
def publisher
|
||||
github = self
|
||||
files = @files
|
||||
Class.new(PublishedChannels::Publisher) do
|
||||
define_method(:api) { |endpoint, **args| github.api(endpoint, **args) }
|
||||
define_method(:download) { |version| files.fetch(version) }
|
||||
end.new('test/repo')
|
||||
end
|
||||
|
||||
def prepare(version)
|
||||
Dir.mktmpdir do |dir|
|
||||
File.write(File.join(dir, 'checksums.txt'), '')
|
||||
publisher.prepare(version, dir)
|
||||
content = File.read(File.join(dir, 'version.yaml'))
|
||||
assert_equal "#{Digest::SHA256.hexdigest(content)} version.yaml\n", File.read(File.join(dir, 'checksums.txt'))
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def current = JSON.parse(@pointer.fetch('body')).fetch('current')
|
||||
|
||||
def test_interrupted_publication_recovers_without_losing_another_channel
|
||||
publish_assets('3.41.2')
|
||||
stable = prepare('3.42.0')
|
||||
assert_equal nil, current
|
||||
assert_rejected { publisher.finish('3.42.0') }
|
||||
publish_assets('3.42.0', stable)
|
||||
# 模拟公开后更新指针中断,下一次发布在同一把锁内补写,再合并自己的通道
|
||||
beta = prepare('3.43.0-beta.1')
|
||||
assert_equal '3.42.0', current
|
||||
assert_equal({ 'stable' => '3.42.0', 'rc' => '3.42.0', 'beta' => '3.43.0-beta.1', 'alpha' => '3.43.0-beta.1' },
|
||||
YAML.safe_load(beta).dig('published', '3'))
|
||||
publish_assets('3.43.0-beta.1', beta)
|
||||
publisher.finish('3.43.0-beta.1')
|
||||
publisher.finish('3.43.0-beta.1')
|
||||
assert_rejected { publisher.finish('3.42.0') }
|
||||
assert_equal '3.43.0-beta.1', current
|
||||
experiment = YAML.safe_load(prepare('3.44.0-exp.1')).dig('published', '3')
|
||||
assert_equal({ '3.44.0' => '3.44.0-exp.1' }, experiment.fetch('exp'))
|
||||
assert_equal '3.43.0-beta.1', experiment.fetch('beta')
|
||||
assert_equal false, @calls.include?(['GET', 'releases'])
|
||||
end
|
||||
|
||||
def test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
|
||||
publish_assets('3.42.0', prepare('3.42.0'))
|
||||
publisher.finish('3.42.0')
|
||||
beta = prepare('3.43.0-beta.1')
|
||||
publish_assets('3.43.0-beta.1', beta)
|
||||
release = @releases.fetch('3.43.0-beta.1')
|
||||
release['draft'] = true
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
release['draft'] = false
|
||||
missing = release.fetch('assets').shift
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
release.fetch('assets') << missing
|
||||
publish_assets('3.43.0-beta.1', beta.sub('3.42.0', '3.10.0'))
|
||||
assert_rejected { publisher.finish('3.43.0-beta.1') }
|
||||
assert_equal '3.42.0', current
|
||||
end
|
||||
end
|
||||
PublishedChannelsTest.new.test_interrupted_publication_recovers_without_losing_another_channel
|
||||
PublishedChannelsTest.new.test_incomplete_corrupt_or_draft_release_keeps_last_public_snapshot
|
||||
puts 'Published channel recovery and integrity checks passed'
|
||||
@@ -7,13 +7,58 @@ on:
|
||||
description: Exact commit SHA to build and validate
|
||||
required: true
|
||||
type: string
|
||||
source_build:
|
||||
description: Validate the complete Docker source build
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
if: ${{ !inputs.source_build }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- uses: ./.github/actions/build-frontend
|
||||
with:
|
||||
upload-artifact: 'true'
|
||||
|
||||
binary:
|
||||
if: ${{ !inputs.source_build }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
- uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ steps.toolchains.outputs.rust }}
|
||||
target: x86_64-unknown-linux-gnu
|
||||
platform: linux-amd64
|
||||
rust-image: ${{ steps.toolchains.outputs.rust_image }}
|
||||
env:
|
||||
CPR_GIT_SHA: ${{ inputs.ref }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-linux-amd64
|
||||
path: dist/binaries/linux-amd64/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
container:
|
||||
needs: [frontend, binary]
|
||||
if: always() && !cancelled()
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -21,6 +66,14 @@ jobs:
|
||||
TRIVY_QUIET: 'true'
|
||||
TRIVY_SKIP_VERSION_CHECK: 'true'
|
||||
steps:
|
||||
# 上游失败必须传递给已有的容器检查,不能让必需状态变成 skipped
|
||||
- name: Require prebuilt inputs
|
||||
if: ${{ !inputs.source_build }}
|
||||
env:
|
||||
FRONTEND_RESULT: ${{ needs.frontend.result }}
|
||||
BINARY_RESULT: ${{ needs.binary.result }}
|
||||
run: test "$FRONTEND_RESULT" = success && test "$BINARY_RESULT" = success
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -30,41 +83,51 @@ jobs:
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Restore Cargo snapshot
|
||||
id: cargo-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
- name: Download frontend
|
||||
if: ${{ !inputs.source_build }}
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
# 同一平台和依赖复用快照,源码变化由 Cargo 指纹判定是否重编译
|
||||
key: container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml', 'deploy/Dockerfile', '.github/actions/cargo-cache/Dockerfile') }}
|
||||
restore-keys: |
|
||||
container-cargo-v4-${{ runner.os }}-${{ runner.arch }}-
|
||||
name: frontend-dist
|
||||
path: dist/docker/web/dist
|
||||
|
||||
- name: Restore Cargo mounts to BuildKit
|
||||
id: cargo-restore
|
||||
uses: ./.github/actions/cargo-cache
|
||||
- name: Download binary
|
||||
if: ${{ !inputs.source_build }}
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
operation: restore
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
name: codex-proxy-rs-linux-amd64
|
||||
path: dist/docker-artifacts/codex-proxy-rs-linux-amd64
|
||||
|
||||
- name: Release restored Cargo snapshot
|
||||
run: rm -rf "$RUNNER_TEMP/container-cargo"
|
||||
- name: Prepare prebuilt image
|
||||
if: ${{ !inputs.source_build }}
|
||||
env:
|
||||
MATRIX_OS: linux
|
||||
MATRIX_ARCH: amd64
|
||||
MATRIX_BINARY: codex-proxy-rs
|
||||
run: |
|
||||
ruby -r './.github/scripts/release-metadata' -e '
|
||||
ReleaseMetadata.seal_manifest(
|
||||
"version" => ReleaseMetadata.version(YAML.load_file("release/version.yaml").fetch("version")),
|
||||
"git_sha" => ReleaseMetadata.git("rev-parse", "HEAD")
|
||||
)'
|
||||
ruby .github/scripts/package-release.rb image
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
target: runtime
|
||||
target: ${{ inputs.source_build && 'runtime' || 'runtime-prebuilt' }}
|
||||
build-args: |
|
||||
CPR_PREBUILT_DIR=dist/docker
|
||||
CPR_GIT_SHA=${{ inputs.ref }}
|
||||
load: true
|
||||
tags: ${{ env.CPR_IMAGE }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'type=gha,mode=max,scope=runtime-ci' || '' }}
|
||||
runtime-prebuilt
|
||||
cache-from: type=gha,scope=runtime-${{ inputs.source_build && 'source' || 'prebuilt' }}
|
||||
cache-to: ${{ (github.event_name == 'push' || github.event_name == 'schedule') && github.ref == 'refs/heads/main' && format('type=gha,mode=max,scope=runtime-{0}', inputs.source_build && 'source' || 'prebuilt') || '' }}
|
||||
|
||||
- name: Verify runtime image
|
||||
uses: ./.github/actions/verify-container
|
||||
@@ -72,23 +135,3 @@ jobs:
|
||||
image: ${{ env.CPR_IMAGE }}
|
||||
platform: linux/amd64
|
||||
artifact-name: container-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-amd64
|
||||
|
||||
# 构建层命中时 mount 仍可能为空,只有实际检查过内容的快照才允许保存。
|
||||
- name: Export Cargo snapshot
|
||||
if: >-
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/main' &&
|
||||
(steps.cargo-cache.outputs.cache-hit != 'true' || steps.cargo-restore.outputs.usable != 'true')
|
||||
id: cargo-snapshot
|
||||
uses: ./.github/actions/cargo-cache
|
||||
with:
|
||||
operation: snapshot
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
architecture: amd64
|
||||
|
||||
- name: Save Cargo snapshot
|
||||
if: steps.cargo-snapshot.outputs.usable == 'true' && steps.cargo-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/container-cargo
|
||||
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_PROFILE_DEV_DEBUG: '1'
|
||||
# 编译 AWS SDK 调试信息需要更大的 codegen 线程栈
|
||||
RUST_MIN_STACK: 16777216
|
||||
CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy
|
||||
@@ -111,7 +112,9 @@ jobs:
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
shared-key: quality
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Rust format
|
||||
working-directory: backend
|
||||
@@ -122,7 +125,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -131,7 +134,7 @@ jobs:
|
||||
working-directory: backend
|
||||
run: |
|
||||
cargo metadata --format-version 1 --locked > "$RUNNER_TEMP/nextest-cargo-metadata.json"
|
||||
cargo nextest list --test main --locked --list-type binaries-only --message-format json \
|
||||
cargo nextest list --test main --locked --timings --list-type binaries-only --message-format json \
|
||||
> "$RUNNER_TEMP/nextest-binaries.json"
|
||||
|
||||
- name: Configure PostgreSQL and Redis test authentication
|
||||
@@ -173,10 +176,22 @@ jobs:
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
cargo nextest run \
|
||||
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
|
||||
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" \
|
||||
--cargo-metadata "$RUNNER_TEMP/nextest-cargo-metadata.json" \
|
||||
--binaries-metadata "$RUNNER_TEMP/nextest-binaries.json" \
|
||||
--test-threads 8 --no-tests fail
|
||||
--test-threads 8 --no-tests fail --profile ci
|
||||
|
||||
- name: Upload test and build reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: backend-quality-reports
|
||||
path: |
|
||||
backend/target/nextest/ci/junit.xml
|
||||
backend/target/cargo-timings/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Store tests with Redis ACL authentication
|
||||
working-directory: backend
|
||||
@@ -243,4 +258,5 @@ jobs:
|
||||
for script in .github/**/*.sh; do bash -n "$script"; done
|
||||
for script in .github/scripts/*.rb; do ruby -c "$script"; done
|
||||
ruby .github/scripts/toolchains.rb
|
||||
ruby .github/scripts/tests/published-channels-test.rb
|
||||
ruby -c backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source_container: ${{ steps.filter.outputs.source_container }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -67,10 +68,30 @@ jobs:
|
||||
- '.dockerignore'
|
||||
- 'release/version.yaml'
|
||||
- '.github/actions/verify-container/**'
|
||||
- '.github/actions/cargo-cache/**'
|
||||
- '.github/actions/build-binary/**'
|
||||
- '.github/actions/build-frontend/**'
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- '.github/scripts/package-release.rb'
|
||||
- '.github/scripts/release-metadata.rb'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
source_container:
|
||||
- 'deploy/Dockerfile'
|
||||
- 'deploy/Dockerfile.dockerignore'
|
||||
- '.dockerignore'
|
||||
- 'backend/**/Cargo.toml'
|
||||
- 'backend/Cargo.lock'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'backend/**/build.rs'
|
||||
- 'backend/.cargo/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/pnpm-lock.yaml'
|
||||
- 'frontend/pnpm-workspace.yaml'
|
||||
- 'frontend/.npmrc'
|
||||
- 'frontend/patches/**'
|
||||
- '.github/workflows/_container.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
quality:
|
||||
needs: changes
|
||||
@@ -78,7 +99,8 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run_backend: ${{ needs.changes.outputs.backend == 'true' }}
|
||||
run_frontend: ${{ needs.changes.outputs.frontend == 'true' }}
|
||||
# 预构建容器复用同一次前端检查产物,源码 Docker 构建仍独立验证
|
||||
run_frontend: ${{ needs.changes.outputs.frontend == 'true' && (needs.changes.outputs.container != 'true' || needs.changes.outputs.source_container == 'true') }}
|
||||
|
||||
container:
|
||||
needs: changes
|
||||
@@ -89,3 +111,4 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
source_build: ${{ needs.changes.outputs.source_container == 'true' }}
|
||||
|
||||
+129
-86
@@ -7,6 +7,10 @@ on:
|
||||
description: 'Tag to release, for example v0.1.0'
|
||||
required: true
|
||||
type: string
|
||||
index_only:
|
||||
description: 'Recover the index for an already public release without rebuilding'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -21,8 +25,12 @@ env:
|
||||
|
||||
jobs:
|
||||
release-info:
|
||||
if: ${{ !inputs.index_only }}
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
@@ -32,12 +40,14 @@ jobs:
|
||||
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
||||
docker_matrix: ${{ steps.version.outputs.docker_matrix }}
|
||||
non_docker_matrix: ${{ steps.version.outputs.non_docker_matrix }}
|
||||
windows_matrix: ${{ steps.version.outputs.windows_matrix }}
|
||||
docker_platforms: ${{ steps.version.outputs.docker_platforms }}
|
||||
previous_tag: ${{ steps.version.outputs.previous_tag }}
|
||||
prerelease: ${{ steps.version.outputs.prerelease }}
|
||||
build_type: ${{ steps.version.outputs.build_type }}
|
||||
rust: ${{ steps.toolchains.outputs.rust }}
|
||||
rust_image: ${{ steps.toolchains.outputs.rust_image }}
|
||||
run_backend: ${{ steps.quality.outputs.run_backend }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -56,21 +66,88 @@ jobs:
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check reusable backend CI evidence
|
||||
id: quality
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: ruby .github/scripts/release-quality.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
quality:
|
||||
needs: [release-info]
|
||||
uses: ./.github/workflows/_quality.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
run_backend: ${{ needs.release-info.outputs.run_backend == 'true' }}
|
||||
run_frontend: false
|
||||
|
||||
windows:
|
||||
needs: release-info
|
||||
name: windows-tests (${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.release-info.outputs.windows_matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
RUSTFLAGS: '-C target-feature=+crt-static'
|
||||
CARGO_PROFILE_DEV_DEBUG: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
shared-key: platform-tests-${{ matrix.target }}
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
- name: Verify Windows platform behavior
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
BUILD_TARGET: ${{ matrix.target }}
|
||||
# 同一次构建统一两个 package 的依赖特性,保留原有平台用例和静态运行库
|
||||
run: |
|
||||
printf '[profile.ci.junit]\npath = "junit.xml"\n' > "$RUNNER_TEMP/nextest-ci.toml"
|
||||
cargo nextest run --config-file "$RUNNER_TEMP/nextest-ci.toml" --locked --target "$BUILD_TARGET" \
|
||||
-p gateway-admin -p gateway-host --test main --no-tests fail --profile ci \
|
||||
-E '(package(=gateway-admin) & test(/^use_case::plugins::official::/)) | (package(=gateway-host) & test(/^system_update::/))'
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: windows-tests-${{ matrix.arch }}
|
||||
path: backend/target/nextest/ci/junit.xml
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
security:
|
||||
needs: release-info
|
||||
uses: ./.github/workflows/security.yml
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-26.04
|
||||
@@ -103,40 +180,15 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
# 按目标、工具链和依赖保存,不为每个发行提交复制整个 target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ needs.release-info.outputs.rust_image }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$HOME/.cargo" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
docker run --rm \
|
||||
--platform "${{ matrix.dockerPlatform }}" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e CARGO_HOME=/cargo \
|
||||
-e HOME=/tmp \
|
||||
-e CPR_VERSION="${CPR_VERSION}" \
|
||||
-e CPR_GIT_SHA="${CPR_GIT_SHA}" \
|
||||
-e CPR_BUILD_TIME="${CPR_BUILD_TIME}" \
|
||||
-e CPR_BUILD_TYPE="${CPR_BUILD_TYPE}" \
|
||||
-v "$HOME/.cargo:/cargo" \
|
||||
-v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend \
|
||||
"${RUST_IMAGE}" \
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ needs.release-info.outputs.rust }}
|
||||
target: ${{ matrix.target }}
|
||||
platform: ${{ matrix.os }}-${{ matrix.arch }}
|
||||
binary: ${{ matrix.binary }}
|
||||
rust-image: ${{ needs.release-info.outputs.rust_image }}
|
||||
env:
|
||||
RUST_IMAGE: ${{ needs.release-info.outputs.rust_image }}
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
@@ -148,7 +200,7 @@ jobs:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
build-binary:
|
||||
name: build-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
@@ -165,61 +217,26 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ needs.release-info.outputs.rust }}
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Configure Windows runtime linkage
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
# 发行包静态链接 MSVC 运行库,解压运行时无需另外安装 VC++ Redistributable
|
||||
run: echo 'RUSTFLAGS=-C target-feature=+crt-static' >> "$GITHUB_ENV"
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-v3-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-v3-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
run: |
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --help
|
||||
"target/${{ matrix.target }}/release/${{ matrix.binary }}" --version
|
||||
mkdir -p "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
uses: ./.github/actions/build-binary
|
||||
with:
|
||||
rust: ${{ needs.release-info.outputs.rust }}
|
||||
target: ${{ matrix.target }}
|
||||
platform: ${{ matrix.os }}-${{ matrix.arch }}
|
||||
binary: ${{ matrix.binary }}
|
||||
env:
|
||||
CPR_VERSION: ${{ needs.release-info.outputs.version }}
|
||||
CPR_GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
CPR_BUILD_TIME: ${{ needs.release-info.outputs.build_time }}
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Verify Windows platform behavior
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
working-directory: backend
|
||||
env:
|
||||
RUST_MIN_STACK: '16777216'
|
||||
run: |
|
||||
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-admin --test main use_case::plugins::official::
|
||||
cargo test --release --locked --target "${{ matrix.target }}" -p gateway-host --test main system_update::
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
@@ -227,8 +244,6 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
attestations: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -262,18 +277,13 @@ jobs:
|
||||
PLATFORM_MATRIX: ${{ needs.release-info.outputs.platform_matrix }}
|
||||
run: ruby .github/scripts/package-release.rb archives
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload release assets
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
# tar.gz 已压缩,Actions 只负责传递原始发行字节。
|
||||
compression-level: 0
|
||||
|
||||
@@ -385,14 +395,14 @@ jobs:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
concurrency:
|
||||
group: release-publication
|
||||
queue: max
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
needs: [release-info, quality, windows, security, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
@@ -528,6 +538,17 @@ jobs:
|
||||
File.write(output, "#{notes}\n\n#{installation}\n")
|
||||
RUBY
|
||||
|
||||
- name: Prepare versioned channel snapshot under the publication lock
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb prepare "$VERSION"
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload GitHub Release assets
|
||||
id: release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
@@ -551,3 +572,25 @@ jobs:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
RELEASE_ID: ${{ steps.release.outputs.id }}
|
||||
run: ruby .github/scripts/publish-release.rb
|
||||
|
||||
- name: Advance update-index to the verified snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
run: ruby .github/scripts/published-channels.rb finish "$VERSION"
|
||||
|
||||
recover-index:
|
||||
if: inputs.index_only
|
||||
runs-on: ubuntu-26.04
|
||||
concurrency:
|
||||
group: release-publication
|
||||
queue: max
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Verify public assets and finish pending publication
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: ruby .github/scripts/published-channels.rb finish "$TAG"
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Exact commit SHA to audit
|
||||
required: true
|
||||
type: string
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,7 +20,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: security-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
group: security-${{ github.workflow }}-${{ inputs.ref || github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -25,10 +31,10 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -44,7 +50,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
结合上下文识别“提交吧”“看看这个问题”等省略说法:普通 Git 提交不自动变成 PR,代码排查不自动变成 Issue;审查与发版准备不代表获准远程写入
|
||||
|
||||
先完整读取命中技能的 `SKILL.md`,再读取其领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
首次命中技能时完整读取其 `SKILL.md`,再读取领域必读规范,之后按当前步骤补充实现、命令和示例。技能名称、描述或入口摘要不能代替首次读取的正文与必读引用;引用中明确要求“先读”“必须读取”或“执行”的规范也要落实,其他资料按需读取
|
||||
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过。只复用当前上下文中已实际读取且仍适用的内容;输出截断、上下文压缩后缺失的必要内容须补读,范围或阶段变化时重新匹配
|
||||
按用户意图、行为影响和实际文件共同匹配,跨领域叠加;确定方案或给出审查结论前完成对应领域必读,不能因改动小、路径未落在该模块或自认为熟悉而跳过
|
||||
|
||||
执行所选流程中的问题核实、自审、验证与交付要求,协作流程和审查标准见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
@@ -23,6 +23,13 @@ Issue 和 PR 回复统一按 [GitHub 回复口吻](CONTRIBUTING.md#github-回复
|
||||
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
|
||||
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则
|
||||
|
||||
### 任务延续与收尾
|
||||
|
||||
- 在任务上下文和压缩摘要中保留目标、授权、当前阶段、已读规范的路径与关键约束、验证命令与结果及对应代码状态、剩余事项
|
||||
- 同一任务可复用实际完成的读取与验证记录;仅在规范变更、进入未覆盖领域或具体决策缺少条款时补读对应内容。输出截断只补缺失部分,上下文压缩与阶段切换本身不触发重读或重验
|
||||
- 远端更新或合并冲突后,按实际变更及其依赖影响补充自审和验证,复用未受影响的证据
|
||||
- 实现、自审与必要验证完成后,按[验证停止条件](CONTRIBUTING.md#验证)停止扩围;完成已获授权的剩余交付步骤并确认结果后结束
|
||||
|
||||
<!-- CODEGRAPH_START -->
|
||||
## CodeGraph
|
||||
|
||||
|
||||
+6
-1
@@ -126,7 +126,8 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
- AI 进行 Rust 开发时,加载并使用 `$rust-best-practices` 技能。模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置
|
||||
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式
|
||||
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`
|
||||
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并
|
||||
- AI 参与的提交按实际助手添加 `Co-authored-by: 名称 <邮箱>`,名称与邮箱采用该助手已知的署名身份,并保留其他实际共同作者署名;PR 的 AI 使用披露不替代提交署名
|
||||
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项
|
||||
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理
|
||||
|
||||
@@ -232,6 +233,10 @@ git diff --check
|
||||
|
||||
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
|
||||
|
||||
常规容器检查与发行构建共用二进制构建入口,并复用已检查的前端产物;Dockerfile、工具链、依赖或构建配置变化时执行完整源码镜像构建,定期安全检查也覆盖源码构建。两条路径均验证容器运行、安全扫描与关闭行为
|
||||
|
||||
发行构建与质量检查并行,Windows 两种架构的插件与系统更新测试独立运行。仅当发行提交只修改版本与发布说明,且其父提交有本仓库主分支成功的后端 CI 时复用该结果;缺少证据则完整重跑。对外发布等待质量、Windows 测试、依赖审计和镜像验证全部成功,构建耗时与测试报告保留 7 天
|
||||
|
||||
新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试
|
||||
|
||||
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)
|
||||
|
||||
@@ -91,7 +91,7 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
|
||||
|
||||
1. 在「账号」中添加账号,完成授权或导入
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组,**不选分组表示可使用全部账号**
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置
|
||||
3. [连接客户端](#客户端接入)
|
||||
|
||||
## 客户端接入
|
||||
|
||||
|
||||
Generated
+14
-12
@@ -286,9 +286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.156.0"
|
||||
version = "1.157.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1072c9aa20159b9b417115342d38c4c53108fc98e5359fcb86b7d4575747eb59"
|
||||
checksum = "6ea9794c741520d8baec9462bcd0ad12164d4f5f78d008a8da9d6a0b7222caa8"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -503,9 +503,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-schema"
|
||||
version = "1.1.0"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
|
||||
checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1081,9 +1081,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.3"
|
||||
version = "1.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||
checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -1834,6 +1834,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
"yaml-rust2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2729,9 +2730,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.1"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
@@ -3578,9 +3579,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.8.0"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7acd809e80afa754c63f586955699d8990efe1c4b6613183ee5f027a11fbc8b5"
|
||||
checksum = "db7f3d75b5a3301746bff39a767bf4c5c42506b08513527308b3875bfb64a031"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -5646,14 +5647,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.3"
|
||||
version = "9.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
|
||||
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"indexmap",
|
||||
"libc",
|
||||
"memchr",
|
||||
"nix",
|
||||
"typed-path",
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use gateway_admin::ports::provider::ProviderAdminRegistry;
|
||||
use gateway_core::{engine::provider::ProviderRegistry, time::DeploymentTimeZone};
|
||||
use gateway_host::{ConfigError, HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_host::{HostBundle, HostConfig, outbound::HttpClient};
|
||||
use gateway_store::{StoreBundle, StoreConfig};
|
||||
|
||||
use super::BootstrapError;
|
||||
@@ -33,6 +33,8 @@ impl Environment {
|
||||
let timezone = host.timezone;
|
||||
let store = store.with_timezone(timezone);
|
||||
let plugin_cache = host.runtime_data_dir().join("plugins");
|
||||
// 插件兼容检查只需本地运行版本,管理端版本查询还会等待远端发行服务
|
||||
let version = host.system_update.version.clone();
|
||||
let host = match mode {
|
||||
Mode::Server => gateway_host::initialize(host).await?,
|
||||
Mode::Command | Mode::Help => gateway_host::initialize_command_line(host).await?,
|
||||
@@ -44,12 +46,6 @@ impl Environment {
|
||||
Mode::Help => gateway_store::initialize_read_only(store).await?,
|
||||
};
|
||||
host.report_startup_ready("Store");
|
||||
let version = host
|
||||
.system_operations()
|
||||
.version()
|
||||
.await
|
||||
.map_err(|_| ConfigError::InvalidField("host.version"))?
|
||||
.version;
|
||||
let plugin_http = Arc::new(HttpClient::new()?);
|
||||
Ok(Self {
|
||||
host,
|
||||
|
||||
@@ -91,7 +91,7 @@ fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
|
||||
|
||||
fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> {
|
||||
if arguments.next().is_some() {
|
||||
return Err(invalid_cli("unexpected extra command arguments"));
|
||||
return Err(invalid_cli("too many arguments"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ cargo +1.99.1 install --locked --path backend/apps/plugin-cli --root /path/to/lo
|
||||
|
||||
## 打包
|
||||
|
||||
先构建目标平台的插件二进制;插件提供管理页面时,还需构建页面静态资源。打包命令传入作者清单、二进制和所需的资源目录映射,通过 `cpr-plugin package --help` 查看完整参数:
|
||||
先构建目标平台的二进制;有管理页面时,也构建静态资源。全部参数见 `cpr-plugin package --help`
|
||||
|
||||
```bash
|
||||
cpr-plugin package \
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use gateway_core::account::{
|
||||
/// 账号可用的管理操作
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderAccountCapabilities {
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
pub profile: bool,
|
||||
@@ -110,6 +111,7 @@ pub enum AccountGroupFilter {
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
@@ -249,6 +251,7 @@ pub struct AccountUpdateResult {
|
||||
/// 仅修改显式字段的批量账号设置命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub concurrency_limit: Option<Option<AccountConcurrencyLimit>>,
|
||||
|
||||
@@ -472,6 +472,90 @@ impl CostCoverage {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求健康计数与服务成功率,调用方错误和取消不计入有效样本
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct RequestHealth {
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
}
|
||||
|
||||
impl RequestHealth {
|
||||
#[must_use]
|
||||
pub fn from_counts(
|
||||
success: u64,
|
||||
failed: u64,
|
||||
cancelled: u64,
|
||||
incomplete: u64,
|
||||
caller_error: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
success_requests: success,
|
||||
failed_requests: failed.saturating_sub(caller_error),
|
||||
cancelled_requests: cancelled,
|
||||
incomplete_requests: incomplete,
|
||||
caller_error_requests: caller_error,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_metrics(metrics: &RequestMetrics) -> Self {
|
||||
Self::from_counts(
|
||||
metrics.success_count,
|
||||
metrics.failure_count,
|
||||
metrics.cancelled_count,
|
||||
metrics.incomplete_count,
|
||||
metrics.caller_error_count,
|
||||
)
|
||||
}
|
||||
|
||||
/// 账号与健康时间线使用百分数,洞察接口使用 0 到 1 的比例
|
||||
#[must_use]
|
||||
pub fn success_rate(self) -> Option<f64> {
|
||||
self.success_ratio().map(|ratio| ratio * 100.0)
|
||||
}
|
||||
|
||||
pub(crate) fn success_ratio(self) -> Option<f64> {
|
||||
let total = self.eligible_requests();
|
||||
(total > 0).then(|| self.success_requests as f64 / total as f64)
|
||||
}
|
||||
|
||||
pub(crate) fn error_ratio(self) -> Option<f64> {
|
||||
let total = self.eligible_requests();
|
||||
(total > 0).then(|| self.non_success_requests() as f64 / total as f64)
|
||||
}
|
||||
|
||||
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
|
||||
pub(crate) fn non_success_requests(self) -> u64 {
|
||||
self.failed_requests
|
||||
.saturating_add(self.incomplete_requests)
|
||||
}
|
||||
|
||||
pub(crate) fn eligible_requests(self) -> u64 {
|
||||
self.success_requests
|
||||
.saturating_add(self.non_success_requests())
|
||||
}
|
||||
|
||||
pub(crate) fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.add_window(Self::from_metrics(metrics));
|
||||
}
|
||||
|
||||
pub(crate) fn add_window(&mut self, other: Self) {
|
||||
self.success_requests = self.success_requests.saturating_add(other.success_requests);
|
||||
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(other.cancelled_requests);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(other.incomplete_requests);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(other.caller_error_requests);
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求级聚合指标
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RequestMetrics {
|
||||
@@ -622,7 +706,7 @@ pub struct AccountPoolMetrics {
|
||||
pub struct AccountModelUsage {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub health: RequestHealth,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_input_tokens: Option<u64>,
|
||||
@@ -635,7 +719,7 @@ pub struct AccountModelUsage {
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 账号在单小时窗口内的请求数
|
||||
@@ -1007,7 +1091,9 @@ pub struct DiagnosticObservation {
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub first_token_p95_ms: Option<u64>,
|
||||
pub non_completion_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub caller_error_count: u64,
|
||||
pub retry_count: u64,
|
||||
pub retried_request_count: u64,
|
||||
pub cost_coverage: CostCoverage,
|
||||
@@ -1095,7 +1181,7 @@ pub struct TrendPoint {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub granularity: Granularity,
|
||||
pub metrics: RequestMetrics,
|
||||
pub service_failure_count: u64,
|
||||
pub error_count: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub average_first_token_latency_ms: Option<u64>,
|
||||
pub cached_token_rate: f64,
|
||||
@@ -1113,7 +1199,7 @@ pub struct TrendSummary {
|
||||
pub output_tokens: u64,
|
||||
pub cached_input_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub service_failure_count: u64,
|
||||
pub error_count: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub max_latency_ms: Option<u64>,
|
||||
pub min_latency_ms: Option<u64>,
|
||||
@@ -1277,7 +1363,7 @@ pub struct UsageInsightsHealthPoint {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察健康汇总;失败数已排除调用方错误
|
||||
@@ -1289,7 +1375,7 @@ pub struct UsageInsightsHealth {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub success_rate: f64,
|
||||
pub success_rate: Option<f64>,
|
||||
pub completion_rate: f64,
|
||||
pub points: Vec<UsageInsightsHealthPoint>,
|
||||
}
|
||||
@@ -1476,7 +1562,7 @@ pub struct DiagnosticsItem {
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
|
||||
@@ -53,7 +53,9 @@ pub struct RuntimeSettingsValues {
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub key_queue_timeout_seconds: u32,
|
||||
pub account_queue_timeout_seconds: u32,
|
||||
pub switch_account_on_queue_failure: bool,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
@@ -201,7 +203,8 @@ impl RuntimeSettingsValues {
|
||||
gateway_core::settings::validate_request_limits(
|
||||
self.max_waiting_per_key,
|
||||
self.max_waiting_per_account,
|
||||
self.concurrency_wait_timeout_seconds,
|
||||
self.key_queue_timeout_seconds,
|
||||
self.account_queue_timeout_seconds,
|
||||
self.max_account_rotations,
|
||||
self.openai_session_affinity_ttl_hours,
|
||||
)?;
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 摘要只读取发布索引,完整详情可按请求刷新
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemUpdateCheck {
|
||||
Summary,
|
||||
Detail,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
/// 本次检查或安装选择的更新稳定性范围,具体版本准入由 Host 判定
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -69,6 +77,7 @@ pub struct SystemUpdateDetail {
|
||||
pub update_supported: bool,
|
||||
pub unsupported_reason: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub retry_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 系统操作类型
|
||||
|
||||
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderUpstreamInfo {
|
||||
/// 当前连接曾成功识别互联,失权时保留身份但清除观测内容
|
||||
/// 当前连接的互联识别结果,明确关闭共享或不支持协议时清除
|
||||
pub discovered: bool,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub stale: bool,
|
||||
|
||||
@@ -222,6 +222,15 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// Key 可以继承当前全局画像;额外字段的合法性由对应 Provider 判定
|
||||
fn preview_key_client_profile(
|
||||
&self,
|
||||
key: &gateway_core::account::OpaqueProviderData,
|
||||
_global: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, ProviderAdminError> {
|
||||
self.preview_client_profile(key)
|
||||
}
|
||||
|
||||
/// 提供最终套餐展示名称;未覆盖时采用通用的大驼峰格式
|
||||
fn plan_type_display(&self, plan_type: &str) -> String {
|
||||
default_plan_type_display(plan_type)
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
use std::{pin::Pin, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::system::{
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateEvent, SystemUpdateStatus, SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 已校验并解包的同一更新候选;字节只含发行清单,不含运行配置或凭据
|
||||
@@ -43,6 +44,7 @@ pub enum SystemOperationErrorKind {
|
||||
pub struct SystemOperationError {
|
||||
kind: SystemOperationErrorKind,
|
||||
message: String,
|
||||
retry_at: Option<DateTime<Utc>>,
|
||||
source: Option<gateway_core::error::ErrorSource>,
|
||||
sensitive_context_redacted: bool,
|
||||
}
|
||||
@@ -53,11 +55,23 @@ impl SystemOperationError {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
retry_at: None,
|
||||
source: None,
|
||||
sensitive_context_redacted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_retry_at(mut self, retry_at: DateTime<Utc>) -> Self {
|
||||
self.retry_at = Some(retry_at);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn retry_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.retry_at
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_source(mut self, source: impl Into<gateway_core::error::ErrorSource>) -> Self {
|
||||
self.source = Some(source.into());
|
||||
@@ -135,7 +149,7 @@ pub trait SystemOperations: Send + Sync {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
|
||||
@@ -759,6 +759,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
auto_use_quota_credits: None,
|
||||
account_ids: vec![account_id.to_string()],
|
||||
enabled: Some(true),
|
||||
concurrency_limit: None,
|
||||
@@ -837,15 +838,28 @@ impl AccountsService for DefaultAccountsService {
|
||||
(
|
||||
std::sync::Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
Vec<ProviderAccountId>,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
for account_id in &account_ids {
|
||||
let (item, provider) = self.provider_for_account(account_id).await?;
|
||||
providers
|
||||
if command.auto_use_quota_credits.is_some()
|
||||
&& !provider
|
||||
.account_capabilities(account_id, &item.account.authentication_kind)
|
||||
.quota_credits
|
||||
{
|
||||
return Err(AdminError::invalid("该账号不支持使用额度点数"));
|
||||
}
|
||||
let (_, selected, exhausted) = providers
|
||||
.entry(item.account.provider_kind)
|
||||
.or_insert_with(|| (provider, Vec::new()))
|
||||
.1
|
||||
.push(account_id.clone());
|
||||
.or_insert_with(|| (provider, Vec::new(), Vec::new()));
|
||||
selected.push(account_id.clone());
|
||||
if command.auto_use_quota_credits == Some(true)
|
||||
&& !item.account.auto_use_quota_credits
|
||||
&& item.account.quota.is_exhausted()
|
||||
{
|
||||
exhausted.push(account_id.clone());
|
||||
}
|
||||
}
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
@@ -853,7 +867,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
.batch_update_accounts(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
for (provider, provider_ids, _) in providers.values() {
|
||||
if enabled == Some(false) {
|
||||
for account_id in provider_ids {
|
||||
super::notify_account_unavailable(
|
||||
@@ -872,6 +886,18 @@ impl AccountsService for DefaultAccountsService {
|
||||
.await;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
for (provider, _, exhausted) in providers.into_values() {
|
||||
if exhausted.is_empty() {
|
||||
continue;
|
||||
}
|
||||
super::credential_mutation::observe_account_quotas(
|
||||
self.upstream_info.as_ref(),
|
||||
&provider,
|
||||
&self.accounts,
|
||||
&exhausted,
|
||||
context.request_id.as_str(),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ impl BackupService for DefaultBackupService {
|
||||
ok: false,
|
||||
stage: "putObject",
|
||||
code: Some(code::INVALID_CONFIG),
|
||||
message: "备份存储配置不完整,无法执行连接测试".to_owned(),
|
||||
message: "备份存储配置不完整".to_owned(),
|
||||
});
|
||||
};
|
||||
let result = self
|
||||
@@ -198,14 +198,10 @@ impl BackupService for DefaultBackupService {
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
if !settings.storage_configured() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储配置不完整,请先保存存储配置",
|
||||
));
|
||||
return Err(AdminError::bad_request("请先补全并保存备份存储配置"));
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储尚未通过连接测试,请先测试存储配置",
|
||||
));
|
||||
return Err(AdminError::bad_request("请先通过备份存储连接测试"));
|
||||
}
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let seed = build_backup_seed(
|
||||
@@ -241,9 +237,7 @@ impl BackupService for DefaultBackupService {
|
||||
) -> Result<DownloadUrlResult, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if record.status != BackupStatus::Completed {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 的备份才能创建下载地址",
|
||||
));
|
||||
return Err(AdminError::conflict("只能下载已完成的备份"));
|
||||
}
|
||||
let settings = self
|
||||
.repository
|
||||
@@ -280,9 +274,7 @@ impl BackupService for DefaultBackupService {
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if !record.status.can_be_deleted() {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 或 failed 的备份才能删除",
|
||||
));
|
||||
return Err(AdminError::conflict("只能删除已完成或失败的备份"));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let deleting = self
|
||||
@@ -290,7 +282,7 @@ impl BackupService for DefaultBackupService {
|
||||
.transition_to_deleting(backup_id, now, Some(context))
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
|
||||
.ok_or_else(|| AdminError::conflict("备份状态已变更,请刷新列表"))?;
|
||||
Ok(deleting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
for (provider, profile) in &command.request_profile_overrides {
|
||||
self.providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.and_then(|provider| provider.preview_key_client_profile(profile, None))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
validate_peer_sharing(command.peer_sharing, &command.group_ids)?;
|
||||
@@ -209,7 +209,7 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
if let Some(profile) = profile {
|
||||
self.providers
|
||||
.require(provider)
|
||||
.and_then(|provider| provider.preview_client_profile(profile))
|
||||
.and_then(|provider| provider.preview_key_client_profile(profile, None))
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,18 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
notify_account_facts_changed(peer.map(AsRef::as_ref), provider.as_ref(), account_ids).await;
|
||||
publish_committed(snapshot, revision).await?;
|
||||
|
||||
// 凭据已经提交;额度只是可重建的观察,不能拖住管理请求或回滚提交结果
|
||||
observe_account_quotas(peer, provider, accounts, account_ids, request_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn observe_account_quotas(
|
||||
peer: Option<&std::sync::Arc<dyn crate::ports::peer::PeerUpstreamService>>,
|
||||
provider: &std::sync::Arc<dyn ProviderAdmin>,
|
||||
accounts: &std::sync::Arc<dyn AccountStore>,
|
||||
account_ids: &[ProviderAccountId],
|
||||
request_id: &str,
|
||||
) {
|
||||
// 配置已提交,额度查询失败不回滚配置
|
||||
let provider = provider.clone();
|
||||
let peer = peer.cloned();
|
||||
let accounts = accounts.clone();
|
||||
@@ -48,14 +59,14 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
Ok(Some(account)) => account,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
tracing::warn!(request_id, account_id = %account_id, error = %error, "initial quota account lookup failed");
|
||||
tracing::warn!(request_id, account_id = %account_id, error = %error, "quota observation account lookup failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !account.enabled || &account.provider_kind != provider.provider_kind() {
|
||||
continue;
|
||||
}
|
||||
// 首查与手动刷新共用认证能力分派,互联协议由 Admin 解释
|
||||
// 后台查询与手动刷新共用认证能力分派,互联协议由 Admin 解释
|
||||
super::peer_link::upstream::read_quota(
|
||||
Some(peer),
|
||||
provider.as_ref(),
|
||||
@@ -72,12 +83,11 @@ pub(super) async fn publish_credentials_and_observe_quota(
|
||||
provider = %provider.provider_kind().as_str(),
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"initial quota observation failed"
|
||||
"account quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn required_credential(
|
||||
|
||||
@@ -207,7 +207,7 @@ impl DefaultImportTasksService {
|
||||
}
|
||||
Err(_) => {
|
||||
item.status = ImportItemStatus::Unknown;
|
||||
item.message = Some("执行中断,请先核对账号列表再决定是否重新导入".to_owned());
|
||||
item.message = Some("导入结果未知,重新导入前请核对账号列表".to_owned());
|
||||
}
|
||||
}
|
||||
entry.finish_if_done();
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::{
|
||||
DashboardPeriodMetrics, DashboardQuery, DashboardResult, DecimalAmount,
|
||||
DiagnosticDimension, DiagnosticsItem, DiagnosticsResult, Granularity, HealthStatus,
|
||||
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
|
||||
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
|
||||
TrendSummary, UsageBilling, UsageBillingProjection, UsageCacheEfficiency,
|
||||
RequestHealth, RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind,
|
||||
TrendPoint, TrendSummary, UsageBilling, UsageBillingProjection, UsageCacheEfficiency,
|
||||
UsageCalculatedBillingFact, UsageConcurrencyPoint, UsageCostComparison,
|
||||
UsageCostSummary, UsageDetail, UsageFilter, UsageInsight, UsageInsightData,
|
||||
UsageInsightMetric, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
|
||||
@@ -356,9 +356,16 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
let error_rate = rate_or_zero(item.failure_count, item.request_count);
|
||||
let non_completion_rate =
|
||||
rate_or_zero(item.non_completion_count, item.request_count);
|
||||
let health = RequestHealth::from_counts(
|
||||
item.success_count,
|
||||
item.failure_count,
|
||||
item.cancelled_count,
|
||||
item.incomplete_count,
|
||||
item.caller_error_count,
|
||||
);
|
||||
let non_completion_count =
|
||||
item.cancelled_count.saturating_add(item.incomplete_count);
|
||||
let non_completion_rate = rate_or_zero(non_completion_count, item.request_count);
|
||||
let retry_rate = rate_or_zero(item.retried_request_count, item.request_count);
|
||||
DiagnosticsItem {
|
||||
account_upstream_peer: peers.get(&item.key).copied(),
|
||||
@@ -374,8 +381,8 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
account_plan_type: item.account_plan_type,
|
||||
request_count: item.request_count,
|
||||
success_count: item.success_count,
|
||||
error_count: item.failure_count,
|
||||
error_rate,
|
||||
error_count: health.non_success_requests(),
|
||||
error_rate: health.error_ratio(),
|
||||
request_share: rate_or_zero(
|
||||
item.request_count,
|
||||
observation.total_request_count,
|
||||
@@ -383,7 +390,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
first_token_p95_ms: item.first_token_p95_ms,
|
||||
non_completion_count: item.non_completion_count,
|
||||
non_completion_count,
|
||||
non_completion_rate,
|
||||
retry_count: item.retry_count,
|
||||
retry_rate,
|
||||
@@ -574,9 +581,8 @@ fn build_usage_insights(
|
||||
}
|
||||
|
||||
let requests = &overview.requests;
|
||||
let health_window = HealthWindow::from_metrics(requests);
|
||||
let health_window = RequestHealth::from_metrics(requests);
|
||||
let failed_requests = health_window.failed_requests;
|
||||
let health_requests = health_window.eligible_requests();
|
||||
let health = UsageInsightsHealth {
|
||||
total_requests: requests.request_count,
|
||||
success_requests: requests.success_count,
|
||||
@@ -584,7 +590,7 @@ fn build_usage_insights(
|
||||
cancelled_requests: requests.cancelled_count,
|
||||
incomplete_requests: requests.incomplete_count,
|
||||
caller_error_requests: requests.caller_error_count,
|
||||
success_rate: rate_or_zero(requests.success_count, health_requests),
|
||||
success_rate: health_window.success_ratio(),
|
||||
completion_rate: rate_or_zero(
|
||||
requests
|
||||
.success_count
|
||||
@@ -594,7 +600,7 @@ fn build_usage_insights(
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let health_window = HealthWindow::from_metrics(&point.metrics);
|
||||
let health_window = RequestHealth::from_metrics(&point.metrics);
|
||||
UsageInsightsHealthPoint {
|
||||
total_requests: point.metrics.request_count,
|
||||
bucket_start: point.bucket_start,
|
||||
@@ -603,10 +609,7 @@ fn build_usage_insights(
|
||||
cancelled_requests: point.metrics.cancelled_count,
|
||||
incomplete_requests: point.metrics.incomplete_count,
|
||||
caller_error_requests: point.metrics.caller_error_count,
|
||||
error_rate: rate_or_zero(
|
||||
health_window.non_success_requests(),
|
||||
health_window.eligible_requests(),
|
||||
),
|
||||
error_rate: health_window.error_ratio(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -1125,7 +1128,7 @@ pub(super) fn health_timeline_at(
|
||||
.map(|index| {
|
||||
(
|
||||
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
|
||||
HealthWindow::default(),
|
||||
RequestHealth::default(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1151,7 +1154,7 @@ pub(super) fn health_timeline_at(
|
||||
let totals = buckets
|
||||
.iter()
|
||||
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
|
||||
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
|
||||
.fold(RequestHealth::default(), |mut totals, (_, bucket)| {
|
||||
totals.add_window(*bucket);
|
||||
totals
|
||||
});
|
||||
@@ -1215,11 +1218,9 @@ fn trend(
|
||||
}
|
||||
}
|
||||
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
|
||||
let service_failure_count = service_failure_count(&metrics);
|
||||
let success_rate = ratio(
|
||||
metrics.success_count,
|
||||
HealthWindow::from_metrics(&metrics).eligible_requests(),
|
||||
);
|
||||
let health = RequestHealth::from_metrics(&metrics);
|
||||
let error_count = health.non_success_requests();
|
||||
let success_rate = health.success_ratio();
|
||||
let cache_hit_request_rate = ratio(
|
||||
metrics.cache_hit_request_count,
|
||||
metrics.cache_eligible_request_count,
|
||||
@@ -1248,7 +1249,7 @@ fn trend(
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_input_tokens: metrics.cached_input_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
service_failure_count,
|
||||
error_count,
|
||||
average_latency_ms,
|
||||
max_latency_ms: metrics.max_latency_ms,
|
||||
min_latency_ms: metrics.min_latency_ms,
|
||||
@@ -1267,11 +1268,11 @@ fn trend(
|
||||
}
|
||||
|
||||
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
let service_failure_count = service_failure_count(&point.metrics);
|
||||
let health = RequestHealth::from_metrics(&point.metrics);
|
||||
TrendPoint {
|
||||
bucket_start: point.bucket_start,
|
||||
granularity: point.granularity,
|
||||
service_failure_count,
|
||||
error_count: health.non_success_requests(),
|
||||
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
|
||||
average_first_token_latency_ms: average(
|
||||
point.metrics.first_token_latency_sum_ms,
|
||||
@@ -1285,10 +1286,7 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
point.metrics.cache_hit_request_count,
|
||||
point.metrics.cache_eligible_request_count,
|
||||
),
|
||||
success_rate: ratio(
|
||||
point.metrics.success_count,
|
||||
HealthWindow::from_metrics(&point.metrics).eligible_requests(),
|
||||
),
|
||||
success_rate: health.success_ratio(),
|
||||
metrics: point.metrics,
|
||||
cost_coverage: point.cost_coverage,
|
||||
costs: point.costs,
|
||||
@@ -1372,57 +1370,7 @@ fn rate_or_zero(numerator: u64, denominator: u64) -> f64 {
|
||||
ratio(numerator, denominator).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct HealthWindow {
|
||||
success_requests: u64,
|
||||
failed_requests: u64,
|
||||
cancelled_requests: u64,
|
||||
incomplete_requests: u64,
|
||||
caller_error_requests: u64,
|
||||
}
|
||||
|
||||
impl HealthWindow {
|
||||
fn from_metrics(metrics: &RequestMetrics) -> Self {
|
||||
Self {
|
||||
success_requests: metrics.success_count,
|
||||
failed_requests: service_failure_count(metrics),
|
||||
cancelled_requests: metrics.cancelled_count,
|
||||
incomplete_requests: metrics.incomplete_count,
|
||||
caller_error_requests: metrics.caller_error_count,
|
||||
}
|
||||
}
|
||||
|
||||
// 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功
|
||||
fn non_success_requests(self) -> u64 {
|
||||
self.failed_requests
|
||||
.saturating_add(self.incomplete_requests)
|
||||
}
|
||||
|
||||
fn eligible_requests(self) -> u64 {
|
||||
self.success_requests
|
||||
.saturating_add(self.non_success_requests())
|
||||
}
|
||||
|
||||
fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.add_window(Self::from_metrics(metrics));
|
||||
}
|
||||
|
||||
fn add_window(&mut self, other: Self) {
|
||||
self.success_requests = self.success_requests.saturating_add(other.success_requests);
|
||||
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(other.cancelled_requests);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(other.incomplete_requests);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(other.caller_error_requests);
|
||||
}
|
||||
}
|
||||
|
||||
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
fn health_status(bucket: RequestHealth, is_future: bool) -> HealthStatus {
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
if is_future {
|
||||
HealthStatus::Future
|
||||
@@ -1443,14 +1391,6 @@ fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
|
||||
let eligible_requests = bucket.eligible_requests();
|
||||
(eligible_requests > 0)
|
||||
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
|
||||
}
|
||||
|
||||
fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
metrics
|
||||
.failure_count
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
fn health_reliability(bucket: RequestHealth) -> Option<f64> {
|
||||
bucket.success_rate()
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ impl PeerInfoService {
|
||||
checked_at: now,
|
||||
scheduling: None,
|
||||
summary: None,
|
||||
unsupported: false,
|
||||
unavailable: None,
|
||||
stored_failure: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -93,21 +92,19 @@ impl PeerInfoService {
|
||||
(
|
||||
connection.clone(),
|
||||
cached.and_then(|cached| cached.summary.clone()),
|
||||
cached.and_then(|cached| cached.unavailable),
|
||||
cached.is_some_and(|cached| cached.unsupported),
|
||||
cached.and_then(|cached| cached.stored_failure),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
Ok(summaries
|
||||
.into_iter()
|
||||
.map(|(connection, peer, unavailable, unsupported)| {
|
||||
.map(|(connection, peer, stored_failure)| {
|
||||
let quota = Self::project_peer(
|
||||
&connection,
|
||||
peer.as_deref(),
|
||||
unavailable,
|
||||
self.last_error(&connection)
|
||||
.or(unsupported.then_some(UpstreamInfoFailure::Unsupported)),
|
||||
stored_failure,
|
||||
self.last_error(&connection),
|
||||
now,
|
||||
);
|
||||
(connection, quota)
|
||||
@@ -138,10 +135,8 @@ impl PeerInfoService {
|
||||
checked_at: now,
|
||||
scheduling,
|
||||
summary,
|
||||
unsupported: observation
|
||||
.is_some_and(|value| snapshot::is_unsupported(&value.quota, connection)),
|
||||
unavailable: observation
|
||||
.and_then(|value| snapshot::unavailable_reason(&value.quota, connection)),
|
||||
stored_failure: observation
|
||||
.and_then(|value| snapshot::stored_failure(&value.quota, connection)),
|
||||
};
|
||||
let previous = self
|
||||
.observations
|
||||
@@ -156,6 +151,9 @@ impl PeerInfoService {
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&connection.account_id)
|
||||
.is_some_and(|cached| cached.connection == *connection && cached.unsupported)
|
||||
.is_some_and(|cached| {
|
||||
cached.connection == *connection
|
||||
&& cached.stored_failure == Some(UpstreamInfoFailure::Unsupported)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ impl PeerInfoService {
|
||||
pub(super) fn project_peer(
|
||||
connection: &UpstreamConnection,
|
||||
peer: Option<&PeerLink>,
|
||||
unavailable: Option<UpstreamInfoFailure>,
|
||||
stored_failure: Option<UpstreamInfoFailure>,
|
||||
last_error: Option<UpstreamInfoFailure>,
|
||||
now: SystemTime,
|
||||
) -> ProviderQuota {
|
||||
@@ -52,12 +52,12 @@ impl PeerInfoService {
|
||||
});
|
||||
let limits = peer.and_then(|peer| peer.key_limits);
|
||||
let info = ProviderUpstreamInfo {
|
||||
discovered: peer.is_some() || unavailable.is_some(),
|
||||
discovered: peer.is_some() || stored_failure == Some(UpstreamInfoFailure::Unauthorized),
|
||||
observed_at,
|
||||
stale,
|
||||
connection_revision: connection.revision.clone(),
|
||||
last_error: last_error
|
||||
.or(unavailable)
|
||||
.or(stored_failure)
|
||||
.map(|error| error.as_str().to_owned()),
|
||||
max_concurrency: limits.and_then(|limits| limits.max_concurrency),
|
||||
requests_per_minute: limits.and_then(|limits| limits.requests_per_minute),
|
||||
|
||||
@@ -105,8 +105,8 @@ impl PeerInfoService {
|
||||
{
|
||||
let error = state.last_error;
|
||||
drop(state);
|
||||
if let Some(error) = error.filter(|error| *error != UpstreamInfoFailure::Unsupported) {
|
||||
return Err(refresh_error(error));
|
||||
if let Some(error) = error.and_then(refresh_error) {
|
||||
return Err(error);
|
||||
}
|
||||
return self
|
||||
.read_quota(id)
|
||||
@@ -130,7 +130,9 @@ impl PeerInfoService {
|
||||
let failure = result.as_ref().err().copied();
|
||||
let delay = match failure {
|
||||
None => Duration::from_secs(240 + stable_jitter(id.as_str())),
|
||||
Some(UpstreamInfoFailure::Unauthorized) => Duration::from_secs(1800),
|
||||
Some(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::SharingDisabled) => {
|
||||
Duration::from_secs(1800)
|
||||
}
|
||||
Some(_) => Duration::from_secs(30),
|
||||
}
|
||||
.max(retry_after.unwrap_or_default());
|
||||
@@ -141,7 +143,11 @@ impl PeerInfoService {
|
||||
state.last_error = failure;
|
||||
let document = match result {
|
||||
Ok(document) => Some(document),
|
||||
Err(error @ (UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported)) => {
|
||||
Err(UpstreamInfoFailure::Unsupported) => Some(snapshot::unsupported(&connection)),
|
||||
Err(error @ UpstreamInfoFailure::SharingDisabled) => {
|
||||
Some(snapshot::unavailable(&connection, error))
|
||||
}
|
||||
Err(error @ UpstreamInfoFailure::Unauthorized) => {
|
||||
let discovered = self
|
||||
.read_summary(id)
|
||||
.await
|
||||
@@ -153,8 +159,6 @@ impl PeerInfoService {
|
||||
// 首次探测失败不能证明对方是互联节点;已确认的连接只留下无共享内容的识别记录
|
||||
Some(if discovered {
|
||||
snapshot::unavailable(&connection, error)
|
||||
} else if error == UpstreamInfoFailure::Unsupported {
|
||||
snapshot::unsupported(&connection)
|
||||
} else {
|
||||
OpaqueProviderData::new(Default::default())
|
||||
})
|
||||
@@ -199,8 +203,8 @@ impl PeerInfoService {
|
||||
}
|
||||
drop(state);
|
||||
// 不支持协议是正常发现结果,管理端保留分类,后台不重复报警
|
||||
if let Some(error) = failure.filter(|error| *error != UpstreamInfoFailure::Unsupported) {
|
||||
return Err(refresh_error(error));
|
||||
if let Some(error) = failure.and_then(refresh_error) {
|
||||
return Err(error);
|
||||
}
|
||||
self.read_quota(id)
|
||||
.await
|
||||
@@ -215,12 +219,13 @@ fn stable_jitter(id: &str) -> u64 {
|
||||
}) % 60
|
||||
}
|
||||
|
||||
fn refresh_error(error: UpstreamInfoFailure) -> ProviderAdminError {
|
||||
ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(match error {
|
||||
fn refresh_error(error: UpstreamInfoFailure) -> Option<ProviderAdminError> {
|
||||
let message = match error {
|
||||
UpstreamInfoFailure::Unauthorized => "上游未授权读取互联信息",
|
||||
UpstreamInfoFailure::Unsupported => "上游不支持互联信息查询",
|
||||
UpstreamInfoFailure::Unsupported | UpstreamInfoFailure::SharingDisabled => return None,
|
||||
UpstreamInfoFailure::RateLimited => "上游互联查询已限流",
|
||||
UpstreamInfoFailure::InvalidResponse => "上游互联响应无效",
|
||||
UpstreamInfoFailure::Unavailable => "上游互联服务暂不可用",
|
||||
})
|
||||
};
|
||||
Some(ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(message))
|
||||
}
|
||||
|
||||
@@ -34,8 +34,7 @@ pub(super) struct CachedObservation {
|
||||
pub checked_at: SystemTime,
|
||||
pub scheduling: Option<Arc<SchedulingObservation>>,
|
||||
pub summary: Option<Arc<PeerLink>>,
|
||||
pub unsupported: bool,
|
||||
pub unavailable: Option<UpstreamInfoFailure>,
|
||||
pub stored_failure: Option<UpstreamInfoFailure>,
|
||||
}
|
||||
|
||||
pub(crate) struct PeerInfoService {
|
||||
@@ -173,21 +172,17 @@ impl PeerInfoService {
|
||||
let peer = observation
|
||||
.as_ref()
|
||||
.and_then(|value| snapshot::decode(&value.quota));
|
||||
let unsupported = observation
|
||||
let stored_failure = observation
|
||||
.as_ref()
|
||||
.is_some_and(|value| snapshot::is_unsupported(&value.quota, &connection));
|
||||
let unavailable = observation
|
||||
.as_ref()
|
||||
.and_then(|value| snapshot::unavailable_reason(&value.quota, &connection));
|
||||
.and_then(|value| snapshot::stored_failure(&value.quota, &connection));
|
||||
if self.current_connection(id).await?.as_ref() != Some(&connection) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Self::project_peer(
|
||||
&connection,
|
||||
peer.as_ref(),
|
||||
unavailable,
|
||||
self.last_error(&connection)
|
||||
.or(unsupported.then_some(UpstreamInfoFailure::Unsupported)),
|
||||
stored_failure,
|
||||
self.last_error(&connection),
|
||||
SystemTime::now(),
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -28,21 +28,7 @@ pub(super) fn unsupported(connection: &UpstreamConnection) -> OpaqueProviderData
|
||||
]))
|
||||
}
|
||||
|
||||
pub(super) fn is_unsupported(data: &OpaqueProviderData, connection: &UpstreamConnection) -> bool {
|
||||
let fields = data.expose_to_provider();
|
||||
(fields.get("object").and_then(serde_json::Value::as_str) == Some("cpr.peer.unsupported")
|
||||
&& fields
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
== Some(1)
|
||||
&& fields
|
||||
.get("connection_revision")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(connection.revision.as_str()))
|
||||
|| unavailable_reason(data, connection) == Some(UpstreamInfoFailure::Unsupported)
|
||||
}
|
||||
|
||||
// 失权后只保留同一连接已确认互联的事实,不保留已撤销的池、预算与模型数据
|
||||
// 授权撤销或共享关闭后只保存连接与失败原因,丢弃全部共享数据
|
||||
pub(super) fn unavailable(
|
||||
connection: &UpstreamConnection,
|
||||
reason: UpstreamInfoFailure,
|
||||
@@ -58,20 +44,24 @@ pub(super) fn unavailable(
|
||||
]))
|
||||
}
|
||||
|
||||
pub(super) fn unavailable_reason(
|
||||
pub(super) fn stored_failure(
|
||||
data: &OpaqueProviderData,
|
||||
connection: &UpstreamConnection,
|
||||
) -> Option<UpstreamInfoFailure> {
|
||||
let fields = data.expose_to_provider();
|
||||
if fields.get("object")?.as_str()? != "cpr.peer.unavailable"
|
||||
|| fields.get("schema_version")?.as_u64()? != 1
|
||||
if fields.get("schema_version")?.as_u64()? != 1
|
||||
|| fields.get("connection_revision")?.as_str()? != connection.revision
|
||||
{
|
||||
return None;
|
||||
}
|
||||
match fields.get("reason")?.as_str()? {
|
||||
"unauthorized" => Some(UpstreamInfoFailure::Unauthorized),
|
||||
"unsupported" => Some(UpstreamInfoFailure::Unsupported),
|
||||
match fields.get("object")?.as_str()? {
|
||||
"cpr.peer.unsupported" => Some(UpstreamInfoFailure::Unsupported),
|
||||
"cpr.peer.unavailable" => match fields.get("reason")?.as_str()? {
|
||||
"unauthorized" => Some(UpstreamInfoFailure::Unauthorized),
|
||||
"sharing_disabled" => Some(UpstreamInfoFailure::SharingDisabled),
|
||||
"unsupported" => Some(UpstreamInfoFailure::Unsupported),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,9 +361,10 @@ impl ProxiesService for DefaultProxiesService {
|
||||
proxy: Option<&OutboundProxy>,
|
||||
detect_location: bool,
|
||||
) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
let _permit = self
|
||||
.test_slots
|
||||
.try_acquire()
|
||||
.map_err(|_| AdminError::new(AdminErrorKind::RateLimited, "代理测试已达并发上限"))?;
|
||||
Ok(self.probe.test(proxy, detect_location).await)
|
||||
}
|
||||
|
||||
@@ -374,9 +375,10 @@ impl ProxiesService for DefaultProxiesService {
|
||||
detect_location: bool,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
let _permit = self
|
||||
.test_slots
|
||||
.try_acquire()
|
||||
.map_err(|_| AdminError::new(AdminErrorKind::RateLimited, "代理测试已达并发上限"))?;
|
||||
let record = self
|
||||
.store
|
||||
.get(id)
|
||||
|
||||
@@ -252,7 +252,7 @@ impl SettingsService for DefaultSettingsService {
|
||||
.is_some_and(|models| models.contains_key(model))
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
|
||||
return Err(AdminError::invalid("内置价目只能覆盖,不能删除"));
|
||||
}
|
||||
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
|
||||
let defaults = defaults.get(&command.provider);
|
||||
@@ -332,24 +332,35 @@ impl SettingsService for DefaultSettingsService {
|
||||
configuration: Option<&gateway_core::account::OpaqueProviderData>,
|
||||
) -> Result<gateway_core::account::OpaqueProviderData, AdminError> {
|
||||
let profile_provider = self.profile_provider(provider)?;
|
||||
let global;
|
||||
let (configuration, source) = if let Some(configuration) = configuration {
|
||||
(configuration, "override")
|
||||
let independent = configuration.filter(|key| {
|
||||
gateway_core::settings::request_profiles::inherited_fields(key).is_none()
|
||||
});
|
||||
let preview = if let Some(key) = independent {
|
||||
profile_provider.preview_key_client_profile(key, None)
|
||||
} else {
|
||||
global = self
|
||||
let global = self
|
||||
.load()
|
||||
.await?
|
||||
.client_profile(provider)
|
||||
.cloned()
|
||||
.or_else(|| profile_provider.default_client_profile())
|
||||
.ok_or_else(|| AdminError::internal("通用客户端身份尚未初始化"))?;
|
||||
(&global, "global")
|
||||
};
|
||||
let mut preview = profile_provider
|
||||
.preview_client_profile(configuration)
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?
|
||||
.into_inner();
|
||||
preview.insert("source".to_owned(), serde_json::Value::from(source));
|
||||
if let Some(key) = configuration {
|
||||
profile_provider.preview_key_client_profile(key, Some(&global))
|
||||
} else {
|
||||
profile_provider.preview_client_profile(&global)
|
||||
}
|
||||
}
|
||||
.map_err(|error| super::map_provider_error(error, "client profile"))?;
|
||||
let mut preview = preview.into_inner();
|
||||
preview.insert(
|
||||
"source".to_owned(),
|
||||
serde_json::Value::from(if independent.is_some() {
|
||||
"override"
|
||||
} else {
|
||||
"global"
|
||||
}),
|
||||
);
|
||||
Ok(gateway_core::account::OpaqueProviderData::new(preview))
|
||||
}
|
||||
|
||||
@@ -419,7 +430,7 @@ impl SettingsService for DefaultSettingsService {
|
||||
crate::ports::store::AdminStoreErrorKind::Conflict
|
||||
| crate::ports::store::AdminStoreErrorKind::StaleRevision
|
||||
) {
|
||||
AdminError::conflict("运行设置已被其他调用修改,请重新读取并确认后提交")
|
||||
AdminError::conflict("运行设置已变更,请刷新核对后提交")
|
||||
} else {
|
||||
map_store_error(error, "runtime settings")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
system::{
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
SystemOperationAccepted, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
},
|
||||
ports::system::{
|
||||
@@ -25,7 +25,7 @@ pub trait SystemService: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, AdminError>;
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError>;
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
@@ -71,11 +71,11 @@ impl SystemService for DefaultSystemService {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
check: SystemUpdateCheck,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, AdminError> {
|
||||
self.operations
|
||||
.update_detail(refresh, channel)
|
||||
.update_detail(check, channel)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
@@ -1166,7 +1166,9 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -1906,6 +1908,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
auto_use_quota_credits: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
@@ -3396,6 +3399,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
|
||||
@@ -41,7 +41,9 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
|
||||
max_concurrent_per_account: 5,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
|
||||
@@ -1179,7 +1179,7 @@ impl SystemOperations for UnavailableSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
|
||||
@@ -364,21 +364,26 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
let services = observability_services(store.clone()).await;
|
||||
let fixtures = [
|
||||
(162, 0, 2, 0, 0, 98.780_487_804_878_05),
|
||||
(297, 0, 2, 5, 0, 99.331_103_678_929_76),
|
||||
(467, 1, 5, 0, 0, 98.731_501_057_082_45),
|
||||
(95, 2, 3, 4, 1, 95.0),
|
||||
(100, 0, 0, 0, 0, 100.0),
|
||||
(162, 0, 2, 0, 0, 0, Some(98.780_487_804_878_05)),
|
||||
(297, 0, 2, 5, 0, 0, Some(99.331_103_678_929_76)),
|
||||
(467, 1, 5, 0, 0, 0, Some(98.731_501_057_082_45)),
|
||||
(95, 2, 3, 4, 1, 7, Some(95.0)),
|
||||
(100, 0, 0, 0, 0, 0, Some(100.0)),
|
||||
(0, 0, 0, 0, 0, 0, None),
|
||||
(0, 0, 0, 4, 3, 2, None),
|
||||
(0, 1, 2, 0, 0, 0, Some(0.0)),
|
||||
];
|
||||
|
||||
for (index, (success, failed, incomplete, cancelled, caller_error, expected_percent)) in
|
||||
fixtures.into_iter().enumerate()
|
||||
for (
|
||||
index,
|
||||
(success, failed, incomplete, cancelled, caller_error, running, expected_percent),
|
||||
) in fixtures.into_iter().enumerate()
|
||||
{
|
||||
let range = observation_range(
|
||||
now + Duration::seconds(i64::try_from(index).expect("fixture index fits i64") * 2),
|
||||
);
|
||||
let metrics = RequestMetrics {
|
||||
request_count: success + failed + incomplete + cancelled + caller_error,
|
||||
request_count: success + failed + incomplete + cancelled + caller_error + running,
|
||||
success_count: success,
|
||||
failure_count: failed + caller_error,
|
||||
incomplete_count: incomplete,
|
||||
@@ -399,6 +404,27 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
|
||||
providers: Vec::new(),
|
||||
});
|
||||
|
||||
let mut item = diagnostic(
|
||||
"model",
|
||||
success + failed + incomplete + cancelled + caller_error + running,
|
||||
);
|
||||
item.success_count = success;
|
||||
item.failure_count = failed + caller_error;
|
||||
item.incomplete_count = incomplete;
|
||||
item.cancelled_count = cancelled;
|
||||
item.caller_error_count = caller_error;
|
||||
store.replace_diagnostics(DiagnosticsObservation {
|
||||
analytics: Default::default(),
|
||||
total_request_count: item.request_count,
|
||||
items: vec![item],
|
||||
});
|
||||
let diagnostics = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(diagnostics.items[0].error_count, failed + incomplete);
|
||||
|
||||
let dashboard = services
|
||||
.observability()
|
||||
.dashboard_summary(range, TrendKind::Usage)
|
||||
@@ -410,23 +436,12 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
|
||||
.iter()
|
||||
.find(|point| point.bucket_start == current_slot)
|
||||
.expect("current health slot");
|
||||
assert_eq!(point.status, HealthStatus::Stable);
|
||||
assert_eq!(dashboard.trend.summary.error_count, failed + incomplete);
|
||||
assert_eq!(dashboard.trend.points[0].error_count, failed + incomplete);
|
||||
assert_eq!(point.failed_requests, failed);
|
||||
assert_eq!(point.incomplete_requests, incomplete);
|
||||
assert_eq!(point.cancelled_requests, cancelled);
|
||||
assert_eq!(point.caller_error_requests, caller_error);
|
||||
for reliability in [timeline.reliability_percent, point.reliability_percent] {
|
||||
assert!((reliability.expect("health reliability") - expected_percent).abs() < 1e-10);
|
||||
}
|
||||
for success_rate in [
|
||||
dashboard.trend.summary.success_rate,
|
||||
dashboard.trend.points[0].success_rate,
|
||||
] {
|
||||
assert!(
|
||||
(success_rate.expect("trend success rate") * 100.0 - expected_percent).abs()
|
||||
< 1e-10
|
||||
);
|
||||
}
|
||||
|
||||
let insights = services
|
||||
.observability()
|
||||
@@ -435,11 +450,31 @@ async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate
|
||||
.expect("usage insights");
|
||||
assert_eq!(insights.health.failed_requests, failed);
|
||||
assert_eq!(insights.health.incomplete_requests, incomplete);
|
||||
assert!((insights.health.success_rate * 100.0 - expected_percent).abs() < 1e-10);
|
||||
assert!(
|
||||
(insights.health.points[0].error_rate * 100.0 - (100.0 - expected_percent)).abs()
|
||||
< 1e-10
|
||||
);
|
||||
for actual in [
|
||||
timeline.reliability_percent,
|
||||
point.reliability_percent,
|
||||
dashboard
|
||||
.trend
|
||||
.summary
|
||||
.success_rate
|
||||
.map(|rate| rate * 100.0),
|
||||
dashboard.trend.points[0]
|
||||
.success_rate
|
||||
.map(|rate| rate * 100.0),
|
||||
insights.health.success_rate.map(|rate| rate * 100.0),
|
||||
insights.health.points[0]
|
||||
.error_rate
|
||||
.map(|rate| (1.0 - rate) * 100.0),
|
||||
diagnostics.items[0]
|
||||
.error_rate
|
||||
.map(|rate| (1.0 - rate) * 100.0),
|
||||
] {
|
||||
match (actual, expected_percent) {
|
||||
(Some(actual), Some(expected)) => assert!((actual - expected).abs() < 1e-10),
|
||||
(None, None) => {}
|
||||
_ => panic!("unexpected rate: {actual:?}, expected {expected_percent:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +713,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
|
||||
assert_eq!(insights.health.total_requests, 10);
|
||||
assert_eq!(insights.health.failed_requests, 2);
|
||||
assert_eq!(insights.health.success_rate, 0.75);
|
||||
assert_eq!(insights.health.success_rate, Some(0.75));
|
||||
assert_eq!(insights.health.completion_rate, 1.0);
|
||||
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
|
||||
assert_eq!(
|
||||
@@ -833,7 +868,7 @@ async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
|
||||
assert_eq!(result.items[1].key, "single_failure");
|
||||
assert_eq!(result.items[0].error_count, 0);
|
||||
assert_eq!(result.items[1].error_count, 1);
|
||||
assert_eq!(result.items[1].error_rate, 1.0);
|
||||
assert_eq!(result.items[1].error_rate, Some(1.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1385,7 +1420,9 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_concurrent_per_account: self.max_concurrent_per_account,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
@@ -1565,7 +1602,9 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
|
||||
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
DiagnosticObservation {
|
||||
first_token_p95_ms: None,
|
||||
non_completion_count: 0,
|
||||
cancelled_count: 0,
|
||||
incomplete_count: 0,
|
||||
caller_error_count: 0,
|
||||
retry_count: 0,
|
||||
retried_request_count: 0,
|
||||
account_provider_kind: None,
|
||||
|
||||
@@ -456,6 +456,31 @@ impl ProviderAccountStore for TestAccounts {
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_for_provider_accounts(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
account_ids: &[ProviderAccountId],
|
||||
) -> Result<Vec<ProviderAccount>, StoreError> {
|
||||
if account_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if Some(provider.as_str()) == self.failed_provider {
|
||||
return Err(StoreError::new(StoreErrorKind::Unavailable));
|
||||
}
|
||||
Ok(self
|
||||
.accounts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|account| {
|
||||
account.provider() == provider
|
||||
&& account.enabled()
|
||||
&& account_ids.contains(account.id())
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn list_refresh_candidates(
|
||||
&self,
|
||||
_query: ProviderRefreshQuery,
|
||||
|
||||
@@ -176,10 +176,11 @@ async fn uncertain_discovery_failures_retry_without_becoming_unsupported() {
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_data() {
|
||||
async fn known_peer_identity_changes_only_on_definitive_detection() {
|
||||
for failure in [
|
||||
UpstreamInfoFailure::Unauthorized,
|
||||
UpstreamInfoFailure::Unsupported,
|
||||
UpstreamInfoFailure::SharingDisabled,
|
||||
UpstreamInfoFailure::Unavailable,
|
||||
UpstreamInfoFailure::RateLimited,
|
||||
UpstreamInfoFailure::InvalidResponse,
|
||||
@@ -191,14 +192,15 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d
|
||||
tokio::time::advance(Duration::from_secs(15)).await;
|
||||
*transport.failure.lock().unwrap() = Some(failure.into());
|
||||
let result = bundle.service().refresh_quota(&id).await;
|
||||
assert_eq!(result.is_ok(), failure == UpstreamInfoFailure::Unsupported);
|
||||
let clears_data = matches!(
|
||||
let clears_identity = matches!(
|
||||
failure,
|
||||
UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported
|
||||
UpstreamInfoFailure::Unsupported | UpstreamInfoFailure::SharingDisabled
|
||||
);
|
||||
assert_eq!(result.is_ok(), clears_identity);
|
||||
let clears_data = clears_identity || failure == UpstreamInfoFailure::Unauthorized;
|
||||
let quota = bundle.service().read_quota(&id).await.unwrap().unwrap();
|
||||
let info = quota.upstream.as_ref().unwrap();
|
||||
assert!(info.discovered);
|
||||
assert_eq!(info.discovered, !clears_identity);
|
||||
assert_eq!(info.last_error.as_deref(), Some(failure.as_str()));
|
||||
assert_eq!(quota.windows.is_empty(), clears_data);
|
||||
assert_eq!(info.observed_at.is_none(), clears_data);
|
||||
@@ -221,10 +223,24 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d
|
||||
clears_data
|
||||
);
|
||||
|
||||
if failure == UpstreamInfoFailure::Unsupported {
|
||||
// 已运行 v3.40.0 的节点可能持有这种不支持记录,读取时应直接显示为普通 API Key
|
||||
accounts.records.lock().unwrap().get_mut(&id).unwrap().quota =
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"object": "cpr.peer.unavailable",
|
||||
"schema_version": 1,
|
||||
"connection_revision": info.connection_revision,
|
||||
"reason": "unsupported",
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
drop(bundle);
|
||||
let restarted = initialize(accounts, transport);
|
||||
let restored = restarted.service().read_quota(&id).await.unwrap().unwrap();
|
||||
assert!(restored.upstream.as_ref().unwrap().discovered);
|
||||
assert_eq!(
|
||||
restored.upstream.as_ref().unwrap().discovered,
|
||||
!clears_identity
|
||||
);
|
||||
assert_eq!(restored.windows.is_empty(), clears_data);
|
||||
if clears_data {
|
||||
assert_eq!(
|
||||
@@ -246,7 +262,7 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() {
|
||||
async fn peer_detection_after_revocation_is_scoped_to_the_connection() {
|
||||
for failure in [
|
||||
UpstreamInfoFailure::Unauthorized,
|
||||
UpstreamInfoFailure::Unsupported,
|
||||
@@ -261,7 +277,7 @@ async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() {
|
||||
let edited = initial.clone().with_notes(Some("保留连接身份".into()));
|
||||
*accounts.accounts.lock().unwrap() = vec![edited.clone()];
|
||||
bundle.service().account_changed(&id).await.unwrap();
|
||||
assert!(
|
||||
assert_eq!(
|
||||
bundle
|
||||
.service()
|
||||
.read_summary(&id)
|
||||
@@ -270,7 +286,8 @@ async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() {
|
||||
.unwrap()
|
||||
.upstream
|
||||
.unwrap()
|
||||
.discovered
|
||||
.discovered,
|
||||
failure == UpstreamInfoFailure::Unauthorized
|
||||
);
|
||||
|
||||
// 地址和密钥修改推进凭据版本,出口变化则改变同一版本的连接身份
|
||||
|
||||
@@ -333,7 +333,9 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_concurrent_per_account: 1,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
|
||||
@@ -41,7 +41,7 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Ok(SystemUpdateDetail {
|
||||
@@ -60,6 +60,7 @@ impl SystemOperations for RecordingSystemOperations {
|
||||
update_supported: false,
|
||||
unsupported_reason: Some("source build".to_owned()),
|
||||
warning: None,
|
||||
retry_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ impl SystemOperations for PreflightingSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!()
|
||||
|
||||
@@ -12,7 +12,7 @@ name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.9", default-features = false, features = ["json", "multipart", "query", "ws"] }
|
||||
axum = { version = "0.8.9", default-features = false, features = ["json", "matched-path", "multipart", "query", "ws"] }
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
|
||||
@@ -116,6 +116,7 @@ pub(super) fn account_view(
|
||||
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
|
||||
quota.protection_rules = protection_rules;
|
||||
AccountView {
|
||||
auto_use_quota_credits: account.auto_use_quota_credits,
|
||||
upstream,
|
||||
capabilities: capabilities.into(),
|
||||
id: account.id.clone(),
|
||||
@@ -455,20 +456,14 @@ pub(super) fn account_model_usage_view(
|
||||
.costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"));
|
||||
let success_rate = usage.health.success_rate();
|
||||
ModelUsageView {
|
||||
model: usage.model,
|
||||
request_count: usage.request_count,
|
||||
request_count_display: format_number(usage.request_count),
|
||||
success_rate: (usage.request_count > 0)
|
||||
.then(|| usage.success_count as f64 * 100.0 / usage.request_count as f64),
|
||||
success_rate_display: if usage.request_count == 0 {
|
||||
"—".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{:.1}%",
|
||||
usage.success_count as f64 * 100.0 / usage.request_count as f64
|
||||
)
|
||||
},
|
||||
success_rate,
|
||||
success_rate_display: success_rate
|
||||
.map_or_else(|| "—".to_owned(), |rate| format!("{rate:.1}%")),
|
||||
input_tokens: usage.input_tokens,
|
||||
input_tokens_display: display_optional_tokens(usage.input_tokens),
|
||||
output_tokens: usage.output_tokens,
|
||||
@@ -495,9 +490,11 @@ pub(super) fn account_model_usage_view(
|
||||
partial_cost_count: u64::from(cost_estimate_status == "partial"),
|
||||
unknown_cost_count: usage.cost_coverage.unavailable_count,
|
||||
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
|
||||
last_used_at: time.rfc3339(&usage.last_used_at),
|
||||
last_used_at_full_display: Some(time.datetime(&usage.last_used_at)),
|
||||
last_used_at_display: time.relative(usage.last_used_at, now),
|
||||
last_used_at: usage.last_used_at.map(|value| time.rfc3339(&value)),
|
||||
last_used_at_full_display: usage.last_used_at.map(|value| time.datetime(&value)),
|
||||
last_used_at_display: usage
|
||||
.last_used_at
|
||||
.map_or_else(|| "—".to_owned(), |value| time.relative(value, now)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct ListQuery {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
pub account_ids: Vec<String>,
|
||||
@@ -117,7 +118,8 @@ impl BatchUpdateAccountsRequest {
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.enabled.is_none()
|
||||
if self.auto_use_quota_credits.is_none()
|
||||
&& self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
&& self.group_ids.is_none()
|
||||
@@ -133,6 +135,7 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
auto_use_quota_credits: self.auto_use_quota_credits,
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
@@ -266,6 +269,7 @@ pub struct AccountSummaryView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub upstream: Option<super::AccountUpstreamInfoView>,
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
@@ -318,6 +322,7 @@ pub struct AccountCapacityView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapabilitiesView {
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
pub profile: bool,
|
||||
@@ -336,6 +341,7 @@ impl From<gateway_admin::model::accounts::ProviderAccountCapabilities> for Accou
|
||||
subscription: value.subscription,
|
||||
avatar: value.avatar,
|
||||
reset_credits: value.reset_credits,
|
||||
quota_credits: value.quota_credits,
|
||||
consume_reset_credit: value.consume_reset_credit,
|
||||
}
|
||||
}
|
||||
@@ -515,7 +521,7 @@ pub struct ModelUsageView {
|
||||
pub partial_cost_count: u64,
|
||||
pub unknown_cost_count: u64,
|
||||
pub costs: Vec<CurrencyCostView>,
|
||||
pub last_used_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
pub last_used_at_full_display: Option<String>,
|
||||
}
|
||||
|
||||
@@ -701,8 +701,8 @@ pub(crate) fn trend_point_view(
|
||||
cached_input_tokens_value: point.metrics.cached_input_tokens,
|
||||
cache_hit_rate_value: point.cached_token_rate,
|
||||
tokens_value: point.metrics.total_tokens,
|
||||
errors: format_compact_number(point.service_failure_count),
|
||||
errors_value: point.service_failure_count,
|
||||
errors: format_compact_number(point.error_count),
|
||||
errors_value: point.error_count,
|
||||
latency_value: point.average_latency_ms,
|
||||
success_rate: success_rate_value
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
@@ -765,7 +765,7 @@ pub(crate) fn trend_summary_view(
|
||||
TrendKind::Errors => vec![
|
||||
TrendSummaryView {
|
||||
metric: TrendMetric::Errors,
|
||||
value: format_compact_number(summary.service_failure_count),
|
||||
value: format_compact_number(summary.error_count),
|
||||
ratio: None,
|
||||
},
|
||||
TrendSummaryView {
|
||||
|
||||
@@ -656,7 +656,7 @@ pub struct OverviewHealthPointView {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察健康摘要
|
||||
@@ -669,7 +669,7 @@ pub struct OverviewHealthView {
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub success_rate: f64,
|
||||
pub success_rate: Option<f64>,
|
||||
pub completion_rate: f64,
|
||||
pub request_change_rate: Option<f64>,
|
||||
pub success_rate_change: Option<f64>,
|
||||
@@ -879,7 +879,7 @@ pub struct DiagnosticItemView {
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
pub error_rate: f64,
|
||||
pub error_rate: Option<f64>,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
|
||||
@@ -537,14 +537,10 @@ async fn probe<S>(
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.0
|
||||
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.probe(Some(&proxy), request.detect_location)
|
||||
.probe(request.proxy_url.0.as_ref(), request.detect_location)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
|
||||
@@ -44,7 +44,9 @@ pub struct RuntimeSettingsFields {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub max_waiting_per_key: u32,
|
||||
pub max_waiting_per_account: u32,
|
||||
pub concurrency_wait_timeout_seconds: u32,
|
||||
pub key_queue_timeout_seconds: u32,
|
||||
pub account_queue_timeout_seconds: u32,
|
||||
pub switch_account_on_queue_failure: bool,
|
||||
pub max_retry_after_seconds: u32,
|
||||
pub openai_guardian_reserved_concurrency: u32,
|
||||
pub openai_account_affinity: gateway_core::account::AccountAffinity,
|
||||
@@ -751,7 +753,9 @@ impl RuntimeSettingsFields {
|
||||
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
|
||||
max_waiting_per_key: self.max_waiting_per_key,
|
||||
max_waiting_per_account: self.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
|
||||
key_queue_timeout_seconds: self.key_queue_timeout_seconds,
|
||||
account_queue_timeout_seconds: self.account_queue_timeout_seconds,
|
||||
switch_account_on_queue_failure: self.switch_account_on_queue_failure,
|
||||
max_retry_after_seconds: self.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: self.openai_account_affinity,
|
||||
@@ -791,7 +795,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
|
||||
max_waiting_per_key: settings.max_waiting_per_key,
|
||||
max_waiting_per_account: settings.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
|
||||
key_queue_timeout_seconds: settings.key_queue_timeout_seconds,
|
||||
account_queue_timeout_seconds: settings.account_queue_timeout_seconds,
|
||||
switch_account_on_queue_failure: settings.switch_account_on_queue_failure,
|
||||
max_retry_after_seconds: settings.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
|
||||
openai_account_affinity: settings.openai_account_affinity,
|
||||
@@ -824,7 +830,8 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
|
||||
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
|
||||
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
|
||||
"audit_retention_days" => "auditRetentionDays",
|
||||
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
|
||||
"key_queue_timeout_seconds" => "keyQueueTimeoutSeconds",
|
||||
"account_queue_timeout_seconds" => "accountQueueTimeoutSeconds",
|
||||
"max_account_rotations" => "maxAccountRotations",
|
||||
"max_concurrent_per_account" => "maxConcurrentPerAccount",
|
||||
"max_waiting_per_account" => "maxWaitingPerAccount",
|
||||
|
||||
@@ -20,8 +20,9 @@ use axum::{
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemRestartPlan, SystemUpdateChannel, SystemUpdateDetail, SystemUpdateEvent,
|
||||
SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus, SystemVersion,
|
||||
SystemRestartPlan, SystemUpdateChannel, SystemUpdateCheck, SystemUpdateDetail,
|
||||
SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdatePolicy, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -34,7 +35,10 @@ use super::{
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
#[serde(default)]
|
||||
refresh: bool,
|
||||
#[serde(default)]
|
||||
summary_only: bool,
|
||||
channel: Option<SystemUpdateChannel>,
|
||||
}
|
||||
|
||||
@@ -44,10 +48,16 @@ impl UpdateDetailQuery {
|
||||
self.channel
|
||||
}
|
||||
|
||||
/// 是否强制从发布源刷新
|
||||
/// 摘要只读取发布索引;手动刷新有独立的最短间隔
|
||||
#[must_use]
|
||||
pub fn refresh(&self) -> bool {
|
||||
self.refresh.unwrap_or(false)
|
||||
pub const fn check(&self) -> SystemUpdateCheck {
|
||||
if self.summary_only {
|
||||
SystemUpdateCheck::Summary
|
||||
} else if self.refresh {
|
||||
SystemUpdateCheck::Refresh
|
||||
} else {
|
||||
SystemUpdateCheck::Detail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +126,12 @@ struct SystemUpdateDetailView {
|
||||
update_supported: bool,
|
||||
unsupported_reason: Option<String>,
|
||||
warning: Option<String>,
|
||||
retry_at: Option<String>,
|
||||
retry_at_display: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
fn from(detail: SystemUpdateDetail) -> Self {
|
||||
impl From<(SystemUpdateDetail, crate::time::TimePresenter)> for SystemUpdateDetailView {
|
||||
fn from((detail, time): (SystemUpdateDetail, crate::time::TimePresenter)) -> Self {
|
||||
Self {
|
||||
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
|
||||
build_type_label: build_type_label(&detail.build_type),
|
||||
@@ -135,6 +147,8 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
update_supported: detail.update_supported,
|
||||
unsupported_reason: detail.unsupported_reason,
|
||||
warning: detail.warning,
|
||||
retry_at: detail.retry_at.map(|at| at.to_rfc3339()),
|
||||
retry_at_display: detail.retry_at.map(|at| time.label(at, "%m-%d %H:%M:%S")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,12 +298,15 @@ where
|
||||
let detail = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_detail(query.refresh(), query.channel())
|
||||
.update_detail(query.check(), query.channel())
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemUpdateDetailView::from(detail)),
|
||||
AdminEnvelope::ok(SystemUpdateDetailView::from((
|
||||
detail,
|
||||
crate::time::TimePresenter::new(state.admin_services().timezone()),
|
||||
))),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// 认证边界提供的稳定拒绝原因,不携带认证输入
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AccessRejection(pub &'static str);
|
||||
|
||||
/// 执行已交给 Core 观测,HTTP 入口只补充交付边界日志
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ExecutionObserved;
|
||||
|
||||
/// OpenAI 风格的协议错误 JSON
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ProtocolErrorBody {
|
||||
@@ -107,6 +115,7 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
|
||||
error.client_error_code().unwrap_or(default_code),
|
||||
)
|
||||
.into_response();
|
||||
response.extensions_mut().insert(error.clone());
|
||||
if let Some(delay) = error.retry_after()
|
||||
&& let Ok(value) = HeaderValue::from_str(
|
||||
&delay
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! 数据面 HTTP 入口诊断;执行前失败进入运维事件,不伪造模型请求或上游状态
|
||||
|
||||
use std::{
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{MatchedPath, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure, TraceContext};
|
||||
use http_body::{Body as _, Frame, SizeHint};
|
||||
use serde_json::json;
|
||||
use tower_http::request_id::RequestId;
|
||||
|
||||
use crate::client_error::{AccessRejection, ProtocolError, ProtocolErrorBody};
|
||||
|
||||
pub(crate) async fn record_ingress(
|
||||
State(diagnostics): State<Arc<dyn OperationalDiagnostics>>,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if request.uri().path() != "/v1" && !request.uri().path().starts_with("/v1/") {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let correlation_id = request
|
||||
.extensions()
|
||||
.get::<RequestId>()
|
||||
.and_then(|id| id.header_value().to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let endpoint = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(|path| path.as_str().to_owned());
|
||||
let method = request.method().clone();
|
||||
let dump = if tracing::enabled!(target: "request_dump", tracing::Level::INFO) {
|
||||
let trace = TraceContext::new(correlation_id.as_deref().unwrap_or("ingress"));
|
||||
trace.dump(
|
||||
"ingress.request",
|
||||
&serde_json::to_vec(&json!({
|
||||
"method":method.as_str(), "uri":request.uri().to_string(),
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
trace.headers(
|
||||
"ingress.request.headers",
|
||||
json!({}),
|
||||
request
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| (name.as_str(), value.as_bytes())),
|
||||
);
|
||||
let body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
*request.body_mut() = Body::new(DumpBody::new(body, trace.clone(), "ingress.request.body"));
|
||||
Some(trace)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut response = if endpoint.is_none() {
|
||||
// 数据面未知路径不能落入 SPA fallback,否则会把协议错误伪装成 HTML 成功
|
||||
crate::openai::error::protocol_error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "invalid_request_error",
|
||||
code: "unknown_endpoint",
|
||||
message: "The requested API endpoint does not exist.".into(),
|
||||
param: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
next.run(request).await
|
||||
};
|
||||
let protocol = response.extensions_mut().remove::<ProtocolErrorBody>();
|
||||
let gateway = response
|
||||
.extensions_mut()
|
||||
.remove::<gateway_core::error::GatewayError>();
|
||||
let access = response.extensions_mut().remove::<AccessRejection>();
|
||||
let execution_observed = response
|
||||
.extensions_mut()
|
||||
.remove::<crate::client_error::ExecutionObserved>()
|
||||
.is_some();
|
||||
let status = response.status();
|
||||
if status.is_client_error() || status.is_server_error() {
|
||||
let code = access
|
||||
.map(|error| error.0)
|
||||
.or_else(|| protocol.as_ref().map(|body| body.error.code))
|
||||
.or_else(|| gateway.as_ref().map(|error| error.kind().as_str()))
|
||||
.unwrap_or(match status {
|
||||
StatusCode::UNAUTHORIZED => "invalid_api_key",
|
||||
StatusCode::METHOD_NOT_ALLOWED => "method_not_allowed",
|
||||
StatusCode::NOT_FOUND => "not_found",
|
||||
_ => "http_rejection",
|
||||
});
|
||||
// 未知参数名可由客户端任意构造,只保留 decoder 生成的已知参数路径
|
||||
let parameter = protocol
|
||||
.as_ref()
|
||||
.filter(|body| body.error.code != "unknown_parameter")
|
||||
.and_then(|body| body.error.param.as_deref())
|
||||
.filter(|path| {
|
||||
path.len() <= 256
|
||||
&& path.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'[' | b']')
|
||||
})
|
||||
});
|
||||
let endpoint = endpoint.as_deref().unwrap_or("/v1/<unmatched>");
|
||||
let model_request_id = execution_observed
|
||||
.then(|| {
|
||||
response
|
||||
.headers()
|
||||
.get("x-gateway-request-id")
|
||||
.and_then(|id| id.to_str().ok())
|
||||
})
|
||||
.flatten();
|
||||
tracing::info!(target: "request_trace", stage="ingress.rejected", status=status.as_u16(),
|
||||
code, parameter, endpoint, model_request_id, method=method.as_str(), "Data-plane HTTP request rejected");
|
||||
if !execution_observed {
|
||||
let mut failure = OperationalFailure::new(
|
||||
"api",
|
||||
"http_ingress",
|
||||
code,
|
||||
json!({"method":method.as_str(), "endpoint":endpoint, "status":status.as_u16(),
|
||||
"code":code, "param":parameter})
|
||||
.to_string(),
|
||||
);
|
||||
failure.correlation_id = correlation_id;
|
||||
failure.details = gateway.as_ref().and_then(|error| error.error_details());
|
||||
if diagnostics.record_failure(failure).await.is_err() {
|
||||
tracing::warn!("HTTP ingress diagnostic could not be recorded");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(trace) = dump {
|
||||
trace.dump(
|
||||
"ingress.response",
|
||||
&serde_json::to_vec(&json!({"status":status.as_u16()})).unwrap_or_default(),
|
||||
);
|
||||
trace.headers(
|
||||
"ingress.response.headers",
|
||||
json!({"status":status.as_u16()}),
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| (name.as_str(), value.as_bytes())),
|
||||
);
|
||||
let body = std::mem::replace(response.body_mut(), Body::empty());
|
||||
*response.body_mut() = Body::new(DumpBody::new(body, trace, "ingress.response.body"));
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
struct DumpBody {
|
||||
inner: Body,
|
||||
trace: TraceContext,
|
||||
stage: &'static str,
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
impl DumpBody {
|
||||
fn new(inner: Body, trace: TraceContext, stage: &'static str) -> Self {
|
||||
let complete = inner.is_end_stream();
|
||||
Self {
|
||||
inner,
|
||||
trace,
|
||||
stage,
|
||||
complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl http_body::Body for DumpBody {
|
||||
type Data = Bytes;
|
||||
type Error = axum::Error;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Bytes>, axum::Error>>> {
|
||||
let result = Pin::new(&mut self.inner).poll_frame(cx);
|
||||
if let Poll::Ready(Some(Ok(frame))) = &result
|
||||
&& let Some(bytes) = frame.data_ref()
|
||||
{
|
||||
self.trace.dump(self.stage, bytes);
|
||||
}
|
||||
if matches!(result, Poll::Ready(Some(Err(_)))) {
|
||||
self.complete = false;
|
||||
} else if matches!(result, Poll::Ready(None)) || self.inner.is_end_stream() {
|
||||
self.complete = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.inner.is_end_stream()
|
||||
}
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DumpBody {
|
||||
fn drop(&mut self) {
|
||||
// 不为诊断主动读取剩余正文;取消或未消费的请求必须明确标出转储不完整
|
||||
self.trace.dump(
|
||||
"ingress.body.finished",
|
||||
&serde_json::to_vec(&json!({
|
||||
"stage":self.stage, "complete":self.complete,
|
||||
}))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ use crate::openai::service::OpenAiService;
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
mod client_error;
|
||||
mod diagnostics;
|
||||
mod health;
|
||||
mod key_usage;
|
||||
mod middleware;
|
||||
@@ -280,6 +281,10 @@ pub fn initialize(
|
||||
headers_ms = latency.as_millis(), "HTTP response headers ready");
|
||||
});
|
||||
let router = router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
diagnostics.clone(),
|
||||
diagnostics::record_ingress,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
diagnostics,
|
||||
admin::diagnostics::record_failure,
|
||||
|
||||
@@ -128,7 +128,12 @@ fn client_authentication_request(
|
||||
}
|
||||
|
||||
pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response {
|
||||
match error {
|
||||
let code = match &error {
|
||||
ClientAccessError::Authentication(error) => error.reason(),
|
||||
ClientAccessError::Version(error) if error.current().is_some() => "client_version_too_old",
|
||||
ClientAccessError::Version(_) => "client_version_unavailable",
|
||||
};
|
||||
let mut response = match error {
|
||||
ClientAccessError::Authentication(error) => {
|
||||
log_client_api_key_auth_failure(error);
|
||||
if error == ClientApiKeyAuthError::RuntimeUnavailable {
|
||||
@@ -146,7 +151,11 @@ pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response
|
||||
);
|
||||
client_version_rejection_response(&error).into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::client_error::AccessRejection(code));
|
||||
response
|
||||
}
|
||||
|
||||
/// Desktop 优先于内嵌 CLI/Core;未知客户端及未提供应用版本的手机远程客户端返回 `None`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 将 Chat 请求转换为 Responses 正文,协议选项留在 API,业务 JSON 与上游兼容规则保持原有归属
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::openai::responses::RequestDecodeError;
|
||||
@@ -50,10 +52,10 @@ pub(in crate::openai) fn convert_request(
|
||||
body.insert("tool_choice".into(), json!({"type": "web_search"}));
|
||||
} else {
|
||||
if let Some(value) = optional(&mut chat, "tools") {
|
||||
body.insert("tools".into(), function_tools(value)?.into());
|
||||
body.insert("tools".into(), tools(value)?.into());
|
||||
}
|
||||
if let Some(value) = optional(&mut chat, "tool_choice") {
|
||||
let choice = tool_choice(value, body.get("tools"))?;
|
||||
let choice = tool_choice(value, "tool_choice")?;
|
||||
body.insert("tool_choice".into(), choice);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +63,10 @@ pub(in crate::openai) fn convert_request(
|
||||
body.insert("parallel_tool_calls".into(), value.into());
|
||||
}
|
||||
|
||||
let mut text = Map::new();
|
||||
let mut text = optional(&mut chat, "text")
|
||||
.map(|value| object(value, "text"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
if let Some(value) = optional(&mut chat, "response_format") {
|
||||
text.insert("format".into(), response_format(value)?);
|
||||
}
|
||||
@@ -72,47 +77,81 @@ pub(in crate::openai) fn convert_request(
|
||||
if !text.is_empty() {
|
||||
body.insert("text".into(), text.into());
|
||||
}
|
||||
let mut reasoning = optional(&mut chat, "reasoning")
|
||||
.map(|value| object(value, "reasoning"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
if let Some(value) = optional(&mut chat, "reasoning_effort") {
|
||||
string(&value, "reasoning_effort", false)?;
|
||||
body.insert("reasoning".into(), json!({"effort": value}));
|
||||
reasoning.insert("effort".into(), value);
|
||||
}
|
||||
for field in ["max_tokens", "max_completion_tokens"] {
|
||||
if !reasoning.is_empty() {
|
||||
body.insert("reasoning".into(), reasoning.into());
|
||||
}
|
||||
for field in ["max_output_tokens", "max_tokens", "max_completion_tokens"] {
|
||||
if let Some(value) = optional(&mut chat, field) {
|
||||
if !value.as_u64().is_some_and(|tokens| tokens > 0) {
|
||||
return Err(invalid(field));
|
||||
}
|
||||
// 新字段优先,具体上游是否接受长度控制仍由现有 Provider 决定
|
||||
body.insert("max_output_tokens".into(), value);
|
||||
}
|
||||
}
|
||||
for (field, maximum) in [("temperature", 2.0), ("top_p", 1.0)] {
|
||||
if let Some(value) = optional(&mut chat, field) {
|
||||
if !value
|
||||
.as_f64()
|
||||
.is_some_and(|number| (0.0..=maximum).contains(&number))
|
||||
{
|
||||
return Err(invalid(field));
|
||||
}
|
||||
body.insert(field.into(), value);
|
||||
}
|
||||
}
|
||||
// 同语义字段不在入口预判模型支持范围,交由既有 Provider 与上游校验
|
||||
for field in [
|
||||
"temperature",
|
||||
"top_p",
|
||||
"user",
|
||||
"safety_identifier",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_retention",
|
||||
"metadata",
|
||||
"prompt_cache_options",
|
||||
"moderation",
|
||||
"instructions",
|
||||
"include",
|
||||
"max_tool_calls",
|
||||
"truncation",
|
||||
"context_management",
|
||||
] {
|
||||
if let Some(value) = optional(&mut chat, field) {
|
||||
string(&value, field, true)?;
|
||||
body.insert(field.into(), value);
|
||||
}
|
||||
}
|
||||
for field in ["metadata", "prompt_cache_options", "moderation"] {
|
||||
if let Some(value) = optional(&mut chat, field) {
|
||||
let object = object(value, field)?;
|
||||
body.insert(field.into(), object.into());
|
||||
if optional_bool(&mut chat, "logprobs", "logprobs")? == Some(true) {
|
||||
let mut include = body
|
||||
.remove("include")
|
||||
.map(|value| array(value, "include"))
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let logprobs = json!("message.output_text.logprobs");
|
||||
if !include.contains(&logprobs) {
|
||||
include.push(logprobs);
|
||||
}
|
||||
body.insert("include".into(), include.into());
|
||||
if let Some(value) = optional(&mut chat, "top_logprobs") {
|
||||
body.insert("top_logprobs".into(), value);
|
||||
}
|
||||
}
|
||||
let ignored = [
|
||||
"stop",
|
||||
"seed",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"logit_bias",
|
||||
"prediction",
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|field| {
|
||||
chat.get(*field).is_some_and(|value| {
|
||||
!value.is_null()
|
||||
&& value.as_f64() != Some(0.0)
|
||||
&& !value.as_array().is_some_and(Vec::is_empty)
|
||||
&& !value.as_object().is_some_and(Map::is_empty)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !ignored.is_empty() {
|
||||
tracing::info!(target: "request_trace", stage="chat.compatibility", ignored_options=?ignored,
|
||||
"Chat-only optional controls omitted from Responses request");
|
||||
}
|
||||
// 未识别的客户端顶层扩展不等于 Responses 参数,不进入上游正文
|
||||
Ok(ConvertedChatRequest {
|
||||
@@ -125,26 +164,10 @@ fn validate_options(chat: &Map<String, Value>) -> Result<(), RequestDecodeError>
|
||||
for (field, value) in chat.iter().filter(|(_, value)| !value.is_null()) {
|
||||
let supported = match field.as_str() {
|
||||
"n" => value.as_u64() == Some(1),
|
||||
"store" | "logprobs" => value == false,
|
||||
"frequency_penalty" | "presence_penalty" => value.as_f64() == Some(0.0),
|
||||
"top_logprobs" => value.as_u64() == Some(0),
|
||||
"logit_bias" => value.as_object().is_some_and(Map::is_empty),
|
||||
"functions" | "stop" => value.as_array().is_some_and(Vec::is_empty),
|
||||
"store" | "background" => value == false,
|
||||
"functions" => value.as_array().is_some_and(Vec::is_empty),
|
||||
"modalities" => value == &json!(["text"]),
|
||||
"audio"
|
||||
| "prediction"
|
||||
| "seed"
|
||||
| "function_call"
|
||||
| "reasoning_content"
|
||||
| "input"
|
||||
| "previous_response_id"
|
||||
| "conversation"
|
||||
| "instructions"
|
||||
| "reasoning"
|
||||
| "text"
|
||||
| "include"
|
||||
| "background"
|
||||
| "max_output_tokens" => false,
|
||||
"audio" | "function_call" | "input" | "previous_response_id" | "conversation" => false,
|
||||
_ => true,
|
||||
};
|
||||
if !supported {
|
||||
@@ -215,6 +238,7 @@ fn messages(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
});
|
||||
}
|
||||
let mut input = Vec::with_capacity(messages.len());
|
||||
let mut custom_calls = HashSet::new();
|
||||
for (index, value) in messages.into_iter().enumerate() {
|
||||
let path = format!("messages[{index}]");
|
||||
let mut message = object(value, &path)?;
|
||||
@@ -229,11 +253,9 @@ fn messages(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
if let Some(name) = &name {
|
||||
let name_path = format!("{path}.name");
|
||||
string(name, &name_path, true)?;
|
||||
if role != "tool" {
|
||||
return Err(unsupported(&name_path));
|
||||
}
|
||||
}
|
||||
for field in ["function_call", "reasoning_content", "reasoning", "audio"] {
|
||||
// Chat 的显示名称和明文推理历史没有对应的 Responses 输入语义
|
||||
for field in ["function_call", "audio"] {
|
||||
if optional(&mut message, field).is_some() {
|
||||
return Err(unsupported(&format!("{path}.{field}")));
|
||||
}
|
||||
@@ -261,8 +283,12 @@ fn messages(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
} else {
|
||||
content_parts(content, &role, &content_path, false)?.into()
|
||||
};
|
||||
let mut item =
|
||||
json!({"type": "function_call_output", "call_id": call_id, "output": output});
|
||||
let kind = if custom_calls.contains(&call_id) {
|
||||
"custom_tool_call_output"
|
||||
} else {
|
||||
"function_call_output"
|
||||
};
|
||||
let mut item = json!({"type": kind, "call_id": call_id, "output": output});
|
||||
if let Some(name) = name {
|
||||
item["name"] = name;
|
||||
}
|
||||
@@ -300,7 +326,15 @@ fn messages(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
}
|
||||
input.push(item);
|
||||
}
|
||||
input.extend(calls);
|
||||
for call in calls {
|
||||
let id = string(&call["call_id"], &format!("{path}.tool_calls.id"), false)?.to_owned();
|
||||
if call["type"] == "custom_tool_call" {
|
||||
custom_calls.insert(id);
|
||||
} else {
|
||||
custom_calls.remove(&id);
|
||||
}
|
||||
input.push(call);
|
||||
}
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
@@ -322,17 +356,22 @@ fn content_parts(
|
||||
let mut part = object(value, &path)?;
|
||||
let kind = required_string(&mut part, "type", &format!("{path}.type"), false)?;
|
||||
let mut converted = match kind.as_str() {
|
||||
"text" => text_part(
|
||||
"text" | "input_text" | "output_text" => text_part(
|
||||
required_string(&mut part, "text", &format!("{path}.text"), true)?,
|
||||
is_assistant,
|
||||
),
|
||||
"image_url" if role == "user" => image_part(
|
||||
"image_url" if matches!(role, "user" | "tool") => image_part(
|
||||
required(&mut part, "image_url", &format!("{path}.image_url"))?,
|
||||
&format!("{path}.image_url"),
|
||||
)?,
|
||||
"file" if role == "user" => {
|
||||
"file" if matches!(role, "user" | "tool") => {
|
||||
let file_path = format!("{path}.file");
|
||||
inline_pdf_part(required(&mut part, "file", &file_path)?, &file_path)?
|
||||
file_part(required(&mut part, "file", &file_path)?, &file_path)?
|
||||
}
|
||||
"input_audio" if matches!(role, "user" | "tool") => audio_part(&mut part, &path)?,
|
||||
"input_image" | "input_file" if matches!(role, "user" | "tool") => {
|
||||
part.insert("type".into(), kind.into());
|
||||
Value::Object(std::mem::take(&mut part))
|
||||
}
|
||||
"refusal" if role == "assistant" => json!({
|
||||
"type": "refusal",
|
||||
@@ -360,23 +399,32 @@ fn text_part(text: String, is_assistant: bool) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_pdf_part(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
fn file_part(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
let mut file = object(value, path)?;
|
||||
if optional(&mut file, "file_id").is_some() {
|
||||
return Err(unsupported(&format!("{path}.file_id")));
|
||||
}
|
||||
let data_path = format!("{path}.file_data");
|
||||
let data = required_string(&mut file, "file_data", &data_path, false)?;
|
||||
if !data
|
||||
.strip_prefix("data:application/pdf;base64,")
|
||||
.is_some_and(|payload| !payload.trim().is_empty())
|
||||
if !["file_id", "file_url", "file_data"]
|
||||
.iter()
|
||||
.any(|field| file.get(*field).is_some_and(|value| !value.is_null()))
|
||||
{
|
||||
return Err(invalid(&data_path));
|
||||
return Err(invalid(path));
|
||||
}
|
||||
// 文件可用性、格式和组合由接收文件的上游判断,网关不下载或重编码
|
||||
file.insert("type".into(), "input_file".into());
|
||||
Ok(file.into())
|
||||
}
|
||||
|
||||
fn audio_part(part: &mut Map<String, Value>, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
if let Some(value) = optional(part, "input_audio") {
|
||||
let path = format!("{path}.input_audio");
|
||||
let mut audio = object(value, &path)?;
|
||||
let data = required_string(&mut audio, "data", &format!("{path}.data"), false)?;
|
||||
let format = required_string(&mut audio, "format", &format!("{path}.format"), false)?;
|
||||
let mime = if format == "mp3" { "mpeg" } else { &format };
|
||||
Ok(json!({"type":"input_audio", "audio_url":format!("data:audio/{mime};base64,{data}")}))
|
||||
} else {
|
||||
// Codex 使用 audio_url/file_id 引用,原生扩展保持其结构
|
||||
part.insert("type".into(), "input_audio".into());
|
||||
Ok(Value::Object(std::mem::take(part)))
|
||||
}
|
||||
let name = required_string(&mut file, "filename", &format!("{path}.filename"), false)?;
|
||||
let mut converted = json!({"type": "input_file", "filename": name});
|
||||
converted["file_data"] = data.into();
|
||||
Ok(converted)
|
||||
}
|
||||
|
||||
fn image_part(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
@@ -402,104 +450,82 @@ fn image_part(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
}
|
||||
|
||||
fn tool_calls(value: Value, path: &str) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
let calls = array(value, path)?;
|
||||
let mut result = Vec::with_capacity(calls.len());
|
||||
for (index, value) in calls.into_iter().enumerate() {
|
||||
let path = format!("{path}[{index}]");
|
||||
let mut call = object(value, &path)?;
|
||||
let id = required_string(&mut call, "id", &format!("{path}.id"), false)?;
|
||||
if required_string(&mut call, "type", &format!("{path}.type"), false)? != "function" {
|
||||
return Err(unsupported(&format!("{path}.type")));
|
||||
}
|
||||
let function_path = format!("{path}.function");
|
||||
let mut function = object(
|
||||
required(&mut call, "function", &function_path)?,
|
||||
&function_path,
|
||||
)?;
|
||||
let name = required_string(
|
||||
&mut function,
|
||||
"name",
|
||||
&format!("{function_path}.name"),
|
||||
false,
|
||||
)?;
|
||||
let arguments = required_string(
|
||||
&mut function,
|
||||
"arguments",
|
||||
&format!("{function_path}.arguments"),
|
||||
true,
|
||||
)?;
|
||||
result.push(
|
||||
json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}),
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
array(value, path)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
let path = format!("{path}[{index}]");
|
||||
let mut call = object(value, &path)?;
|
||||
let id = required_string(&mut call, "id", &format!("{path}.id"), false)?;
|
||||
let kind = required_string(&mut call, "type", &format!("{path}.type"), false)?;
|
||||
let (output_type, data_field) = match kind.as_str() {
|
||||
"function" => ("function_call", "arguments"),
|
||||
"custom" => ("custom_tool_call", "input"),
|
||||
_ => return Err(unsupported(&format!("{path}.type"))),
|
||||
};
|
||||
let nested_path = format!("{path}.{kind}");
|
||||
let mut nested = object(required(&mut call, &kind, &nested_path)?, &nested_path)?;
|
||||
let name = required_string(&mut nested, "name", &format!("{nested_path}.name"), false)?;
|
||||
let data = required_string(
|
||||
&mut nested,
|
||||
data_field,
|
||||
&format!("{nested_path}.{data_field}"),
|
||||
true,
|
||||
)?;
|
||||
Ok(json!({"type":output_type, "call_id":id, "name":name, data_field:data}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn function_tools(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
fn tools(value: Value) -> Result<Vec<Value>, RequestDecodeError> {
|
||||
array(value, "tools")?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| {
|
||||
let path = format!("tools[{index}]");
|
||||
let mut tool = object(value, &path)?;
|
||||
if required_string(&mut tool, "type", &format!("{path}.type"), false)? != "function" {
|
||||
return Err(unsupported(&format!("{path}.type")));
|
||||
let kind = required_string(&mut tool, "type", &format!("{path}.type"), false)?;
|
||||
if matches!(kind.as_str(), "function" | "custom") {
|
||||
if let Some(nested) = optional(&mut tool, &kind) {
|
||||
tool = object(nested, &format!("{path}.{kind}"))?;
|
||||
}
|
||||
if kind == "function" && tool.get("strict").is_none_or(Value::is_null) {
|
||||
// Chat 的缺省是宽松 schema,不能让 Responses 收紧参数合同
|
||||
tool.insert("strict".into(), false.into());
|
||||
}
|
||||
}
|
||||
let function_path = format!("{path}.function");
|
||||
let mut function = object(
|
||||
required(&mut tool, "function", &function_path)?,
|
||||
&function_path,
|
||||
)?;
|
||||
let name = required_string(
|
||||
&mut function,
|
||||
"name",
|
||||
&format!("{function_path}.name"),
|
||||
false,
|
||||
)?;
|
||||
// Chat 的缺省是宽松函数 schema,不能让 Responses 的缺省收紧原始参数合同
|
||||
let strict =
|
||||
optional_bool(&mut function, "strict", &format!("{function_path}.strict"))?
|
||||
.unwrap_or(false);
|
||||
let mut converted = json!({"type": "function", "name": name, "strict": strict});
|
||||
if let Some(description) = optional(&mut function, "description") {
|
||||
string(&description, &format!("{function_path}.description"), true)?;
|
||||
converted["description"] = description;
|
||||
}
|
||||
if let Some(parameters) = optional(&mut function, "parameters") {
|
||||
converted["parameters"] = parameters;
|
||||
}
|
||||
Ok(converted)
|
||||
// hosted 工具采用原生 Responses 结构,支持范围由选中的 Provider 决定
|
||||
tool.insert("type".into(), kind.into());
|
||||
Ok(tool.into())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_choice(value: Value, tools: Option<&Value>) -> Result<Value, RequestDecodeError> {
|
||||
let tools = tools.and_then(Value::as_array);
|
||||
if let Value::String(choice) = &value {
|
||||
return match choice.as_str() {
|
||||
"auto" | "none" => Ok(value),
|
||||
"required" if tools.is_some_and(|tools| !tools.is_empty()) => Ok(value),
|
||||
"required" => Err(invalid("tool_choice")),
|
||||
_ => Err(unsupported("tool_choice")),
|
||||
};
|
||||
fn tool_choice(value: Value, path: &str) -> Result<Value, RequestDecodeError> {
|
||||
if value.is_string() {
|
||||
return Ok(value);
|
||||
}
|
||||
let mut choice = object(value, "tool_choice")?;
|
||||
if required_string(&mut choice, "type", "tool_choice.type", false)? != "function" {
|
||||
return Err(unsupported("tool_choice.type"));
|
||||
let mut choice = object(value, path)?;
|
||||
let kind = required_string(&mut choice, "type", &format!("{path}.type"), false)?;
|
||||
if matches!(kind.as_str(), "function" | "custom") {
|
||||
if let Some(nested) = optional(&mut choice, &kind) {
|
||||
choice = object(nested, &format!("{path}.{kind}"))?;
|
||||
}
|
||||
} else if kind == "allowed_tools" {
|
||||
if let Some(nested) = optional(&mut choice, "allowed_tools") {
|
||||
choice = object(nested, &format!("{path}.allowed_tools"))?;
|
||||
}
|
||||
if let Some(value) = optional(&mut choice, "tools") {
|
||||
let mapped = array(value, &format!("{path}.tools"))?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| tool_choice(value, &format!("{path}.tools[{index}]")))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
choice.insert("tools".into(), mapped.into());
|
||||
}
|
||||
}
|
||||
let mut function = object(
|
||||
required(&mut choice, "function", "tool_choice.function")?,
|
||||
"tool_choice.function",
|
||||
)?;
|
||||
let name = required_string(&mut function, "name", "tool_choice.function.name", false)?;
|
||||
if !tools.is_some_and(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.any(|tool| tool.get("name").and_then(Value::as_str) == Some(name.as_str()))
|
||||
}) {
|
||||
return Err(invalid("tool_choice.function.name"));
|
||||
}
|
||||
Ok(json!({"type": "function", "name": name}))
|
||||
choice.insert("type".into(), kind.into());
|
||||
Ok(choice.into())
|
||||
}
|
||||
|
||||
fn response_format(value: Value) -> Result<Value, RequestDecodeError> {
|
||||
@@ -538,7 +564,10 @@ fn response_format(value: Value) -> Result<Value, RequestDecodeError> {
|
||||
}
|
||||
Ok(converted)
|
||||
}
|
||||
_ => Err(unsupported("response_format.type")),
|
||||
_ => {
|
||||
format.insert("type".into(), kind.into());
|
||||
Ok(format.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use gateway_protocol::openai::events::{billable_usage_is_complete, extract_usage
|
||||
use gateway_protocol::openai::sse::encode_sse_event;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::client_error::{ProtocolError, ProtocolErrorBody};
|
||||
|
||||
@@ -48,16 +49,23 @@ pub(in crate::openai) fn project_response(
|
||||
let mut tools = Vec::new();
|
||||
let mut annotations = Vec::new();
|
||||
let mut content_chars = 0;
|
||||
let mut reasoning = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
let mut logprobs = None;
|
||||
for item in output {
|
||||
match required_str(item, "type")? {
|
||||
"message" => {
|
||||
// 两侧索引都按消息中的字符计数,合并多条 assistant 消息时平移引用
|
||||
let message_offset = content_chars;
|
||||
for part in message_parts(item)? {
|
||||
let (kind, text) = part_text(part)?;
|
||||
let target = match kind {
|
||||
PartKind::Text => {
|
||||
append_url_citations(part, message_offset, &mut annotations)?;
|
||||
append_url_citations(part, content_chars, &mut annotations)?;
|
||||
if let Some(values) = part.get("logprobs").and_then(Value::as_array) {
|
||||
logprobs
|
||||
.get_or_insert_with(Vec::new)
|
||||
.extend(values.iter().cloned());
|
||||
}
|
||||
content_chars += text.chars().count() as u64;
|
||||
&mut content
|
||||
}
|
||||
@@ -66,14 +74,25 @@ pub(in crate::openai) fn project_response(
|
||||
target.get_or_insert_with(String::new).push_str(text);
|
||||
}
|
||||
}
|
||||
"function_call" => {
|
||||
let (id, name, arguments) = function_fields(item)?;
|
||||
tools.push(json!({
|
||||
"id": id, "type": "function",
|
||||
"function": {"name": name, "arguments": arguments}
|
||||
}));
|
||||
"function_call" | "custom_tool_call" => {
|
||||
let kind = ToolKind::from_item(item)?;
|
||||
let (id, name, data) = tool_fields(item, kind)?;
|
||||
tools.push(json!({"id":id, "type":kind.chat_type(),
|
||||
kind.chat_type():{"name":name, kind.data_field():data}}));
|
||||
}
|
||||
"reasoning" | "web_search_call" => {}
|
||||
"reasoning" => {
|
||||
for (_, _, text) in reasoning_parts(item)? {
|
||||
if !text.is_empty() {
|
||||
reasoning.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"image_generation_call" => {
|
||||
if let Some(image) = image_payload(item)? {
|
||||
images.push(image);
|
||||
}
|
||||
}
|
||||
kind if is_auxiliary_output(kind) => {}
|
||||
_ => return Err(invalid_response()),
|
||||
}
|
||||
}
|
||||
@@ -85,10 +104,17 @@ pub(in crate::openai) fn project_response(
|
||||
if !annotations.is_empty() {
|
||||
message["annotations"] = Value::Array(annotations);
|
||||
}
|
||||
if !reasoning.is_empty() {
|
||||
message["reasoning_content"] = reasoning.join("\n\n").into();
|
||||
}
|
||||
if !images.is_empty() {
|
||||
message["images"] = images.into();
|
||||
}
|
||||
let logprobs = logprobs.map(|content| json!({"content":content, "refusal":null}));
|
||||
serde_json::to_value(Envelope {
|
||||
identity,
|
||||
object: "chat.completion",
|
||||
choices: json!([{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": null}]),
|
||||
choices: json!([{"index": 0, "message": message, "finish_reason": finish_reason, "logprobs": logprobs}]),
|
||||
usage: chat_usage(response),
|
||||
metadata: response.get("metadata"),
|
||||
service_tier: response.get("service_tier"),
|
||||
@@ -115,9 +141,47 @@ impl PartKind {
|
||||
struct Part {
|
||||
kind: PartKind,
|
||||
text: String,
|
||||
logprobs: Vec<Value>,
|
||||
annotations: BTreeMap<u64, Value>,
|
||||
spans: Vec<TextSpan>,
|
||||
}
|
||||
|
||||
struct TextSpan {
|
||||
start: u64,
|
||||
end: u64,
|
||||
delivered_start: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ToolKind {
|
||||
Function,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl ToolKind {
|
||||
fn from_item(item: &Value) -> Result<Self, ProtocolErrorBody> {
|
||||
match required_str(item, "type")? {
|
||||
"function_call" => Ok(Self::Function),
|
||||
"custom_tool_call" => Ok(Self::Custom),
|
||||
_ => Err(invalid_response()),
|
||||
}
|
||||
}
|
||||
const fn chat_type(self) -> &'static str {
|
||||
match self {
|
||||
Self::Function => "function",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
const fn data_field(self) -> &'static str {
|
||||
match self {
|
||||
Self::Function => "arguments",
|
||||
Self::Custom => "input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Tool {
|
||||
kind: ToolKind,
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
@@ -126,9 +190,13 @@ struct Tool {
|
||||
|
||||
enum Output {
|
||||
Message(BTreeMap<u64, Part>),
|
||||
Function(Tool),
|
||||
Reasoning,
|
||||
WebSearch,
|
||||
Tool(Tool),
|
||||
Reasoning(BTreeMap<(bool, u64), String>),
|
||||
Image {
|
||||
index: usize,
|
||||
fingerprint: Option<[u8; 32]>,
|
||||
},
|
||||
Auxiliary(String),
|
||||
}
|
||||
|
||||
struct OutputItem {
|
||||
@@ -149,6 +217,7 @@ pub(in crate::openai) struct ChatStreamEncoder {
|
||||
role_sent: bool,
|
||||
output: BTreeMap<u64, OutputItem>,
|
||||
terminal: Option<Terminal>,
|
||||
content_chars: u64,
|
||||
}
|
||||
|
||||
impl ChatStreamEncoder {
|
||||
@@ -159,6 +228,7 @@ impl ChatStreamEncoder {
|
||||
role_sent: false,
|
||||
output: BTreeMap::new(),
|
||||
terminal: None,
|
||||
content_chars: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,11 +266,7 @@ impl ChatStreamEncoder {
|
||||
PartKind::Text
|
||||
};
|
||||
let delta = required_str(data, "delta")?;
|
||||
let part = self.event_part(data, kind)?;
|
||||
part.text.push_str(delta);
|
||||
if !delta.is_empty() {
|
||||
frames.push(self.delta(json!({kind.field(): delta}))?);
|
||||
}
|
||||
self.text_event(data, kind, delta, false, data, &mut frames)?;
|
||||
}
|
||||
Some("response.output_text.done" | "response.refusal.done") => {
|
||||
self.require_open()?;
|
||||
@@ -209,22 +275,29 @@ impl ChatStreamEncoder {
|
||||
} else {
|
||||
(PartKind::Text, "text")
|
||||
};
|
||||
let text = required_str(data, field)?;
|
||||
let part = self.event_part(data, kind)?;
|
||||
let suffix = append_snapshot(&mut part.text, text)?;
|
||||
if !suffix.is_empty() {
|
||||
frames.push(self.delta(json!({kind.field(): suffix}))?);
|
||||
}
|
||||
self.text_event(
|
||||
data,
|
||||
kind,
|
||||
required_str(data, field)?,
|
||||
true,
|
||||
data,
|
||||
&mut frames,
|
||||
)?;
|
||||
}
|
||||
Some("response.content_part.added" | "response.content_part.done") => {
|
||||
self.require_open()?;
|
||||
let part = data.get("part").ok_or_else(invalid_response)?;
|
||||
let (kind, text) = part_text(part)?;
|
||||
let part = self.event_part(data, kind)?;
|
||||
let suffix = append_snapshot(&mut part.text, text)?;
|
||||
if !suffix.is_empty() {
|
||||
frames.push(self.delta(json!({kind.field(): suffix}))?);
|
||||
}
|
||||
self.text_event(data, kind, text, true, part, &mut frames)?;
|
||||
}
|
||||
Some("response.output_text.annotation.added") => {
|
||||
self.require_open()?;
|
||||
let part = self.event_part(data, PartKind::Text)?;
|
||||
insert_annotation(
|
||||
part,
|
||||
required_index(data, "annotation_index")?,
|
||||
data.get("annotation").ok_or_else(invalid_response)?,
|
||||
)?;
|
||||
}
|
||||
Some("response.output_item.added" | "response.output_item.done") => {
|
||||
self.require_open()?;
|
||||
@@ -236,27 +309,73 @@ impl ChatStreamEncoder {
|
||||
)?;
|
||||
}
|
||||
Some(
|
||||
"response.function_call_arguments.delta" | "response.function_call_arguments.done",
|
||||
"response.function_call_arguments.delta"
|
||||
| "response.function_call_arguments.done"
|
||||
| "response.custom_tool_call_input.delta"
|
||||
| "response.custom_tool_call_input.done",
|
||||
) => {
|
||||
self.require_open()?;
|
||||
let index = required_index(data, "output_index")?;
|
||||
let item = self.output.get_mut(&index).ok_or_else(invalid_response)?;
|
||||
bind_id(item, optional_id(data, "item_id")?)?;
|
||||
let Output::Function(tool) = &mut item.output else {
|
||||
let Output::Tool(tool) = &mut item.output else {
|
||||
return Err(invalid_response());
|
||||
};
|
||||
let delta = if event_type == Some("response.function_call_arguments.delta") {
|
||||
let kind = tool.kind;
|
||||
let custom_event = event_type
|
||||
.is_some_and(|event| event.starts_with("response.custom_tool_call_input."));
|
||||
if custom_event != (kind == ToolKind::Custom) {
|
||||
return Err(invalid_response());
|
||||
}
|
||||
let delta = if event_type.is_some_and(|event| event.ends_with(".delta")) {
|
||||
let delta = required_str(data, "delta")?;
|
||||
tool.arguments.push_str(delta);
|
||||
delta
|
||||
} else {
|
||||
append_snapshot(&mut tool.arguments, required_str(data, "arguments")?)?
|
||||
append_snapshot(&mut tool.arguments, required_str(data, kind.data_field())?)?
|
||||
};
|
||||
let tool_index = tool.index;
|
||||
if !delta.is_empty() {
|
||||
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, "function": {"arguments": delta}}]}))?);
|
||||
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, kind.chat_type(): {kind.data_field(): delta}}]}))?);
|
||||
}
|
||||
}
|
||||
Some(
|
||||
"response.reasoning_summary_text.delta"
|
||||
| "response.reasoning_summary_text.done"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.reasoning_text.done",
|
||||
) => {
|
||||
self.require_open()?;
|
||||
let summary =
|
||||
event_type.is_some_and(|event| event.starts_with("response.reasoning_summary"));
|
||||
let snapshot = event_type.is_some_and(|event| event.ends_with(".done"));
|
||||
self.reasoning_text(
|
||||
required_index(data, "output_index")?,
|
||||
optional_id(data, "item_id")?,
|
||||
(
|
||||
summary,
|
||||
required_index(
|
||||
data,
|
||||
if summary {
|
||||
"summary_index"
|
||||
} else {
|
||||
"content_index"
|
||||
},
|
||||
)?,
|
||||
),
|
||||
required_str(data, if snapshot { "text" } else { "delta" })?,
|
||||
snapshot,
|
||||
&mut frames,
|
||||
)?;
|
||||
}
|
||||
Some("response.image_generation_call.partial_image") => {
|
||||
self.require_open()?;
|
||||
self.item_snapshot(required_index(data, "output_index")?, &json!({
|
||||
"id": required_nonempty_str(data, "item_id")?, "type":"image_generation_call",
|
||||
"result": required_str(data, "partial_image_b64")?,
|
||||
"output_format": data.get("output_format").unwrap_or(&json!("png")),
|
||||
}), false, &mut frames)?;
|
||||
}
|
||||
Some("response.completed" | "response.incomplete") => {
|
||||
self.require_open()?;
|
||||
let response = data.get("response").ok_or_else(invalid_response)?;
|
||||
@@ -275,6 +394,10 @@ impl ChatStreamEncoder {
|
||||
} else if self.output.is_empty() {
|
||||
return Err(invalid_response());
|
||||
}
|
||||
let annotations = self.stream_annotations()?;
|
||||
if !annotations.is_empty() {
|
||||
frames.push(self.delta(json!({"annotations":annotations}))?);
|
||||
}
|
||||
let finish_reason = finish_reason(response, self.tool_count() > 0)?;
|
||||
frames.push(self.chunk(
|
||||
json!([{"index": 0, "delta": {}, "finish_reason": finish_reason, "logprobs": null}]),
|
||||
@@ -310,7 +433,7 @@ impl ChatStreamEncoder {
|
||||
// 辅助事件可随上游演进;实际 output item 和 content part 仍逐项检查可表达性
|
||||
_ => {}
|
||||
}
|
||||
// done-only 流也要提供完整 Chat 消息身份,辅助事件或私有推理不触发角色帧
|
||||
// done-only 流也要提供完整 Chat 消息身份,不含可交付内容的辅助事件不触发角色帧
|
||||
if !self.role_sent && !frames.is_empty() && !self.has_wire_failure() {
|
||||
frames.insert(0, self.delta(json!({"role": "assistant"}))?);
|
||||
self.role_sent = true;
|
||||
@@ -318,6 +441,106 @@ impl ChatStreamEncoder {
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
fn text_event(
|
||||
&mut self,
|
||||
data: &Value,
|
||||
kind: PartKind,
|
||||
text: &str,
|
||||
snapshot: bool,
|
||||
metadata: &Value,
|
||||
frames: &mut Vec<Bytes>,
|
||||
) -> Result<(), ProtocolErrorBody> {
|
||||
let mut content_chars = self.content_chars;
|
||||
let part = self.event_part(data, kind)?;
|
||||
let delta = if snapshot {
|
||||
append_snapshot(&mut part.text, text)?
|
||||
} else {
|
||||
part.text.push_str(text);
|
||||
text
|
||||
};
|
||||
record_text_span(part, &mut content_chars, delta);
|
||||
merge_annotations(part, metadata)?;
|
||||
let logprobs = append_logprobs(part, metadata, snapshot)?;
|
||||
self.content_chars = content_chars;
|
||||
if !delta.is_empty() {
|
||||
frames.push(self.delta(json!({kind.field():delta}))?);
|
||||
}
|
||||
if !logprobs.is_empty() {
|
||||
frames.push(logprobs_frame(
|
||||
&self.identity,
|
||||
self.include_usage,
|
||||
kind,
|
||||
logprobs,
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stream_annotations(&self) -> Result<Vec<Value>, ProtocolErrorBody> {
|
||||
let mut result = Vec::new();
|
||||
for item in self.output.values() {
|
||||
if let Output::Message(parts) = &item.output {
|
||||
for part in parts.values() {
|
||||
let mut citations = Vec::new();
|
||||
append_url_citations(
|
||||
&json!({"annotations":part.annotations.values().collect::<Vec<_>>()}),
|
||||
0,
|
||||
&mut citations,
|
||||
)?;
|
||||
for mut citation in citations {
|
||||
for field in ["start_index", "end_index"] {
|
||||
let position = required_index(&citation["url_citation"], field)?;
|
||||
citation["url_citation"][field] =
|
||||
delivered_position(part, position, field == "end_index")?.into();
|
||||
}
|
||||
result.push(citation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn reasoning_text(
|
||||
&mut self,
|
||||
index: u64,
|
||||
id: Option<&str>,
|
||||
part: (bool, u64),
|
||||
text: &str,
|
||||
snapshot: bool,
|
||||
frames: &mut Vec<Bytes>,
|
||||
) -> Result<(), ProtocolErrorBody> {
|
||||
let has_text = self.output.values().any(|item| {
|
||||
matches!(&item.output,
|
||||
Output::Reasoning(parts) if parts.values().any(|text| !text.is_empty()))
|
||||
});
|
||||
let item = self.output.entry(index).or_insert_with(|| OutputItem {
|
||||
id: None,
|
||||
output: Output::Reasoning(BTreeMap::new()),
|
||||
});
|
||||
bind_id(item, id)?;
|
||||
let Output::Reasoning(parts) = &mut item.output else {
|
||||
return Err(invalid_response());
|
||||
};
|
||||
let previous = parts.entry(part).or_default();
|
||||
let separator = previous.is_empty() && has_text;
|
||||
let delta = if snapshot {
|
||||
append_snapshot(previous, text)?
|
||||
} else {
|
||||
previous.push_str(text);
|
||||
text
|
||||
};
|
||||
if !delta.is_empty() {
|
||||
let delta = if separator {
|
||||
format!("\n\n{delta}")
|
||||
} else {
|
||||
delta.to_owned()
|
||||
};
|
||||
frames.push(self.delta(json!({"reasoning_content":delta}))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_open(&self) -> Result<(), ProtocolErrorBody> {
|
||||
if self.terminal.is_some() {
|
||||
Err(invalid_response())
|
||||
@@ -329,7 +552,7 @@ impl ChatStreamEncoder {
|
||||
fn tool_count(&self) -> usize {
|
||||
self.output
|
||||
.values()
|
||||
.filter(|item| matches!(item.output, Output::Function(_)))
|
||||
.filter(|item| matches!(item.output, Output::Tool(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
@@ -354,7 +577,7 @@ impl ChatStreamEncoder {
|
||||
) -> Result<(), ProtocolErrorBody> {
|
||||
let id = optional_id(snapshot, "id")?;
|
||||
let kind = required_str(snapshot, "type")?;
|
||||
let call_id = if kind == "function_call" {
|
||||
let call_id = if matches!(kind, "function_call" | "custom_tool_call") {
|
||||
Some(required_nonempty_str(snapshot, "call_id")?)
|
||||
} else {
|
||||
None
|
||||
@@ -368,13 +591,23 @@ impl ChatStreamEncoder {
|
||||
if !self.output.contains_key(&index) {
|
||||
let output = match kind {
|
||||
"message" => Output::Message(BTreeMap::new()),
|
||||
"reasoning" => Output::Reasoning,
|
||||
"web_search_call" => Output::WebSearch,
|
||||
"function_call" => {
|
||||
let (call_id, name, _) = function_fields(snapshot)?;
|
||||
"reasoning" => Output::Reasoning(BTreeMap::new()),
|
||||
"image_generation_call" => Output::Image {
|
||||
index: self
|
||||
.output
|
||||
.values()
|
||||
.filter(|item| matches!(item.output, Output::Image { .. }))
|
||||
.count(),
|
||||
fingerprint: None,
|
||||
},
|
||||
kind if is_auxiliary_output(kind) => Output::Auxiliary(kind.to_owned()),
|
||||
"function_call" | "custom_tool_call" => {
|
||||
let kind = ToolKind::from_item(snapshot)?;
|
||||
let (call_id, name, _) = tool_fields(snapshot, kind)?;
|
||||
let tool_index = self.tool_count();
|
||||
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, "id": call_id, "type": "function", "function": {"name": name, "arguments": ""}}]}))?);
|
||||
Output::Function(Tool {
|
||||
frames.push(self.delta(json!({"tool_calls": [{"index": tool_index, "id": call_id, "type": kind.chat_type(), kind.chat_type(): {"name": name, kind.data_field(): ""}}]}))?);
|
||||
Output::Tool(Tool {
|
||||
kind,
|
||||
index: tool_index,
|
||||
call_id: call_id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
@@ -391,8 +624,20 @@ impl ChatStreamEncoder {
|
||||
(Output::Message(parts), "message") => {
|
||||
for (index, part) in message_parts(snapshot)?.iter().enumerate() {
|
||||
let (kind, text) = part_text(part)?;
|
||||
let snapshot = part;
|
||||
let part = part_entry(parts, index as u64, kind)?;
|
||||
let suffix = append_snapshot(&mut part.text, text)?;
|
||||
record_text_span(part, &mut self.content_chars, suffix);
|
||||
merge_annotations(part, snapshot)?;
|
||||
let logprobs = append_logprobs(part, snapshot, true)?;
|
||||
if !logprobs.is_empty() {
|
||||
frames.push(logprobs_frame(
|
||||
&self.identity,
|
||||
self.include_usage,
|
||||
kind,
|
||||
logprobs,
|
||||
)?);
|
||||
}
|
||||
if !suffix.is_empty() {
|
||||
frames.push(delta_frame(
|
||||
&self.identity,
|
||||
@@ -402,17 +647,41 @@ impl ChatStreamEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
(Output::Function(tool), "function_call") => {
|
||||
let (id, name, arguments) = function_fields(snapshot)?;
|
||||
(Output::Tool(tool), "function_call" | "custom_tool_call") => {
|
||||
let kind = ToolKind::from_item(snapshot)?;
|
||||
if kind != tool.kind {
|
||||
return Err(invalid_response());
|
||||
}
|
||||
let (id, name, arguments) = tool_fields(snapshot, kind)?;
|
||||
if tool.call_id != id || tool.name != name {
|
||||
return Err(invalid_response());
|
||||
}
|
||||
let suffix = append_snapshot(&mut tool.arguments, arguments)?;
|
||||
if !suffix.is_empty() {
|
||||
frames.push(delta_frame(&self.identity, self.include_usage, json!({"tool_calls": [{"index": tool.index, "function": {"arguments": suffix}}]}))?);
|
||||
frames.push(delta_frame(&self.identity, self.include_usage, json!({"tool_calls": [{"index": tool.index, kind.chat_type(): {kind.data_field(): suffix}}]}))?);
|
||||
}
|
||||
}
|
||||
(Output::Reasoning, "reasoning") | (Output::WebSearch, "web_search_call") => {}
|
||||
(Output::Reasoning(_), "reasoning") => {
|
||||
for (summary, part_index, text) in reasoning_parts(snapshot)? {
|
||||
self.reasoning_text(index, id, (summary, part_index), text, true, frames)?;
|
||||
}
|
||||
}
|
||||
(Output::Image { index, fingerprint }, "image_generation_call") => {
|
||||
if let Some(mut image) = image_payload(snapshot)? {
|
||||
let hash: [u8; 32] =
|
||||
Sha256::digest(required_str(&image["image_url"], "url")?.as_bytes()).into();
|
||||
if *fingerprint != Some(hash) {
|
||||
*fingerprint = Some(hash);
|
||||
image["index"] = (*index).into();
|
||||
frames.push(delta_frame(
|
||||
&self.identity,
|
||||
self.include_usage,
|
||||
json!({"images":[image]}),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Output::Auxiliary(previous), kind) if previous == kind => {}
|
||||
_ => return Err(invalid_response()),
|
||||
}
|
||||
Ok(())
|
||||
@@ -426,7 +695,7 @@ impl ChatStreamEncoder {
|
||||
let mut found = None;
|
||||
for (&index, item) in &self.output {
|
||||
let same_id = id.is_some() && item.id.as_deref() == id;
|
||||
let same_call = matches!(&item.output, Output::Function(tool) if call_id == Some(tool.call_id.as_str()));
|
||||
let same_call = matches!(&item.output, Output::Tool(tool) if call_id == Some(tool.call_id.as_str()));
|
||||
if same_id || same_call {
|
||||
if found.is_some_and(|previous| previous != index) {
|
||||
return Err(invalid_response());
|
||||
@@ -470,6 +739,9 @@ fn part_entry(
|
||||
let part = parts.entry(index).or_insert_with(|| Part {
|
||||
kind,
|
||||
text: String::new(),
|
||||
logprobs: Vec::new(),
|
||||
annotations: BTreeMap::new(),
|
||||
spans: Vec::new(),
|
||||
});
|
||||
if part.kind != kind {
|
||||
return Err(invalid_response());
|
||||
@@ -477,6 +749,68 @@ fn part_entry(
|
||||
Ok(part)
|
||||
}
|
||||
|
||||
fn record_text_span(part: &mut Part, delivered: &mut u64, delta: &str) {
|
||||
if part.kind != PartKind::Text || delta.is_empty() {
|
||||
return;
|
||||
}
|
||||
let length = delta.chars().count() as u64;
|
||||
let start = part.spans.last().map_or(0, |span| span.end);
|
||||
if let Some(last) = part.spans.last_mut()
|
||||
&& last.delivered_start + last.end - last.start == *delivered
|
||||
{
|
||||
last.end += length;
|
||||
} else {
|
||||
part.spans.push(TextSpan {
|
||||
start,
|
||||
end: start + length,
|
||||
delivered_start: *delivered,
|
||||
});
|
||||
}
|
||||
*delivered += length;
|
||||
}
|
||||
|
||||
fn delivered_position(part: &Part, position: u64, end: bool) -> Result<u64, ProtocolErrorBody> {
|
||||
let matches = |span: &&TextSpan| {
|
||||
if end && position > 0 {
|
||||
span.start < position && position <= span.end
|
||||
} else {
|
||||
span.start <= position && position < span.end
|
||||
}
|
||||
};
|
||||
let span = part
|
||||
.spans
|
||||
.iter()
|
||||
.find(matches)
|
||||
.or_else(|| part.spans.last().filter(|span| position == span.end))
|
||||
.ok_or_else(invalid_response)?;
|
||||
Ok(span.delivered_start + position - span.start)
|
||||
}
|
||||
|
||||
fn merge_annotations(part: &mut Part, value: &Value) -> Result<(), ProtocolErrorBody> {
|
||||
if let Some(values) = value.get("annotations").filter(|value| !value.is_null()) {
|
||||
for (index, annotation) in values
|
||||
.as_array()
|
||||
.ok_or_else(invalid_response)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
insert_annotation(part, index as u64, annotation)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_annotation(part: &mut Part, index: u64, value: &Value) -> Result<(), ProtocolErrorBody> {
|
||||
if let Some(previous) = part.annotations.get(&index) {
|
||||
if previous != value {
|
||||
return Err(invalid_response());
|
||||
}
|
||||
} else {
|
||||
part.annotations.insert(index, value.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bind_id(item: &mut OutputItem, id: Option<&str>) -> Result<(), ProtocolErrorBody> {
|
||||
if let Some(id) = id {
|
||||
if item.id.as_deref().is_some_and(|previous| previous != id) {
|
||||
@@ -542,11 +876,109 @@ fn append_url_citations(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn function_fields(item: &Value) -> Result<(&str, &str, &str), ProtocolErrorBody> {
|
||||
fn is_auxiliary_output(kind: &str) -> bool {
|
||||
// 服务端工具记录和压缩密文不要求 Chat 客户端执行动作,也不投影为可读内容
|
||||
matches!(
|
||||
kind,
|
||||
"web_search_call"
|
||||
| "file_search_call"
|
||||
| "code_interpreter_call"
|
||||
| "mcp_call"
|
||||
| "mcp_list_tools"
|
||||
| "compaction"
|
||||
| "compaction_summary"
|
||||
| "context_compaction"
|
||||
)
|
||||
}
|
||||
|
||||
fn reasoning_parts(item: &Value) -> Result<Vec<(bool, u64, &str)>, ProtocolErrorBody> {
|
||||
let mut result = Vec::new();
|
||||
// 只交付上游明确公开的推理文本,不解读或暴露 encrypted_content
|
||||
for (field, kind) in [("summary", "summary_text"), ("content", "reasoning_text")] {
|
||||
if let Some(parts) = item.get(field).filter(|value| !value.is_null()) {
|
||||
for (index, part) in parts
|
||||
.as_array()
|
||||
.ok_or_else(invalid_response)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
if part["type"] == kind {
|
||||
result.push((
|
||||
field == "summary",
|
||||
index as u64,
|
||||
required_str(part, "text")?,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn image_payload(item: &Value) -> Result<Option<Value>, ProtocolErrorBody> {
|
||||
let Some(data) = item.get("result").filter(|value| !value.is_null()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = data.as_str().ok_or_else(invalid_response)?;
|
||||
if data.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let format = item
|
||||
.get("output_format")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("png");
|
||||
let mime = match format {
|
||||
"png" => "image/png",
|
||||
"jpeg" | "jpg" => "image/jpeg",
|
||||
"webp" => "image/webp",
|
||||
_ => return Err(invalid_response()),
|
||||
};
|
||||
Ok(Some(
|
||||
json!({"type":"image_url", "image_url":{"url":format!("data:{mime};base64,{data}")}}),
|
||||
))
|
||||
}
|
||||
|
||||
fn append_logprobs(
|
||||
part: &mut Part,
|
||||
value: &Value,
|
||||
snapshot: bool,
|
||||
) -> Result<Vec<Value>, ProtocolErrorBody> {
|
||||
let Some(values) = value.get("logprobs").filter(|value| !value.is_null()) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let values = values.as_array().ok_or_else(invalid_response)?;
|
||||
let delta = if snapshot {
|
||||
values
|
||||
.strip_prefix(part.logprobs.as_slice())
|
||||
.ok_or_else(invalid_response)?
|
||||
} else {
|
||||
values
|
||||
};
|
||||
let delta = delta.to_vec();
|
||||
part.logprobs.extend(delta.iter().cloned());
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
fn logprobs_frame(
|
||||
identity: &ChatIdentity,
|
||||
include_usage: bool,
|
||||
kind: PartKind,
|
||||
values: Vec<Value>,
|
||||
) -> Result<Bytes, ProtocolErrorBody> {
|
||||
chunk_frame(
|
||||
identity,
|
||||
json!([{"index":0, "delta":{}, "finish_reason":null,
|
||||
"logprobs":{kind.field():values}}]),
|
||||
include_usage.then_some(Value::Null),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_fields(item: &Value, kind: ToolKind) -> Result<(&str, &str, &str), ProtocolErrorBody> {
|
||||
Ok((
|
||||
required_nonempty_str(item, "call_id")?,
|
||||
required_nonempty_str(item, "name")?,
|
||||
required_str(item, "arguments")?,
|
||||
required_str(item, kind.data_field())?,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -559,8 +991,10 @@ fn validate_output_identities(output: &[Value]) -> Result<(), ProtocolErrorBody>
|
||||
{
|
||||
return Err(invalid_response());
|
||||
}
|
||||
if item.get("type").and_then(Value::as_str) == Some("function_call")
|
||||
&& !call_ids.insert(required_nonempty_str(item, "call_id")?)
|
||||
if matches!(
|
||||
item.get("type").and_then(Value::as_str),
|
||||
Some("function_call" | "custom_tool_call")
|
||||
) && !call_ids.insert(required_nonempty_str(item, "call_id")?)
|
||||
{
|
||||
return Err(invalid_response());
|
||||
}
|
||||
@@ -592,19 +1026,41 @@ fn chat_usage(response: &Value) -> Option<Value> {
|
||||
return None;
|
||||
}
|
||||
let mut result = json!({"prompt_tokens": usage.input_tokens, "completion_tokens": usage.output_tokens, "total_tokens": usage.total_tokens});
|
||||
if response
|
||||
.pointer("/usage/input_tokens_details/cached_tokens")
|
||||
.is_some()
|
||||
{
|
||||
result["prompt_tokens_details"] = json!({"cached_tokens": usage.cached_input_tokens});
|
||||
for (source, target) in [
|
||||
("input_tokens_details", "prompt_tokens_details"),
|
||||
("output_tokens_details", "completion_tokens_details"),
|
||||
] {
|
||||
if let Some(details) = response
|
||||
.get("usage")
|
||||
.and_then(|usage| usage.get(source))
|
||||
.filter(|value| value.is_object())
|
||||
{
|
||||
result[target] = details.clone();
|
||||
}
|
||||
}
|
||||
if response
|
||||
.pointer("/usage/output_tokens_details/reasoning_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|tokens| tokens <= usage.output_tokens)
|
||||
if let Some(details) = result
|
||||
.get_mut("prompt_tokens_details")
|
||||
.and_then(Value::as_object_mut)
|
||||
&& details.contains_key("cached_tokens")
|
||||
{
|
||||
result["completion_tokens_details"] =
|
||||
json!({"reasoning_tokens": usage.reasoning_output_tokens});
|
||||
details.insert("cached_tokens".into(), usage.cached_input_tokens.into());
|
||||
}
|
||||
if let Some(details) = result
|
||||
.get_mut("completion_tokens_details")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
if details
|
||||
.get("reasoning_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.is_some_and(|tokens| tokens <= usage.output_tokens)
|
||||
{
|
||||
details.insert(
|
||||
"reasoning_tokens".into(),
|
||||
usage.reasoning_output_tokens.into(),
|
||||
);
|
||||
} else {
|
||||
details.remove("reasoning_tokens");
|
||||
}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界
|
||||
|
||||
use std::{future::Future, net::IpAddr};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::IpAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
@@ -49,6 +56,8 @@ where
|
||||
};
|
||||
let request_id = prepared.request_id().clone();
|
||||
let endpoint = input.endpoint.clone();
|
||||
let execution_entered = Arc::new(AtomicBool::new(false));
|
||||
let terminal_entered = Arc::clone(&execution_entered);
|
||||
let result = invoke_request(
|
||||
execution,
|
||||
prepared,
|
||||
@@ -60,6 +69,7 @@ where
|
||||
return Err(MiddlewareError::Rejected);
|
||||
}
|
||||
let operation = request.apply_capabilities(decode(body, headers).await?)?;
|
||||
terminal_entered.store(true, Ordering::Relaxed);
|
||||
let started = match service
|
||||
.start_prepared_provider_endpoint(
|
||||
prepared, operation,
|
||||
@@ -82,11 +92,18 @@ where
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
|
||||
Err(error) => error_response(error),
|
||||
};
|
||||
if response.headers().contains_key("x-gateway-request-id") {
|
||||
if !execution_entered.load(Ordering::Relaxed) {
|
||||
return response;
|
||||
}
|
||||
let mut response = if response.headers().contains_key("x-gateway-request-id") {
|
||||
response
|
||||
} else {
|
||||
super::with_model_request_id(response, &request_id)
|
||||
}
|
||||
};
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::client_error::ExecutionObserved);
|
||||
response
|
||||
}
|
||||
|
||||
/// 只收集正文,不提前 commit;外层完成响应变换与校验后才提交 Core
|
||||
|
||||
@@ -23,7 +23,9 @@ pub use crate::client_error::{
|
||||
|
||||
/// 严格协议 decoder/encoder 返回的安全错误 body
|
||||
pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> Response {
|
||||
(status, Json(body.into_value())).into_response()
|
||||
let mut response = (status, Json(body.clone().into_value())).into_response();
|
||||
response.extensions_mut().insert(body);
|
||||
response
|
||||
}
|
||||
|
||||
/// 下游 Client API Key 无效
|
||||
|
||||
@@ -91,6 +91,7 @@ pub(crate) async fn live_call(
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let request_id = prepared.request_id().clone();
|
||||
let started = match service
|
||||
.start_prepared_provider_endpoint(
|
||||
prepared,
|
||||
@@ -103,7 +104,7 @@ pub(crate) async fn live_call(
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
Err(error) => return with_model_request_id(gateway_error_response(&error), &request_id),
|
||||
};
|
||||
collect_live_call_response(started, uri.path().to_owned()).await
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ pub(crate) fn with_model_request_id(
|
||||
let Ok(gateway_id) = HeaderValue::from_str(request_id.as_str()) else {
|
||||
return response;
|
||||
};
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::client_error::ExecutionObserved);
|
||||
let headers = response.headers_mut();
|
||||
let usable = |value: &HeaderValue| value.to_str().is_ok_and(|id| !id.trim().is_empty());
|
||||
if !headers.get("x-request-id").is_some_and(usable) {
|
||||
|
||||
@@ -85,7 +85,7 @@ async fn peer_response(state: ApiState, client: AuthenticatedClient, uri: Uri) -
|
||||
StatusCode::FORBIDDEN,
|
||||
"CPR Link sharing is not enabled for this API key.",
|
||||
"permission_error",
|
||||
"peer_sharing_disabled",
|
||||
gateway_protocol::peer::SHARING_DISABLED_CODE,
|
||||
)
|
||||
.into_response(),
|
||||
Err(PeerLinkError::RateLimited { retry_after }) => {
|
||||
|
||||
@@ -5,7 +5,10 @@ use crate::request_context::request_client_context;
|
||||
use std::collections::VecDeque;
|
||||
use std::future::Future;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
@@ -159,6 +162,8 @@ pub(in crate::openai) async fn execute_prepared_response(
|
||||
let execution = service.execution();
|
||||
let request_id = prepared.request_id().clone();
|
||||
let cancellation = prepared.cancellation();
|
||||
let execution_entered = Arc::new(AtomicBool::new(false));
|
||||
let terminal_entered = Arc::clone(&execution_entered);
|
||||
let started_request_id = Arc::new(OnceLock::new());
|
||||
let terminal_request_id = Arc::clone(&started_request_id);
|
||||
let (admitted, keepalive) = tokio::sync::oneshot::channel();
|
||||
@@ -217,6 +222,7 @@ pub(in crate::openai) async fn execute_prepared_response(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
terminal_entered.store(true, Ordering::Relaxed);
|
||||
let started = terminal_service
|
||||
.start_prepared_response(prepared, decoded, transport, endpoint)
|
||||
.await
|
||||
@@ -276,7 +282,15 @@ pub(in crate::openai) async fn execute_prepared_response(
|
||||
} else {
|
||||
response.await
|
||||
};
|
||||
super::super::with_model_request_id(response, started_request_id.get().unwrap_or(&request_id))
|
||||
// 预留 ID 没有对应执行记录,中间件提前返回时保留 HTTP 入口关联 ID
|
||||
if execution_entered.load(Ordering::Relaxed) {
|
||||
super::super::with_model_request_id(
|
||||
response,
|
||||
started_request_id.get().unwrap_or(&request_id),
|
||||
)
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
async fn await_with_authorization<T>(
|
||||
|
||||
@@ -170,6 +170,7 @@ pub(super) async fn forward_response(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut interrupt = super::interrupt::ResponseInterrupt::default();
|
||||
let mut reconnect_after_terminal = false;
|
||||
let first = match next_body_input(
|
||||
connection,
|
||||
body.as_mut(),
|
||||
@@ -177,6 +178,7 @@ pub(super) async fn forward_response(
|
||||
&mut interrupt,
|
||||
&request_id,
|
||||
false,
|
||||
&mut reconnect_after_terminal,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -231,6 +233,7 @@ pub(super) async fn forward_response(
|
||||
&mut interrupt,
|
||||
&request_id,
|
||||
terminal_write.is_some(),
|
||||
&mut reconnect_after_terminal,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -252,7 +255,11 @@ pub(super) async fn forward_response(
|
||||
.filter(|_| terminal_write == Some(WriteOutcome::Written));
|
||||
let outcome = replay.commit(&capture, delivered);
|
||||
body.close().await;
|
||||
return outcome;
|
||||
return if reconnect_after_terminal {
|
||||
ForwardOutcome::ReconnectIfIdle
|
||||
} else {
|
||||
outcome
|
||||
};
|
||||
}
|
||||
BodyInput::Frame(Ok(None)) => {
|
||||
return fail_body(
|
||||
@@ -348,6 +355,7 @@ async fn next_body_input(
|
||||
interrupt: &mut super::interrupt::ResponseInterrupt,
|
||||
request_id: &Arc<str>,
|
||||
terminal_seen: bool,
|
||||
reconnect_after_terminal: &mut bool,
|
||||
) -> BodyInput {
|
||||
// 处理控制帧时继续持有同一个读取 future,不能取消正在加工正文的中间件
|
||||
let frame = body.next_frame();
|
||||
@@ -383,8 +391,11 @@ async fn next_body_input(
|
||||
}
|
||||
}
|
||||
event = response_control.receive(), if terminal_seen => {
|
||||
if forward_control_event(connection, response_control, event).await == ForwardOutcome::Disconnect {
|
||||
return BodyInput::Disconnect;
|
||||
match forward_control_event(connection, response_control, event).await {
|
||||
ForwardOutcome::Disconnect => return BodyInput::Disconnect,
|
||||
// 终态写出后仍需完成结算,失效通知留到响应收尾后再触发空闲关闭
|
||||
ForwardOutcome::ReconnectIfIdle => *reconnect_after_terminal = true,
|
||||
ForwardOutcome::Continue => {}
|
||||
}
|
||||
}
|
||||
frame = &mut frame => return BodyInput::Frame(frame),
|
||||
@@ -824,7 +835,7 @@ pub(super) async fn forward_control_event(
|
||||
) -> ForwardOutcome {
|
||||
let Ok(payload) = event else {
|
||||
control.clear();
|
||||
return ForwardOutcome::Continue;
|
||||
return ForwardOutcome::ReconnectIfIdle;
|
||||
};
|
||||
if connection
|
||||
.send_text(payload, WriteContext::connection(FramePhase::Data))
|
||||
|
||||
@@ -201,7 +201,8 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
|
||||
event
|
||||
}
|
||||
event = response_control.receive() => {
|
||||
if forward_control_event(&mut connection, &response_control, event).await == ForwardOutcome::Disconnect {
|
||||
let outcome = forward_control_event(&mut connection, &response_control, event).await;
|
||||
if disconnect_after_forward(&mut connection, outcome, reconnect_for_replay).await {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
@@ -415,12 +416,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
|
||||
&response_control,
|
||||
)
|
||||
.await;
|
||||
if outcome == ForwardOutcome::Disconnect
|
||||
|| (outcome == ForwardOutcome::ReconnectIfIdle
|
||||
&& reconnect_for_replay
|
||||
&& !connection.is_expired()
|
||||
&& connection.close_for_replay_if_idle().await)
|
||||
{
|
||||
if disconnect_after_forward(&mut connection, outcome, reconnect_for_replay).await {
|
||||
break;
|
||||
}
|
||||
if connection.is_expired() {
|
||||
@@ -432,6 +428,18 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
|
||||
connection.log_summary(request_count);
|
||||
}
|
||||
|
||||
async fn disconnect_after_forward(
|
||||
connection: &mut ResponsesWebSocketConnection,
|
||||
outcome: ForwardOutcome,
|
||||
reconnect_for_replay: bool,
|
||||
) -> bool {
|
||||
outcome == ForwardOutcome::Disconnect
|
||||
|| (outcome == ForwardOutcome::ReconnectIfIdle
|
||||
&& reconnect_for_replay
|
||||
&& !connection.is_expired()
|
||||
&& connection.close_for_replay_if_idle().await)
|
||||
}
|
||||
|
||||
// 尚未建立模型请求的拒绝也保留原文入口,用返回给客户端的 correlation ID 检索
|
||||
fn trace_rejected_request(
|
||||
correlation_id: &str,
|
||||
|
||||
@@ -406,6 +406,7 @@ fn account_page_item(
|
||||
AccountPageItem {
|
||||
protection_rules: Vec::new(),
|
||||
account: AccountRecord {
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
id: "acct_cooldown".to_owned(),
|
||||
@@ -446,7 +447,8 @@ fn account_page_item(
|
||||
#[tokio::test]
|
||||
async fn account_usage_preserves_periods_cache_counts_and_missing_values() {
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::accounts::AccountUsage;
|
||||
use gateway_admin::model::accounts::{AccountModelUsage, AccountUsage};
|
||||
use gateway_admin::model::observability::RequestHealth;
|
||||
use gateway_admin::model::provider_credentials::{
|
||||
ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
};
|
||||
@@ -497,7 +499,35 @@ async fn account_usage_preserves_periods_cache_counts_and_missing_values() {
|
||||
bucket_start: now,
|
||||
request_count: 1,
|
||||
}],
|
||||
models: Vec::new(),
|
||||
models: [
|
||||
(
|
||||
"mixed",
|
||||
RequestHealth::from_counts(1, 2, 3, 1, 1),
|
||||
Some(now),
|
||||
),
|
||||
("failed", RequestHealth::from_counts(0, 1, 0, 0, 0), None),
|
||||
("no-sample", RequestHealth::from_counts(0, 1, 1, 0, 1), None),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(model, health, last_used_at)| AccountModelUsage {
|
||||
model: model.to_owned(),
|
||||
request_count: u64::from(last_used_at.is_some()),
|
||||
health,
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_input_tokens: None,
|
||||
cache_write_input_tokens: None,
|
||||
reasoning_output_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,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
*fixture.account_usage.lock().unwrap() = vec![local_usage.clone()];
|
||||
*fixture.provider_quota.lock().unwrap() =
|
||||
@@ -545,6 +575,13 @@ async fn account_usage_preserves_periods_cache_counts_and_missing_values() {
|
||||
"general"
|
||||
}
|
||||
);
|
||||
assert_eq!(usage["models"][0]["successRateDisplay"], "33.3%");
|
||||
assert!((usage["models"][0]["successRate"].as_f64().unwrap() - 100.0 / 3.0).abs() < 0.001);
|
||||
assert_eq!(usage["models"][1]["successRate"], 0.0);
|
||||
assert_eq!(usage["models"][1]["successRateDisplay"], "0.0%");
|
||||
assert!(usage["models"][1]["lastUsedAt"].is_null());
|
||||
assert!(usage["models"][2]["successRate"].is_null());
|
||||
assert_eq!(usage["models"][2]["successRateDisplay"], "—");
|
||||
assert_eq!(usage["requestCount"], 1);
|
||||
assert_eq!(usage["totalTokens"], 120);
|
||||
if authentication_kind == "oauth" {
|
||||
|
||||
@@ -578,6 +578,7 @@ mod provider {
|
||||
total_slots: None,
|
||||
},
|
||||
account: AccountRecord {
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
|
||||
@@ -650,7 +650,9 @@ impl SettingsStore for MemorySettingsStore {
|
||||
max_concurrent_per_account: command.values.max_concurrent_per_account,
|
||||
max_waiting_per_key: command.values.max_waiting_per_key,
|
||||
max_waiting_per_account: command.values.max_waiting_per_account,
|
||||
concurrency_wait_timeout_seconds: command.values.concurrency_wait_timeout_seconds,
|
||||
key_queue_timeout_seconds: command.values.key_queue_timeout_seconds,
|
||||
account_queue_timeout_seconds: command.values.account_queue_timeout_seconds,
|
||||
switch_account_on_queue_failure: command.values.switch_account_on_queue_failure,
|
||||
max_retry_after_seconds: command.values.max_retry_after_seconds,
|
||||
openai_guardian_reserved_concurrency: command
|
||||
.values
|
||||
@@ -1831,7 +1833,7 @@ impl SystemOperations for UnusedSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
@@ -1895,7 +1897,9 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
max_concurrent_per_account: 3,
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
key_queue_timeout_seconds: 30,
|
||||
account_queue_timeout_seconds: 30,
|
||||
switch_account_on_queue_failure: false,
|
||||
max_retry_after_seconds: 240,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
|
||||
@@ -757,7 +757,9 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(3800),
|
||||
first_token_p95_ms: Some(1200),
|
||||
non_completion_count: 0,
|
||||
cancelled_count: 0,
|
||||
incomplete_count: 0,
|
||||
caller_error_count: 0,
|
||||
retry_count: 3,
|
||||
retried_request_count: 1,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
|
||||
@@ -354,9 +354,12 @@ impl ProxyProbe for SuccessfulProbe {
|
||||
proxy.expose_url(),
|
||||
"http://test-user:private-password@proxy.example:8080/"
|
||||
);
|
||||
} else {
|
||||
assert!(detect_location);
|
||||
}
|
||||
let exit_ip = if proxy.is_some() {
|
||||
"203.0.113.2"
|
||||
} else {
|
||||
"203.0.113.3"
|
||||
};
|
||||
ProxyTestResult {
|
||||
location: if detect_location {
|
||||
ProxyLocationDetection::Detected { location: serde_json::from_value(json!({"country":"JP","region":"Tokyo","city":"Tokyo","timezone":"Asia/Tokyo"})).unwrap() }
|
||||
@@ -365,8 +368,8 @@ impl ProxyProbe for SuccessfulProbe {
|
||||
},
|
||||
success: true,
|
||||
latency_ms: 15,
|
||||
exit_ip: Some("203.0.113.2".parse().unwrap()),
|
||||
exit_ipv4: Some("203.0.113.2".parse().unwrap()),
|
||||
exit_ip: Some(exit_ip.parse().unwrap()),
|
||||
exit_ipv4: Some(exit_ip.parse().unwrap()),
|
||||
exit_ipv6: None,
|
||||
message: "Connected".to_owned(),
|
||||
}
|
||||
@@ -567,11 +570,50 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
|
||||
async fn proxy_probe_checks_direct_egress_without_changing_saved_proxies() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let (_, created) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/create",
|
||||
Some(json!([{"name": "Saved", "proxyUrl": "http://saved.example:8080"}])),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
for detect_location in [false, true] {
|
||||
let (status, probed) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/probe",
|
||||
Some(json!({"proxyUrl": "", "detectLocation": detect_location})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(probed["data"]["success"], true);
|
||||
assert_eq!(probed["data"]["exitIpv4"], "203.0.113.3");
|
||||
assert_eq!(
|
||||
probed["data"]["location"]["status"],
|
||||
if detect_location {
|
||||
"detected"
|
||||
} else {
|
||||
"notRequested"
|
||||
}
|
||||
);
|
||||
}
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["page"]["total"], 1);
|
||||
assert_eq!(
|
||||
listed["data"]["items"][0],
|
||||
created["data"][0]["data"]["record"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_rejects_missing_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": " "}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
|
||||
@@ -56,7 +56,9 @@ fn update_body() -> Value {
|
||||
"openaiAccountAffinity": "relaxed",
|
||||
"maxAccountRotations": 3,
|
||||
"openaiSessionAffinityTtlHours": 24,
|
||||
"concurrencyWaitTimeoutSeconds": 30,
|
||||
"keyQueueTimeoutSeconds": 30,
|
||||
"accountQueueTimeoutSeconds": 30,
|
||||
"switchAccountOnQueueFailure": false,
|
||||
"maxRetryAfterSeconds": 240,
|
||||
"responsesMaxDecompressedBodyBytes": 67108864,
|
||||
"rotationStrategy": "round_robin",
|
||||
@@ -659,8 +661,10 @@ fn concurrency_queue_fields_validate_bounds_and_accept_disabled_queues() {
|
||||
for (field, invalid) in [
|
||||
("maxWaitingPerKey", 1001),
|
||||
("maxWaitingPerAccount", 1001),
|
||||
("concurrencyWaitTimeoutSeconds", 0),
|
||||
("concurrencyWaitTimeoutSeconds", 121),
|
||||
("keyQueueTimeoutSeconds", 0),
|
||||
("accountQueueTimeoutSeconds", 0),
|
||||
("keyQueueTimeoutSeconds", 121),
|
||||
("accountQueueTimeoutSeconds", 121),
|
||||
] {
|
||||
let mut body = update_body();
|
||||
body[field] = json!(invalid);
|
||||
@@ -670,7 +674,7 @@ fn concurrency_queue_fields_validate_bounds_and_accept_disabled_queues() {
|
||||
let mut body = update_body();
|
||||
body["maxWaitingPerKey"] = json!(0);
|
||||
body["maxWaitingPerAccount"] = json!(1000);
|
||||
body["concurrencyWaitTimeoutSeconds"] = json!(120);
|
||||
body["accountQueueTimeoutSeconds"] = json!(120);
|
||||
serde_json::from_value::<UpdateRuntimeSettingsRequest>(body)
|
||||
.unwrap()
|
||||
.validate()
|
||||
|
||||
@@ -27,7 +27,22 @@ use super::{AdminTestFixture, AdminTestState, unavailable_system};
|
||||
#[test]
|
||||
fn update_detail_query_should_default_to_no_refresh_and_reject_unknown_fields() {
|
||||
let query: UpdateDetailQuery = serde_json::from_str("{}").expect("empty query object");
|
||||
assert!(!query.refresh());
|
||||
use gateway_admin::model::system::SystemUpdateCheck;
|
||||
assert_eq!(query.check(), SystemUpdateCheck::Detail);
|
||||
for (json, mode) in [
|
||||
(r#"{"refresh":true}"#, SystemUpdateCheck::Refresh),
|
||||
(
|
||||
r#"{"summaryOnly":true,"refresh":true}"#,
|
||||
SystemUpdateCheck::Summary,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<UpdateDetailQuery>(json)
|
||||
.unwrap()
|
||||
.check(),
|
||||
mode
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_str::<UpdateDetailQuery>(r#"{"refresh":true,"extra":1}"#).is_err());
|
||||
}
|
||||
|
||||
@@ -191,7 +206,7 @@ impl SystemOperations for ProgressSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
check: gateway_admin::model::system::SystemUpdateCheck,
|
||||
channel: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
use gateway_admin::model::system::{SystemUpdateChannel, SystemUpdatePolicy};
|
||||
@@ -206,11 +221,16 @@ impl SystemOperations for ProgressSystem {
|
||||
deployment_mode: "docker".into(),
|
||||
build_type: "release".into(),
|
||||
release_url: None,
|
||||
notes: self.notes.clone(),
|
||||
notes: if check == gateway_admin::model::system::SystemUpdateCheck::Summary {
|
||||
None
|
||||
} else {
|
||||
self.notes.clone()
|
||||
},
|
||||
cached: false,
|
||||
update_supported: true,
|
||||
unsupported_reason: None,
|
||||
warning: None,
|
||||
retry_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,7 +301,10 @@ impl SystemOperations for ProgressSystem {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_detail_should_validate_and_forward_the_temporary_channel() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem::default())).await;
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem {
|
||||
notes: Some("notes".into()),
|
||||
}))
|
||||
.await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let router = app(fixture.state());
|
||||
for (authenticated, query, expected) in [
|
||||
@@ -289,6 +312,8 @@ async fn update_detail_should_validate_and_forward_the_temporary_channel() {
|
||||
(true, "channel=nightly", StatusCode::BAD_REQUEST),
|
||||
(true, "channel=beta&extra=true", StatusCode::BAD_REQUEST),
|
||||
(true, "channel=beta", StatusCode::OK),
|
||||
(true, "channel=beta&refresh=true", StatusCode::OK),
|
||||
(true, "channel=beta&summaryOnly=true", StatusCode::OK),
|
||||
(true, "", StatusCode::OK),
|
||||
] {
|
||||
let mut request = Request::builder()
|
||||
@@ -307,6 +332,10 @@ async fn update_detail_should_validate_and_forward_the_temporary_channel() {
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.expect("body"))
|
||||
.expect("json");
|
||||
assert_eq!(
|
||||
body["data"]["notes"].is_null(),
|
||||
query.contains("summaryOnly")
|
||||
);
|
||||
assert_eq!(
|
||||
body["data"]["policy"]["channel"],
|
||||
if query.is_empty() { "stable" } else { "beta" }
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! HTTP 入口拒绝可检索,诊断摘要不保存凭据和请求内容
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::support::RecordingDiagnostics;
|
||||
|
||||
async fn fixture() -> (axum::Router, Arc<RecordingDiagnostics>) {
|
||||
let admin = crate::admin::AdminTestFixture::new().await;
|
||||
let diagnostics = Arc::new(RecordingDiagnostics::default());
|
||||
let router = crate::openai::api_bundle_with_diagnostics(
|
||||
admin.services,
|
||||
gateway_api::ApiConfig {
|
||||
asset_directory: env!("CARGO_MANIFEST_DIR").into(),
|
||||
cors_allowed_origins: Vec::new(),
|
||||
request_timeout_seconds: None,
|
||||
request_id_header: "x-correlation-id".into(),
|
||||
},
|
||||
diagnostics.clone(),
|
||||
)
|
||||
.router();
|
||||
(router, diagnostics)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decode_rejections_record_the_ingress_id_and_safe_parameter_without_model_facts() {
|
||||
for (path, body, field) in [
|
||||
(
|
||||
"/v1/chat/completions",
|
||||
json!({"model":"model-a", "messages":[{"role":"user", "content":[{"type":"SENSITIVE_CONTENT"}]}]}),
|
||||
"messages[0].content[0].type",
|
||||
),
|
||||
(
|
||||
"/v1/responses",
|
||||
json!({"model":42, "input":"SENSITIVE_CONTENT"}),
|
||||
"model",
|
||||
),
|
||||
] {
|
||||
let (router, diagnostics) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::post(path)
|
||||
.header("authorization", "Bearer unused-client-route-key")
|
||||
.header("x-correlation-id", "ingress-test-608")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(response.headers()["x-correlation-id"], "ingress-test-608");
|
||||
let body: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(body["error"]["param"], field);
|
||||
let failures = diagnostics.0.lock().unwrap();
|
||||
assert_eq!(failures.len(), 1);
|
||||
let failure = &failures[0];
|
||||
assert_eq!(failure.component, "api");
|
||||
assert_eq!(failure.operation, "http_ingress");
|
||||
assert_eq!(failure.correlation_id.as_deref(), Some("ingress-test-608"));
|
||||
let summary: Value = serde_json::from_str(&failure.message).unwrap();
|
||||
assert_eq!(summary["param"], field);
|
||||
assert_eq!(summary["endpoint"], path);
|
||||
assert!(failure.upstream_status.is_none());
|
||||
assert!(failure.account_id.is_none());
|
||||
assert!(!failure.message.contains("SENSITIVE_CONTENT"));
|
||||
assert!(!failure.message.contains("unused-client-route-key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentication_unknown_routes_and_method_errors_are_observable() {
|
||||
for (method, path, status, code) in [
|
||||
(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing_authorization",
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/v1/messages?secret=SENSITIVE_QUERY",
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_endpoint",
|
||||
),
|
||||
(
|
||||
"DELETE",
|
||||
"/v1/chat/completions",
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"method_not_allowed",
|
||||
),
|
||||
] {
|
||||
let (router, diagnostics) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.body(Body::from("SENSITIVE_CONTENT"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), status);
|
||||
let failures = diagnostics.0.lock().unwrap();
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(failures[0].kind, code);
|
||||
assert!(!failures[0].message.contains("SENSITIVE"));
|
||||
assert!(failures[0].correlation_id.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Recorder {
|
||||
dump_enabled: bool,
|
||||
events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
|
||||
}
|
||||
|
||||
impl tracing::Subscriber for Recorder {
|
||||
fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
|
||||
metadata.target() != "request_dump" || self.dump_enabled
|
||||
}
|
||||
fn register_callsite(
|
||||
&self,
|
||||
_: &'static tracing::Metadata<'static>,
|
||||
) -> tracing::subscriber::Interest {
|
||||
tracing::subscriber::Interest::sometimes()
|
||||
}
|
||||
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
|
||||
tracing::span::Id::from_u64(1)
|
||||
}
|
||||
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
|
||||
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
|
||||
fn enter(&self, _: &tracing::span::Id) {}
|
||||
fn exit(&self, _: &tracing::span::Id) {}
|
||||
fn event(&self, event: &tracing::Event<'_>) {
|
||||
struct Fields(std::collections::BTreeMap<String, String>);
|
||||
impl tracing::field::Visit for Fields {
|
||||
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||
self.0.insert(field.name().to_owned(), value.to_owned());
|
||||
}
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.insert(field.name().to_owned(), format!("{value:?}"));
|
||||
}
|
||||
}
|
||||
let mut fields = Fields(std::collections::BTreeMap::new());
|
||||
event.record(&mut fields);
|
||||
fields
|
||||
.0
|
||||
.insert("target".into(), event.metadata().target().into());
|
||||
self.events.lock().unwrap().push(fields.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn original_rejected_request_is_dumped_only_when_explicitly_enabled() {
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use tracing::instrument::WithSubscriber as _;
|
||||
for enabled in [false, true] {
|
||||
let recorder = Recorder {
|
||||
dump_enabled: enabled,
|
||||
events: Arc::default(),
|
||||
};
|
||||
let events = recorder.events.clone();
|
||||
async {
|
||||
let (router, _) = fixture().await;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/v1/chat/completions")
|
||||
.header("authorization", "Bearer unused-client-route-key")
|
||||
.body(Body::from("{\"secret\":\"SENSITIVE_CONTENT\","))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::post("/v1/unmatched")
|
||||
.body(Body::from("UNCONSUMED_CONTENT"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
}
|
||||
.with_subscriber(recorder)
|
||||
.await;
|
||||
let events = events.lock().unwrap();
|
||||
let dumps = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event
|
||||
.get("target")
|
||||
.is_some_and(|target| target == "request_dump")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(!dumps.is_empty(), enabled);
|
||||
if enabled {
|
||||
let original = dumps
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event
|
||||
.get("stage")
|
||||
.is_some_and(|stage| stage == "ingress.request.body")
|
||||
})
|
||||
.flat_map(|event| STANDARD.decode(event.get("body_base64").unwrap()).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(original, b"{\"secret\":\"SENSITIVE_CONTENT\",");
|
||||
let completion = dumps
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event
|
||||
.get("stage")
|
||||
.is_some_and(|stage| stage == "ingress.body.finished")
|
||||
})
|
||||
.map(|event| {
|
||||
serde_json::from_slice::<Value>(
|
||||
&STANDARD.decode(event.get("body_base64").unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(completion.iter().any(
|
||||
|event| event["stage"] == "ingress.request.body" && event["complete"] == false
|
||||
));
|
||||
assert!(
|
||||
completion
|
||||
.iter()
|
||||
.any(|event| event["stage"] == "ingress.request.body"
|
||||
&& event["complete"] == true)
|
||||
);
|
||||
}
|
||||
for event in events.iter().filter(|event| {
|
||||
event
|
||||
.get("target")
|
||||
.is_none_or(|target| target != "request_dump")
|
||||
}) {
|
||||
assert!(
|
||||
!event
|
||||
.values()
|
||||
.any(|value| value.contains("SENSITIVE_CONTENT")
|
||||
|| value.contains("unused-client-route-key"))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod admin;
|
||||
mod architecture;
|
||||
mod auth;
|
||||
mod diagnostics;
|
||||
mod health;
|
||||
mod key_usage;
|
||||
mod middleware;
|
||||
|
||||
@@ -622,3 +622,50 @@ async fn error_after_admission_keepalive_uses_chat_error_encoding() {
|
||||
assert_eq!(fixture.trace.commits.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(fixture.trace.response_commit.status_code(), Some(200));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_middleware_failure_is_recorded_at_ingress_without_a_reserved_model_id() {
|
||||
let middleware = Arc::new(
|
||||
RequestMiddleware {
|
||||
expected_operation: Some(gateway_core::operation::OperationKind::Generate),
|
||||
..Default::default()
|
||||
}
|
||||
.with_json_short_circuit(Bytes::from_static(b"{}")),
|
||||
);
|
||||
let mut execution = Execution::new(Session::new(vec![completed(json!([message("OK")]))]));
|
||||
execution.middleware = Some(middleware.frozen());
|
||||
let execution = Arc::new(execution);
|
||||
let diagnostics = Arc::new(crate::support::RecordingDiagnostics::default());
|
||||
let admin = crate::admin::AdminTestFixture::new().await;
|
||||
let router = gateway_api::initialize(
|
||||
gateway_api::ApiConfig {
|
||||
asset_directory: env!("CARGO_MANIFEST_DIR").into(),
|
||||
cors_allowed_origins: Vec::new(),
|
||||
request_timeout_seconds: None,
|
||||
request_id_header: "x-correlation-id".into(),
|
||||
},
|
||||
execution.clone(),
|
||||
admin.services,
|
||||
Vec::new(),
|
||||
Arc::new(crate::openai::EmptyWorkerHealth),
|
||||
Arc::new(crate::openai::TestLifecycle::default()),
|
||||
diagnostics.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
.router();
|
||||
let mut request = request(chat_request(false));
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-correlation-id", "ingress-short-circuit".parse().unwrap());
|
||||
let response = router.oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
|
||||
assert!(!response.headers().contains_key("x-gateway-request-id"));
|
||||
assert!(execution.captured.lock().unwrap().is_none());
|
||||
let failures = diagnostics.0.lock().unwrap();
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(failures[0].operation, "http_ingress");
|
||||
assert_eq!(
|
||||
failures[0].correlation_id.as_deref(),
|
||||
Some("ingress-short-circuit")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,19 +229,11 @@ async fn refuses_known_unrepresentable_features_before_starting_execution() {
|
||||
for (name, value) in [
|
||||
("n", json!(2)),
|
||||
("store", json!(true)),
|
||||
("stop", json!(["stop"])),
|
||||
("seed", json!(7)),
|
||||
("frequency_penalty", json!(0.3)),
|
||||
("logprobs", json!(true)),
|
||||
("functions", json!([{"name":"old"}])),
|
||||
("function_call", json!("auto")),
|
||||
("input", json!("not chat")),
|
||||
("previous_response_id", json!("resp_previous")),
|
||||
("audio", json!({"voice":"alloy","format":"wav"})),
|
||||
(
|
||||
"prediction",
|
||||
json!({"type":"content","content":"predicted"}),
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new(Session::new(Vec::new())).await;
|
||||
let mut body = chat_request(false);
|
||||
@@ -260,14 +252,8 @@ async fn refuses_known_unrepresentable_features_before_starting_execution() {
|
||||
#[tokio::test]
|
||||
async fn rejects_unmapped_message_content_and_identity_fields() {
|
||||
for message in [
|
||||
json!({"role":"user","name":"speaker","content":"hello"}),
|
||||
json!({"role":"tool","tool_call_id":"call-lookup","name":42,"content":"result"}),
|
||||
json!({"role":"user","content":[{"type":"file","file":{"file_id":"file-example"}}]}),
|
||||
json!({"role":"user","content":[{"type":"file","file":{"file_data":"data:application/pdf;base64,JVBERg=="}}]}),
|
||||
json!({"role":"user","content":[{"type":"file","file":{"filename":"a.pdf","file_data":"data:application/pdf;base64,"}}]}),
|
||||
json!({"role":"user","content":[{"type":"file","file":{"filename":"a.txt","file_data":"data:text/plain;base64,SGk="}}]}),
|
||||
json!({"role":"assistant","content":[{"type":"file","file":{"filename":"a.pdf","file_data":"data:application/pdf;base64,JVBERg=="}}]}),
|
||||
json!({"role":"assistant","content":"hello","reasoning_content":"private"}),
|
||||
json!({"role":"assistant","content":[{"type":"text","text":"history","prompt_cache_breakpoint":{"type":"default"}}]}),
|
||||
json!({"role":"tool","content":"unassociated output"}),
|
||||
] {
|
||||
@@ -313,3 +299,121 @@ async fn assistant_tool_history_keeps_arguments_opaque_and_cache_annotations_str
|
||||
);
|
||||
assert_eq!(converted["moderation"], json!({"future":{"keep":true}}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_chat_controls_degrade_without_blocking_upstream_execution() {
|
||||
for stop in [json!("end"), json!(["end", "stop"]), json!([])] {
|
||||
let converted = convert(json!({"model":"model-a", "messages":[
|
||||
{"role":"user", "name":"speaker", "content":"hello"},
|
||||
{"role":"assistant", "reasoning_content":"display-only", "content":"previous"}
|
||||
], "stop":stop, "seed":7, "frequency_penalty":0.3, "presence_penalty":0.5,
|
||||
"logit_bias":{"10":-10}, "prediction":{"type":"content", "content":"hint"}}))
|
||||
.await;
|
||||
assert_eq!(converted["input"][0]["content"][0]["text"], "hello");
|
||||
assert_eq!(converted["input"][1]["content"][0]["text"], "previous");
|
||||
for field in [
|
||||
"stop",
|
||||
"seed",
|
||||
"frequency_penalty",
|
||||
"presence_penalty",
|
||||
"logit_bias",
|
||||
"prediction",
|
||||
] {
|
||||
assert!(converted.get(field).is_none(), "{field}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_options_and_native_extensions_reach_upstream_without_model_prejudgment() {
|
||||
let mut request = chat_request(false);
|
||||
request.as_object_mut().unwrap().extend(json!({
|
||||
"max_output_tokens":10, "temperature":3.0, "top_p":1.5,
|
||||
"reasoning":{"effort":"low", "summary":"auto", "future":true}, "reasoning_effort":"high",
|
||||
"text":{"verbosity":"low", "future":true}, "verbosity":"high",
|
||||
"response_format":{"type":"future_format", "future":true},
|
||||
"tools":[{"type":"file_search", "vector_store_ids":["vs_test"]}],
|
||||
"tool_choice":"future_choice", "logprobs":true, "top_logprobs":3,
|
||||
"include":["reasoning.encrypted_content"], "truncation":"auto", "max_tool_calls":5
|
||||
}).as_object().unwrap().clone());
|
||||
let converted = convert(request).await;
|
||||
assert_eq!(converted["max_output_tokens"], 10);
|
||||
assert_eq!(converted["temperature"], 3.0);
|
||||
assert_eq!(converted["top_p"], 1.5);
|
||||
assert_eq!(
|
||||
converted["reasoning"],
|
||||
json!({"effort":"high", "summary":"auto", "future":true})
|
||||
);
|
||||
assert_eq!(
|
||||
converted["text"],
|
||||
json!({"verbosity":"high", "future":true, "format":{"type":"future_format", "future":true}})
|
||||
);
|
||||
assert_eq!(converted["tool_choice"], "future_choice");
|
||||
assert_eq!(
|
||||
converted["include"],
|
||||
json!([
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs"
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
converted["tools"][0]["vector_store_ids"],
|
||||
json!(["vs_test"])
|
||||
);
|
||||
assert_eq!(converted["top_logprobs"], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_tools_keep_native_input_and_mixed_call_families_through_history() {
|
||||
let schema = json!({"type":"grammar", "grammar":{"syntax":"lark", "definition":"start: /.+/"}});
|
||||
let converted = convert(json!({"model":"model-a", "tools":[
|
||||
{"type":"custom", "custom":{"name":"patch", "format":schema}},
|
||||
{"type":"function", "function":{"name":"lookup"}}
|
||||
], "tool_choice":{"type":"allowed_tools", "allowed_tools":{"mode":"auto", "tools":[
|
||||
{"type":"custom", "custom":{"name":"patch"}}, {"type":"function", "function":{"name":"lookup"}}
|
||||
]}}, "messages":[
|
||||
{"role":"assistant", "tool_calls":[
|
||||
{"id":"call_patch", "type":"custom", "custom":{"name":"patch", "input":"raw\ninput"}},
|
||||
{"id":"call_lookup", "type":"function", "function":{"name":"lookup", "arguments":"{}"}}
|
||||
]},
|
||||
{"role":"tool", "tool_call_id":"call_patch", "content":"done"},
|
||||
{"role":"tool", "tool_call_id":"call_lookup", "content":[{"type":"image_url", "image_url":{"url":"https://example.com/result.png"}}]},
|
||||
{"role":"assistant", "tool_calls":[{"id":"call_patch", "type":"function", "function":{"name":"lookup", "arguments":"{}"}}]},
|
||||
{"role":"tool", "tool_call_id":"call_patch", "content":"reused id"}
|
||||
]})).await;
|
||||
assert_eq!(
|
||||
converted["tools"][0],
|
||||
json!({"type":"custom", "name":"patch", "format":schema})
|
||||
);
|
||||
assert_eq!(
|
||||
converted["tool_choice"],
|
||||
json!({"type":"allowed_tools", "mode":"auto", "tools":[{"type":"custom","name":"patch"},{"type":"function","name":"lookup"}]})
|
||||
);
|
||||
assert_eq!(
|
||||
converted["input"][0],
|
||||
json!({"type":"custom_tool_call","call_id":"call_patch","name":"patch","input":"raw\ninput"})
|
||||
);
|
||||
assert_eq!(converted["input"][2]["type"], "custom_tool_call_output");
|
||||
assert_eq!(converted["input"][3]["output"][0]["type"], "input_image");
|
||||
assert_eq!(converted["input"][5]["type"], "function_call_output");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_and_audio_are_preserved_as_inputs_instead_of_losing_the_user_turn() {
|
||||
for file in [
|
||||
json!({"file_id":"file_test"}),
|
||||
json!({"file_url":"https://example.com/report.txt"}),
|
||||
json!({"file_data":"data:text/plain;base64,aGk=", "filename":"report.txt", "detail":"high"}),
|
||||
] {
|
||||
let converted = convert(json!({"model":"model-a", "messages":[{"role":"user", "content":[
|
||||
{"type":"file", "file":file}, {"type":"input_audio", "input_audio":{"data":"aGk=", "format":"mp3"}}
|
||||
]}]})).await;
|
||||
let mut expected = file;
|
||||
expected["type"] = json!("input_file");
|
||||
assert_eq!(converted["input"][0]["content"][0], expected);
|
||||
assert_eq!(
|
||||
converted["input"][0]["content"][1],
|
||||
json!({"type":"input_audio","audio_url":"data:audio/mpeg;base64,aGk="})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ async fn web_search_projects_citations_in_json_and_streams_the_original_links()
|
||||
async fn json_tool_arguments_and_refusal_do_not_become_assistant_text() {
|
||||
for output in [
|
||||
json!([tool("fc1", "call1", "weather", "{ \"city\" : \"北京\" }")]),
|
||||
json!([{"type":"reasoning","id":"rs1","summary":[{"type":"summary_text","text":"private"}]},
|
||||
json!([{"type":"reasoning","id":"rs1","summary":[{"type":"summary_text","text":"public summary"}]},
|
||||
{"type":"message","id":"refusal1","role":"assistant","status":"completed",
|
||||
"content":[{"type":"refusal","refusal":"cannot help"}]}]),
|
||||
] {
|
||||
@@ -141,7 +141,9 @@ async fn json_tool_arguments_and_refusal_do_not_become_assistant_text() {
|
||||
let body = json_body(response).await;
|
||||
let choice = &body["choices"][0];
|
||||
assert_eq!(choice["message"]["content"], Value::Null);
|
||||
assert!(!body.to_string().contains("private"));
|
||||
if output[0]["type"] == "reasoning" {
|
||||
assert_eq!(choice["message"]["reasoning_content"], "public summary");
|
||||
}
|
||||
if output[0]["type"] == "function_call" {
|
||||
assert_eq!(choice["finish_reason"], "tool_calls");
|
||||
assert_eq!(
|
||||
@@ -296,7 +298,7 @@ async fn extra_attributes_and_auxiliary_events_preserve_existing_chat_results()
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
wire("response.future_auxiliary", json!({"future":true})),
|
||||
wire(
|
||||
"response.reasoning_summary_text.delta",
|
||||
"response.future_reasoning.delta",
|
||||
json!({"output_index":0,"summary_index":0,"delta":"private"}),
|
||||
),
|
||||
created(),
|
||||
@@ -319,6 +321,45 @@ async fn extra_attributes_and_auxiliary_events_preserve_existing_chat_results()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_state_preserves_chat_answer_without_exposing_ciphertext() {
|
||||
for kind in ["compaction", "compaction_summary", "context_compaction"] {
|
||||
for streaming in [false, true] {
|
||||
let state =
|
||||
json!({"type":kind,"id":"ctx_1","encrypted_content":"opaque-private-state"});
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
created(),
|
||||
wire(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":0,"item":state}),
|
||||
),
|
||||
wire(
|
||||
"response.output_item.done",
|
||||
json!({"output_index":0,"item":state}),
|
||||
),
|
||||
completed(json!([state, message("preserved")])),
|
||||
]))
|
||||
.await;
|
||||
let response = fixture.post(chat_request(streaming)).await;
|
||||
if streaming {
|
||||
let (text, chunks) = sse_body(response).await;
|
||||
assert_eq!(combined(&chunks, "content"), "preserved");
|
||||
assert!(!text.contains("opaque-private-state"));
|
||||
assert_eq!(
|
||||
chunks.last().unwrap()["choices"][0]["finish_reason"],
|
||||
"stop"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(body["choices"][0]["message"]["content"], "preserved");
|
||||
assert_eq!(body["choices"][0]["finish_reason"], "stop");
|
||||
assert!(!body.to_string().contains("opaque-private-state"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incomplete_projects_only_supported_finish_reasons() {
|
||||
for (reason, expected) in [
|
||||
@@ -355,7 +396,7 @@ async fn incomplete_projects_only_supported_finish_reasons() {
|
||||
async fn encrypted_reasoning_stays_private_while_public_text_and_usage_are_projected() {
|
||||
let reasoning = json!({
|
||||
"id":"rs_private","type":"reasoning",
|
||||
"summary":[{"type":"summary_text","text":"PRIVATE_SUMMARY_MARKER"}],
|
||||
"summary":[{"type":"summary_text","text":"PUBLIC_SUMMARY_MARKER"}],
|
||||
"encrypted_content":"OPAQUE_ENCRYPTED_REASONING_MARKER"
|
||||
});
|
||||
for streaming in [false, true] {
|
||||
@@ -367,7 +408,7 @@ async fn encrypted_reasoning_stays_private_while_public_text_and_usage_are_proje
|
||||
),
|
||||
wire(
|
||||
"response.reasoning_summary_text.delta",
|
||||
json!({"item_id":"rs_private","output_index":0,"summary_index":0,"delta":"PRIVATE_SUMMARY_MARKER"}),
|
||||
json!({"item_id":"rs_private","output_index":0,"summary_index":0,"delta":"PUBLIC_SUMMARY_MARKER"}),
|
||||
),
|
||||
wire(
|
||||
"response.output_item.done",
|
||||
@@ -377,7 +418,13 @@ async fn encrypted_reasoning_stays_private_while_public_text_and_usage_are_proje
|
||||
"response.output_item.done",
|
||||
json!({"output_index":1,"item":message("public answer")}),
|
||||
),
|
||||
completed(json!([reasoning, message("public answer")])),
|
||||
wire("response.completed", json!({"response":{
|
||||
"id":"resp_chat_test","object":"response","model":"upstream-model","status":"completed",
|
||||
"output":[reasoning,message("public answer")],
|
||||
"usage":{"input_tokens":11,"output_tokens":7,"total_tokens":18,
|
||||
"input_tokens_details":{"cached_tokens":3,"cache_write_tokens":4},
|
||||
"output_tokens_details":{"reasoning_tokens":2,"accepted_prediction_tokens":1}}
|
||||
}})),
|
||||
]))
|
||||
.await;
|
||||
let mut request = chat_request(streaming);
|
||||
@@ -406,12 +453,17 @@ async fn encrypted_reasoning_stays_private_while_public_text_and_usage_are_proje
|
||||
)
|
||||
};
|
||||
assert_eq!(content, "public answer");
|
||||
assert!(!body.contains("PRIVATE_SUMMARY_MARKER"), "{body}");
|
||||
assert!(body.contains("PUBLIC_SUMMARY_MARKER"), "{body}");
|
||||
assert!(
|
||||
!body.contains("OPAQUE_ENCRYPTED_REASONING_MARKER"),
|
||||
"{body}"
|
||||
);
|
||||
assert_eq!(usage["completion_tokens_details"]["reasoning_tokens"], 2);
|
||||
assert_eq!(
|
||||
usage["completion_tokens_details"]["accepted_prediction_tokens"],
|
||||
1
|
||||
);
|
||||
assert_eq!(usage["prompt_tokens_details"]["cache_write_tokens"], 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +471,7 @@ async fn encrypted_reasoning_stays_private_while_public_text_and_usage_are_proje
|
||||
async fn unrepresentable_business_output_fails_without_successful_chat_completion() {
|
||||
for output in [
|
||||
json!({"id":"future_output","type":"future_business_output","payload":"unrepresentable"}),
|
||||
json!({"id":"ig_native","type":"image_generation_call","status":"completed","result":"OPAQUE_IMAGE_MARKER"}),
|
||||
json!({"id":"action_native","type":"computer_call","status":"completed","call_id":"call_action","action":{"type":"click","x":1,"y":2}}),
|
||||
] {
|
||||
for streaming in [false, true] {
|
||||
let fixture =
|
||||
@@ -660,3 +712,183 @@ async fn conflicting_text_snapshot_fails_instead_of_appending_replacement_conten
|
||||
.any(|chunk| chunk["choices"][0]["finish_reason"].is_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_and_function_calls_keep_distinct_shapes_in_json_and_interleaved_streams() {
|
||||
let custom = json!({"id":"ct_1","type":"custom_tool_call","call_id":"call_custom","name":"patch","input":"first\nsecond"});
|
||||
let function = json!({"id":"fc_1","type":"function_call","call_id":"call_function","name":"lookup","arguments":"{}"});
|
||||
for streaming in [false, true] {
|
||||
let mut initial_custom = custom.clone();
|
||||
initial_custom["input"] = json!("");
|
||||
let mut initial_function = function.clone();
|
||||
initial_function["arguments"] = json!("");
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
created(),
|
||||
wire(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":0,"item":initial_custom}),
|
||||
),
|
||||
wire(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":1,"item":initial_function}),
|
||||
),
|
||||
wire(
|
||||
"response.custom_tool_call_input.delta",
|
||||
json!({"output_index":0,"item_id":"ct_1","delta":"first\n"}),
|
||||
),
|
||||
wire(
|
||||
"response.function_call_arguments.delta",
|
||||
json!({"output_index":1,"item_id":"fc_1","delta":"{}"}),
|
||||
),
|
||||
wire(
|
||||
"response.custom_tool_call_input.done",
|
||||
json!({"output_index":0,"item_id":"ct_1","input":"first\nsecond"}),
|
||||
),
|
||||
completed(json!([custom, function])),
|
||||
]))
|
||||
.await;
|
||||
let response = fixture.post(chat_request(streaming)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
if streaming {
|
||||
let (_, chunks) = sse_body(response).await;
|
||||
let calls = chunks
|
||||
.iter()
|
||||
.filter_map(|chunk| chunk["choices"][0]["delta"]["tool_calls"].as_array())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
calls.iter().filter(|call| call["type"] == "custom").count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
calls
|
||||
.iter()
|
||||
.filter(|call| call["type"] == "function")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
calls
|
||||
.iter()
|
||||
.filter_map(|call| call["custom"]["input"].as_str())
|
||||
.collect::<String>(),
|
||||
"first\nsecond"
|
||||
);
|
||||
assert_eq!(
|
||||
calls
|
||||
.iter()
|
||||
.filter_map(|call| call["function"]["arguments"].as_str())
|
||||
.collect::<String>(),
|
||||
"{}"
|
||||
);
|
||||
assert_eq!(
|
||||
chunks.last().unwrap()["choices"][0]["finish_reason"],
|
||||
"tool_calls"
|
||||
);
|
||||
} else {
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(
|
||||
body["choices"][0]["message"]["tool_calls"],
|
||||
json!([
|
||||
{"id":"call_custom","type":"custom","custom":{"name":"patch","input":"first\nsecond"}},
|
||||
{"id":"call_function","type":"function","function":{"name":"lookup","arguments":"{}"}}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_previews_and_final_images_are_projected_once_per_distinct_snapshot() {
|
||||
let image = json!({"id":"ig_one","type":"image_generation_call","status":"completed","result":"ZmluYWw=","output_format":"webp"});
|
||||
for streaming in [false, true] {
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
created(),
|
||||
wire("response.image_generation_call.partial_image", json!({"output_index":0,"item_id":"ig_one","partial_image_b64":"cHJldmlldw==","output_format":"webp"})),
|
||||
wire("response.output_item.done", json!({"output_index":0,"item":image})),
|
||||
completed(json!([image, message("created")])),
|
||||
])).await;
|
||||
let response = fixture.post(chat_request(streaming)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
if streaming {
|
||||
let (_, chunks) = sse_body(response).await;
|
||||
let images = chunks
|
||||
.iter()
|
||||
.filter_map(|chunk| chunk["choices"][0]["delta"]["images"].as_array())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(images.len(), 2);
|
||||
assert_eq!(images[1]["index"], 0);
|
||||
assert_eq!(
|
||||
images[1]["image_url"]["url"],
|
||||
"data:image/webp;base64,ZmluYWw="
|
||||
);
|
||||
} else {
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(
|
||||
body["choices"][0]["message"]["images"][0]["image_url"]["url"],
|
||||
"data:image/webp;base64,ZmluYWw="
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_logprobs_survive_json_projection_and_stream_snapshot_deduplication() {
|
||||
let probabilities =
|
||||
json!([{"token":"hello","logprob":-0.1,"bytes":[104,101,108,108,111],"top_logprobs":[]}]);
|
||||
let mut output = message("hello");
|
||||
output["content"][0]["logprobs"] = probabilities.clone();
|
||||
for streaming in [false, true] {
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
created(),
|
||||
wire("response.output_text.delta", json!({"output_index":0,"content_index":0,"item_id":"msg_chat_test","delta":"hello","logprobs":probabilities})),
|
||||
wire("response.output_text.done", json!({"output_index":0,"content_index":0,"item_id":"msg_chat_test","text":"hello","logprobs":probabilities})),
|
||||
completed(json!([output])),
|
||||
])).await;
|
||||
let response = fixture.post(chat_request(streaming)).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
if streaming {
|
||||
let (_, chunks) = sse_body(response).await;
|
||||
let values = chunks
|
||||
.iter()
|
||||
.filter_map(|chunk| chunk["choices"][0]["logprobs"]["content"].as_array())
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(json!(values), probabilities);
|
||||
} else {
|
||||
assert_eq!(
|
||||
json_body(response).await["choices"][0]["logprobs"]["content"],
|
||||
probabilities
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_citation_offsets_follow_delivered_text_when_messages_interleave() {
|
||||
let citation = json!({"type":"url_citation","start_index":2,"end_index":4,"url":"https://example.com","title":"source"});
|
||||
let mut first = message("abcd");
|
||||
first["content"][0]["annotations"] = json!([citation]);
|
||||
let mut second = message("xy");
|
||||
second["id"] = json!("msg_second");
|
||||
let fixture = Fixture::new(Session::new(vec![
|
||||
created(),
|
||||
wire("response.output_text.delta", json!({"output_index":0,"content_index":0,"item_id":"msg_chat_test","delta":"ab"})),
|
||||
wire("response.output_text.delta", json!({"output_index":1,"content_index":0,"item_id":"msg_second","delta":"xy"})),
|
||||
wire("response.output_text.delta", json!({"output_index":0,"content_index":0,"item_id":"msg_chat_test","delta":"cd"})),
|
||||
wire("response.output_text.annotation.added", json!({"output_index":0,"content_index":0,"item_id":"msg_chat_test","annotation_index":0,"annotation":citation})),
|
||||
completed(json!([first, second])),
|
||||
])).await;
|
||||
let (_, chunks) = sse_body(fixture.post(chat_request(true)).await).await;
|
||||
assert_eq!(combined(&chunks, "content"), "abxycd");
|
||||
let annotations = chunks
|
||||
.iter()
|
||||
.filter_map(|chunk| chunk["choices"][0]["delta"]["annotations"].as_array())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(annotations.len(), 1);
|
||||
assert_eq!(annotations[0]["url_citation"]["start_index"], 4);
|
||||
assert_eq!(annotations[0]["url_citation"]["end_index"], 6);
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ fn no_eligible_provider_account_should_preserve_safe_provider_detail() {
|
||||
);
|
||||
assert_eq!(
|
||||
gateway.safe_message(),
|
||||
"No upstream provider is currently available for this request."
|
||||
"No upstream provider is available for this request."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
assert_eq!(projected.kind(), GatewayErrorKind::NoAvailableProvider);
|
||||
assert_eq!(
|
||||
projected.safe_message(),
|
||||
"No upstream provider is currently available for this request."
|
||||
"No upstream provider is available for this request."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,9 @@ async fn request_settings_reach_query_terminal_without_mutating_authenticated_ba
|
||||
settings: Some(|settings| {
|
||||
let mut runtime = serde_json::to_value(&settings.runtime).unwrap();
|
||||
runtime["model_mappings"] = serde_json::json!({"model-a":"plugin-model"});
|
||||
runtime["concurrency_wait_timeout_seconds"] = serde_json::json!(30);
|
||||
runtime["key_queue_timeout_seconds"] = serde_json::json!(30);
|
||||
runtime["account_queue_timeout_seconds"] = serde_json::json!(30);
|
||||
runtime["switch_account_on_queue_failure"] = serde_json::json!(false);
|
||||
settings.runtime = serde_json::from_value(runtime).unwrap();
|
||||
settings.fast_mode = FastMode::Disabled;
|
||||
}),
|
||||
|
||||
@@ -97,6 +97,7 @@ async fn fixture(sharing: ClientPeerSharing, plaintext: &str) -> (AdminTestFixtu
|
||||
},
|
||||
projection: resolve_account_status(&facts, now.into()),
|
||||
account: AccountRecord {
|
||||
auto_use_quota_credits: false,
|
||||
id: "acct_private_sentinel".into(),
|
||||
provider_kind: provider,
|
||||
groups: vec![group.clone()],
|
||||
|
||||
@@ -190,6 +190,8 @@ async fn client_retry_budget_should_flow_through_http_and_live_websocket() {
|
||||
struct ControlTransport {
|
||||
id: String,
|
||||
active: std::sync::atomic::AtomicBool,
|
||||
closed: CancellationToken,
|
||||
unavailable_observed: tokio::sync::Notify,
|
||||
requests: Mutex<Vec<String>>,
|
||||
sender: tokio::sync::mpsc::Sender<Value>,
|
||||
receiver: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Value>>,
|
||||
@@ -201,6 +203,8 @@ impl ControlTransport {
|
||||
Self {
|
||||
id,
|
||||
active: std::sync::atomic::AtomicBool::new(true),
|
||||
closed: CancellationToken::new(),
|
||||
unavailable_observed: tokio::sync::Notify::new(),
|
||||
requests: Mutex::default(),
|
||||
sender,
|
||||
receiver: tokio::sync::Mutex::new(receiver),
|
||||
@@ -231,13 +235,15 @@ impl ResponseControlTransport for ControlTransport {
|
||||
.map_err(|_| ResponseControlUnavailable)
|
||||
}
|
||||
async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
|
||||
self.receiver
|
||||
.lock()
|
||||
.await
|
||||
.recv()
|
||||
.await
|
||||
.map(|event| event.to_string())
|
||||
.ok_or(ResponseControlUnavailable)
|
||||
tokio::select! {
|
||||
() = self.closed.cancelled() => {
|
||||
self.unavailable_observed.notify_one();
|
||||
Err(ResponseControlUnavailable)
|
||||
}
|
||||
event = async { self.receiver.lock().await.recv().await } => {
|
||||
event.map(|event| event.to_string()).ok_or(ResponseControlUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +680,140 @@ async fn late_interrupt_after_terminal_is_sent_upstream_and_keeps_the_connection
|
||||
socket.close(None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_idle_upstream_closes_client_and_allows_a_fresh_request() {
|
||||
let provider = Arc::new(InterruptProvider::default());
|
||||
let admissions = Arc::new(InterruptAdmissions::default());
|
||||
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
|
||||
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
|
||||
send(&mut socket, create.clone()).await;
|
||||
assert_eq!(next(&mut socket).await["type"], "response.created");
|
||||
provider.complete.notify_one();
|
||||
assert_eq!(next(&mut socket).await["type"], "response.completed");
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while admissions.active.load(Ordering::SeqCst) != 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
provider
|
||||
.transport
|
||||
.lock()
|
||||
.unwrap()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.closed
|
||||
.cancel();
|
||||
assert_replay_close(&mut socket).await;
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(provider.store.finalizations.lock().unwrap().len(), 1);
|
||||
|
||||
let tokio_tungstenite::MaybeTlsStream::Plain(transport) = socket.get_ref() else {
|
||||
panic!("test server must use plain TCP");
|
||||
};
|
||||
let mut request = format!("ws://{}/v1/responses", transport.peer_addr().unwrap())
|
||||
.into_client_request()
|
||||
.unwrap();
|
||||
request.headers_mut().insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer sk_ws_interrupt"),
|
||||
);
|
||||
let (mut reconnected, _) = connect_async(request).await.unwrap();
|
||||
send(&mut reconnected, create).await;
|
||||
assert_eq!(
|
||||
next(&mut reconnected).await["response"]["id"],
|
||||
"resp_interrupt_1"
|
||||
);
|
||||
provider.complete.notify_one();
|
||||
assert_eq!(next(&mut reconnected).await["type"], "response.completed");
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
|
||||
reconnected.close(None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_upstream_waits_for_settlement_and_preserves_queued_requests() {
|
||||
for queued in [false, true] {
|
||||
let provider = Arc::new(InterruptProvider::default());
|
||||
let (release, wait_for_release) = tokio::sync::oneshot::channel();
|
||||
let admissions = Arc::new(InterruptAdmissions {
|
||||
release_gate: Mutex::new(Some(wait_for_release)),
|
||||
..Default::default()
|
||||
});
|
||||
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
|
||||
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
|
||||
send(&mut socket, create.clone()).await;
|
||||
assert_eq!(next(&mut socket).await["type"], "response.created");
|
||||
provider.complete.notify_one();
|
||||
assert_eq!(next(&mut socket).await["type"], "response.completed");
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
admissions.release_started.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let transport = provider.transport.lock().unwrap().clone().unwrap();
|
||||
transport.closed.cancel();
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
transport.unavailable_observed.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if queued {
|
||||
send(&mut socket, create).await;
|
||||
}
|
||||
socket
|
||||
.send(ClientMessage::Ping(vec![1].into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = tokio::time::timeout(Duration::from_secs(5), socket.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(reply, ClientMessage::Pong(_)));
|
||||
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(admissions.active.load(Ordering::SeqCst), 1);
|
||||
release.send(()).unwrap();
|
||||
if queued {
|
||||
assert_eq!(
|
||||
next(&mut socket).await["response"]["id"],
|
||||
"resp_interrupt_1"
|
||||
);
|
||||
provider.complete.notify_one();
|
||||
assert_eq!(next(&mut socket).await["type"], "response.completed");
|
||||
// 旧连接的失效不能关闭已绑定新上游的客户端
|
||||
socket
|
||||
.send(ClientMessage::Ping(vec![2].into()))
|
||||
.await
|
||||
.unwrap();
|
||||
let reply = tokio::time::timeout(Duration::from_secs(5), socket.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(reply, ClientMessage::Pong(_)));
|
||||
socket.close(None).await.unwrap();
|
||||
} else {
|
||||
assert_replay_close(&mut socket).await;
|
||||
assert_eq!(admissions.active.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(provider.store.finalizations.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_replay_close(socket: &mut TestSocket) {
|
||||
let message = tokio::time::timeout(Duration::from_secs(5), socket.next())
|
||||
.await
|
||||
.expect("idle upstream loss must notify the client")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(matches!(message, ClientMessage::Close(Some(frame))
|
||||
if frame.code == tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal
|
||||
&& frame.reason == "Reconnect with full input."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_interrupt_during_terminal_settlement_does_not_abort_or_reorder_requests() {
|
||||
let provider = Arc::new(InterruptProvider::default());
|
||||
|
||||
@@ -105,7 +105,7 @@ impl SystemOperations for VersionSystem {
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
_: bool,
|
||||
_: gateway_admin::model::system::SystemUpdateCheck,
|
||||
_: Option<gateway_admin::model::system::SystemUpdateChannel>,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
unreachable!("client route must not request update details")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user