mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-08 16:05:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78bc57c5cf | ||
|
|
ef11b72d8a | ||
|
|
52fb1e7180 | ||
|
|
7a7a7ed6ad | ||
|
|
b8479b59db | ||
|
|
2480f07f2d | ||
|
|
b2dc9f493d | ||
|
|
d92d310e45 | ||
|
|
3caf83131a | ||
|
|
af35cde374 | ||
|
|
5c115fb38f | ||
|
|
ed558e5396 | ||
|
|
929f781a63 | ||
|
|
e0326ecba4 | ||
|
|
6057088300 | ||
|
|
e3a49a6217 | ||
|
|
b32f87fa8a | ||
|
|
91e8105e5d | ||
|
|
c4d9576639 | ||
|
|
015cd60be1 | ||
|
|
bad2a245ab | ||
|
|
b49b4528e7 | ||
|
|
d678f350c1 | ||
|
|
17b05d4671 | ||
|
|
7148303ad6 | ||
|
|
f6538d433d | ||
|
|
406256bcc0 | ||
|
|
d912fa0c59 | ||
|
|
a166580aed | ||
|
|
d1aec7311c | ||
|
|
16d8a3f873 | ||
|
|
ac6033f6fa | ||
|
|
cef9f0b21e | ||
|
|
591f95cc18 | ||
|
|
99ba5b1120 |
@@ -15,6 +15,7 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
|
||||
| 修改服务端接口、路由、调度、账号与用量规则、持久化、后台任务或协议行为;目标是 `backend/` 中的实现、配置、迁移,或 Rust 源码 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)、所属模块、[后端自审](../../../docs/architecture.md#后端自审);涉及 Rust 时使用 `$rust-best-practices` 和下方后端注释要求,验证时读[命令](../../../docs/architecture.md#验证命令) |
|
||||
| 修改页面接口调用,或开发、排查、审查面向管理端或模型客户端的 HTTP/WS API 合同 | [API](../../../docs/api.md)对应章节;优先复用已有接口,核对调用方 → 服务端解析 → 实际查询或执行,不能只凭前端类型认定参数生效;按实际改动叠加前后端规范 |
|
||||
| 模块或调用链变更 | [Workspace 边界](../../../docs/architecture.md#3-workspace-边界)及所属模块章节,不通读整份架构 |
|
||||
| 新增、调整、清理或审查测试 | 下方[测试维护](#测试维护),按当前行为、职责归属与已有覆盖判断保留、合并或删除 |
|
||||
| 修改部署行为、`deploy/` 配置或数据库迁移 | 按范围读取[部署](../../../deploy/README.md)、[迁移](../../../backend/migrations/README.md)对应章节 |
|
||||
| 开发、排查或审查宿主 SDK、Runtime、扩展接口或独立插件;目标在 `backend/crates/gateway-plugin/` | [插件职责边界](references/plugin-boundaries.md)及所用 [SDK](../../../backend/crates/gateway-plugin/sdk/README.md) 类型与能力合同;宿主守住自身合同、资源与稳定性,不猜测或补偿插件业务错误;仅独立插件创作、排查另用 [cpr-plugin-dev](../cpr-plugin-dev/SKILL.md) |
|
||||
| 修改或审查文档 | [文档检查](references/documentation.md)及目标章节,不默认读取代码开发、页面验收或发布流程 |
|
||||
@@ -35,6 +36,16 @@ description: Codex Proxy RS 前后端开发、排障、代码审查和文档修
|
||||
4. 按变更范围执行[验证](../../../CONTRIBUTING.md#验证)及其停止条件,沿用本轮有效证据与用户已确定的验收分工,区分通过、失败、跳过与未执行;提交时补读[提交约定](../../../CONTRIBUTING.md#项目约定)
|
||||
5. 交付实际结果与验证缺口;前端按[前端流程](references/frontend.md)核对最终差异和运行证据,后端按上表检查生命周期,不把构建通过当作行为验收
|
||||
|
||||
## 测试维护
|
||||
|
||||
- 测试随当前功能维护,名称、夹具和断言描述当前输入、行为与结果;功能变更时优先更新所属模块的现有用例,不为每次改动叠加一组记录变更经过的测试
|
||||
- 清理只证明旧字段、旧路由或旧实现已经消失的用例与附带断言;未知字段校验使用合法请求基线加无业务含义的未知字段,避免因缺少必填项等其他错误而误通过
|
||||
- 同一层对同一行为的重复覆盖合并为清晰的场景矩阵,复用相邻测试夹具;跨层测试确有不同职责或失败路径时保留,不为统一而扩大测试辅助抽象
|
||||
- 断言可观察的行为与业务不变量,避免复述结构体赋值、罗列表列或字段清单、匹配 SQL 文本与源码布局;项目明确要求的架构边界检查继续维护
|
||||
- 当前协议兼容、数据读取兼容、安全边界、并发和资源生命周期仍需覆盖,不按 `old`、`legacy` 等名称机械删除,也不能用改名代替无效用例清理
|
||||
- 不在仓库中新增或保留迁移 SQL 专项测试,包括迁移文本、schema 演变与指定版本升级用例;需要验证迁移执行、数据保留或回滚时,在临时目录编写一次性验证并使用隔离数据库,测试通过即可,临时脚本不纳入维护测试集
|
||||
- 持久化功能测试使用当前完整 schema,验证现行读写、事务与数据约束;插件运行时状态迁移等业务能力按当前功能测试,不归为迁移 SQL 专项测试
|
||||
|
||||
## 后端注释
|
||||
|
||||
- 每个 Rust 文件顶层使用简短的中文 `//!` 注释说明文件职责,覆盖生产源码、测试与构建脚本;测试文件说明测试范围或辅助用途,生成文件同步维护生成器
|
||||
|
||||
@@ -18,7 +18,7 @@ runs:
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
@@ -38,7 +38,7 @@ runs:
|
||||
|
||||
- name: Upload frontend artifact
|
||||
if: inputs.upload-artifact == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
|
||||
@@ -242,7 +242,7 @@ runs:
|
||||
|
||||
- name: Upload container diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ${{ inputs.artifact-name }}
|
||||
path: ${{ runner.temp }}/codex-proxy-rs-diagnostics
|
||||
|
||||
@@ -52,7 +52,7 @@ updates:
|
||||
- patch
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
directory: /frontend
|
||||
labels: []
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -156,6 +156,20 @@ jobs:
|
||||
echo "CPR_TEST_REDIS_URL=redis://:${redis_password}@127.0.0.1:6379"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Expose matching PostgreSQL backup and restore tools
|
||||
shell: bash
|
||||
env:
|
||||
CPR_TEST_POSTGRES_CONTAINER: ${{ job.services.postgres.id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
client_dir="$RUNNER_TEMP/pg-client"
|
||||
mkdir -p "$client_dir"
|
||||
for tool in pg_dump psql; do
|
||||
printf '#!/bin/sh\nexec docker exec -i --env PGPASSWORD "%s" %s "$@"\n' "$CPR_TEST_POSTGRES_CONTAINER" "$tool" > "$client_dir/$tool"
|
||||
chmod +x "$client_dir/$tool"
|
||||
done
|
||||
echo "$client_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
- name: Setup Rust
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload sealed plugin assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
CPR_BUILD_TYPE: ${{ needs.release-info.outputs.build_type }}
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/
|
||||
@@ -219,19 +219,19 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/frontend
|
||||
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: codex-proxy-rs-*
|
||||
path: dist/binaries
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -250,7 +250,7 @@ jobs:
|
||||
subject-path: dist/release/*
|
||||
|
||||
- name: Upload release assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
@@ -283,19 +283,19 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download frontend artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/docker/web/dist
|
||||
|
||||
- name: Download Docker binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/docker-artifacts/codex-proxy-rs-${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
- name: Download sealed plugin assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: plugin-release-assets
|
||||
path: dist/plugin-release-assets
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
printf '%s\n' "$IMAGE_DIGEST" > "dist/digests/linux-${IMAGE_ARCH}.txt"
|
||||
|
||||
- name: Upload verified image digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: image-digest-${{ matrix.os }}-${{ matrix.arch }}
|
||||
path: dist/digests/*.txt
|
||||
@@ -390,13 +390,13 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: release-assets
|
||||
path: dist/release
|
||||
|
||||
- name: Download verified image digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: image-digest-*
|
||||
path: dist/digests
|
||||
|
||||
@@ -33,14 +33,23 @@ jobs:
|
||||
...params,
|
||||
per_page: 100,
|
||||
});
|
||||
// 按 PR 作者识别官方机器人,维护者重开或编辑 PR 时仍使用同一判断
|
||||
const isDependabot = pr.user.login === 'dependabot[bot]'
|
||||
&& pr.user.type === 'Bot'
|
||||
&& pr.head.repo?.full_name === `${owner}/${repo}`;
|
||||
const isDependencyUpdate = file => isDependabot
|
||||
&& file.status === 'modified'
|
||||
&& (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(file.filename)
|
||||
|| /^\.github\/actions\/.+\/(?:action\.ya?ml|Dockerfile)$/.test(file.filename));
|
||||
const isProtectedRule = path => typeof path === 'string'
|
||||
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md')
|
||||
|| path === 'CONTRIBUTING.md'
|
||||
|| path === '.agents/skills' || path.startsWith('.agents/skills/')
|
||||
|| path === '.github' || path.startsWith('.github/'));
|
||||
|
||||
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
|
||||
if (!files.some(file => isProtectedRule(file.filename) || isProtectedRule(file.previous_filename))) {
|
||||
// 依赖更新只豁免已有文件的修改,重命名前后的受保护路径仍参与检查
|
||||
if (!files.some(file => !isDependencyUpdate(file)
|
||||
&& (isProtectedRule(file.filename) || isProtectedRule(file.previous_filename)))) {
|
||||
core.info('No protected contribution rule changes detected.');
|
||||
return;
|
||||
}
|
||||
@@ -56,7 +65,7 @@ jobs:
|
||||
const marker = '<!-- agents-md-guard -->';
|
||||
const body = [
|
||||
marker,
|
||||
'佬友,我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'我看到这个 PR 涉及受保护的协作规则文件,按仓库约定需要先关闭',
|
||||
'',
|
||||
'保护范围包括根目录或子目录的 `AGENTS.md`、根目录的 `CONTRIBUTING.md`,以及 `.agents/skills/` 和 `.github/` 中的所有文件,涵盖新增、修改、删除和重命名',
|
||||
'',
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
cache: pnpm
|
||||
|
||||
+8
-4
@@ -23,10 +23,12 @@
|
||||
|
||||
AI 草拟或发布 Issue、PR 讨论及审查意见的回复时:
|
||||
|
||||
- 用第一人称“我”,自然称呼对方为“佬友”,不逐句重复,也不用“我们”“本助手”代称自己
|
||||
- 客气、委婉地回应具体问题,补充信息或提出不同意见时说明缘由,避免命令式、责备式表达
|
||||
- 围绕问题、事实和结论展开,能自然省略主语时省略;需要说明个人判断或行动归属时用“我”,避免反复以“我”起句或强调自己,不用“我们”“本助手”代称自己
|
||||
- 普通回复直接回应问题,不加问候语或称呼;向对方提问时称呼“佬”,同一条回复不重复称呼
|
||||
- 使用自然、客气的日常交流口吻,适度用“看了下”“方便补充一下吗”等缓和表达,例如“我看了截图”可写为“我看了下截图”,不机械堆叠客套或语气词
|
||||
- 补充信息或提出不同意见时先说明观察和依据,再温和表达判断,避免生硬纠正、命令或责备;已确认的事实保持明确,不为委婉写成猜测
|
||||
- 使用自然短句,简单回复用短段,并列信息较多时再分点;少用句号,避免客服套话、机械模板和空泛保证
|
||||
- 只把实际完成的排查、复现、修改和验证写成“我已……”;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
- 排查、复现、修改和验证的完成状态与实际一致;未采纳的意见说明依据,未确认、待处理或未验证的内容如实交代,说清结论、限制和下一步
|
||||
|
||||
## 问题与方案依据
|
||||
|
||||
@@ -84,7 +86,9 @@ PR 不接受以下协作规则文件的新增、修改、删除或重命名:
|
||||
- `.agents/skills/` 中的所有文件,包括技能入口、引用文档、配置和脚本
|
||||
- `.github/` 中的所有文件,包括工作流、Actions、Issue/PR 模板和依赖更新配置
|
||||
|
||||
调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
官方 `dependabot[bot]` 从本仓库分支发起的依赖更新可修改 `.github/workflows/` 中已有的 YAML 工作流,以及 `.github/actions/` 下已有的 `action.yml`、`action.yaml` 和 `Dockerfile`,继续执行正常审查与 CI;其他受保护文件以及新增、删除、重命名不在例外范围内
|
||||
|
||||
除此之外,调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/rules.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查
|
||||
|
||||
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流
|
||||
|
||||
|
||||
Generated
+26
-23
@@ -198,7 +198,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -856,7 +856,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -910,7 +910,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1657,7 +1657,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1720,7 +1720,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -1903,6 +1903,7 @@ dependencies = [
|
||||
name = "gateway-store"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-credential-types",
|
||||
@@ -1915,6 +1916,7 @@ dependencies = [
|
||||
"gateway-admin",
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"redis",
|
||||
"secrecy",
|
||||
"serde",
|
||||
@@ -3266,7 +3268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3626,7 +3628,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3831,9 +3833,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -3999,7 +4001,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4014,10 +4016,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -4098,9 +4101,9 @@ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -4480,9 +4483,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4580,7 +4583,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4734,9 +4737,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
version = "0.7.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5051,9 +5054,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.27.0"
|
||||
version = "1.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||
checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -5220,9 +5223,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.5"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ opt-level = 2
|
||||
[profile.dev.package.blake2]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."sha2@0.11.0"]
|
||||
[profile.dev.package."sha2@0.11.1"]
|
||||
opt-level = 2
|
||||
|
||||
[profile.dev.package."miniz_oxide@0.9.1"]
|
||||
|
||||
@@ -96,6 +96,10 @@ async fn execute(
|
||||
Some(plugin_runtime.policy_registry()),
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
|
||||
@@ -31,6 +31,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
Some(plugin_runtime.execution_registry()),
|
||||
Some(plugin_runtime.frontend_authentication_registry()),
|
||||
);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_snapshots(&core.snapshots())
|
||||
);
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
|
||||
@@ -106,8 +106,11 @@ fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
)
|
||||
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
|
||||
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
|
||||
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
|
||||
.env(
|
||||
"CPR_DATABASE_PASSWORD",
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh",
|
||||
)
|
||||
.env("CPR_REDIS_PASSWORD", "ManagedRedis9!abcdefGh")
|
||||
.status()
|
||||
.expect("run isolated environment override test");
|
||||
assert!(status.success());
|
||||
@@ -152,14 +155,6 @@ fn config_loader_should_ignore_unknown_fields_in_configuration_sections() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_tls_and_fingerprint_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["tls"] = serde_json::json!({});
|
||||
document["openai"]["fingerprint"] = serde_json::json!({"browser": "removed"});
|
||||
parse_config(&document.to_string()).expect("removed provider settings are ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_known_fields_alongside_unknown_fields() {
|
||||
for port in [serde_json::json!(0), serde_json::json!("not-a-port")] {
|
||||
@@ -189,7 +184,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
if case == "unused" {
|
||||
document["openai"]["wire_profile"]["location"] = serde_json::json!(UNUSED_SECRET);
|
||||
document["openai"]["unused_setting"] = serde_json::json!(UNUSED_SECRET);
|
||||
} else if case == "missing" {
|
||||
document["host"]["listen"]
|
||||
.as_object_mut()
|
||||
@@ -229,7 +224,7 @@ fn config_loader_should_report_startup_configuration_diagnostics() {
|
||||
);
|
||||
match case {
|
||||
"unused" => assert!(
|
||||
stderr.contains("openai.wire_profile") && stderr.contains("已忽略"),
|
||||
stderr.contains("openai.unused_setting") && stderr.contains("已忽略"),
|
||||
"{stderr}"
|
||||
),
|
||||
"missing" => assert!(stderr.contains("host.listen.port"), "{stderr}"),
|
||||
@@ -253,19 +248,7 @@ fn config_loader_should_allow_missing_provider_configuration() {
|
||||
let mut document = valid_config_document();
|
||||
document.as_object_mut().unwrap().remove("openai");
|
||||
document.as_object_mut().unwrap().remove("xai");
|
||||
parse_config(&document.to_string()).expect("provider defaults require no YAML identity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_ignore_removed_provider_identity_sections() {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["wire_profile"] = serde_json::json!({"codex_version":"legacy-invalid"});
|
||||
document["xai"] = serde_json::json!({"wire_profile": {"client_identifier": "legacy-client"}});
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("removed identities ignored");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains("legacy-invalid"));
|
||||
assert!(!debug.contains("legacy-client"));
|
||||
parse_config(&document.to_string()).expect("providers use defaults when omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -282,13 +265,53 @@ fn config_loader_should_reject_embedded_database_password() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_non_hex_postgres_password() {
|
||||
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
|
||||
fn config_loader_should_accept_managed_store_passwords() {
|
||||
for password in [
|
||||
"RdsTest9!abcdefGh".to_owned(),
|
||||
"RdsTest9!abcdefGhRdsTest9!abcdefGh".to_owned(),
|
||||
POSTGRES_PASSWORD.to_owned(),
|
||||
"x".repeat(128),
|
||||
"Managed@:/?#%&+Password9!".to_owned(),
|
||||
"数据库密码".repeat(4),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
let (config, _directory) =
|
||||
parse_config(&document.to_string()).expect("managed store password");
|
||||
assert!(!format!("{config:?}").contains(&password));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_wrong_length_redis_password() {
|
||||
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
|
||||
fn config_loader_should_reject_invalid_store_passwords() {
|
||||
for password in [
|
||||
String::new(),
|
||||
"x".repeat(15),
|
||||
"x".repeat(129),
|
||||
" ".repeat(32),
|
||||
"\u{3000}".repeat(6),
|
||||
"数据库密码".repeat(9),
|
||||
"RdsTest9!abcdefGh\u{0000}".to_owned(),
|
||||
"RdsTest9!abcdefGh\n".to_owned(),
|
||||
"RdsTest9!abcdefGh\t".to_owned(),
|
||||
"RdsTest9!abcdefGh\u{007f}".to_owned(),
|
||||
] {
|
||||
for field in ["database", "redis"] {
|
||||
let mut document = valid_config_document();
|
||||
document["store"][field]["password"] = serde_json::json!(password);
|
||||
assert_rejected(document.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_embedded_redis_password() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"redis://127.0.0.1:6379/",
|
||||
"redis://default:embedded@127.0.0.1:6379/",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,7 @@ use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
task::{DaemonTask, WorkerDaemonContext, WorkerTaskError},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -75,18 +75,20 @@ impl BackupTask {
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(cancellation).await })
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(context).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
async fn run_daemon(&self, cancellation: CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
async fn run_daemon(&self, context: WorkerDaemonContext) -> Result<(), WorkerTaskError> {
|
||||
let cancellation = context.cancellation();
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle(&cancellation).await?;
|
||||
self.run_cycle(cancellation).await?;
|
||||
context.succeeded();
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return Ok(()),
|
||||
@@ -706,6 +708,10 @@ impl BackupTask {
|
||||
fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
match error.code() {
|
||||
code::CANCELLED => (code::CANCELLED, "备份已取消"),
|
||||
code::ANALYTICS_NOT_READY => (
|
||||
code::ANALYTICS_NOT_READY,
|
||||
"用量统计正在重建,请在统计就绪后重试备份",
|
||||
),
|
||||
code::STAGING_SPACE_EXHAUSTED => (code::STAGING_SPACE_EXHAUSTED, "暂存磁盘空间不足"),
|
||||
_ => (code::PG_DUMP_FAILED, "数据库导出失败"),
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ pub struct AccountRecord {
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy_enabled: bool,
|
||||
pub credential_state: CredentialState,
|
||||
|
||||
@@ -15,6 +15,8 @@ pub const STATE_CONFLICT: &str = "backup.state_conflict";
|
||||
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
|
||||
/// `pg_dump` 非零退出或失败
|
||||
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
|
||||
/// 用量汇总尚未完整发布
|
||||
pub const ANALYTICS_NOT_READY: &str = "backup.analytics_not_ready";
|
||||
/// 暂存磁盘空间不足
|
||||
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
|
||||
/// S3 认证失败
|
||||
|
||||
@@ -499,7 +499,7 @@ impl BackupError {
|
||||
/// 构造稳定错误码常量
|
||||
pub mod code;
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.sql.gz`
|
||||
///
|
||||
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
|
||||
/// 输出统一使用 `/` 分隔且不含空段
|
||||
@@ -510,7 +510,7 @@ pub fn build_object_key(
|
||||
) -> Result<String, BackupError> {
|
||||
let prefix = normalize_prefix(prefix)?;
|
||||
Ok(format!(
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.dump",
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.sql.gz",
|
||||
at.format("%Y"),
|
||||
at.format("%m"),
|
||||
at.format("%d"),
|
||||
@@ -519,10 +519,10 @@ pub fn build_object_key(
|
||||
))
|
||||
}
|
||||
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.sql.gz`,与对象 key 的文件名一致
|
||||
#[must_use]
|
||||
pub fn build_download_file_name(backup_id: &str) -> String {
|
||||
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
|
||||
format!("codex-proxy-rs_{}.sql.gz", short_backup_id(backup_id))
|
||||
}
|
||||
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex
|
||||
|
||||
@@ -722,10 +722,6 @@ pub struct UsageListRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
@@ -803,10 +799,6 @@ pub struct UsageRecord {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub admission_decision_ms: Option<u64>,
|
||||
pub account_selection_wait_ms: Option<u64>,
|
||||
|
||||
@@ -9,10 +9,7 @@ use std::{
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{FutureExt as _, StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext, WorkerTaskError};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -332,8 +329,9 @@ impl ImportTasksService for DefaultImportTasksService {
|
||||
pub(crate) struct ImportTaskWorker(pub Arc<DefaultImportTasksService>);
|
||||
|
||||
impl DaemonTask for ImportTaskWorker {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = context.cancellation();
|
||||
let mut active = FuturesUnordered::new();
|
||||
// 固定一个取消 future,避免循环反复注册中立 CancellationToken 的 waiter
|
||||
let cancelled = cancellation.cancelled();
|
||||
@@ -354,6 +352,7 @@ impl DaemonTask for ImportTaskWorker {
|
||||
};
|
||||
active.push(self.0.execute(id, index, input));
|
||||
}
|
||||
context.succeeded();
|
||||
tokio::select! {
|
||||
() = &mut cancelled => {},
|
||||
_ = active.next(), if !active.is_empty() => {},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! 供 backup::task 与 use_case::backup 的测试复用
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -92,6 +93,7 @@ pub(crate) fn queued_record(seed: &BackupRecordSeed) -> BackupRecord {
|
||||
/// 内存版备份仓储
|
||||
pub(crate) struct FakeBackupRepository {
|
||||
settings: Mutex<BackupSettings>,
|
||||
settings_unavailable: AtomicBool,
|
||||
records: Mutex<Vec<BackupRecord>>,
|
||||
}
|
||||
|
||||
@@ -99,10 +101,16 @@ impl FakeBackupRepository {
|
||||
pub(crate) fn new(settings: BackupSettings) -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(settings),
|
||||
settings_unavailable: AtomicBool::new(false),
|
||||
records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_settings_unavailable(&self, unavailable: bool) {
|
||||
self.settings_unavailable
|
||||
.store(unavailable, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 测试辅助:读取当前全部记录
|
||||
pub(crate) fn all_records(&self) -> Vec<BackupRecord> {
|
||||
self.records.lock().expect("records").clone()
|
||||
@@ -134,6 +142,13 @@ impl FakeBackupRepository {
|
||||
#[async_trait]
|
||||
impl BackupRepository for FakeBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
if self.settings_unavailable.load(Ordering::SeqCst) {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"backup",
|
||||
"test store unavailable",
|
||||
));
|
||||
}
|
||||
Ok(self.settings.lock().expect("settings").clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use gateway_admin::{
|
||||
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{DaemonTask, WorkerDaemonContext};
|
||||
|
||||
use super::support::{
|
||||
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
|
||||
@@ -379,3 +380,40 @@ async fn manual_backup_preserves_primary_and_final_delete_failures() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn daemon_reports_success_only_after_a_complete_cycle_and_recovers_after_store_failure() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let task = Arc::new(BackupTask::new(
|
||||
repository.clone(),
|
||||
Arc::new(FakeDumpPort::new()),
|
||||
Arc::new(FakeObjectStore::new()),
|
||||
));
|
||||
let context = WorkerDaemonContext::new(CancellationToken::new());
|
||||
repository.set_settings_unavailable(true);
|
||||
let error = task.run(context.clone()).await.unwrap_err();
|
||||
assert_eq!(error.as_safe_str(), "backup store failed");
|
||||
assert_eq!(context.completed_cycles(), 0);
|
||||
|
||||
repository.set_settings_unavailable(false);
|
||||
let recovered = WorkerDaemonContext::new(CancellationToken::new());
|
||||
let worker = tokio::spawn({
|
||||
let task = task.clone();
|
||||
let context = recovered.clone();
|
||||
async move { task.run(context).await }
|
||||
});
|
||||
recovered.progress_since(0).await;
|
||||
assert_eq!(recovered.completed_cycles(), 1);
|
||||
repository.set_settings_unavailable(true);
|
||||
tokio::time::advance(std::time::Duration::from_secs(30)).await;
|
||||
assert_eq!(
|
||||
worker.await.unwrap().unwrap_err().as_safe_str(),
|
||||
"backup store failed"
|
||||
);
|
||||
assert_eq!(recovered.completed_cycles(), 1);
|
||||
|
||||
let cancelled = WorkerDaemonContext::new(CancellationToken::new());
|
||||
cancelled.cancellation().cancel();
|
||||
task.run(cancelled.clone()).await.unwrap();
|
||||
assert_eq!(cancelled.completed_cycles(), 0);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,16 @@ use std::str::FromStr as _;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::account::OpaqueProviderData;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
use gateway_admin::model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
ProviderDocument, ProviderQuota, ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
},
|
||||
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
|
||||
settings::AdminApiKey,
|
||||
};
|
||||
|
||||
mod backup;
|
||||
@@ -99,20 +95,6 @@ fn login_command_debug_should_redact_password() {
|
||||
assert!(!format!("{command:?}").contains("secret-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_store_capability_traits_should_be_object_safe() {
|
||||
fn assert_object_safe<T: ?Sized + Send + Sync>() {}
|
||||
|
||||
assert_object_safe::<dyn AccountStore>();
|
||||
assert_object_safe::<dyn AuthStore>();
|
||||
assert_object_safe::<dyn ClientKeyStore>();
|
||||
assert_object_safe::<dyn ObservabilityStore>();
|
||||
assert_object_safe::<dyn SettingsStore>();
|
||||
|
||||
fn assert_send_object_safe<T: ?Sized + Send>() {}
|
||||
assert_send_object_safe::<dyn CredentialCommitGuard>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
let document = ProviderDocument::new(OpaqueProviderData::new(Default::default()));
|
||||
|
||||
@@ -58,7 +58,7 @@ fn object_key_shape() {
|
||||
let key = build_object_key("production/backups", "backup_abc", at).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.dump"
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.sql.gz"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3194,6 +3194,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 {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
|
||||
@@ -14,7 +14,7 @@ use gateway_admin::{
|
||||
use gateway_core::{
|
||||
lifecycle::CancellationToken,
|
||||
routing::ProviderKind,
|
||||
task::{WorkerContribution, WorkerKind, WorkerRunnable},
|
||||
task::{WorkerContribution, WorkerDaemonContext, WorkerKind, WorkerRunnable},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -75,7 +75,9 @@ fn start(bundle: &mut AdminBundle) -> (CancellationToken, tokio::task::JoinHandl
|
||||
let cancellation = CancellationToken::new();
|
||||
let token = cancellation.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
task.run(token).await.expect("import daemon exits cleanly");
|
||||
task.run(WorkerDaemonContext::new(token))
|
||||
.await
|
||||
.expect("import daemon exits cleanly");
|
||||
});
|
||||
(cancellation, handle)
|
||||
}
|
||||
|
||||
@@ -1390,10 +1390,6 @@ fn total_record(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(100),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
|
||||
@@ -21,7 +21,7 @@ fn counters_preserve_integer_precision_and_reject_json_numbers() {
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_absent() {
|
||||
async fn usage_mutations_require_a_trusted_actor() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let denied = registry
|
||||
@@ -38,21 +38,6 @@ async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_a
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let ops = registry
|
||||
.operations()
|
||||
.filter(|op| op.starts_with("usage.") || *op == "pricing.evaluate")
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ops,
|
||||
std::collections::BTreeSet::from([
|
||||
"usage.records.get",
|
||||
"usage.records.list",
|
||||
"usage.records.write",
|
||||
"usage.aggregates.list",
|
||||
"usage.aggregates.write",
|
||||
"pricing.evaluate"
|
||||
])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sdk_wire_types_match_host_contracts() {
|
||||
|
||||
@@ -120,6 +120,7 @@ pub(super) fn account_view(
|
||||
},
|
||||
weight: account.weight.get(),
|
||||
model_access: account.model_access,
|
||||
outbound_proxy_id: account.outbound_proxy_id,
|
||||
outbound_proxy_endpoint: account
|
||||
.outbound_proxy
|
||||
.as_ref()
|
||||
|
||||
@@ -267,6 +267,7 @@ pub struct AccountSummaryView {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_endpoint: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
||||
@@ -304,10 +304,6 @@ pub(crate) fn usage_list_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
first_token_latency_ms: record.first_token_ms,
|
||||
latency_ms: record.latency_ms,
|
||||
@@ -411,10 +407,6 @@ pub(crate) fn usage_record_view(
|
||||
upstream_response_ms: record.upstream_response_ms,
|
||||
upstream_api_overhead_ms: record.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: record.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: record.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: record.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: record.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: record.upstream_engine_service_tbt_ms,
|
||||
},
|
||||
attempt_index: None,
|
||||
attempt_count: u64::from(record.attempt_count),
|
||||
|
||||
@@ -230,14 +230,6 @@ pub struct UsageLatencyDetailsView {
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示
|
||||
|
||||
@@ -213,14 +213,6 @@ struct OutputTimingView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
first_reasoning_ms: Option<u64>,
|
||||
@@ -260,10 +252,6 @@ fn usage_record(value: UsageListRecord, time: crate::time::TimePresenter) -> Rec
|
||||
upstream_response_ms: value.upstream_response_ms,
|
||||
upstream_api_overhead_ms: value.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: value.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: value.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: value.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: value.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: value.upstream_engine_service_tbt_ms,
|
||||
first_event_ms: value.first_event_ms,
|
||||
first_reasoning_ms: value.first_reasoning_ms,
|
||||
first_text_ms: value.first_text_ms,
|
||||
|
||||
@@ -58,7 +58,8 @@ fn decode_response_create_inner(
|
||||
else {
|
||||
return Err(ResponseCreateFrameError::ExpectedObject);
|
||||
};
|
||||
match body.remove("type") {
|
||||
// 保持剩余字段顺序,避免末尾元数据交换到模型等路由字段之前
|
||||
match body.shift_remove("type") {
|
||||
Some(Value::String(message_type)) if message_type == "response.create" => {}
|
||||
_ => return Err(ResponseCreateFrameError::UnsupportedType),
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ async fn list_route_should_keep_camel_case_group_and_page_wire() {
|
||||
value["data"]["items"][0]["usage"]["retainedTotalUsd"],
|
||||
"5.5"
|
||||
);
|
||||
assert!(value["data"]["items"][0].get("member_count").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -9,6 +9,67 @@ use tower::ServiceExt as _;
|
||||
|
||||
use super::super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() {
|
||||
use chrono::Utc;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, OutboundProxy, QuotaState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (proxy_id, proxy_url, enabled) in [
|
||||
(
|
||||
Some("proxy-a"),
|
||||
Some("socks5h://alice:secret@127.0.0.1:50101"),
|
||||
true,
|
||||
),
|
||||
(
|
||||
Some("proxy-b"),
|
||||
Some("socks5h://bob:secret@127.0.0.1:50101"),
|
||||
false,
|
||||
),
|
||||
(None, Some("socks5h://alice:secret@127.0.0.1:50101"), true),
|
||||
(None, None, true),
|
||||
] {
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
quota: QuotaState::unknown(),
|
||||
cooldown: None,
|
||||
last_error_reason: None,
|
||||
last_error_message: None,
|
||||
};
|
||||
let mut item = account_page_item(facts, now, "oauth");
|
||||
item.account.outbound_proxy_id = proxy_id.map(str::to_owned);
|
||||
item.account.outbound_proxy = proxy_url.map(|url| OutboundProxy::parse(url).unwrap());
|
||||
item.account.outbound_proxy_enabled = enabled;
|
||||
*fixture.account.lock().unwrap() = Some(item);
|
||||
let response = admin::accounts::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/accounts")
|
||||
.header("x-request-id", "req_proxy_binding")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 32768).await.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let account = &value["data"]["items"][0];
|
||||
assert_eq!(account["outboundProxyId"], serde_json::json!(proxy_id));
|
||||
assert_eq!(
|
||||
account["outboundProxyEndpoint"],
|
||||
serde_json::json!(proxy_url.map(|_| "socks5h://127.0.0.1:50101"))
|
||||
);
|
||||
assert!(!String::from_utf8(body.to_vec()).unwrap().contains("secret"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
@@ -112,6 +173,7 @@ fn account_page_item(
|
||||
|
||||
AccountPageItem {
|
||||
account: AccountRecord {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
id: "acct_cooldown".to_owned(),
|
||||
provider_kind: gateway_core::routing::ProviderKind::new("openai").unwrap(),
|
||||
@@ -149,7 +211,7 @@ fn account_page_item(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_aliases() {
|
||||
async fn account_usage_preserves_cache_read_and_write_counts_and_missing_values() {
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::accounts::AccountUsage;
|
||||
use gateway_core::account::{AccountStatusFacts, CredentialState, QuotaState};
|
||||
@@ -215,37 +277,9 @@ async fn account_usage_returns_distinct_cache_read_and_write_tokens_without_alia
|
||||
usage["cacheWriteInputTokensDisplay"],
|
||||
if write.is_some() { "7" } else { "—" }
|
||||
);
|
||||
for alias in [
|
||||
"createdTokens",
|
||||
"createdTokensDisplay",
|
||||
"readTokens",
|
||||
"readTokensDisplay",
|
||||
] {
|
||||
assert!(usage.get(alias).is_none(), "obsolete token alias: {alias}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_credential_rotation_route_is_not_exposed() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = admin::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/admin/accounts/rotate")
|
||||
.header(header::COOKIE, "cpr_session=valid-session")
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connection_update_requires_admin_and_validates_before_calling_the_service() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
|
||||
@@ -248,9 +248,8 @@ mod profile_statistics {
|
||||
value["activityInsights"]["invocations"][0]["usageCount"],
|
||||
29
|
||||
);
|
||||
assert!(value.get("cycle").is_none());
|
||||
assert!(value.get("models").is_none());
|
||||
assert!(value.get("estimatedCost").is_none());
|
||||
assert_eq!(value["activityInsights"]["fastModePercent"], 0.0);
|
||||
assert!(value["activityInsights"]["invocations"][0]["skillId"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -375,7 +374,7 @@ mod batch_update {
|
||||
assert!(request.concurrency_limit.is_none());
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"], "enabled": true, "legacy": true
|
||||
"accountIds": ["acct_test"], "enabled": true, "unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -583,12 +582,12 @@ mod actions {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
|
||||
fn account_actions_should_validate_account_ids_and_reject_unknown_fields() {
|
||||
let id: AccountIdQuery =
|
||||
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
|
||||
assert!(id.validate().is_ok());
|
||||
let action: AccountActionRequest =
|
||||
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
|
||||
serde_json::from_value(json!({ "accountId": "invalid-id" })).expect("decode action");
|
||||
assert_eq!(action.validate().unwrap_err().field(), "accountId");
|
||||
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1"
|
||||
@@ -598,7 +597,7 @@ mod actions {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountRefreshRequest>(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
"unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
@@ -772,15 +771,14 @@ mod actions {
|
||||
assert_eq!(invalid.validate().unwrap_err().field(), "data");
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"document": {}
|
||||
"provider": "openai"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"unknownField": true,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
|
||||
@@ -11,7 +11,7 @@ use gateway_api::admin::accounts::{
|
||||
use gateway_core::operation::RawJsonPayload;
|
||||
|
||||
#[test]
|
||||
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
fn quota_forecast_projection_formats_capacity_and_preserves_null_zero() {
|
||||
let now = "2026-09-12T00:00:00Z".parse().unwrap();
|
||||
let forecast = AccountQuotaForecast {
|
||||
period: AccountUsagePeriod::Weekly,
|
||||
@@ -54,16 +54,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(week["estimatedTokensDisplay"], "1M");
|
||||
assert!(week["estimatedUsd"].is_null());
|
||||
assert_eq!(week["estimatedUsdDisplay"], "—");
|
||||
for forecast in value["forecasts"].as_array().unwrap() {
|
||||
for field in [
|
||||
"remainingTokens",
|
||||
"remainingTokensDisplay",
|
||||
"remainingUsd",
|
||||
"remainingUsdDisplay",
|
||||
] {
|
||||
assert!(forecast.get(field).is_none(), "unexpected field: {field}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
week["source"],
|
||||
serde_json::json!({
|
||||
@@ -77,9 +67,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
"usdDisplay": "$0.1234"
|
||||
})
|
||||
);
|
||||
assert!(week.get("method").is_none());
|
||||
assert!(week.get("methodDisplay").is_none());
|
||||
assert!(value.get("generatedAtDisplay").is_none());
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokens"], 0);
|
||||
assert_eq!(value["forecasts"][1]["estimatedTokensDisplay"], "0");
|
||||
assert_eq!(value["forecasts"][1]["estimatedUsd"], 0.0);
|
||||
@@ -87,7 +74,6 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
|
||||
assert_eq!(value["forecasts"][1]["period"], "monthly");
|
||||
assert_eq!(value["forecasts"][1]["targetDays"], 30.0);
|
||||
assert_eq!(value["forecasts"][1]["extrapolated"], true);
|
||||
assert!(value.get("account").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -375,18 +375,17 @@ fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateClientKeyRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0,
|
||||
"tokensPerMinute": 0
|
||||
"unknownField": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"unknownField": true,
|
||||
"name": "terminal key",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 2,
|
||||
@@ -458,7 +457,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
));
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["items"][0]["id"], "key_visible");
|
||||
assert_eq!(
|
||||
@@ -468,12 +466,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
assert_eq!(list["items"][0]["routingScope"], "all");
|
||||
assert_eq!(list["items"][0]["maxConcurrency"], 2);
|
||||
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
|
||||
assert!(list["items"][0].get("tokensPerMinute").is_none());
|
||||
assert!(list["items"][0].get("policyJson").is_none());
|
||||
assert_eq!(list["items"][0]["dailyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["weeklyLimitUsd"], "0");
|
||||
assert_eq!(list["items"][0]["dailyUsedUsd"], "0");
|
||||
assert!(list["items"][0].get("unresolvedRequests").is_none());
|
||||
assert_eq!(
|
||||
DateTime::parse_from_rfc3339(
|
||||
list["items"][0]["lastUsedAt"]
|
||||
|
||||
@@ -511,6 +511,7 @@ mod provider {
|
||||
total_slots: None,
|
||||
},
|
||||
account: AccountRecord {
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
|
||||
@@ -7,7 +7,7 @@ use gateway_api::admin::observability::{
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dashboard_query_should_parse_terminal_trend_kinds() {
|
||||
fn dashboard_query_should_parse_error_trends() {
|
||||
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
|
||||
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_accept_element_plus_pagination() {
|
||||
fn usage_query_should_accept_page_number_and_size() {
|
||||
let query: UsageQuery = serde_json::from_value(json!({
|
||||
"currentPage": 129,
|
||||
"pageSize": 10
|
||||
@@ -29,32 +29,20 @@ fn usage_query_should_accept_element_plus_pagination() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_removed_total_toggle() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"includeTotal": true})).is_err());
|
||||
fn observability_queries_should_reject_unknown_fields() {
|
||||
let query = json!({"unknownFilter": true});
|
||||
assert!(serde_json::from_value::<DashboardQuery>(query.clone()).is_err());
|
||||
assert!(serde_json::from_value::<UsageQuery>(query.clone()).is_err());
|
||||
assert!(serde_json::from_value::<OpsQuery>(query.clone()).is_err());
|
||||
assert!(serde_json::from_value::<DiagnosticsQuery>(query).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_removed_cursor_contract() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_nonstandard_page_name() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ops_query_should_reject_page_size_above_terminal_limit() {
|
||||
fn ops_query_should_reject_page_size_above_maximum() {
|
||||
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
|
||||
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ops_query_should_reject_removed_filters() {
|
||||
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
|
||||
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_query_should_keep_wire_dimension_name() {
|
||||
let query: DiagnosticsQuery =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 响应 DTO 与固定 wire 形状的合同测试
|
||||
//! 验证观测响应的指标、展示投影与查询时间边界
|
||||
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
@@ -288,10 +288,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
record.upstream_response_ms = Some(1_000);
|
||||
record.upstream_api_overhead_ms = Some(120.25);
|
||||
record.upstream_engine_ms = Some(6400.0);
|
||||
record.upstream_engine_iapi_ttft_ms = Some(650.5);
|
||||
record.upstream_engine_service_ttft_ms = Some(720.25);
|
||||
record.upstream_engine_iapi_tbt_ms = Some(18.45);
|
||||
record.upstream_engine_service_tbt_ms = Some(20.12);
|
||||
fixture
|
||||
.usage_detail
|
||||
.lock()
|
||||
@@ -362,29 +358,6 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
value["data"]["latencyDetails"]["upstreamEngineMs"],
|
||||
json!(6400.0)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTtftMs"],
|
||||
json!(650.5)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTtftMs"],
|
||||
json!(720.25)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
json!(18.45)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["latencyDetails"]["upstreamEngineServiceTbtMs"],
|
||||
json!(20.12)
|
||||
);
|
||||
assert!(value["data"].get("firstTokenMs").is_none());
|
||||
assert!(value["data"].get("firstTokenLatencyMsDisplay").is_none());
|
||||
assert!(
|
||||
value["data"]["latencyDetails"]
|
||||
.get("firstTokenMs")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"accountId": value["data"]["accountId"],
|
||||
@@ -490,8 +463,6 @@ async fn zero_attempt_failure_detail_omits_trace_and_keeps_missing_upstream_fact
|
||||
assert_eq!(data["clientStatusCode"], 503);
|
||||
assert_eq!(data["message"], "no upstream account is available");
|
||||
assert_eq!(data["model"], "coding");
|
||||
assert!(data.get("upstreamStatusCode").is_none());
|
||||
assert!(data.get("httpVersion").is_none());
|
||||
for field in [
|
||||
"provider",
|
||||
"accountId",
|
||||
@@ -642,7 +613,6 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
.await
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert!(value["data"]["items"][0].get("impactScore").is_none());
|
||||
assert_eq!(value["data"]["items"][0]["requestShare"], 0.5);
|
||||
assert_eq!(value["data"]["items"][0]["retryCount"], 3);
|
||||
assert_eq!(value["data"]["items"][0]["retryRate"], 0.5);
|
||||
@@ -734,10 +704,6 @@ pub(super) fn usage_record_with_account(
|
||||
upstream_response_ms: None,
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: None,
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
@@ -877,10 +843,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
upstream_response_ms: Some(2_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: None,
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
latency_ms: Some(31),
|
||||
admission_decision_ms: Some(1),
|
||||
account_selection_wait_ms: Some(2),
|
||||
|
||||
@@ -35,7 +35,7 @@ use std::{collections::BTreeMap, sync::Arc};
|
||||
mod management;
|
||||
|
||||
#[test]
|
||||
fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
|
||||
fn artifact_metadata_wire_preserves_identity_icons_and_contributions() {
|
||||
use gateway_admin::model::plugins::{
|
||||
PluginArtifactIcon, PluginArtifactIconVariants, PluginArtifactMetadata, PluginContribution,
|
||||
};
|
||||
@@ -70,7 +70,7 @@ fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
|
||||
secret_fields: vec![],
|
||||
state_namespaces: vec![],
|
||||
};
|
||||
let value = serde_json::to_value(metadata).expect("v2 artifact metadata JSON");
|
||||
let value = serde_json::to_value(metadata).expect("artifact metadata JSON");
|
||||
assert_eq!(value["displayName"], "Example");
|
||||
assert_eq!(value["publisher"], "test");
|
||||
assert!(value["author"].is_null());
|
||||
@@ -80,33 +80,29 @@ fn artifact_metadata_wire_exposes_v2_identity_and_contributions() {
|
||||
value["contributes"]["middleware"]["id"],
|
||||
"test.example.middleware"
|
||||
);
|
||||
assert!(value.get("capabilities").is_none());
|
||||
assert!(value.get("permissionDescriptions").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_binding_wire_uses_contribution_without_capability_alias() {
|
||||
fn instance_binding_requires_a_contribution_and_rejects_unknown_fields() {
|
||||
use gateway_admin::model::plugins::instances::PluginCapabilityBinding;
|
||||
|
||||
let binding = serde_json::from_value::<PluginCapabilityBinding>(serde_json::json!({
|
||||
let input = serde_json::json!({
|
||||
"contribution": "test.example.usage",
|
||||
"stage": "observation",
|
||||
"order": 0,
|
||||
"failurePolicy": "observe"
|
||||
}))
|
||||
.expect("v2 contribution binding");
|
||||
});
|
||||
let binding = serde_json::from_value::<PluginCapabilityBinding>(input.clone())
|
||||
.expect("contribution binding");
|
||||
assert_eq!(binding.contribution, "test.example.usage");
|
||||
|
||||
assert!(
|
||||
serde_json::from_value::<PluginCapabilityBinding>(serde_json::json!({
|
||||
"capability": "usage",
|
||||
"stage": "observation",
|
||||
"order": 0,
|
||||
"failurePolicy": "observe"
|
||||
}))
|
||||
.is_err(),
|
||||
"legacy capability binding must be rejected"
|
||||
);
|
||||
let mut missing = input.clone();
|
||||
missing.as_object_mut().unwrap().remove("contribution");
|
||||
assert!(serde_json::from_value::<PluginCapabilityBinding>(missing).is_err());
|
||||
|
||||
let mut unknown = input;
|
||||
unknown["unknownField"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<PluginCapabilityBinding>(unknown).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -381,7 +377,7 @@ async fn plugin_upload_endpoints_require_admin_and_bound_raw_bodies() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artifact_acceptance_returns_the_flat_install_contract() {
|
||||
async fn artifact_acceptance_returns_installed_artifact_and_configuration_status() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode},
|
||||
@@ -418,22 +414,11 @@ async fn artifact_acceptance_returns_the_flat_install_contract() {
|
||||
assert_eq!(result["defaultInstanceId"], serde_json::Value::Null);
|
||||
assert_eq!(result["configurationRequired"], false);
|
||||
assert_eq!(result["artifact"]["metadata"]["sha256"], "a".repeat(64));
|
||||
assert!(
|
||||
result["artifact"]["metadata"]
|
||||
.get("permissionDescriptions")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
result["artifact"]["metadata"]
|
||||
.get("requestedPermissions")
|
||||
.is_none()
|
||||
);
|
||||
assert!(result["artifact"]["acceptedAt"].is_string());
|
||||
assert!(result.get("mutation").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_instance_policy_fields_and_extra_accept_fields_are_rejected() {
|
||||
async fn plugin_mutations_reject_unknown_fields() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
@@ -442,24 +427,25 @@ async fn removed_instance_policy_fields_and_extra_accept_fields_are_rejected() {
|
||||
|
||||
let fixture = super::AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (path, body) in [
|
||||
(
|
||||
"/api/admin/plugins/instances",
|
||||
serde_json::json!({
|
||||
"name":"Example",
|
||||
"artifactSha256":"a".repeat(64),
|
||||
"enabled":false,
|
||||
"trustedProcess":true,
|
||||
"configuration":{},
|
||||
"grants":[],
|
||||
"bindings":[]
|
||||
}),
|
||||
),
|
||||
let instance = serde_json::json!({
|
||||
"name":"Example",
|
||||
"artifactSha256":"a".repeat(64),
|
||||
"enabled":false,
|
||||
"configuration":{},
|
||||
"bindings":[]
|
||||
});
|
||||
serde_json::from_value::<gateway_admin::model::plugins::instances::ConfigurePluginInstance>(
|
||||
instance.clone(),
|
||||
)
|
||||
.expect("valid instance request");
|
||||
for (path, mut body) in [
|
||||
("/api/admin/plugins/instances", instance),
|
||||
(
|
||||
"/api/admin/plugins/artifacts/accept",
|
||||
serde_json::json!({"sha256":"a".repeat(64),"trusted":true}),
|
||||
serde_json::json!({"sha256":"a".repeat(64)}),
|
||||
),
|
||||
] {
|
||||
body["unknownField"] = serde_json::json!(true);
|
||||
let response = gateway_api::admin::router::<super::AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
|
||||
@@ -181,7 +181,7 @@ fn privacy_policy_is_required_and_rejects_unknown_contract_fields() {
|
||||
.remove("codexPrivacyPolicy");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
|
||||
let mut unknown = update_body();
|
||||
unknown["codexPrivacyPolicy"]["protectedFields"] = json!([]);
|
||||
unknown["codexPrivacyPolicy"]["unknownField"] = json!(true);
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(unknown).is_err());
|
||||
}
|
||||
|
||||
@@ -471,110 +471,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use gateway_admin::model::Revision;
|
||||
use gateway_admin::model::settings::RuntimeSettings;
|
||||
use gateway_api::admin::settings::RuntimeSettingsView;
|
||||
use gateway_core::account::RotationStrategy;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(update_body()).expect("decode settings");
|
||||
request.validate().expect("fixture settings must validate");
|
||||
|
||||
let request_fields: BTreeSet<String> = update_body()
|
||||
.as_object()
|
||||
.expect("request body object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let settings = RuntimeSettings {
|
||||
request_profiles: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
model_mappings: request
|
||||
.model_mappings
|
||||
.iter()
|
||||
.map(|(public, upstream)| {
|
||||
Ok((
|
||||
PublicModelId::new(public.clone())?,
|
||||
UpstreamModelId::new(upstream.clone())?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, gateway_core::error::IdentifierError>>()
|
||||
.expect("valid model mappings"),
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
.expect("fixture rotation strategy"),
|
||||
updated_at: chrono::Utc::now(),
|
||||
values: gateway_admin::model::settings::RuntimeSettingsValues {
|
||||
codex_privacy_policy: Default::default(),
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
refresh_margin_seconds: request.values.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(request.values.refresh_concurrency).expect("u32"),
|
||||
max_concurrent_per_account: u32::try_from(request.values.max_concurrent_per_account)
|
||||
.expect("u32"),
|
||||
max_waiting_per_key: 0,
|
||||
max_waiting_per_account: 0,
|
||||
concurrency_wait_timeout_seconds: 30,
|
||||
openai_guardian_reserved_concurrency: 0,
|
||||
openai_account_affinity: gateway_core::account::AccountAffinity::Relaxed,
|
||||
max_account_rotations: 3,
|
||||
openai_session_affinity_ttl_hours: 24,
|
||||
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
|
||||
smart_scheduling: request.values.smart_scheduling,
|
||||
min_codex_desktop_version: request.values.min_codex_desktop_version,
|
||||
min_codex_cli_version: request.values.min_codex_cli_version,
|
||||
usage_retention_days: u32::try_from(request.values.usage_retention_days).expect("u32"),
|
||||
ops_event_retention_days: u32::try_from(request.values.ops_event_retention_days)
|
||||
.expect("u32"),
|
||||
audit_retention_days: u32::try_from(request.values.audit_retention_days).expect("u32"),
|
||||
account_auto_freeze_enabled: true,
|
||||
account_auto_freeze_threshold: 12,
|
||||
account_auto_freeze_window_seconds: 600,
|
||||
account_auto_freeze_duration_seconds: 7_200,
|
||||
account_auto_freeze_probe_enabled: true,
|
||||
account_auto_freeze_probe_model: None,
|
||||
account_auto_freeze_adaptive_concurrency: true,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
};
|
||||
|
||||
let response_fields: BTreeSet<String> = serde_json::to_value(RuntimeSettingsView::from((
|
||||
settings,
|
||||
gateway_api::TimePresenter::new(Default::default()),
|
||||
)))
|
||||
.expect("serialize view")
|
||||
.as_object()
|
||||
.expect("view object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut expected_fields = request_fields;
|
||||
expected_fields.insert("providerRequestProfiles".to_owned());
|
||||
expected_fields.insert("openaiClientProfile".to_owned());
|
||||
expected_fields.insert("xaiClientProfile".to_owned());
|
||||
expected_fields.insert("updatedAt".to_owned());
|
||||
expected_fields.insert("updatedAtDisplay".to_owned());
|
||||
expected_fields.insert("smartSchedulingDefaults".to_owned());
|
||||
|
||||
assert_eq!(response_fields, expected_fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_accepts_json_bytes_with_flattened_fields() {
|
||||
serde_json::from_slice::<UpdateRuntimeSettingsRequest>(
|
||||
&serde_json::to_vec(&update_body()).unwrap(),
|
||||
)
|
||||
.expect("settings JSON bytes must decode like the HTTP request body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
|
||||
use gateway_admin::model::settings::{ReplaceRuntimeSettings, RuntimeSettings};
|
||||
@@ -610,17 +506,9 @@ fn settings_service_contract_stays_flat_and_rejects_unknown_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_unknown_revision_field() {
|
||||
fn settings_request_should_reject_unknown_fields() {
|
||||
let mut body = update_body();
|
||||
body["expectedConfigRevision"] = json!(7);
|
||||
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_removed_bucket_retention() {
|
||||
let mut body = update_body();
|
||||
body["bucketRetentionDays"] = json!(365);
|
||||
body["unknownSetting"] = json!(true);
|
||||
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
@@ -960,36 +848,23 @@ fn decompression_setting_should_reject_invalid_values() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_reject_removed_global_fast_policy() {
|
||||
async fn settings_update_rejects_unknown_fields_without_writing() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let app = app(fixture.state());
|
||||
let initial = fixture.settings.settings.lock().unwrap().clone();
|
||||
for value in [json!(true), json!(false), Value::Null] {
|
||||
let mut body = update_body();
|
||||
body["disableFast"] = value;
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
|
||||
}
|
||||
let mut body = update_body();
|
||||
body["unknownSetting"] = json!(true);
|
||||
let response = app
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response_json(response).await["data"]
|
||||
.get("disableFast")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn update_event_stream_should_preserve_event_id_and_download_progress_perc
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_should_return_accepted_and_report_restart_only_through_status() {
|
||||
async fn update_should_return_accepted_and_expose_restart_status() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem::default())).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let router = app(fixture.state());
|
||||
@@ -110,7 +110,6 @@ async fn update_should_return_accepted_and_report_restart_only_through_status()
|
||||
.expect("JSON");
|
||||
assert_eq!(body["data"]["operationId"], "update-1");
|
||||
assert_eq!(body["data"]["targetVersion"], "0.2.0");
|
||||
assert!(body["data"].get("needRestart").is_none());
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
|
||||
@@ -297,15 +297,15 @@ async fn unified_login_returns_server_identity_and_rotates_the_previous_session(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_rejects_legacy_type_without_replacing_the_current_session() {
|
||||
async fn login_rejects_missing_mode_and_unknown_fields_without_replacing_the_session() {
|
||||
let (app, _) = auth_app().await;
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for body in [
|
||||
json!({"type": "key", "apiKey": RAW_KEY}),
|
||||
json!({"type": "admin", "password": "strong-admin-password"}),
|
||||
json!({"mode": "key", "type": "key", "apiKey": RAW_KEY}),
|
||||
json!({"mode": "admin", "type": "admin", "password": "strong-admin-password"}),
|
||||
json!({"apiKey": RAW_KEY}),
|
||||
json!({"password": "strong-admin-password"}),
|
||||
json!({"mode": "key", "unknownField": true, "apiKey": RAW_KEY}),
|
||||
json!({"mode": "admin", "unknownField": true, "password": "strong-admin-password"}),
|
||||
] {
|
||||
let response = login(&app, body, Some(&cookie)).await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
@@ -458,26 +458,26 @@ async fn key_store_outage_is_unavailable_not_expired_or_authenticated() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_auth_routes_do_not_fall_back_to_spa_or_accept_old_cookies() {
|
||||
async fn unknown_auth_routes_return_json_not_found() {
|
||||
let (app, _) = auth_app().await;
|
||||
let response = app
|
||||
.oneshot(empty_request(Method::GET, "/api/auth/unknown"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(response_json(response).await["code"], 40401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_authentication_requires_the_configured_cookie_name() {
|
||||
let (app, _) = auth_app().await;
|
||||
for path in ["/api/admin/auth/login", "/api/auth/unknown"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(empty_request(Method::GET, path))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(response_json(response).await["code"], 40401);
|
||||
}
|
||||
let cookie =
|
||||
session_cookie(&login(&app, json!({"mode": "key", "apiKey": RAW_KEY}), None).await);
|
||||
for old_name in ["cpr_admin_session", "cpr_client_session"] {
|
||||
let old_cookie = cookie.replace("cpr_session", old_name);
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &old_cookie).await).await["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
let unrelated_cookie = cookie.replace("cpr_session", "unrelated_session");
|
||||
assert_eq!(
|
||||
response_json(get(&app, "/api/auth/status", &unrelated_cookie).await).await["data"]["authenticated"],
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -140,11 +140,7 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_processing_ms: None,
|
||||
upstream_response_ms: Some(1_000),
|
||||
upstream_api_overhead_ms: None,
|
||||
upstream_engine_ms: None,
|
||||
upstream_engine_iapi_ttft_ms: None,
|
||||
upstream_engine_service_ttft_ms: None,
|
||||
upstream_engine_iapi_tbt_ms: Some(2.450638),
|
||||
upstream_engine_service_tbt_ms: None,
|
||||
upstream_engine_ms: Some(2.450638),
|
||||
latency_ms: Some(1250),
|
||||
admission_decision_ms: None,
|
||||
account_selection_wait_ms: None,
|
||||
|
||||
@@ -320,7 +320,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
&record["latencyDetails"],
|
||||
&[
|
||||
"upstreamResponseMs",
|
||||
"upstreamEngineIapiTbtMs",
|
||||
"upstreamEngineMs",
|
||||
"firstEventMs",
|
||||
"firstReasoningMs",
|
||||
"firstTextMs",
|
||||
@@ -328,10 +328,7 @@ async fn records_keep_pagination_and_hide_admin_and_upstream_data() {
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["firstEventMs"], 100);
|
||||
assert_eq!(record["latencyDetails"]["upstreamResponseMs"], 1000);
|
||||
assert_eq!(
|
||||
record["latencyDetails"]["upstreamEngineIapiTbtMs"],
|
||||
2.450638
|
||||
);
|
||||
assert_eq!(record["latencyDetails"]["upstreamEngineMs"], 2.450638);
|
||||
assert!(record["statusCode"].is_null());
|
||||
} else {
|
||||
assert_eq!(record["statusCode"], 502);
|
||||
|
||||
@@ -799,7 +799,7 @@ async fn websocket_failure_before_first_event_should_return_observed_metadata_th
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit() {
|
||||
async fn large_websocket_response_create_frames_should_reach_execution() {
|
||||
let trace = Arc::new(AtomicFailureTrace::default());
|
||||
let execution = Arc::new(AtomicFailureExecution {
|
||||
client: authenticated_client("sk_ws_atomic"),
|
||||
@@ -829,15 +829,14 @@ async fn websocket_response_create_should_not_have_a_private_16_mib_frame_limit(
|
||||
let payload = json!({
|
||||
"type": "response.create",
|
||||
"model": "model-a",
|
||||
"input": "x".repeat(16 * 1024 * 1024 + 1),
|
||||
"input": "x".repeat(17 * 1024 * 1024),
|
||||
})
|
||||
.to_string();
|
||||
assert!(payload.len() > 16 * 1024 * 1024);
|
||||
|
||||
socket
|
||||
.send(ClientMessage::Text(payload.into()))
|
||||
.await
|
||||
.expect("send response.create above the former frame limit");
|
||||
.expect("send large response.create frame");
|
||||
|
||||
let mut saw_upstream_event = false;
|
||||
for _ in 0..3 {
|
||||
|
||||
@@ -8,6 +8,26 @@ use serde_json::json;
|
||||
|
||||
use super::decode_response_create;
|
||||
|
||||
#[test]
|
||||
fn response_create_should_preserve_field_order_when_removing_frame_type() {
|
||||
let expected = r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#;
|
||||
for payload in [
|
||||
r#"{"type":"response.create","model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"type":"response.create","input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"}}"#,
|
||||
r#"{"model":"smart-code","stream":true,"input":"hello","future_field":{"z":1,"a":2},"client_metadata":{"thread_id":"thread","turn_id":"turn"},"type":"response.create"}"#,
|
||||
] {
|
||||
let decoded = decode_response_create(payload).expect("decode response.create");
|
||||
let body = decoded
|
||||
.operation()
|
||||
.middleware_body()
|
||||
.expect("encode transparent body");
|
||||
assert_eq!(
|
||||
std::str::from_utf8(&body).expect("UTF-8 JSON body"),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_retry_identity_should_follow_each_frame_instead_of_handshake_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode, header::AUTHORIZATION},
|
||||
http::{Method, Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::api_router_with_origins;
|
||||
use super::models::ModelsExecution;
|
||||
|
||||
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
const LARGE_BODY_BYTES: usize = 17 * 1024 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_origin_controls_http_admin_sessions_without_configuration() {
|
||||
@@ -117,64 +117,16 @@ async fn browser_origin_controls_http_admin_sessions_without_configuration() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/responses/review")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed review request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed review request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_models_catalog_extension_should_use_standard_model_lookup() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models/catalog")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed model catalog request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed model catalog request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_model_info_route_should_return_not_found() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models/model-a/info")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed model info request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed model info request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_body_should_accept_payload_above_the_removed_private_limit() {
|
||||
async fn large_responses_bodies_should_reach_authentication() {
|
||||
let router = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::post("/v1/responses")
|
||||
.body(Body::from(vec![b'a'; REMOVED_BODY_LIMIT_BYTES + 1]))
|
||||
.expect("build request above the removed limit"),
|
||||
.body(Body::from(vec![b'a'; LARGE_BODY_BYTES]))
|
||||
.expect("build large request"),
|
||||
)
|
||||
.await
|
||||
.expect("route request above the removed limit");
|
||||
.expect("route large request");
|
||||
|
||||
// 请求进入处理器后应因缺少凭据而被拒绝
|
||||
// 若提前施加正文大小限制,则会在认证前返回 413
|
||||
|
||||
@@ -18,6 +18,7 @@ struct State {
|
||||
pub struct ConnectionBudget {
|
||||
state: Arc<Mutex<State>>,
|
||||
now: fn() -> Instant,
|
||||
reserve_next_opening: bool,
|
||||
}
|
||||
|
||||
impl Default for ConnectionBudget {
|
||||
@@ -37,9 +38,26 @@ impl ConnectionBudget {
|
||||
Self {
|
||||
state: Arc::default(),
|
||||
now,
|
||||
reserve_next_opening: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅本次恢复使用一半剩余时间并留下一次 opening;已启动的后台连接不受影响
|
||||
#[must_use]
|
||||
pub fn reserve_next_opening(mut self) -> Self {
|
||||
self.reserve_next_opening = true;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn remaining_openings(&self) -> u32 {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
MAX_OPENINGS.saturating_sub(state.openings)
|
||||
}
|
||||
|
||||
/// 在可能建立连接前预约一次 opening;返回本次连接允许使用的剩余时间
|
||||
/// HTTP 连接池隐藏了是否冷建连,因此一次 HTTP opening 也计入次数上限
|
||||
pub fn begin(&self) -> Result<Option<Duration>, ConnectionBudgetExhausted> {
|
||||
@@ -53,11 +71,16 @@ impl ConnectionBudget {
|
||||
let started = *state.started.get_or_insert_with(self.now);
|
||||
let remaining =
|
||||
STARTUP_TIMEOUT.saturating_sub((self.now)().saturating_duration_since(started));
|
||||
if state.openings >= MAX_OPENINGS || remaining.is_zero() {
|
||||
let opening_limit = MAX_OPENINGS - u32::from(self.reserve_next_opening);
|
||||
if state.openings >= opening_limit || remaining.is_zero() {
|
||||
return Err(ConnectionBudgetExhausted);
|
||||
}
|
||||
state.openings += 1;
|
||||
Ok(Some(remaining))
|
||||
Ok(Some(if self.reserve_next_opening {
|
||||
remaining / 2
|
||||
} else {
|
||||
remaining
|
||||
}))
|
||||
}
|
||||
|
||||
/// 仅连接成功不足以清零;确认业务发送后退出新增的 NotSent 恢复策略
|
||||
|
||||
@@ -318,6 +318,7 @@ where
|
||||
concurrency_wait_budget: ConcurrencyWaitBudget::default(),
|
||||
connection_budget: super::connection::ConnectionBudget::default(),
|
||||
connection_retries: 0,
|
||||
connection_rotation: None,
|
||||
observation: ResponseObservation::new(timing_started_at),
|
||||
request_observation,
|
||||
budget_prior_attempts_usd: Decimal::ZERO,
|
||||
@@ -424,6 +425,7 @@ struct CurrentAttempt {
|
||||
metadata: ProviderCallMetadata,
|
||||
trigger: AttemptTrigger,
|
||||
transport: AttemptTransport,
|
||||
connection_rotation_reserved: bool,
|
||||
index: NonZeroU32,
|
||||
started_at: SystemTime,
|
||||
send_observed: bool,
|
||||
@@ -458,6 +460,7 @@ struct PendingAttemptRetry {
|
||||
transport: AttemptTransport,
|
||||
delay: Duration,
|
||||
transport_recovery: bool,
|
||||
reserve_connection_rotation: bool,
|
||||
}
|
||||
|
||||
/// API 可逐事件消费的 Core 执行会话
|
||||
@@ -471,6 +474,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
|
||||
concurrency_wait_budget: ConcurrencyWaitBudget,
|
||||
connection_budget: super::connection::ConnectionBudget,
|
||||
connection_retries: u32,
|
||||
/// 原账号恢复仍建连失败后尝试重选;选号不可用时消费该状态,恢复原账号剩余预算
|
||||
connection_rotation: Option<PendingAttemptRetry>,
|
||||
observation: ResponseObservation,
|
||||
request_observation: Option<RequestObservationDispatch>,
|
||||
budget_prior_attempts_usd: Decimal,
|
||||
@@ -999,6 +1004,9 @@ where
|
||||
let is_transport_recovery = pending_retry
|
||||
.as_ref()
|
||||
.is_some_and(|retry| retry.transport_recovery);
|
||||
let connection_rotation_reserved = pending_retry
|
||||
.as_ref()
|
||||
.is_some_and(|retry| retry.reserve_connection_rotation);
|
||||
if !is_transport_recovery && self.routing_attempts >= self.plan.max_attempts().get() {
|
||||
return Err(EngineError::EmptyRoutingPlan);
|
||||
}
|
||||
@@ -1060,7 +1068,9 @@ where
|
||||
self.recovery_account
|
||||
.take()
|
||||
.or_else(|| self.account_selection.required_account().cloned()),
|
||||
AttemptTransport::Default,
|
||||
self.connection_rotation
|
||||
.as_ref()
|
||||
.map_or(AttemptTransport::Default, |recovery| recovery.transport),
|
||||
),
|
||||
}
|
||||
};
|
||||
@@ -1122,8 +1132,13 @@ where
|
||||
.with_pricing(self.plan.pricing())
|
||||
.with_request_location(self.plan.request_location().cloned())
|
||||
.with_privacy(self.plan.privacy())
|
||||
.with_request_body_limit(self.plan.request_body_limit())
|
||||
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
|
||||
.with_connection_budget(self.connection_budget.clone())
|
||||
.with_connection_budget(if connection_rotation_reserved {
|
||||
self.connection_budget.clone().reserve_next_opening()
|
||||
} else {
|
||||
self.connection_budget.clone()
|
||||
})
|
||||
.with_timing_started_at(self.observation.timing_started_at)
|
||||
.with_request_policy(self.request_policy.clone())
|
||||
.with_execution_effects(self.execution_effects.as_ref().map(Arc::clone))
|
||||
@@ -1186,9 +1201,21 @@ where
|
||||
Ok(stream) => stream,
|
||||
Err(error) => {
|
||||
attempt_trace.record_provider_failure(&error);
|
||||
let connection_fallback = self.connection_rotation.is_some()
|
||||
&& !self.connection_budget.exhausted()
|
||||
&& !error.retry_is_prohibited()
|
||||
&& error.send_state() == UpstreamSendState::NotSent
|
||||
&& matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::NoEligibleAccount
|
||||
| ProviderErrorKind::AccountCapacityUnavailable
|
||||
| ProviderErrorKind::QuotaExhausted
|
||||
| ProviderErrorKind::ConcurrencyQueueFull
|
||||
| ProviderErrorKind::ConcurrencyQueueTimeout
|
||||
);
|
||||
let continuation_retry = !error.retry_is_prohibited()
|
||||
&& self.prepare_unavailable_native_continuation_replay(&error);
|
||||
let candidate_retry = !error.retry_is_prohibited() && !continuation_retry
|
||||
let candidate_retry = !error.retry_is_prohibited() && !connection_fallback && !continuation_retry
|
||||
&& matches!(
|
||||
error.kind(),
|
||||
ProviderErrorKind::AccountCapacityUnavailable
|
||||
@@ -1224,12 +1251,15 @@ where
|
||||
.get()
|
||||
.saturating_sub(self.routing_attempts),
|
||||
remaining_deadline: Duration::ZERO,
|
||||
retry_allowed: continuation_retry || candidate_retry,
|
||||
retry_allowed: connection_fallback
|
||||
|| continuation_retry
|
||||
|| candidate_retry,
|
||||
})
|
||||
.await?;
|
||||
attempt_trace.record("retry.decided", json!({
|
||||
"phase": "prepare", "retryable": retryable,
|
||||
"continuationRetry": continuation_retry, "candidateRetry": candidate_retry,
|
||||
"connectionFallback": connection_fallback,
|
||||
"retryProhibited": error.retry_is_prohibited(),
|
||||
"sendState": error.send_state().as_str(),
|
||||
"requestSendState": self.current_send_state().as_str(),
|
||||
@@ -1239,6 +1269,17 @@ where
|
||||
"remainingRoutingAttempts": self.plan.max_attempts().get().saturating_sub(self.routing_attempts),
|
||||
}));
|
||||
if retryable {
|
||||
if connection_fallback {
|
||||
let recovery = self
|
||||
.connection_rotation
|
||||
.take()
|
||||
.expect("pending connection rotation");
|
||||
self.excluded_accounts.remove(&recovery.account);
|
||||
self.pending_retry = Some(recovery);
|
||||
} else if candidate_retry {
|
||||
// 原账号只属于原 Provider,跨候选后不能拿旧钉选作恢复兜底
|
||||
self.connection_rotation = None;
|
||||
}
|
||||
return Ok(Some(PullOutcome::AttemptDiscarded));
|
||||
}
|
||||
if matches!(
|
||||
@@ -1306,6 +1347,7 @@ where
|
||||
}
|
||||
|
||||
let metadata = stream.metadata().clone();
|
||||
self.connection_rotation = None;
|
||||
attempt_trace.record("account.selected", json!({
|
||||
"provider": metadata.provider().as_str(), "accountId": metadata.provider_account_id().as_str(),
|
||||
"transport": metadata.transport().as_str(),
|
||||
@@ -1429,6 +1471,7 @@ where
|
||||
metadata,
|
||||
trigger,
|
||||
transport: attempt_transport,
|
||||
connection_rotation_reserved,
|
||||
index: next_attempt,
|
||||
started_at: SystemTime::now(),
|
||||
send_observed: false,
|
||||
@@ -1539,6 +1582,12 @@ where
|
||||
.attempt(self.attempts)
|
||||
.record_provider_failure(&error);
|
||||
let mut atomic_client_events = error.take_atomic_client_events();
|
||||
// 失败随附的观测也属于当前 attempt,换号记录不能等到最终交付才读取
|
||||
for event in &atomic_client_events {
|
||||
if let Some(observation) = event.response_observation() {
|
||||
self.observe_response(observation.clone());
|
||||
}
|
||||
}
|
||||
let current = self.current.take().ok_or(EngineError::NoActiveAttempt)?;
|
||||
if self.request_observation.is_some() {
|
||||
let websocket_attempt = websocket_observation_attempt(¤t);
|
||||
@@ -1642,6 +1691,18 @@ where
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let can_rotate_connection = connection_retry_requested
|
||||
&& self.account_selection.required_account().is_none()
|
||||
&& self.routing_attempts < self.plan.max_attempts().get()
|
||||
&& self.account_rotations
|
||||
< self.plan.account_selection_policy().max_account_rotations();
|
||||
// A 先恢复一次,再利用预留预算重选;重选继续走已有降级传输,避免 WS 抢占最后一次 opening
|
||||
let connection_rotation = transport_recovery
|
||||
.filter(|_| can_rotate_connection && current.connection_rotation_reserved);
|
||||
let reserve_connection_rotation = can_rotate_connection
|
||||
&& !current.connection_rotation_reserved
|
||||
&& self.connection_budget.remaining_openings() >= 2;
|
||||
let transport_recovery = transport_recovery.filter(|_| connection_rotation.is_none());
|
||||
let ordinary_retry = !self.connection_budget.exhausted()
|
||||
&& !connection_retry_requested
|
||||
&& !execution_effect_observed
|
||||
@@ -1714,6 +1775,7 @@ where
|
||||
|| same_account_retry
|
||||
|| transient_retry.is_some()
|
||||
|| transport_recovery.is_some()
|
||||
|| connection_rotation.is_some()
|
||||
|| rotation_retry);
|
||||
|
||||
let retryable = match self
|
||||
@@ -1750,6 +1812,8 @@ where
|
||||
"retryable": retryable, "continuationRetry": continuation_retry,
|
||||
"sameAccountRetry": same_account_retry, "accountRotationRetry": account_rotation_retry,
|
||||
"ordinaryRetry": ordinary_retry, "transportRecovery": transport_recovery.is_some(),
|
||||
"connectionRotation": connection_rotation.is_some(),
|
||||
"reserveConnectionRotation": reserve_connection_rotation,
|
||||
"transientRetry": transient_retry.is_some(),
|
||||
"clientRetrySuppressed": suppress_transient_retry,
|
||||
"rotationRetry": rotation_retry, "accountRotations": self.account_rotations,
|
||||
@@ -1795,6 +1859,7 @@ where
|
||||
transport: current.transport,
|
||||
delay,
|
||||
transport_recovery: false,
|
||||
reserve_connection_rotation: false,
|
||||
});
|
||||
} else if let Some((transport, delay)) = transport_recovery {
|
||||
self.pending_retry = Some(PendingAttemptRetry {
|
||||
@@ -1802,6 +1867,17 @@ where
|
||||
transport,
|
||||
delay,
|
||||
transport_recovery: true,
|
||||
reserve_connection_rotation,
|
||||
});
|
||||
} else if let Some((transport, delay)) = connection_rotation {
|
||||
let account = current.metadata.provider_account_id().clone();
|
||||
self.excluded_accounts.insert(account.clone());
|
||||
self.connection_rotation = Some(PendingAttemptRetry {
|
||||
account,
|
||||
transport,
|
||||
delay,
|
||||
transport_recovery: true,
|
||||
reserve_connection_rotation: false,
|
||||
});
|
||||
} else if !continuation_retry {
|
||||
self.excluded_accounts
|
||||
|
||||
@@ -65,6 +65,22 @@ pub struct ExecutionRequestMetadata {
|
||||
pub previous_response_id: Option<PreviousResponseId>,
|
||||
}
|
||||
|
||||
impl ExecutionRequestMetadata {
|
||||
pub(super) fn inherit_request_origin(&mut self, scope: &ExtensionCallScope) {
|
||||
let Some(request) = scope.request() else {
|
||||
return;
|
||||
};
|
||||
// 执行入口可以是内部调用,客户端来源仍属于原始入口快照
|
||||
self.client_ip = request.client_ip;
|
||||
self.user_agent = request
|
||||
.headers
|
||||
.iter()
|
||||
.find(|header| header.name().eq_ignore_ascii_case("user-agent"))
|
||||
.and_then(|header| std::str::from_utf8(header.value()).ok())
|
||||
.map(str::to_owned);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthenticatedClient {
|
||||
pub(super) settings: Option<RequestSettings>,
|
||||
|
||||
@@ -197,6 +197,15 @@ pub struct BoundModelExecutionContext {
|
||||
}
|
||||
|
||||
impl BoundModelExecutionContext {
|
||||
/// 与实际执行共用绑定时的快照,不能回退到后续发布的配置
|
||||
#[must_use]
|
||||
pub fn request_body_limit(&self) -> usize {
|
||||
self.authority
|
||||
.client
|
||||
.snapshot
|
||||
.responses_max_decompressed_body_bytes()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn request_settings(&self) -> Option<&RequestSettings> {
|
||||
self.authority.client.request_settings()
|
||||
|
||||
@@ -448,6 +448,12 @@ impl DefaultExecutionService {
|
||||
account_group_ids.clone(),
|
||||
)
|
||||
.with_extension_scope(authorization.extension_scope.clone())
|
||||
.with_request_body_limit(
|
||||
request
|
||||
.client
|
||||
.snapshot
|
||||
.responses_max_decompressed_body_bytes(),
|
||||
)
|
||||
.with_execution_effects(Arc::clone(&authorization.graph.as_ref()?.effects)),
|
||||
)
|
||||
});
|
||||
@@ -995,8 +1001,7 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = parent.graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.client_ip = None;
|
||||
metadata.user_agent = None;
|
||||
metadata.inherit_request_origin(&extension_scope);
|
||||
let authorization = AuthorizedExecution {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
@@ -1149,8 +1154,7 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.client_ip = None;
|
||||
metadata.user_agent = None;
|
||||
metadata.inherit_request_origin(&authority.extension_scope);
|
||||
self.client_api_key_usage
|
||||
.record_used(authority.client.policy.key_id());
|
||||
self.start_authorized(
|
||||
|
||||
@@ -559,9 +559,22 @@ pub struct MiddlewareContext {
|
||||
deadline: crate::lifecycle::Deadline,
|
||||
extension_scope: ExtensionCallScope,
|
||||
execution_effects: Option<Arc<ExecutionEffects>>,
|
||||
request_body_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl MiddlewareContext {
|
||||
/// attempt 只继承执行预算,不开放 request 设置改写
|
||||
#[must_use]
|
||||
pub const fn with_request_body_limit(mut self, bytes: Option<usize>) -> Self {
|
||||
self.request_body_limit = bytes;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> Option<usize> {
|
||||
self.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plan(&self) -> Option<FrozenMiddlewarePlan> {
|
||||
self.plan.clone()
|
||||
@@ -590,6 +603,7 @@ impl MiddlewareContext {
|
||||
} = authority;
|
||||
Self {
|
||||
plan: None,
|
||||
request_body_limit: None,
|
||||
request_id,
|
||||
mount,
|
||||
attempt_index,
|
||||
|
||||
@@ -286,6 +286,7 @@ impl ContinuationAttempt {
|
||||
/// Provider 每次执行可见的 request-local context
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestAttemptContext {
|
||||
request_body_limit: Option<usize>,
|
||||
lease_metadata: Option<Arc<crate::provider_ports::ProviderLeaseMetadata>>,
|
||||
response_control: Option<response_control::ResponseControl>,
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
@@ -311,6 +312,12 @@ pub struct RequestAttemptContext {
|
||||
}
|
||||
|
||||
impl RequestAttemptContext {
|
||||
#[must_use]
|
||||
pub const fn with_request_body_limit(mut self, bytes: usize) -> Self {
|
||||
self.request_body_limit = Some(bytes);
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_lease_metadata(
|
||||
mut self,
|
||||
@@ -387,6 +394,7 @@ impl RequestAttemptContext {
|
||||
pub fn new(request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId) -> Self {
|
||||
Self {
|
||||
response_control: None,
|
||||
request_body_limit: None,
|
||||
lease_metadata: None,
|
||||
request_id,
|
||||
client_api_key_ref,
|
||||
@@ -542,6 +550,11 @@ pub struct AttemptContext {
|
||||
}
|
||||
|
||||
impl AttemptContext {
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> Option<usize> {
|
||||
self.request.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn lease_metadata(&self) -> Option<Arc<crate::provider_ports::ProviderLeaseMetadata>> {
|
||||
self.request.lease_metadata.clone()
|
||||
@@ -747,7 +760,8 @@ impl AttemptContext {
|
||||
extension_scope: self.request.extension_scope.clone(),
|
||||
execution_effects: self.request.execution_effects.as_ref().map(Arc::clone),
|
||||
},
|
||||
);
|
||||
)
|
||||
.with_request_body_limit(self.request.request_body_limit);
|
||||
provider::execute_attempt_middleware(
|
||||
self.request.middleware.as_ref(),
|
||||
context,
|
||||
@@ -955,10 +969,6 @@ pub struct ModelRequestTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -807,10 +807,6 @@ impl ResponseObservation {
|
||||
self.timings.upstream_response_ms = None;
|
||||
self.timings.upstream_api_overhead_ms = None;
|
||||
self.timings.upstream_engine_ms = None;
|
||||
self.timings.upstream_engine_iapi_ttft_ms = None;
|
||||
self.timings.upstream_engine_service_ttft_ms = None;
|
||||
self.timings.upstream_engine_iapi_tbt_ms = None;
|
||||
self.timings.upstream_engine_service_tbt_ms = None;
|
||||
}
|
||||
|
||||
pub(super) fn observe_response(&mut self, observation: &ProviderResponseObservation) {
|
||||
@@ -848,17 +844,5 @@ impl ResponseObservation {
|
||||
if let Some(value) = observed.upstream_engine_ms {
|
||||
self.timings.upstream_engine_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_ttft_ms {
|
||||
self.timings.upstream_engine_iapi_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_ttft_ms {
|
||||
self.timings.upstream_engine_service_ttft_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_iapi_tbt_ms {
|
||||
self.timings.upstream_engine_iapi_tbt_ms = Some(value);
|
||||
}
|
||||
if let Some(value) = observed.upstream_engine_service_tbt_ms {
|
||||
self.timings.upstream_engine_service_tbt_ms = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,15 @@ pub struct ModelRouteInput {
|
||||
requested_model: PublicModelId,
|
||||
available_providers: BTreeSet<ProviderKind>,
|
||||
execution_effects: Arc<ExecutionEffects>,
|
||||
request_body_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl ModelRouteInput {
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> Option<usize> {
|
||||
self.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_id(&self) -> &super::ModelRequestId {
|
||||
&self.request_id
|
||||
@@ -188,9 +194,15 @@ pub struct AccountScheduleInput {
|
||||
model: Option<String>,
|
||||
candidates: Vec<AccountScheduleCandidate>,
|
||||
execution_effects: Arc<ExecutionEffects>,
|
||||
request_body_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl AccountScheduleInput {
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> Option<usize> {
|
||||
self.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_id(&self) -> &super::ModelRequestId {
|
||||
&self.request_id
|
||||
@@ -282,9 +294,15 @@ pub struct RetryInput {
|
||||
pub extension_scope: ExtensionCallScope,
|
||||
pub facts: RetryFacts,
|
||||
execution_effects: Arc<ExecutionEffects>,
|
||||
request_body_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RetryInput {
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> Option<usize> {
|
||||
self.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn execution_effects(&self) -> Arc<ExecutionEffects> {
|
||||
Arc::clone(&self.execution_effects)
|
||||
@@ -339,6 +357,7 @@ pub struct RequestPolicyContext {
|
||||
account_group_ids: Arc<[AccountGroupId]>,
|
||||
extension_scope: ExtensionCallScope,
|
||||
execution_effects: Arc<ExecutionEffects>,
|
||||
request_body_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RequestPolicyContext {
|
||||
@@ -353,6 +372,13 @@ impl fmt::Debug for RequestPolicyContext {
|
||||
}
|
||||
|
||||
impl RequestPolicyContext {
|
||||
/// 路由、调度和重试沿用同一次模型执行的冻结预算
|
||||
#[must_use]
|
||||
pub const fn with_request_body_limit(mut self, bytes: usize) -> Self {
|
||||
self.request_body_limit = Some(bytes);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn retry_decision(&self, facts: RetryFacts) -> RetryDecision {
|
||||
self.plan
|
||||
.retry_decision(RetryInput {
|
||||
@@ -362,6 +388,7 @@ impl RequestPolicyContext {
|
||||
extension_scope: self.extension_scope.clone(),
|
||||
facts,
|
||||
execution_effects: Arc::clone(&self.execution_effects),
|
||||
request_body_limit: self.request_body_limit,
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
@@ -391,6 +418,7 @@ impl RequestPolicyContext {
|
||||
account_group_ids: account_group_ids.into(),
|
||||
extension_scope: ExtensionCallScope::default(),
|
||||
execution_effects: Arc::new(ExecutionEffects::default()),
|
||||
request_body_limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +456,7 @@ impl RequestPolicyContext {
|
||||
requested_model,
|
||||
available_providers,
|
||||
execution_effects: Arc::clone(&self.execution_effects),
|
||||
request_body_limit: self.request_body_limit,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -474,6 +503,7 @@ impl RequestPolicyContext {
|
||||
model: model.map(str::to_owned),
|
||||
candidates: projected,
|
||||
execution_effects: Arc::clone(&self.execution_effects),
|
||||
request_body_limit: self.request_body_limit,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AccountPolicyError::Fault)?;
|
||||
|
||||
@@ -536,12 +536,8 @@ pub struct ProviderResponseTimings {
|
||||
pub upstream_response_ms: Option<u64>,
|
||||
/// 上游排除引擎与客户端工具时间后的 API 耗时
|
||||
pub upstream_api_overhead_ms: Option<f64>,
|
||||
/// 上游响应级引擎耗时;以下专项指标保留毫秒小数及各自内部计量层级
|
||||
/// 上游响应级引擎耗时,保留毫秒小数
|
||||
pub upstream_engine_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_service_ttft_ms: Option<f64>,
|
||||
pub upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
pub upstream_engine_service_tbt_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// Provider 已筛选的响应观测 JSON
|
||||
@@ -551,7 +547,7 @@ pub struct ProviderResponseTimings {
|
||||
/// 这样 Provider 专有协议不会渗入路由
|
||||
/// 或管理领域
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResponseMetadata(String);
|
||||
pub struct ProviderResponseMetadata(Arc<str>);
|
||||
|
||||
impl ProviderResponseMetadata {
|
||||
const MAX_BYTES: usize = 32 * 1024;
|
||||
@@ -565,7 +561,7 @@ impl ProviderResponseMetadata {
|
||||
serde_json::from_str::<Value>(&json)
|
||||
.ok()
|
||||
.filter(Value::is_object)
|
||||
.map(|_| Self(json))
|
||||
.map(|_| Self(json.into()))
|
||||
}
|
||||
|
||||
/// 返回不透明的 JSON 文本,供持久化 adapter 原样验证和写入
|
||||
@@ -630,7 +626,8 @@ pub struct ProviderResponseObservation {
|
||||
service_tier: Option<String>,
|
||||
upstream_response_model: Option<String>,
|
||||
timings: ProviderResponseTimings,
|
||||
client_headers: Vec<ProviderResponseHeader>,
|
||||
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
|
||||
client_headers: Arc<[ProviderResponseHeader]>,
|
||||
provider_metadata: Option<ProviderResponseMetadata>,
|
||||
}
|
||||
|
||||
@@ -646,7 +643,7 @@ impl ProviderResponseObservation {
|
||||
service_tier: None,
|
||||
upstream_response_model: None,
|
||||
timings: ProviderResponseTimings::default(),
|
||||
client_headers: Vec::new(),
|
||||
client_headers: Arc::default(),
|
||||
provider_metadata: None,
|
||||
}
|
||||
}
|
||||
@@ -723,7 +720,7 @@ impl ProviderResponseObservation {
|
||||
|
||||
#[must_use]
|
||||
pub fn with_client_headers(mut self, client_headers: Vec<ProviderResponseHeader>) -> Self {
|
||||
self.client_headers = client_headers;
|
||||
self.client_headers = client_headers.into();
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,15 @@ pub struct ExtensionPreparationError;
|
||||
|
||||
/// 准备阶段读取与请求快照相同的持久 revision;不得自行发布当前代次
|
||||
pub trait ExtensionPreparationPort: Send + Sync {
|
||||
/// 初始发布尚无可读快照,注册阶段沿用本次编译的正文预算
|
||||
fn prepare_with_request_body_limit(
|
||||
&self,
|
||||
revision: ConfigRevision,
|
||||
_request_body_limit: usize,
|
||||
) -> BoxFuture<'_, Result<ExtensionSetReference, ExtensionPreparationError>> {
|
||||
self.prepare(revision)
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
&self,
|
||||
revision: ConfigRevision,
|
||||
|
||||
@@ -561,6 +561,7 @@ impl ProviderCandidate {
|
||||
/// 一次请求冻结的 Provider 尝试顺序
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoutingPlan {
|
||||
request_body_limit: usize,
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
request_location: Option<crate::account::RequestLocation>,
|
||||
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
|
||||
@@ -573,6 +574,11 @@ pub struct RoutingPlan {
|
||||
}
|
||||
|
||||
impl RoutingPlan {
|
||||
#[must_use]
|
||||
pub const fn request_body_limit(&self) -> usize {
|
||||
self.request_body_limit
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pricing(&self) -> Arc<crate::metering::PricingOverrides> {
|
||||
Arc::clone(&self.pricing)
|
||||
|
||||
@@ -331,7 +331,13 @@ impl RuntimeSnapshotCompiler {
|
||||
});
|
||||
if must_prepare {
|
||||
let prepared = preparer
|
||||
.prepare(revision)
|
||||
.prepare_with_request_body_limit(
|
||||
revision,
|
||||
usize::try_from(
|
||||
facts.settings.responses_max_decompressed_body_bytes,
|
||||
)
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| RuntimeSnapshotCompileError::ExtensionsUnavailable)?;
|
||||
prepared_extensions = Some((revision, prepared));
|
||||
@@ -1201,6 +1207,7 @@ impl RuntimeSnapshot {
|
||||
}
|
||||
|
||||
Ok(RoutingPlan {
|
||||
request_body_limit: self.responses_max_decompressed_body_bytes(),
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.settings.values.pricing),
|
||||
request_location: self.settings.request_location.clone(),
|
||||
@@ -1282,6 +1289,7 @@ impl RuntimeSnapshot {
|
||||
account_scope: Arc::clone(&account_scope),
|
||||
};
|
||||
Ok(RoutingPlan {
|
||||
request_body_limit: self.responses_max_decompressed_body_bytes(),
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.settings.values.pricing),
|
||||
request_location: self.settings.request_location.clone(),
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
mod publications;
|
||||
mod publisher;
|
||||
|
||||
pub use publications::{RuntimeSnapshotHandle, RuntimeSnapshotUnavailable};
|
||||
pub use publications::{
|
||||
RuntimeSnapshotHandle, RuntimeSnapshotUnavailable, WeakRuntimeSnapshotHandle,
|
||||
};
|
||||
pub use publisher::{
|
||||
RuntimeSnapshotPublisher, SnapshotControl, SnapshotRevisionStream, SnapshotSubscriptionError,
|
||||
SnapshotSubscriptionPort, runtime_revision_needs_refresh,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
use futures::future::BoxFuture;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex as SyncMutex, RwLock},
|
||||
sync::{Arc, Mutex as SyncMutex, RwLock, Weak},
|
||||
};
|
||||
|
||||
/// 请求级冻结失败;此状态必须 fail closed
|
||||
@@ -23,7 +23,27 @@ pub struct RuntimeSnapshotHandle {
|
||||
publications: Arc<SyncMutex<Vec<futures::channel::mpsc::Sender<()>>>>,
|
||||
}
|
||||
|
||||
/// 不延长发布集合生命周期的只读句柄,供集合持有的扩展读取后续配置
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WeakRuntimeSnapshotHandle {
|
||||
current: Weak<RwLock<Option<Arc<RuntimeSnapshot>>>>,
|
||||
}
|
||||
|
||||
impl WeakRuntimeSnapshotHandle {
|
||||
pub fn acquire(&self) -> Result<Arc<RuntimeSnapshot>, RuntimeSnapshotUnavailable> {
|
||||
let current = self.current.upgrade().ok_or(RuntimeSnapshotUnavailable)?;
|
||||
acquire_current(¤t)
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeSnapshotHandle {
|
||||
#[must_use]
|
||||
pub fn downgrade(&self) -> WeakRuntimeSnapshotHandle {
|
||||
WeakRuntimeSnapshotHandle {
|
||||
current: Arc::downgrade(&self.current),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn new(initial: RuntimeSnapshot) -> Self {
|
||||
Self {
|
||||
@@ -86,10 +106,7 @@ impl RuntimeSnapshotHandle {
|
||||
|
||||
/// 冻结当前 Arc;后续发布不改变已经开始的请求
|
||||
pub fn acquire(&self) -> Result<Arc<RuntimeSnapshot>, RuntimeSnapshotUnavailable> {
|
||||
read_unpoisoned(&self.current)
|
||||
.clone()
|
||||
.filter(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()))
|
||||
.ok_or(RuntimeSnapshotUnavailable)
|
||||
acquire_current(&self.current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +126,15 @@ impl HealthProbe for RuntimeSnapshotHandle {
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_current(
|
||||
current: &RwLock<Option<Arc<RuntimeSnapshot>>>,
|
||||
) -> Result<Arc<RuntimeSnapshot>, RuntimeSnapshotUnavailable> {
|
||||
read_unpoisoned(current)
|
||||
.clone()
|
||||
.filter(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()))
|
||||
.ok_or(RuntimeSnapshotUnavailable)
|
||||
}
|
||||
|
||||
fn read_unpoisoned<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
lock.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
|
||||
@@ -372,8 +372,12 @@ struct RuntimeSnapshotSubscriptionTask {
|
||||
}
|
||||
|
||||
impl DaemonTask for RuntimeSnapshotSubscriptionTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
fn run(
|
||||
&self,
|
||||
context: crate::task::WorkerDaemonContext,
|
||||
) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = context.cancellation();
|
||||
let mut retry_delay = INITIAL_BACKOFF;
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
@@ -383,10 +387,11 @@ impl DaemonTask for RuntimeSnapshotSubscriptionTask {
|
||||
let mut subscription = match subscription {
|
||||
Ok(subscription) => {
|
||||
retry_delay = INITIAL_BACKOFF;
|
||||
context.succeeded();
|
||||
subscription
|
||||
}
|
||||
Err(_) => {
|
||||
wait_or_cancel(&cancellation, retry_delay).await;
|
||||
wait_or_cancel(cancellation, retry_delay).await;
|
||||
retry_delay = (retry_delay * 2).min(MAXIMUM_BACKOFF);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
|
||||
use std::fmt;
|
||||
use std::num::NonZeroU64;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use event_listener::Event;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::lifecycle::CancellationToken;
|
||||
@@ -341,11 +344,60 @@ pub trait ScheduledTask: Send + Sync {
|
||||
fn run_cycle(&self, context: WorkerCycleContext) -> BoxFuture<'_, Result<(), WorkerTaskError>>;
|
||||
}
|
||||
|
||||
/// 一次 daemon 运行的取消与成功进度,重启时必须创建新上下文
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerDaemonContext {
|
||||
cancellation: CancellationToken,
|
||||
progress: Arc<DaemonProgress>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DaemonProgress {
|
||||
completed: AtomicU64,
|
||||
changed: Event,
|
||||
}
|
||||
|
||||
impl WorkerDaemonContext {
|
||||
#[must_use]
|
||||
pub fn new(cancellation: CancellationToken) -> Self {
|
||||
Self {
|
||||
cancellation,
|
||||
progress: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn cancellation(&self) -> &CancellationToken {
|
||||
&self.cancellation
|
||||
}
|
||||
|
||||
/// 仅在实际工作或依赖检查成功后通知 Host;进入 run 不代表恢复
|
||||
pub fn succeeded(&self) {
|
||||
if !self.cancellation.is_cancelled() {
|
||||
self.progress.completed.fetch_add(1, Ordering::Release);
|
||||
self.progress.changed.notify(usize::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn completed_cycles(&self) -> u64 {
|
||||
self.progress.completed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
/// 先注册再检查计数,避免报告与等待交错时丢失成功通知
|
||||
pub async fn progress_since(&self, completed: u64) {
|
||||
let changed = self.progress.changed.listen();
|
||||
if self.completed_cycles() == completed {
|
||||
changed.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 长驻任务
|
||||
///
|
||||
/// 实现负责连接级重连退避;Host 负责任务退出或 panic 后的重启退避
|
||||
pub trait DaemonTask: Send + Sync {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>>;
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>>;
|
||||
}
|
||||
|
||||
/// Host 可执行的两种任务形态,无法组合出“守护任务 + 周期调度”等非法状态
|
||||
|
||||
@@ -27,6 +27,40 @@ fn elapsed_recovery_window_is_shared_without_waiting_for_real_time() {
|
||||
assert_eq!(fallback.startup_remaining(), Some(Duration::ZERO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_account_retry_preserves_time_and_an_opening_for_rotation() {
|
||||
use std::{
|
||||
cell::Cell,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
thread_local! {
|
||||
static NOW: Cell<Instant> = Cell::new(Instant::now());
|
||||
}
|
||||
fn now() -> Instant {
|
||||
NOW.get()
|
||||
}
|
||||
let request = ConnectionBudget::with_clock(now);
|
||||
// 后台 WS 先占一次,HTTP 首次建连再占一次,模拟代理持续不响应
|
||||
let background = request.clone();
|
||||
assert_eq!(background.begin().unwrap(), Some(Duration::from_secs(30)));
|
||||
NOW.set(now() + Duration::from_secs(1));
|
||||
assert_eq!(request.begin().unwrap(), Some(Duration::from_secs(29)));
|
||||
NOW.set(now() + Duration::from_secs(15));
|
||||
assert_eq!(request.remaining_openings(), 2);
|
||||
let retry = request.clone().reserve_next_opening();
|
||||
assert_eq!(retry.begin().unwrap(), Some(Duration::from_secs(7)));
|
||||
assert_eq!(request.remaining_openings(), 1);
|
||||
assert!(retry.begin().is_err());
|
||||
assert!(!request.exhausted());
|
||||
NOW.set(now() + Duration::from_secs(7));
|
||||
// 重选使用原预算,既不继承 A 的局部时间限制,也不能重置总次数
|
||||
assert_eq!(request.begin().unwrap(), Some(Duration::from_secs(7)));
|
||||
assert!(request.exhausted());
|
||||
assert!(background.begin().is_err());
|
||||
request.complete();
|
||||
assert_eq!(retry.begin().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_transports_share_one_opening_budget_but_other_requests_do_not() {
|
||||
let request = ConnectionBudget::default();
|
||||
|
||||
@@ -505,10 +505,6 @@ struct FinalState {
|
||||
upstream_response_ms: Option<u64>,
|
||||
upstream_api_overhead_ms: Option<f64>,
|
||||
upstream_engine_ms: Option<f64>,
|
||||
upstream_engine_iapi_ttft_ms: Option<f64>,
|
||||
upstream_engine_service_ttft_ms: Option<f64>,
|
||||
upstream_engine_iapi_tbt_ms: Option<f64>,
|
||||
upstream_engine_service_tbt_ms: Option<f64>,
|
||||
provider_metadata_json: Option<String>,
|
||||
cost_source: CostSource,
|
||||
cost_ticks: Option<u128>,
|
||||
@@ -684,12 +680,6 @@ impl ExecutionStore for FakeStore {
|
||||
upstream_response_ms: finalization.timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: finalization.timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: finalization.timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: finalization.timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: finalization
|
||||
.timings
|
||||
.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: finalization.timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: finalization.timings.upstream_engine_service_tbt_ms,
|
||||
provider_metadata_json: finalization.provider_metadata_json,
|
||||
cost_source: finalization.cost.source(),
|
||||
cost_ticks: finalization
|
||||
@@ -2067,10 +2057,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
upstream_response_ms: Some(7_000),
|
||||
upstream_api_overhead_ms: Some(120.25),
|
||||
upstream_engine_ms: Some(6400.0),
|
||||
upstream_engine_iapi_ttft_ms: Some(650.5),
|
||||
upstream_engine_service_ttft_ms: Some(720.25),
|
||||
upstream_engine_iapi_tbt_ms: Some(18.45),
|
||||
upstream_engine_service_tbt_ms: Some(20.12),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
let second_observation = ProviderResponseObservation::new(
|
||||
@@ -2164,10 +2150,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
assert_eq!(finalization.upstream_response_ms, None);
|
||||
assert_eq!(finalization.upstream_api_overhead_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_ttft_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_iapi_tbt_ms, None);
|
||||
assert_eq!(finalization.upstream_engine_service_tbt_ms, None);
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 2);
|
||||
assert_eq!(
|
||||
@@ -3485,13 +3467,16 @@ fn explicit_429_after_structural_event_should_retry_before_commit() {
|
||||
fn atomic_response_failed_should_rotate_account_before_stream_commit() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let mut failure = atomic_response_failed("response-first-failed", "first account failure");
|
||||
let mut events = failure.take_atomic_client_events();
|
||||
events[0].attach_observation(
|
||||
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").unwrap())
|
||||
.with_request_id(OpaqueUpstreamValue::new("upstream-failed-attempt")),
|
||||
);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(atomic_response_failed(
|
||||
"response-first-failed",
|
||||
"first account failure",
|
||||
))],
|
||||
items: vec![Err(failure.with_atomic_client_events(events))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
@@ -3534,6 +3519,10 @@ fn atomic_response_failed_should_rotate_account_before_stream_commit() {
|
||||
let state = store.state.lock().expect("store lock");
|
||||
assert_eq!(state.commits, 1);
|
||||
assert_eq!(state.intermediate_failures, 1);
|
||||
assert_eq!(
|
||||
state.intermediate_request_ids,
|
||||
[Some("upstream-failed-attempt".to_owned())]
|
||||
);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
@@ -5849,11 +5838,11 @@ fn provider_default_profile_is_applied_once_and_frozen_across_account_retries()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_recovery_keeps_one_account_and_http_transport_until_success() {
|
||||
fn connection_recovery_rotates_only_after_retrying_the_first_account() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let failure = || Script::Stream {
|
||||
account_id: "acct_only",
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
@@ -5861,11 +5850,10 @@ fn connection_recovery_keeps_one_account_and_http_transport_until_success() {
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
};
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
failure(),
|
||||
failure(),
|
||||
failure(),
|
||||
Script::Stream {
|
||||
account_id: "acct_only",
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
@@ -5881,37 +5869,250 @@ fn connection_recovery_keeps_one_account_and_http_transport_until_success() {
|
||||
block_on(session.collect_uncommitted()).unwrap();
|
||||
block_on(session.commit_downstream(Some(200))).unwrap();
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 4);
|
||||
let account = ProviderAccountId::new("acct_only").unwrap();
|
||||
assert_eq!(contexts.len(), 3);
|
||||
let first = ProviderAccountId::new("acct_first").unwrap();
|
||||
assert_eq!(contexts[1].required_account(), Some(&first));
|
||||
assert!(!contexts[1].excluded_accounts().contains(&first));
|
||||
assert!(contexts[2].required_account().is_none());
|
||||
assert!(contexts[2].excluded_accounts().contains(&first));
|
||||
for context in contexts.iter().skip(1) {
|
||||
assert_eq!(context.required_account(), Some(&account));
|
||||
assert_eq!(context.transport(), AttemptTransport::Fallback);
|
||||
assert!(!context.excluded_accounts().contains(&account));
|
||||
}
|
||||
let state = store.state.lock().unwrap();
|
||||
assert_eq!(state.intermediate_failures, 3);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(store.state.lock().unwrap().intermediate_failures, 2);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 2);
|
||||
drop(session);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_recovery_keeps_one_account_and_http_transport_until_success() {
|
||||
for unavailable in [
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
ProviderErrorKind::ConcurrencyQueueFull,
|
||||
ProviderErrorKind::ConcurrencyQueueTimeout,
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let failure = || Script::Stream {
|
||||
account_id: "acct_only",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
};
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
failure(),
|
||||
failure(),
|
||||
Script::Error(ProviderError::new(unavailable, UpstreamSendState::NotSent)),
|
||||
failure(),
|
||||
Script::Stream {
|
||||
account_id: "acct_only",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
block_on(session.collect_uncommitted()).unwrap();
|
||||
block_on(session.commit_downstream(Some(200))).unwrap();
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 5);
|
||||
let account = ProviderAccountId::new("acct_only").unwrap();
|
||||
assert!(contexts[2].required_account().is_none());
|
||||
assert!(contexts[2].excluded_accounts().contains(&account));
|
||||
assert_eq!(contexts[2].transport(), AttemptTransport::Fallback);
|
||||
for index in [1, 3, 4] {
|
||||
let context = &contexts[index];
|
||||
assert_eq!(context.required_account(), Some(&account));
|
||||
assert_eq!(context.transport(), AttemptTransport::Fallback);
|
||||
assert!(!context.excluded_accounts().contains(&account));
|
||||
}
|
||||
let state = store.state.lock().unwrap();
|
||||
assert_eq!(state.intermediate_failures, 3);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 3);
|
||||
drop(session);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_rotation_does_not_bypass_selection_replay_prohibition() {
|
||||
for send_state in [
|
||||
UpstreamSendState::NotSent,
|
||||
UpstreamSendState::Sent,
|
||||
UpstreamSendState::Ambiguous,
|
||||
] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let failure = || Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
};
|
||||
let selection_error = ProviderError::new(ProviderErrorKind::NoEligibleAccount, send_state);
|
||||
let selection_error = if send_state == UpstreamSendState::NotSent {
|
||||
selection_error.with_retry_prohibited()
|
||||
} else {
|
||||
selection_error
|
||||
};
|
||||
let (coordinator, _, provider) =
|
||||
coordinator(vec![failure(), failure(), Script::Error(selection_error)]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(block_on(session.collect_uncommitted()).is_err());
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 3);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_recovery_does_not_delay_existing_account_rotation() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(vec![
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_first",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(429)
|
||||
.with_replay_safe())],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_second",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
block_on(session.collect_uncommitted()).unwrap();
|
||||
block_on(session.commit_downstream(Some(200))).unwrap();
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 3);
|
||||
let first = ProviderAccountId::new("acct_first").unwrap();
|
||||
// 临时建连失败先在原账号恢复,后续明确可重放的限流拒绝仍按已有规则换号
|
||||
assert_eq!(contexts[1].required_account(), Some(&first));
|
||||
assert_eq!(contexts[1].transport(), AttemptTransport::Fallback);
|
||||
assert!(!contexts[1].excluded_accounts().contains(&first));
|
||||
assert!(contexts[2].required_account().is_none());
|
||||
assert_eq!(contexts[2].transport(), AttemptTransport::Default);
|
||||
assert!(contexts[2].excluded_accounts().contains(&first));
|
||||
let state = store.state.lock().unwrap();
|
||||
assert_eq!(state.intermediate_failures, 2);
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 2);
|
||||
drop(session);
|
||||
assert_eq!(provider.released_leases.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_recovery_stays_on_account_when_rotation_is_not_allowed() {
|
||||
for required in [true, false] {
|
||||
let operation = generate_operation();
|
||||
let route_plan = if required {
|
||||
plan(&operation)
|
||||
} else {
|
||||
plan_with_policy(
|
||||
&operation,
|
||||
AccountSelectionPolicy::new(RotationStrategy::Smart, NonZeroU32::new(2).unwrap())
|
||||
.with_max_account_rotations(0),
|
||||
)
|
||||
};
|
||||
let failure = || Script::Stream {
|
||||
account_id: "acct_required",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
};
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
failure(),
|
||||
failure(),
|
||||
Script::Stream {
|
||||
account_id: "acct_required",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let account = ProviderAccountId::new("acct_required").unwrap();
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
required.then_some(account.clone()),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.unwrap();
|
||||
block_on(session.collect_uncommitted()).unwrap();
|
||||
let contexts = provider.contexts.lock().unwrap();
|
||||
assert_eq!(contexts.len(), 3);
|
||||
assert_eq!(contexts[0].required_account(), required.then_some(&account));
|
||||
for context in contexts.iter().skip(1) {
|
||||
assert_eq!(context.required_account(), Some(&account));
|
||||
assert!(!context.excluded_accounts().contains(&account));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_recovery_exhaustion_preserves_transport_failure() {
|
||||
let operation = generate_operation();
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, store, provider) = coordinator(
|
||||
(0..4)
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_only",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
})
|
||||
.collect(),
|
||||
let mut scripts: Vec<_> = (0..4)
|
||||
.map(|_| Script::Stream {
|
||||
account_id: "acct_only",
|
||||
items: vec![Err(ProviderError::new(
|
||||
ProviderErrorKind::Transport,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_connection_retry(AttemptTransport::Fallback))],
|
||||
})
|
||||
.collect();
|
||||
scripts.insert(
|
||||
2,
|
||||
Script::Error(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
)),
|
||||
);
|
||||
let (coordinator, store, provider) = coordinator(scripts);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
@@ -5925,7 +6126,7 @@ fn connection_recovery_exhaustion_preserves_transport_failure() {
|
||||
assert!(
|
||||
matches!(error, EngineError::Provider(ref error) if error.kind() == ProviderErrorKind::Transport)
|
||||
);
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 4);
|
||||
assert_eq!(provider.contexts.lock().unwrap().len(), 5);
|
||||
assert_eq!(
|
||||
store.state.lock().unwrap().finalizations[0].attempt_count,
|
||||
4
|
||||
|
||||
@@ -1195,6 +1195,114 @@ fn request_policy_snapshot(
|
||||
.with_extensions(Some(generation))
|
||||
}
|
||||
|
||||
fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope {
|
||||
gateway_core::engine::extensions::ExtensionCallScope::default().with_request(Arc::new(
|
||||
gateway_core::engine::extensions::RequestContext {
|
||||
method: "POST".into(),
|
||||
uri: "/plugin/proxy".into(),
|
||||
http_version: "HTTP/1.1".into(),
|
||||
headers: vec![gateway_core::engine::middleware::MiddlewareHeader::new(
|
||||
"User-Agent",
|
||||
Bytes::from_static(b"fixture-client/494"),
|
||||
)],
|
||||
peer_addr: None,
|
||||
client_ip: Some("192.0.2.49".parse().unwrap()),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
|
||||
use gateway_core::engine::nested::{
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
|
||||
};
|
||||
block_on(async {
|
||||
for has_origin in [true, false] {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(request_policy_snapshot(
|
||||
super::extensions::reference("origin-fixture"),
|
||||
&[ProviderKind::new("openai").unwrap()],
|
||||
)),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(ProcessingProvider) as Arc<dyn Provider>]).unwrap(),
|
||||
Arc::new(Admissions::default()),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(RecordingClientApiKeyUsage::default()),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let input = request(&service, ClientTransport::InternalPlugin);
|
||||
let context = NestedModelExecutionPort::bind(
|
||||
&service,
|
||||
BoundModelExecutionBinding {
|
||||
settings: None,
|
||||
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
initiating_plugin_instance_id: "origin-fixture".into(),
|
||||
cancellation: Default::default(),
|
||||
extension_scope: if has_origin {
|
||||
plugin_origin_scope()
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
context: context.clone(),
|
||||
public_model: input.public_model,
|
||||
operation: input.operation,
|
||||
metadata: input.metadata,
|
||||
provider: None,
|
||||
account: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut child = NestedModelExecutionPort::start(
|
||||
&service,
|
||||
NestedModelExecutionRequest {
|
||||
parent_request_id: started.request_id.clone(),
|
||||
initiating_plugin_instance_id: "nested-origin-fixture".into(),
|
||||
public_model: PublicModelId::new("gpt-start").unwrap(),
|
||||
operation: start_operation(),
|
||||
metadata: request(&service, ClientTransport::InternalPlugin).metadata,
|
||||
provider: None,
|
||||
account: None,
|
||||
parent_account: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
child.session.collect_uncommitted().await.unwrap();
|
||||
child.session.commit_downstream(Some(200)).await.unwrap();
|
||||
child.session.detach_finalize().await;
|
||||
started.session.collect_uncommitted().await.unwrap();
|
||||
{
|
||||
let requests = store.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert_eq!(
|
||||
request.client_ip,
|
||||
has_origin.then(|| "192.0.2.49".parse().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
request.user_agent.as_deref(),
|
||||
has_origin.then_some("fixture-client/494")
|
||||
);
|
||||
assert_eq!(
|
||||
request.client_transport,
|
||||
ClientTransport::InternalPlugin.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
started.session.detach_finalize().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_parent_rejects_new_nested_execution_and_cancels_an_active_child() {
|
||||
block_on(async {
|
||||
|
||||
@@ -3,16 +3,15 @@
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{
|
||||
ContentItem, ContentKind, EventSequenceError, EventSequenceValidator, GatewayEvent,
|
||||
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
|
||||
ReasoningDelta, ResponseMeta, TextDelta, ToolCallDelta,
|
||||
ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseMetadata,
|
||||
ProviderResponseObservation, ProviderResponseTimings, ReasoningDelta, ResponseMeta, TextDelta,
|
||||
ToolCallDelta,
|
||||
};
|
||||
use gateway_core::upstream::UpstreamTransport;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn provider_event_keeps_large_wire_and_observation_payloads_behind_one_indirection() {
|
||||
assert!(std::mem::size_of::<ProviderEvent>() <= 64);
|
||||
|
||||
fn provider_event_should_distinguish_client_delivery_from_observation() {
|
||||
let wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json("openai", None, json!({"type": "response.created"}))
|
||||
.expect("wire event"),
|
||||
@@ -337,3 +336,61 @@ fn validator_should_accept_reasoning_and_tool_call_stream() {
|
||||
|
||||
assert_eq!(validator.finish(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_observation_snapshots_preserve_values_without_copying_large_payloads() {
|
||||
let metadata = format!("{{\"raw\":\"{}\"}}", "a".repeat(32 * 1024 - 10));
|
||||
let observation =
|
||||
ProviderResponseObservation::new(UpstreamTransport::new("http_sse").expect("transport"))
|
||||
.with_provider_metadata(
|
||||
ProviderResponseMetadata::new(metadata.clone()).expect("metadata"),
|
||||
)
|
||||
.with_client_headers(
|
||||
(0..32)
|
||||
.map(|_| {
|
||||
ProviderResponseHeader::new("set-cookie", Bytes::from_static(b"raw-\xff"))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
first_event_ms: Some(17),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
|
||||
let allocations = allocation_counter::measure(|| {
|
||||
for _ in 0..1_000 {
|
||||
std::hint::black_box(observation.clone());
|
||||
}
|
||||
});
|
||||
// 允许小标量字段的复制,但流式快照不能随大头集合和 metadata 重复分配
|
||||
assert!(allocations.bytes_total < 256 * 1_000, "{allocations:?}");
|
||||
assert_eq!(allocations.bytes_current, 0);
|
||||
|
||||
let updated = observation
|
||||
.clone()
|
||||
.with_client_headers(Vec::new())
|
||||
.with_provider_metadata(
|
||||
ProviderResponseMetadata::new("{\"updated\":true}".to_owned()).expect("metadata"),
|
||||
)
|
||||
.with_timings(ProviderResponseTimings {
|
||||
first_event_ms: Some(23),
|
||||
..ProviderResponseTimings::default()
|
||||
});
|
||||
assert_eq!(
|
||||
observation.provider_metadata().expect("metadata").as_json(),
|
||||
metadata
|
||||
);
|
||||
assert_eq!(observation.client_headers().len(), 32);
|
||||
assert!(
|
||||
observation.client_headers().iter().all(|header| {
|
||||
header.name() == "set-cookie" && header.value().as_ref() == b"raw-\xff"
|
||||
})
|
||||
);
|
||||
assert_eq!(observation.timings().first_event_ms, Some(17));
|
||||
assert_eq!(
|
||||
updated.provider_metadata().expect("metadata").as_json(),
|
||||
"{\"updated\":true}"
|
||||
);
|
||||
assert!(updated.client_headers().is_empty());
|
||||
assert_eq!(updated.timings().first_event_ms, Some(23));
|
||||
}
|
||||
|
||||
@@ -1253,7 +1253,8 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication()
|
||||
));
|
||||
}
|
||||
let handle = RuntimeSnapshotHandle::new(compile(1, 64 * 1024 * 1024).unwrap());
|
||||
let frozen = handle.acquire().unwrap();
|
||||
let reader = handle.downgrade();
|
||||
let frozen = reader.acquire().unwrap();
|
||||
handle.publish(compile(2, 128 * 1024 * 1024).unwrap());
|
||||
assert_eq!(
|
||||
handle
|
||||
@@ -1266,6 +1267,13 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication()
|
||||
frozen.responses_max_decompressed_body_bytes(),
|
||||
64 * 1024 * 1024
|
||||
);
|
||||
assert_eq!(
|
||||
reader
|
||||
.acquire()
|
||||
.unwrap()
|
||||
.responses_max_decompressed_body_bytes(),
|
||||
128 * 1024 * 1024
|
||||
);
|
||||
handle.publish(compile(3, 1024 * 1024).unwrap());
|
||||
assert_eq!(
|
||||
handle
|
||||
@@ -1274,6 +1282,10 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication()
|
||||
.responses_max_decompressed_body_bytes(),
|
||||
1024 * 1024
|
||||
);
|
||||
handle.suspend();
|
||||
assert!(reader.acquire().is_err());
|
||||
drop(handle);
|
||||
assert!(reader.acquire().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -28,7 +28,8 @@ use gateway_core::runtime::{
|
||||
};
|
||||
use gateway_core::settings::SettingsValues;
|
||||
use gateway_core::task::{
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerKind, WorkerRunnable,
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDaemonContext, WorkerKind,
|
||||
WorkerRunnable,
|
||||
};
|
||||
|
||||
type ReadGate<T> = Arc<Mutex<Option<oneshot::Receiver<Result<T, SnapshotStoreError>>>>>;
|
||||
@@ -490,7 +491,7 @@ fn subscription_refresh_should_wait_for_inflight_compile_and_reload_authoritativ
|
||||
})
|
||||
.expect("subscription task");
|
||||
let cancellation = CancellationToken::new();
|
||||
let mut daemon = task.run(cancellation.clone());
|
||||
let mut daemon = task.run(WorkerDaemonContext::new(cancellation.clone()));
|
||||
let mut old = Box::pin(publisher.refresh());
|
||||
assert!(futures::poll!(old.as_mut()).is_pending());
|
||||
*store.facts.lock().expect("facts lock") = Ok(scoped_facts(11, false));
|
||||
|
||||
@@ -40,10 +40,13 @@ pub struct HttpRequest {
|
||||
pub url: String,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub body: Vec<u8>,
|
||||
/// 调用方从有效配置派生的请求体上限;零只允许空正文
|
||||
pub maximum_body_bytes: usize,
|
||||
}
|
||||
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub version: http::Version,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub body: HttpBody,
|
||||
}
|
||||
@@ -51,6 +54,7 @@ pub struct HttpResponse {
|
||||
pub struct HttpBody {
|
||||
response: hyper::body::Incoming,
|
||||
pending: Bytes,
|
||||
trailers: Vec<(String, Vec<u8>)>,
|
||||
received: usize,
|
||||
deadline: Instant,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
@@ -64,7 +68,8 @@ pub struct HttpError {
|
||||
pub send_state: UpstreamSendState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HttpErrorKind {
|
||||
InvalidRequest,
|
||||
AddressDenied,
|
||||
@@ -191,7 +196,7 @@ impl HttpClient {
|
||||
scope: Option<&str>,
|
||||
websocket_key: Option<&str>,
|
||||
) -> Result<PendingResponse, HttpError> {
|
||||
if request.body.len() > 16 * 1024 * 1024
|
||||
if request.body.len() > request.maximum_body_bytes
|
||||
|| request.headers.len() > 128
|
||||
|| timeout.is_zero()
|
||||
|| timeout > Duration::from_secs(120)
|
||||
@@ -364,10 +369,12 @@ impl PendingResponse {
|
||||
pub(super) fn into_http(self) -> HttpResponse {
|
||||
HttpResponse {
|
||||
status: self.status,
|
||||
version: self.response.version(),
|
||||
headers: self.headers,
|
||||
body: HttpBody {
|
||||
response: self.response.into_body(),
|
||||
pending: Bytes::new(),
|
||||
trailers: Vec::new(),
|
||||
received: 0,
|
||||
deadline: self.deadline,
|
||||
_permit: self.permit,
|
||||
@@ -377,23 +384,55 @@ impl PendingResponse {
|
||||
}
|
||||
|
||||
impl HttpBody {
|
||||
/// 取出截至当前读取位置收到的尾部头,保留多值与原始字节
|
||||
pub fn take_trailers(&mut self) -> Vec<(String, Vec<u8>)> {
|
||||
std::mem::take(&mut self.trailers)
|
||||
}
|
||||
|
||||
pub async fn read(&mut self, maximum: usize) -> Result<Option<Bytes>, HttpError> {
|
||||
self.read_until(maximum, self.deadline).await
|
||||
}
|
||||
|
||||
/// 资源 owner 已接管总寿命时,每次读取使用独立预算,取消仍由 owner 回收正文
|
||||
pub async fn read_with_timeout(
|
||||
&mut self,
|
||||
maximum: usize,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Bytes>, HttpError> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or_else(|| HttpError::sent("read timeout"))?;
|
||||
self.read_until(maximum, deadline).await
|
||||
}
|
||||
|
||||
async fn read_until(
|
||||
&mut self,
|
||||
maximum: usize,
|
||||
deadline: Instant,
|
||||
) -> Result<Option<Bytes>, HttpError> {
|
||||
if maximum == 0 || maximum > 64 * 1024 {
|
||||
return Err(HttpError::sent("read size"));
|
||||
}
|
||||
loop {
|
||||
// 已缓冲的数据和就绪帧也受同一期限约束,慢读者不能无限续用 HTTP 资源
|
||||
if Instant::now() >= self.deadline {
|
||||
// 已缓冲的数据和就绪帧也受本次期限约束,不能绕过 owner 的读取预算
|
||||
if Instant::now() >= deadline {
|
||||
return Err(HttpError::sent("response deadline").with_kind(HttpErrorKind::Timeout));
|
||||
}
|
||||
if !self.pending.is_empty() {
|
||||
return Ok(Some(self.pending.split_to(maximum.min(self.pending.len()))));
|
||||
}
|
||||
self.pending = match tokio::time::timeout_at(self.deadline, self.response.frame()).await
|
||||
{
|
||||
self.pending = match tokio::time::timeout_at(deadline, self.response.frame()).await {
|
||||
Ok(Some(Ok(frame))) => match frame.into_data() {
|
||||
Ok(chunk) => chunk,
|
||||
Err(_) => continue,
|
||||
Err(frame) => {
|
||||
let trailers = frame
|
||||
.into_trailers()
|
||||
.map_err(|_| HttpError::sent("response frame"))?;
|
||||
self.trailers.extend(trailers.iter().map(|(name, value)| {
|
||||
(name.as_str().to_owned(), value.as_bytes().to_vec())
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Ok(None) => return Ok(None),
|
||||
Ok(Some(Err(_))) => return Err(HttpError::sent("response body")),
|
||||
|
||||
@@ -27,6 +27,8 @@ type Socket = WebSocketStream<TokioIo<hyper::upgrade::Upgraded>>;
|
||||
|
||||
/// 读写分别串行,允许等待输出时发送控制消息;关闭会唤醒在途操作并释放容量
|
||||
pub struct ManagedWebSocket {
|
||||
handshake_version: http::Version,
|
||||
handshake_headers: Vec<(String, Vec<u8>)>,
|
||||
writer: Mutex<Option<(SplitSink<Socket, Message>, OwnedSemaphorePermit)>>,
|
||||
reader: Mutex<Option<SplitStream<Socket>>>,
|
||||
closed: watch::Sender<bool>,
|
||||
@@ -39,6 +41,7 @@ pub enum WebSocketMessage {
|
||||
|
||||
pub struct WebSocketResponse {
|
||||
pub status: u16,
|
||||
pub version: http::Version,
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
pub connection: Option<ManagedWebSocket>,
|
||||
pub body: Option<HttpBody>,
|
||||
@@ -70,6 +73,7 @@ impl HttpClient {
|
||||
let response = response.into_http();
|
||||
return Ok(WebSocketResponse {
|
||||
status: response.status,
|
||||
version: response.version,
|
||||
headers: response.headers,
|
||||
connection: None,
|
||||
body: Some(response.body),
|
||||
@@ -112,8 +116,11 @@ impl HttpClient {
|
||||
let (writer, reader) = socket.split();
|
||||
Ok(WebSocketResponse {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
version: response.response.version(),
|
||||
headers: response.headers.clone(),
|
||||
connection: Some(ManagedWebSocket {
|
||||
handshake_version: response.response.version(),
|
||||
handshake_headers: response.headers,
|
||||
writer: Mutex::new(Some((writer, response.permit))),
|
||||
reader: Mutex::new(Some(reader)),
|
||||
closed: watch::channel(false).0,
|
||||
@@ -124,6 +131,17 @@ impl HttpClient {
|
||||
}
|
||||
|
||||
impl ManagedWebSocket {
|
||||
/// 连接复用仍可读取最初握手的原始元数据,不伪造一次新握手
|
||||
#[must_use]
|
||||
pub const fn handshake_version(&self) -> http::Version {
|
||||
self.handshake_version
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handshake_headers(&self) -> &[(String, Vec<u8>)] {
|
||||
&self.handshake_headers
|
||||
}
|
||||
|
||||
pub async fn send(
|
||||
&self,
|
||||
message: WebSocketMessage,
|
||||
|
||||
@@ -96,6 +96,7 @@ impl Downloads {
|
||||
url: url.to_string(),
|
||||
headers,
|
||||
body: vec![],
|
||||
maximum_body_bytes: 0,
|
||||
},
|
||||
egress.map(|egress| &egress.proxy),
|
||||
&self.network,
|
||||
|
||||
@@ -33,7 +33,8 @@ pub fn worker(store: Arc<dyn RetentionStore>) -> Result<WorkerContribution, Work
|
||||
// 保持稳定的租约身份,滚动升级期间仍与已有进程互斥
|
||||
let id = WorkerId::try_new(WorkerKind::Retention, "postgres")?;
|
||||
let schedule = WorkerSchedule::try_new(
|
||||
Duration::from_secs(60 * 60),
|
||||
// 有界单轮结束后一分钟继续检查,避免日级写入量超过清理容量
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(60),
|
||||
Duration::from_secs(15 * 60),
|
||||
|
||||
@@ -14,9 +14,9 @@ use gateway_core::health::{
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
|
||||
WorkerDefinitionError, WorkerDisabledReason, WorkerId, WorkerKind, WorkerLeaderLeaseGuard,
|
||||
WorkerLeaderLeasePort, WorkerLeaseAcquisition, WorkerLeaseRequest, WorkerRegistration,
|
||||
WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
WorkerDaemonContext, WorkerDefinitionError, WorkerDisabledReason, WorkerId, WorkerKind,
|
||||
WorkerLeaderLeaseGuard, WorkerLeaderLeasePort, WorkerLeaseAcquisition, WorkerLeaseRequest,
|
||||
WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
@@ -235,7 +235,7 @@ pub enum WorkerStartError {
|
||||
#[derive(Clone)]
|
||||
struct HealthEntry {
|
||||
snapshot: WorkerHealthSnapshot,
|
||||
success_freshness: Duration,
|
||||
success_freshness: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -253,11 +253,14 @@ impl WorkerHealthRegistry {
|
||||
.values()
|
||||
.map(|registration| {
|
||||
let freshness = match ®istration.runnable {
|
||||
WorkerRunnable::Scheduled { schedule, .. } => (*schedule)
|
||||
.interval()
|
||||
.saturating_mul(2)
|
||||
.saturating_add(schedule.maximum_backoff()),
|
||||
WorkerRunnable::Daemon { restart, .. } => restart.maximum_backoff(),
|
||||
WorkerRunnable::Scheduled { schedule, .. } => Some(
|
||||
(*schedule)
|
||||
.interval()
|
||||
.saturating_mul(2)
|
||||
.saturating_add(schedule.maximum_backoff()),
|
||||
),
|
||||
// daemon 可长期空闲或执行备份,重启退避不约束工作耗时
|
||||
WorkerRunnable::Daemon { .. } => None,
|
||||
};
|
||||
let key = WorkerHealthKey::Task(registration.id.clone());
|
||||
(
|
||||
@@ -275,7 +278,7 @@ impl WorkerHealthRegistry {
|
||||
key.clone(),
|
||||
HealthEntry {
|
||||
snapshot: empty_snapshot(key, WorkerRuntimeState::Disabled),
|
||||
success_freshness: Duration::ZERO,
|
||||
success_freshness: None,
|
||||
},
|
||||
)
|
||||
}));
|
||||
@@ -315,11 +318,11 @@ impl WorkerHealthRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
fn succeeded(&self, id: &WorkerId) {
|
||||
fn succeeded(&self, id: &WorkerId, runtime_state: WorkerRuntimeState, cycles: u64) {
|
||||
self.update(id, |state| {
|
||||
state.state = WorkerRuntimeState::Idle;
|
||||
state.state = runtime_state;
|
||||
state.consecutive_failures = 0;
|
||||
state.completed_cycles = state.completed_cycles.saturating_add(1);
|
||||
state.completed_cycles = state.completed_cycles.saturating_add(cycles);
|
||||
state.last_success_at = Some(SystemTime::now());
|
||||
state.last_error = None;
|
||||
});
|
||||
@@ -361,8 +364,11 @@ impl WorkerHealthSource for WorkerHealthRegistry {
|
||||
snapshot.state,
|
||||
WorkerRuntimeState::Running | WorkerRuntimeState::Idle
|
||||
) && snapshot.last_success_at.is_some_and(|last_success| {
|
||||
now.duration_since(last_success)
|
||||
.is_ok_and(|age| age > entry.success_freshness)
|
||||
now.duration_since(last_success).is_ok_and(|age| {
|
||||
entry
|
||||
.success_freshness
|
||||
.is_some_and(|freshness| age > freshness)
|
||||
})
|
||||
});
|
||||
if stale {
|
||||
snapshot.state = WorkerRuntimeState::BackingOff;
|
||||
@@ -407,7 +413,7 @@ async fn supervise_scheduled(
|
||||
|
||||
let delay = match outcome {
|
||||
ScheduledOutcome::Succeeded => {
|
||||
health.succeeded(&id);
|
||||
health.succeeded(&id, WorkerRuntimeState::Idle, 1);
|
||||
backoff = schedule.initial_backoff();
|
||||
schedule.interval()
|
||||
}
|
||||
@@ -595,17 +601,33 @@ async fn supervise_daemon(
|
||||
break;
|
||||
}
|
||||
health.running(&id, None);
|
||||
let daemon_cancel = CancellationToken::new();
|
||||
let run = AssertUnwindSafe(task.run(daemon_cancel.clone())).catch_unwind();
|
||||
let context = WorkerDaemonContext::new(CancellationToken::new());
|
||||
let run = AssertUnwindSafe(task.run(context.clone())).catch_unwind();
|
||||
tokio::pin!(run);
|
||||
let result = tokio::select! {
|
||||
result = &mut run => Some(result),
|
||||
() = cancellation.cancelled() => {
|
||||
daemon_cancel.cancel();
|
||||
let _ = run.await;
|
||||
None
|
||||
let mut completed = 0;
|
||||
let result = loop {
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => {
|
||||
context.cancellation().cancel();
|
||||
let _ = run.await;
|
||||
break None;
|
||||
}
|
||||
result = &mut run => Some(result),
|
||||
() = context.progress_since(completed) => None,
|
||||
};
|
||||
// 成功与退出可在同一 poll 内发生,先消费进度再记录本次退出故障
|
||||
let cycles = context.completed_cycles();
|
||||
if cycles > completed {
|
||||
health.succeeded(&id, WorkerRuntimeState::Running, cycles - completed);
|
||||
completed = cycles;
|
||||
backoff = restart.initial_backoff();
|
||||
}
|
||||
if let Some(result) = outcome {
|
||||
break Some(result);
|
||||
}
|
||||
};
|
||||
context.cancellation().cancel();
|
||||
let Some(result) = result else { break };
|
||||
let error = match result {
|
||||
Ok(Ok(())) => WorkerTaskError::safe("daemon exited unexpectedly"),
|
||||
|
||||
@@ -162,20 +162,15 @@ fn logging_rejects_zero_retention_windows() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_defaults_to_time_retention_and_ignores_removed_count_retention() {
|
||||
let mut value = serde_json::json!({
|
||||
fn logging_defaults_to_time_retention() {
|
||||
let value = serde_json::json!({
|
||||
"level": "info", "stdout": true,
|
||||
"file": { "enabled": true, "directory": "logs", "max_file_size_mb": 20 }
|
||||
});
|
||||
let config: LoggingConfig = serde_json::from_value(value.clone()).unwrap();
|
||||
let config: LoggingConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.request_dump_retention_days, 1.0);
|
||||
assert_eq!(config.file.retention_days, 7);
|
||||
assert!(!config.oauth_recovery);
|
||||
value["file"]["max_files"] = serde_json::json!(20);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<LoggingConfig>(value).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,7 @@ fn request(port: u16, secure: bool) -> HttpRequest {
|
||||
),
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
maximum_body_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ fn request(url: String) -> HttpRequest {
|
||||
url,
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
maximum_body_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +222,63 @@ async fn buffered_body_cannot_extend_the_request_deadline() {
|
||||
assert_eq!(error.kind(), HttpErrorKind::Timeout);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_body_reads_outlive_opening_but_keep_a_per_read_timeout() {
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (send_next, next) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut head = Vec::new();
|
||||
let mut buffer = [0; 1024];
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
let count = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(count > 0 && head.len() < 16 * 1024);
|
||||
head.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
socket
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nConnection: close\r\n\r\na")
|
||||
.await
|
||||
.unwrap();
|
||||
next.await.unwrap();
|
||||
socket.write_all(b"b").await.unwrap();
|
||||
assert_eq!(socket.read(&mut buffer).await.unwrap(), 0);
|
||||
});
|
||||
let mut response = HttpClient::new()
|
||||
.unwrap()
|
||||
.open(request(url), None, &local_network(), Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.body.read(1).await.unwrap().unwrap(), "a");
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(2)).await;
|
||||
tokio::time::resume();
|
||||
send_next.send(()).unwrap();
|
||||
assert_eq!(
|
||||
response
|
||||
.body
|
||||
.read_with_timeout(1, Duration::from_secs(1))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
"b"
|
||||
);
|
||||
let error = response
|
||||
.body
|
||||
.read_with_timeout(1, Duration::from_millis(100))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), HttpErrorKind::Timeout);
|
||||
assert_eq!(error.send_state, UpstreamSendState::Sent);
|
||||
drop(response);
|
||||
tokio::time::timeout(Duration::from_secs(1), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_dns_releases_the_request_slot_without_starting_http() {
|
||||
use std::sync::Arc;
|
||||
@@ -259,3 +317,40 @@ async fn cancelling_dns_releases_the_request_slot_without_starting_http() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_http_enforces_the_callers_body_limit_before_sending() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(any())
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client = HttpClient::new().unwrap();
|
||||
// 覆盖旧固定上限之外的正文,并验证同一个客户端逐次使用调用方的预算
|
||||
let maximum = 17 * 1024 * 1024;
|
||||
for (size, limit, accepted) in [
|
||||
(maximum, maximum, true),
|
||||
(maximum + 1, maximum, false),
|
||||
(maximum + 1, maximum + 1, true),
|
||||
] {
|
||||
let mut request = request(server.uri());
|
||||
request.method = "POST".into();
|
||||
request.body = vec![b'x'; size];
|
||||
request.maximum_body_bytes = limit;
|
||||
let result = client
|
||||
.open(request, None, &local_network(), Duration::from_secs(10))
|
||||
.await;
|
||||
if accepted {
|
||||
assert_eq!(result.unwrap().status, 200);
|
||||
} else {
|
||||
let error = result.err().unwrap();
|
||||
assert_eq!(error.kind(), HttpErrorKind::InvalidRequest);
|
||||
assert_eq!(error.send_state, UpstreamSendState::NotSent);
|
||||
}
|
||||
}
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].body.len(), maximum);
|
||||
assert_eq!(requests[1].body.len(), maximum + 1);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ fn request(url: String) -> HttpRequest {
|
||||
url,
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
maximum_body_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ fn request(url: String) -> HttpRequest {
|
||||
url,
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
maximum_body_bytes: 0,
|
||||
}
|
||||
}
|
||||
fn network() -> NetworkPolicy {
|
||||
|
||||
@@ -12,7 +12,7 @@ use gateway_admin::ports::system::{
|
||||
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{
|
||||
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerId, WorkerKind,
|
||||
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerDaemonContext, WorkerId, WorkerKind,
|
||||
WorkerLeaderLeasePort, WorkerLeaseAcquisition, WorkerLeaseError, WorkerLeaseRequest,
|
||||
WorkerRegistration, WorkerRunnable, WorkerTaskError,
|
||||
};
|
||||
@@ -365,8 +365,9 @@ impl Drop for TaskLifetime {
|
||||
}
|
||||
|
||||
impl DaemonTask for WriterTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = context.cancellation();
|
||||
let _lifetime = TaskLifetime(self.probe.dropped.clone());
|
||||
let mut received = self.received.lock().await;
|
||||
self.probe.started.cancel();
|
||||
@@ -394,8 +395,9 @@ impl DaemonTask for WriterTask {
|
||||
struct IdleTask;
|
||||
|
||||
impl DaemonTask for IdleTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = context.cancellation();
|
||||
cancellation.cancelled().await;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -11,10 +11,10 @@ use futures::future::BoxFuture;
|
||||
use gateway_core::health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerRuntimeState};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::task::{
|
||||
ScheduledTask, WorkerContribution, WorkerCycleContext, WorkerDisabledReason,
|
||||
WorkerFencingToken, WorkerId, WorkerKind, WorkerLeaderLeaseGuard, WorkerLeaderLeasePort,
|
||||
WorkerLeaseAcquisition, WorkerLeaseError, WorkerLeaseRequest, WorkerRegistration,
|
||||
WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
DaemonRestartPolicy, DaemonTask, ScheduledTask, WorkerContribution, WorkerCycleContext,
|
||||
WorkerDaemonContext, WorkerDisabledReason, WorkerFencingToken, WorkerId, WorkerKind,
|
||||
WorkerLeaderLeaseGuard, WorkerLeaderLeasePort, WorkerLeaseAcquisition, WorkerLeaseError,
|
||||
WorkerLeaseRequest, WorkerRegistration, WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
};
|
||||
use gateway_host::workers::{WorkerStartError, WorkerSupervisor};
|
||||
use tokio::sync::Notify;
|
||||
@@ -1023,3 +1023,187 @@ async fn worker_boundary_records_one_native_failure_and_recovers_health() {
|
||||
.contains("PRIVATE_WORKER_NATIVE_CAUSE")
|
||||
);
|
||||
}
|
||||
|
||||
// 每次运行保存自己的上下文,测试可模拟已退出任务的迟到通知
|
||||
struct ControlledDaemon {
|
||||
contexts: Arc<Mutex<Vec<WorkerDaemonContext>>>,
|
||||
commands: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<DaemonCommand>>,
|
||||
}
|
||||
|
||||
enum DaemonCommand {
|
||||
Succeed,
|
||||
Fail,
|
||||
SucceedThenFail,
|
||||
Panic,
|
||||
Exit,
|
||||
}
|
||||
|
||||
impl DaemonTask for ControlledDaemon {
|
||||
fn run(&self, context: WorkerDaemonContext) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move {
|
||||
lock_unpoisoned(&self.contexts).push(context.clone());
|
||||
let mut commands = self.commands.lock().await;
|
||||
loop {
|
||||
let command = tokio::select! {
|
||||
() = context.cancellation().cancelled() => return Ok(()),
|
||||
command = commands.recv() => command.expect("daemon command"),
|
||||
};
|
||||
match command {
|
||||
DaemonCommand::Succeed => context.succeeded(),
|
||||
DaemonCommand::SucceedThenFail => {
|
||||
context.succeeded();
|
||||
return Err(WorkerTaskError::safe("test daemon failure"));
|
||||
}
|
||||
DaemonCommand::Fail => {
|
||||
return Err(WorkerTaskError::safe("test daemon failure"));
|
||||
}
|
||||
DaemonCommand::Panic => panic!("test daemon panic"),
|
||||
DaemonCommand::Exit => return Ok(()),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_plan(task: impl DaemonTask + 'static) -> Vec<WorkerContribution> {
|
||||
complete_plan(vec![WorkerContribution::Registration(
|
||||
WorkerRegistration::try_new(
|
||||
WorkerId::try_new(WorkerKind::Backup, "backup").unwrap(),
|
||||
WorkerRunnable::Daemon {
|
||||
restart: DaemonRestartPolicy::try_new(
|
||||
Duration::from_millis(10),
|
||||
Duration::from_millis(20),
|
||||
)
|
||||
.unwrap(),
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
)])
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn daemon_recovers_only_after_current_run_succeeds_and_resets_backoff() {
|
||||
let (commands, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let supervisor = supervisor();
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, "backup").unwrap();
|
||||
supervisor
|
||||
.start(
|
||||
daemon_plan(ControlledDaemon {
|
||||
contexts: contexts.clone(),
|
||||
commands: tokio::sync::Mutex::new(receiver),
|
||||
}),
|
||||
Arc::new(FakeLeasePort::default()),
|
||||
Arc::new(RecordingDiagnostics::default()),
|
||||
)
|
||||
.unwrap();
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == 1).await;
|
||||
|
||||
// 连续失败逐步退避;仅重新进入 run 不能恢复健康
|
||||
for (attempt, delay) in [(1, 10), (2, 20)] {
|
||||
commands.send(DaemonCommand::Fail).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).state == WorkerRuntimeState::BackingOff).await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, attempt);
|
||||
tokio::time::advance(Duration::from_millis(delay - 1)).await;
|
||||
assert_eq!(lock_unpoisoned(&contexts).len(), attempt as usize);
|
||||
tokio::time::advance(Duration::from_millis(1)).await;
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == attempt as usize + 1).await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, attempt);
|
||||
}
|
||||
lock_unpoisoned(&contexts)[0].succeeded();
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, 2);
|
||||
|
||||
commands.send(DaemonCommand::Succeed).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).consecutive_failures == 0).await;
|
||||
let recovered = task_health(&supervisor, &id);
|
||||
assert_eq!(recovered.state, WorkerRuntimeState::Running);
|
||||
assert_eq!(recovered.completed_cycles, 1);
|
||||
assert!(recovered.last_error.is_none());
|
||||
assert!(recovered.last_success_at.is_some());
|
||||
assert!(recovered.last_failure_at.is_some());
|
||||
|
||||
commands.send(DaemonCommand::Fail).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).state == WorkerRuntimeState::BackingOff).await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, 1);
|
||||
tokio::time::advance(Duration::from_millis(10)).await;
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == 4).await;
|
||||
commands.send(DaemonCommand::Fail).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).state == WorkerRuntimeState::BackingOff).await;
|
||||
tokio::time::advance(Duration::from_millis(20)).await;
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == 5).await;
|
||||
|
||||
// 同一 poll 中先成功再退出,必须保留最新故障并重新从初始退避开始
|
||||
commands.send(DaemonCommand::SucceedThenFail).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).state == WorkerRuntimeState::BackingOff).await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, 1);
|
||||
assert_eq!(task_health(&supervisor, &id).completed_cycles, 2);
|
||||
tokio::time::advance(Duration::from_millis(10)).await;
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == 6).await;
|
||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
||||
lock_unpoisoned(&contexts)[5].succeeded();
|
||||
assert_eq!(
|
||||
task_health(&supervisor, &id).state,
|
||||
WorkerRuntimeState::Stopped
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn daemon_success_remains_healthy_while_idle_beyond_restart_backoff() {
|
||||
let (commands, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let supervisor = supervisor();
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, "backup").unwrap();
|
||||
supervisor
|
||||
.start(
|
||||
daemon_plan(ControlledDaemon {
|
||||
contexts: Arc::default(),
|
||||
commands: tokio::sync::Mutex::new(receiver),
|
||||
}),
|
||||
Arc::new(FakeLeasePort::default()),
|
||||
Arc::new(RecordingDiagnostics::default()),
|
||||
)
|
||||
.unwrap();
|
||||
commands.send(DaemonCommand::Succeed).unwrap();
|
||||
wait_until(|| task_health(&supervisor, &id).completed_cycles == 1).await;
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
let health = task_health(&supervisor, &id);
|
||||
assert_eq!(health.state, WorkerRuntimeState::Running);
|
||||
assert_eq!(health.consecutive_failures, 0);
|
||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn daemon_panic_and_unexpected_exit_remain_unhealthy_until_success() {
|
||||
for (command, error) in [
|
||||
(DaemonCommand::Panic, "daemon panicked"),
|
||||
(DaemonCommand::Exit, "daemon exited unexpectedly"),
|
||||
] {
|
||||
let (commands, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let contexts = Arc::new(Mutex::new(Vec::new()));
|
||||
let supervisor = supervisor();
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, "backup").unwrap();
|
||||
supervisor
|
||||
.start(
|
||||
daemon_plan(ControlledDaemon {
|
||||
contexts: contexts.clone(),
|
||||
commands: tokio::sync::Mutex::new(receiver),
|
||||
}),
|
||||
Arc::new(FakeLeasePort::default()),
|
||||
Arc::new(RecordingDiagnostics::default()),
|
||||
)
|
||||
.unwrap();
|
||||
commands.send(command).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).state == WorkerRuntimeState::BackingOff).await;
|
||||
assert_eq!(
|
||||
task_health(&supervisor, &id).last_error.as_deref(),
|
||||
Some(error)
|
||||
);
|
||||
tokio::time::advance(Duration::from_millis(10)).await;
|
||||
yield_until(|| lock_unpoisoned(&contexts).len() == 2).await;
|
||||
assert_eq!(task_health(&supervisor, &id).consecutive_failures, 1);
|
||||
commands.send(DaemonCommand::Succeed).unwrap();
|
||||
yield_until(|| task_health(&supervisor, &id).consecutive_failures == 0).await;
|
||||
supervisor.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [4],
|
||||
"protocol_versions": [5],
|
||||
"capabilities": [
|
||||
{ "capability": "frontend_authentication", "versions": [2] },
|
||||
{ "capability": "scheduler", "versions": [3] },
|
||||
@@ -9,7 +9,7 @@
|
||||
{ "capability": "model_catalog", "versions": [1] },
|
||||
{ "capability": "retry_policy", "versions": [2] },
|
||||
{ "capability": "middleware", "versions": [5] },
|
||||
{ "capability": "upstream_adapter", "versions": [3] },
|
||||
{ "capability": "upstream_adapter", "versions": [4] },
|
||||
{ "capability": "observer", "versions": [2] },
|
||||
{ "capability": "command_line", "versions": [1] },
|
||||
{ "capability": "management", "versions": [2] },
|
||||
|
||||
@@ -69,6 +69,7 @@ pub(super) async fn invoke(
|
||||
},
|
||||
maximum_timeout,
|
||||
);
|
||||
call_context.resource_stream = true;
|
||||
call_context.request = crate::adapter::request_context(context.extension_scope().request());
|
||||
call_context.request_id = Some(context.request_id().as_str().to_owned());
|
||||
call_context.attempt_id = context
|
||||
@@ -85,6 +86,7 @@ pub(super) async fn invoke(
|
||||
context.extension_scope().clone(),
|
||||
context.plan(),
|
||||
context.cancellation().clone(),
|
||||
context.request_body_limit(),
|
||||
)
|
||||
.map_err(|_| MiddlewareError::Fault)?;
|
||||
let _binding = invocation_ports.callbacks.bind_middleware(
|
||||
|
||||
@@ -543,10 +543,6 @@ fn wire_timings(observation: &RequestObservation) -> RequestTimings {
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: timings.latency_ms,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ async fn call(
|
||||
input.extension_scope.clone(),
|
||||
None,
|
||||
gateway_core::lifecycle::CancellationToken::new(),
|
||||
input.request_body_limit(),
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
let reply = invocation
|
||||
|
||||
@@ -137,6 +137,7 @@ async fn route_with_entry(
|
||||
input.extension_scope().clone(),
|
||||
None,
|
||||
gateway_core::lifecycle::CancellationToken::new(),
|
||||
input.request_body_limit(),
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
let reply = invocation
|
||||
@@ -212,6 +213,7 @@ async fn schedule_with_entry(
|
||||
input.extension_scope().clone(),
|
||||
None,
|
||||
gateway_core::lifecycle::CancellationToken::new(),
|
||||
input.request_body_limit(),
|
||||
)
|
||||
.map_err(|_| ())?;
|
||||
let reply = invocation
|
||||
|
||||
@@ -24,7 +24,7 @@ use gateway_protocol::openai::sse::SseEventDecoder;
|
||||
pub(super) struct EventDecoder {
|
||||
sequence: EventSequenceValidator,
|
||||
response_id: Option<String>,
|
||||
service_tier: Option<String>,
|
||||
observation: Option<ProviderResponseObservation>,
|
||||
usage: Usage,
|
||||
contents: usize,
|
||||
pub(super) completed: bool,
|
||||
@@ -54,6 +54,7 @@ impl EventDecoder {
|
||||
transport: &str,
|
||||
account: &dyn UpstreamAccountConnection,
|
||||
sent: UpstreamSendState,
|
||||
started_at: std::time::Instant,
|
||||
) -> Result<DecodedEvent, EventDecodeError> {
|
||||
let message = UpstreamAdapterEvent::decode(payload)
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
@@ -65,22 +66,31 @@ impl EventDecoder {
|
||||
.wire
|
||||
.map(|wire| decode_wire(wire, protocol, sent))
|
||||
.transpose()?;
|
||||
let has_observation = self.observation.is_some() || message.observation.is_some();
|
||||
let mut observation = match self.observation.take() {
|
||||
Some(observation) => observation,
|
||||
None => ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(transport)
|
||||
.map_err(|source| invalid(sent).with_source(source))?,
|
||||
),
|
||||
};
|
||||
if let Some(update) = message.observation {
|
||||
observation = super::observation::merge(observation, update, sent)?;
|
||||
}
|
||||
if let Some(failure) = message.failure {
|
||||
if !message.event.facts.is_empty() || message.continuation.is_some() {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
let mut error = failure_error(failure, sent)?;
|
||||
let mut events = Vec::new();
|
||||
if has_observation {
|
||||
events.push(ProviderEvent::observation(observation));
|
||||
}
|
||||
if let Some(wire) = wire {
|
||||
error = error.with_atomic_client_events(vec![ProviderEvent::wire(wire)]);
|
||||
events.push(ProviderEvent::wire(wire));
|
||||
}
|
||||
return Err(EventDecodeError::Upstream(error));
|
||||
}
|
||||
if let Some(tier) = message.service_tier {
|
||||
if tier.is_empty() || tier.len() > 64 || tier.chars().any(char::is_control) {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
// 上游可能在完成时才把 auto 解析为实际档位;与原生观测一样保留最新值
|
||||
self.service_tier = Some(tier);
|
||||
return Err(EventDecodeError::Upstream(
|
||||
failure_error(failure, sent)?.with_atomic_client_events(events),
|
||||
));
|
||||
}
|
||||
let mut facts = Vec::new();
|
||||
for fact in message.event.facts {
|
||||
@@ -181,7 +191,7 @@ impl EventDecoder {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
if let Some(cost) =
|
||||
account.calculate_cost(self.service_tier.as_deref(), &self.usage)
|
||||
account.calculate_cost(observation.service_tier(), &self.usage)
|
||||
{
|
||||
let cost = GatewayEvent::CalculatedCost(cost);
|
||||
self.sequence
|
||||
@@ -213,22 +223,26 @@ impl EventDecoder {
|
||||
if message.continuation.is_some() && !self.completed {
|
||||
return Err(invalid(sent).into());
|
||||
}
|
||||
observation =
|
||||
super::observation::observe_output(observation, &facts, wire.is_some(), started_at);
|
||||
self.observation = Some(observation.clone());
|
||||
let mut event = if let Some(wire) = wire {
|
||||
ProviderEvent::canonical_with_wire(facts, wire)
|
||||
} else {
|
||||
let mut facts = facts.into_iter();
|
||||
let first = facts.next().ok_or_else(|| invalid(sent))?;
|
||||
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
|
||||
match facts.next() {
|
||||
Some(first) => {
|
||||
facts.fold(ProviderEvent::canonical(first), ProviderEvent::with_fact)
|
||||
}
|
||||
None => {
|
||||
return Ok(DecodedEvent {
|
||||
event: ProviderEvent::observation(observation),
|
||||
continuation: message.continuation,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(tier) = &self.service_tier {
|
||||
let observation = ProviderResponseObservation::new(
|
||||
UpstreamTransport::new(transport)
|
||||
.map_err(|source| invalid(sent).with_source(source))?,
|
||||
)
|
||||
.try_with_service_tier(tier.clone())
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
event.attach_observation(observation);
|
||||
}
|
||||
event.attach_observation(observation);
|
||||
Ok(DecodedEvent {
|
||||
event,
|
||||
continuation: message.continuation,
|
||||
@@ -308,6 +322,20 @@ fn decode_wire(
|
||||
ProtocolWireEvent::json_with_sse_metadata(protocol, event, data, id, retry)
|
||||
}
|
||||
}
|
||||
WirePayload::WebSocket {
|
||||
event,
|
||||
data,
|
||||
message,
|
||||
} => {
|
||||
let message =
|
||||
String::from_utf8(message).map_err(|source| invalid(sent).with_source(source))?;
|
||||
if let Some(data) = data {
|
||||
ProtocolWireEvent::json(protocol, event, data)
|
||||
.map(|wire| wire.with_raw_websocket_message(message))
|
||||
} else {
|
||||
ProtocolWireEvent::raw_websocket(protocol, message)
|
||||
}
|
||||
}
|
||||
WirePayload::RawSse { frame } => {
|
||||
single_sse(&frame, sent)?;
|
||||
ProtocolWireEvent::raw_sse(protocol, Bytes::from(frame))
|
||||
|
||||
@@ -106,6 +106,7 @@ impl Execution {
|
||||
self.adapter.declaration.transport.as_str(),
|
||||
self.invocation.account.as_ref(),
|
||||
active.managed.send_state.get(),
|
||||
self.invocation.context.timing_started_at(),
|
||||
)
|
||||
.map_err(|error| match error {
|
||||
EventDecodeError::Invalid(error) => {
|
||||
@@ -131,7 +132,7 @@ impl Execution {
|
||||
}.await;
|
||||
if let Err(error) = terminal {
|
||||
// 尾部故障不得交付成功 wire,但已经解析的计量事实仍归 Core 结算
|
||||
let metering = decoded
|
||||
let mut metering: Vec<_> = decoded
|
||||
.event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
@@ -145,6 +146,11 @@ impl Execution {
|
||||
.cloned()
|
||||
.map(gateway_core::event::ProviderEvent::canonical)
|
||||
.collect();
|
||||
if let Some(observation) = decoded.event.response_observation() {
|
||||
metering.push(gateway_core::event::ProviderEvent::observation(
|
||||
observation.clone(),
|
||||
));
|
||||
}
|
||||
return Err(error.with_atomic_client_events(metering));
|
||||
}
|
||||
self.ended = true;
|
||||
@@ -177,6 +183,8 @@ impl Execution {
|
||||
));
|
||||
}
|
||||
let mut context = self.adapter.session.context(Stage::Upstream, timeout);
|
||||
// 初始回复后由模型执行持有流的期限与取消,RPC 预算只约束建立阶段
|
||||
context.resource_stream = true;
|
||||
context.request =
|
||||
crate::adapter::request_context(invocation.context.extension_scope().request());
|
||||
context.request_id = Some(invocation.context.request_id().as_str().to_owned());
|
||||
@@ -210,10 +218,21 @@ impl Execution {
|
||||
.extension_scope()
|
||||
.extending(self.adapter.instance_id.clone())
|
||||
.ok_or_else(invalid)?;
|
||||
let request_body_limit = invocation
|
||||
.context
|
||||
.request_body_limit()
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| self.adapter.callbacks.request_body_limit())
|
||||
.map_err(|error| fault_error(error, UpstreamSendState::NotSent))?;
|
||||
let scope = self
|
||||
.adapter
|
||||
.callbacks
|
||||
.prepare_upstream(&context, Arc::clone(&managed), extension_scope)
|
||||
.prepare_upstream(
|
||||
&context,
|
||||
Arc::clone(&managed),
|
||||
extension_scope,
|
||||
request_body_limit,
|
||||
)
|
||||
.map_err(|error| fault_error(error, UpstreamSendState::NotSent))?;
|
||||
let request = UpstreamAdapterRequest {
|
||||
adapter_id: declaration.id.clone(),
|
||||
@@ -230,6 +249,10 @@ impl Execution {
|
||||
protocol: invocation.operation.protocol().to_owned(),
|
||||
client_transport: invocation.context.client_transport().as_str().to_owned(),
|
||||
fast_mode: invocation.context.fast_mode().as_str().to_owned(),
|
||||
request_elapsed_ms: u64::try_from(
|
||||
invocation.context.timing_started_at().elapsed().as_millis(),
|
||||
)
|
||||
.unwrap_or(u64::MAX),
|
||||
headers: invocation
|
||||
.headers
|
||||
.iter()
|
||||
@@ -237,14 +260,18 @@ impl Execution {
|
||||
.collect(),
|
||||
continuation: previous.map(|state| state.continuation),
|
||||
};
|
||||
let body = invocation
|
||||
.operation
|
||||
.middleware_body()
|
||||
.map_err(|source| invalid().with_source(source))?;
|
||||
if body.len() > gateway_plugin_sdk::call::model::request_payload_limit(request_body_limit) {
|
||||
return Err(fault_error(
|
||||
crate::callback::request_limits::too_large(),
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
let payload = request
|
||||
.encode(
|
||||
invocation
|
||||
.operation
|
||||
.middleware_body()
|
||||
.map_err(|source| invalid().with_source(source))?
|
||||
.to_vec(),
|
||||
)
|
||||
.encode(body.to_vec())
|
||||
.map_err(|source| invalid().with_source(source))?;
|
||||
invocation.context.trace().record("plugin.upstream", serde_json::json!({ "instanceId": self.adapter.instance_id, "adapterId": declaration.id, "generation": context.generation, "transport": declaration.transport.as_str() }));
|
||||
let mut stream = tokio::select! {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod event;
|
||||
mod execution;
|
||||
mod failure;
|
||||
mod observation;
|
||||
mod registration;
|
||||
pub(crate) mod target;
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 合并适配器的原始上游观测,并记录宿主实际收到规范化输出的时间
|
||||
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProviderResponseHeader, ProviderResponseMetadata, ProviderResponseObservation,
|
||||
ProviderResponseTimings, UpstreamHttpVersion, WebSocketPoolKind,
|
||||
};
|
||||
use gateway_core::upstream::{OpaqueUpstreamValue, UpstreamSendState};
|
||||
use gateway_plugin_sdk::call::model::facts::ResponseObservation;
|
||||
|
||||
use super::failure::invalid;
|
||||
|
||||
pub(super) fn merge(
|
||||
mut current: ProviderResponseObservation,
|
||||
update: ResponseObservation,
|
||||
sent: UpstreamSendState,
|
||||
) -> Result<ProviderResponseObservation, gateway_core::error::ProviderError> {
|
||||
// 传输类型由已选适配器固定,不能让不一致的报告绕过 Core 的观测归属
|
||||
if update.transport != current.transport().as_str() {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
if let Some(value) = update.http_version {
|
||||
let version = if value == "unknown" {
|
||||
UpstreamHttpVersion::Unknown
|
||||
} else {
|
||||
UpstreamHttpVersion::parse(&value).ok_or_else(|| invalid(sent))?
|
||||
};
|
||||
current = current.with_http_version(version);
|
||||
}
|
||||
if let Some(value) = update.websocket_pool {
|
||||
current = current.with_websocket_pool(match value.as_str() {
|
||||
"new" => WebSocketPoolKind::New,
|
||||
"reuse" => WebSocketPoolKind::Reuse,
|
||||
_ => return Err(invalid(sent)),
|
||||
});
|
||||
}
|
||||
if let Some(value) = update.status_code {
|
||||
if !(100..=599).contains(&value) {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
current = current.with_status_code(value);
|
||||
}
|
||||
if let Some(value) = update.request_id {
|
||||
current = current.with_request_id(OpaqueUpstreamValue::new(value));
|
||||
}
|
||||
if let Some(value) = update.service_tier {
|
||||
current = current
|
||||
.try_with_service_tier(value)
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
}
|
||||
if let Some(value) = update.upstream_response_model {
|
||||
current = current.with_upstream_response_model_if_valid(&value);
|
||||
if current.upstream_response_model() != Some(value.trim()) {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
}
|
||||
if !update.client_headers.is_empty() {
|
||||
current = current.with_client_headers(
|
||||
update
|
||||
.client_headers
|
||||
.into_iter()
|
||||
.map(|header| ProviderResponseHeader::new(header.name, header.value.into()))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(value) = update.provider_metadata {
|
||||
current = current.with_provider_metadata(
|
||||
ProviderResponseMetadata::new(value).ok_or_else(|| invalid(sent))?,
|
||||
);
|
||||
}
|
||||
let previous = current.timings();
|
||||
let update = update.timings;
|
||||
let timings = ProviderResponseTimings {
|
||||
transport_decision_wait_ms: update
|
||||
.transport_decision_wait_ms
|
||||
.or(previous.transport_decision_wait_ms),
|
||||
connect_ms: update.connect_ms.or(previous.connect_ms),
|
||||
headers_ms: update.headers_ms.or(previous.headers_ms),
|
||||
first_event_ms: update.first_event_ms.or(previous.first_event_ms),
|
||||
first_reasoning_ms: update.first_reasoning_ms.or(previous.first_reasoning_ms),
|
||||
first_text_ms: update.first_text_ms.or(previous.first_text_ms),
|
||||
first_token_ms: update.first_token_ms.or(previous.first_token_ms),
|
||||
provider_processing_ms: update
|
||||
.provider_processing_ms
|
||||
.or(previous.provider_processing_ms),
|
||||
upstream_response_ms: update
|
||||
.upstream_response_ms
|
||||
.or(previous.upstream_response_ms),
|
||||
upstream_api_overhead_ms: update
|
||||
.upstream_api_overhead_ms
|
||||
.or(previous.upstream_api_overhead_ms),
|
||||
upstream_engine_ms: update.upstream_engine_ms.or(previous.upstream_engine_ms),
|
||||
};
|
||||
Ok(current.with_timings(timings))
|
||||
}
|
||||
|
||||
pub(super) fn observe_output(
|
||||
current: ProviderResponseObservation,
|
||||
facts: &[GatewayEvent],
|
||||
has_wire: bool,
|
||||
started_at: std::time::Instant,
|
||||
) -> ProviderResponseObservation {
|
||||
let elapsed = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
let mut timings = current.timings();
|
||||
if has_wire || !facts.is_empty() {
|
||||
timings.first_event_ms.get_or_insert(elapsed);
|
||||
}
|
||||
// 内容边界来自插件报告的 canonical 事实,不按上游私有事件名猜测
|
||||
for fact in facts {
|
||||
let has_output = match fact {
|
||||
GatewayEvent::TextDelta(delta) if !delta.text.is_empty() => {
|
||||
timings.first_text_ms.get_or_insert(elapsed);
|
||||
true
|
||||
}
|
||||
GatewayEvent::ReasoningDelta(delta) if !delta.text.is_empty() => {
|
||||
timings.first_reasoning_ms.get_or_insert(elapsed);
|
||||
true
|
||||
}
|
||||
GatewayEvent::ToolCallDelta(delta) => {
|
||||
!delta.arguments_delta.is_empty()
|
||||
|| delta.name.as_ref().is_some_and(|name| !name.is_empty())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if has_output {
|
||||
timings.first_token_ms.get_or_insert(elapsed);
|
||||
}
|
||||
}
|
||||
current.with_timings(timings)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use gateway_plugin_sdk::{
|
||||
FinishReason as WireFinishReason, Usage as WireUsage, WireEvent, WirePayload,
|
||||
facts::{self, ExecutionCost, ExecutionFacts},
|
||||
},
|
||||
observation::{RequestMoney, RequestTimings},
|
||||
observation::RequestMoney,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,7 +120,7 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
request_id: value.request_id().map(|value| value.as_str().to_owned()),
|
||||
service_tier: value.service_tier().map(str::to_owned),
|
||||
upstream_response_model: value.upstream_response_model().map(str::to_owned),
|
||||
timings: RequestTimings {
|
||||
timings: facts::ResponseTimings {
|
||||
transport_decision_wait_ms: timings.transport_decision_wait_ms,
|
||||
connect_ms: timings.connect_ms,
|
||||
headers_ms: timings.headers_ms,
|
||||
@@ -132,11 +132,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
upstream_response_ms: timings.upstream_response_ms,
|
||||
upstream_api_overhead_ms: timings.upstream_api_overhead_ms,
|
||||
upstream_engine_ms: timings.upstream_engine_ms,
|
||||
upstream_engine_iapi_ttft_ms: timings.upstream_engine_iapi_ttft_ms,
|
||||
upstream_engine_service_ttft_ms: timings.upstream_engine_service_ttft_ms,
|
||||
upstream_engine_iapi_tbt_ms: timings.upstream_engine_iapi_tbt_ms,
|
||||
upstream_engine_service_tbt_ms: timings.upstream_engine_service_tbt_ms,
|
||||
latency_ms: None,
|
||||
},
|
||||
client_headers: value
|
||||
.client_headers()
|
||||
@@ -275,6 +270,12 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
|
||||
WirePayload::RawJson {
|
||||
body: body.to_vec(),
|
||||
}
|
||||
} else if let Some(message) = wire.raw_websocket_message() {
|
||||
WirePayload::WebSocket {
|
||||
event: wire.event_type().map(str::to_owned),
|
||||
data: wire.has_json_data().then(|| wire.data().clone()),
|
||||
message: message.as_bytes().to_vec(),
|
||||
}
|
||||
} else if wire.has_json_data() {
|
||||
WirePayload::Json {
|
||||
event: wire.event_type().map(str::to_owned),
|
||||
@@ -287,15 +288,6 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
|
||||
WirePayload::RawSse {
|
||||
frame: frame.to_vec(),
|
||||
}
|
||||
} else if let Some(message) = wire.raw_websocket_message() {
|
||||
// 子模型回调采用 SDK 的事件流表达,不把 WebSocket 文本冒充 HTTP body
|
||||
WirePayload::RawSse {
|
||||
frame: gateway_protocol::openai::sse::encode_sse_event(
|
||||
wire.event_type().unwrap_or_default(),
|
||||
message,
|
||||
)
|
||||
.into_bytes(),
|
||||
}
|
||||
} else {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Fault,
|
||||
|
||||
@@ -17,9 +17,18 @@ pub(super) struct HttpCallbacks<'a> {
|
||||
pub(super) scope: &'a Arc<CallbackScope>,
|
||||
pub(super) call: &'a CallResources,
|
||||
pub(super) maximum_payload: usize,
|
||||
pub(super) request_limits: &'a super::request_limits::RequestLimits,
|
||||
}
|
||||
|
||||
impl HttpCallbacks<'_> {
|
||||
pub(super) fn body_limit(&self, body: &[u8]) -> Result<usize, PluginFault> {
|
||||
// 空正文不占请求预算,也允许尚未发布设置的注册阶段执行无正文查询
|
||||
if body.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
self.request_limits.body_bytes(self.call)
|
||||
}
|
||||
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
request: HttpRequest,
|
||||
@@ -32,7 +41,11 @@ impl HttpCallbacks<'_> {
|
||||
scope,
|
||||
call,
|
||||
maximum_payload,
|
||||
..
|
||||
} = *self;
|
||||
if request.body.len() > request.maximum_body_bytes {
|
||||
return Err(super::request_limits::too_large());
|
||||
}
|
||||
let timeout = call.timeout()?;
|
||||
let attempt = call.start_upstream(upstream_purpose)?;
|
||||
let response = client
|
||||
@@ -45,16 +58,12 @@ impl HttpCallbacks<'_> {
|
||||
);
|
||||
let response = response.map_err(http_error)?;
|
||||
let mut body = response.body;
|
||||
let headers = response
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| String::from_utf8(value).map(|value| (name, value)))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| sent_fault(ErrorCode::Fault, "HTTP header cannot be represented"))?;
|
||||
let mut result = wire::HttpResponse {
|
||||
status: response.status,
|
||||
headers,
|
||||
http_version: format!("{:?}", response.version),
|
||||
headers: response.headers,
|
||||
stream: None,
|
||||
trailers: Vec::new(),
|
||||
};
|
||||
let mut payload = Vec::new();
|
||||
if streaming {
|
||||
@@ -90,6 +99,7 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
payload.extend_from_slice(&chunk);
|
||||
}
|
||||
result.trailers = body.take_trailers();
|
||||
}
|
||||
Ok(RpcReply {
|
||||
result: serde_json::to_value(result).map_err(|_| invalid())?,
|
||||
@@ -111,11 +121,8 @@ impl HttpCallbacks<'_> {
|
||||
HttpRequest {
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request
|
||||
.headers
|
||||
.into_iter()
|
||||
.map(|(name, value)| (name, value.into_bytes()))
|
||||
.collect(),
|
||||
headers: request.headers,
|
||||
maximum_body_bytes: self.body_limit(&payload)?,
|
||||
body: payload,
|
||||
},
|
||||
method == "host.http.do_stream",
|
||||
@@ -142,12 +149,16 @@ impl HttpCallbacks<'_> {
|
||||
let mut body = stream.body.try_lock().map_err(|_| {
|
||||
PluginFault::new(ErrorCode::Capacity, "HTTP stream already has a reader")
|
||||
})?;
|
||||
let timeout = call.timeout()?;
|
||||
let mut closed = stream.closed.subscribe();
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = closed.wait_for(|closed| *closed) => Err(denied()),
|
||||
result = body.as_mut().ok_or_else(denied)?.read(request.maximum_bytes as usize) => result.map_err(http_error),
|
||||
result = body.as_mut().ok_or_else(denied)?.read_with_timeout(request.maximum_bytes as usize, timeout) => result.map_err(http_error),
|
||||
};
|
||||
let trailers = body
|
||||
.as_mut()
|
||||
.map_or_else(Vec::new, |body| body.take_trailers());
|
||||
if !matches!(&result, Ok(Some(_))) {
|
||||
body.take();
|
||||
stream.closed.send_replace(true);
|
||||
@@ -159,7 +170,7 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
let chunk = result?;
|
||||
Ok(RpcReply {
|
||||
result: serde_json::json!({"eof":chunk.is_none()}),
|
||||
result: serde_json::json!({"eof":chunk.is_none(), "trailers": trailers}),
|
||||
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
|
||||
})
|
||||
}
|
||||
@@ -216,6 +227,10 @@ pub(super) fn http_error(error: HttpError) -> PluginFault {
|
||||
HttpErrorKind::Response => (ErrorCode::Upstream, "目标网站响应读取失败"),
|
||||
};
|
||||
let mut fault = PluginFault::new(code, message);
|
||||
fault.details = Some(serde_json::json!({
|
||||
"kind": error.kind(),
|
||||
"reason": error.reason(),
|
||||
}));
|
||||
fault.send_state = match error.send_state {
|
||||
UpstreamSendState::NotSent => SendState::NotSent,
|
||||
UpstreamSendState::Sent => SendState::Sent,
|
||||
|
||||
@@ -15,6 +15,7 @@ mod log;
|
||||
mod middleware;
|
||||
mod model;
|
||||
pub(crate) mod private_state;
|
||||
pub(crate) mod request_limits;
|
||||
mod resources;
|
||||
mod scope;
|
||||
pub(crate) mod services;
|
||||
@@ -80,6 +81,7 @@ impl PluginCallbackPorts {
|
||||
}
|
||||
|
||||
pub(crate) struct PluginCallbacks {
|
||||
pub(crate) registration_body_limit: Option<usize>,
|
||||
services: Arc<services::ServicePorts>,
|
||||
service_actor: gateway_admin::model::usage_data::UsageActor,
|
||||
accounts: Arc<accounts::PluginAccounts>,
|
||||
@@ -96,6 +98,7 @@ pub(crate) struct PluginCallbacks {
|
||||
pending_middleware: Mutex<BTreeMap<String, Arc<dyn MiddlewareCallback>>>,
|
||||
calls: Mutex<BTreeMap<u64, Arc<CallResources>>>,
|
||||
maximum_payload: usize,
|
||||
request_limits: Arc<request_limits::RequestLimits>,
|
||||
}
|
||||
|
||||
impl PluginCallbacks {
|
||||
@@ -106,8 +109,10 @@ impl PluginCallbacks {
|
||||
log_slots: Arc<tokio::sync::Semaphore>,
|
||||
private_state: Arc<private_state::PluginPrivateState>,
|
||||
ports: PluginCallbackPorts,
|
||||
request_limits: Arc<request_limits::RequestLimits>,
|
||||
) -> Result<Self, AdminError> {
|
||||
Ok(Self {
|
||||
registration_body_limit: None,
|
||||
services: ports.services,
|
||||
service_actor: gateway_admin::model::usage_data::UsageActor::Plugin {
|
||||
publisher: manifest.publisher.clone(),
|
||||
@@ -126,7 +131,11 @@ impl PluginCallbacks {
|
||||
log::PluginLog::new(manifest, log_slots)
|
||||
.map_err(|_| AdminError::invalid("插件清单身份不合法"))?,
|
||||
),
|
||||
models: Arc::new(model::PluginModels::new(ports.models, maximum_payload)),
|
||||
models: Arc::new(model::PluginModels::new(
|
||||
ports.models,
|
||||
request_limits.clone(),
|
||||
maximum_payload,
|
||||
)),
|
||||
private_state,
|
||||
http: ports.http,
|
||||
network: Arc::new(ports.network),
|
||||
@@ -134,6 +143,7 @@ impl PluginCallbacks {
|
||||
pending_middleware: Mutex::new(BTreeMap::new()),
|
||||
calls: Mutex::new(BTreeMap::new()),
|
||||
maximum_payload,
|
||||
request_limits,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,6 +191,7 @@ impl PluginCallbacks {
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
|
||||
cancellation: CancellationToken,
|
||||
request_body_limit: Option<usize>,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if !matches!(
|
||||
context.stage,
|
||||
@@ -197,6 +208,7 @@ impl PluginCallbacks {
|
||||
scope = scope.with_execution_effects(effects);
|
||||
}
|
||||
scope.extension_scope = extensions;
|
||||
scope.request_body_limit = request_body_limit;
|
||||
scope.origin = plan.map(|plan| InvocationContext {
|
||||
request_id: context
|
||||
.request_id
|
||||
@@ -212,11 +224,16 @@ impl PluginCallbacks {
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) fn request_body_limit(&self) -> Result<usize, PluginFault> {
|
||||
self.request_limits.current_body_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_upstream(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
managed: Arc<upstream::ManagedUpstream>,
|
||||
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
request_body_limit: usize,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::Upstream
|
||||
|| context.account_id.as_deref() != Some(managed.account.account_id().as_str())
|
||||
@@ -227,6 +244,7 @@ impl PluginCallbacks {
|
||||
let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned())
|
||||
.with_upstream(managed);
|
||||
scope.extension_scope = extension_scope;
|
||||
scope.request_body_limit = Some(request_body_limit);
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
@@ -352,7 +370,13 @@ impl CallbackHandler for PluginCallbacks {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&context.resource_scope_id)
|
||||
.and_then(Weak::upgrade)
|
||||
.unwrap_or_else(|| Arc::new(CallbackScope::for_call(context)));
|
||||
.unwrap_or_else(|| {
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
if context.stage == gateway_plugin_sdk::Stage::Registration {
|
||||
scope.request_body_limit = self.registration_body_limit;
|
||||
}
|
||||
Arc::new(scope)
|
||||
});
|
||||
let middleware = self
|
||||
.pending_middleware
|
||||
.lock()
|
||||
@@ -401,6 +425,7 @@ impl CallbackHandler for PluginCallbacks {
|
||||
let data = self.data.clone();
|
||||
let keys = self.keys.clone();
|
||||
let models = self.models.clone();
|
||||
let request_limits = self.request_limits.clone();
|
||||
let affinity = self.affinity.clone();
|
||||
let services = self.services.clone();
|
||||
let service_actor = self.service_actor.clone();
|
||||
@@ -514,6 +539,7 @@ impl CallbackHandler for PluginCallbacks {
|
||||
scope,
|
||||
call: &call,
|
||||
maximum_payload,
|
||||
request_limits: &request_limits,
|
||||
};
|
||||
if method.starts_with("host.upstream.") {
|
||||
return upstream::dispatch(&http, &method, params, payload).await;
|
||||
|
||||
@@ -63,13 +63,19 @@ impl PluginModelPortSlot {
|
||||
|
||||
pub(super) struct PluginModels {
|
||||
slot: Arc<PluginModelPortSlot>,
|
||||
request_limits: Arc<super::request_limits::RequestLimits>,
|
||||
maximum_payload: usize,
|
||||
}
|
||||
|
||||
impl PluginModels {
|
||||
pub(super) fn new(slot: Arc<PluginModelPortSlot>, maximum_payload: usize) -> Self {
|
||||
pub(super) fn new(
|
||||
slot: Arc<PluginModelPortSlot>,
|
||||
request_limits: Arc<super::request_limits::RequestLimits>,
|
||||
maximum_payload: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
slot,
|
||||
request_limits,
|
||||
maximum_payload,
|
||||
}
|
||||
}
|
||||
@@ -121,17 +127,27 @@ impl PluginModels {
|
||||
"host.model.execute" | "host.model.execute_stream" => {
|
||||
let mut request: ModelExecuteRequest =
|
||||
serde_json::from_value(params).map_err(|_| invalid())?;
|
||||
if payload.is_empty() || payload.len() > self.maximum_payload {
|
||||
if payload.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let check_size = |limit| {
|
||||
if payload.len() > gateway_plugin_sdk::call::model::request_payload_limit(limit)
|
||||
{
|
||||
Err(super::request_limits::too_large())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let port = self.slot.upgrade()?;
|
||||
let stream = method == "host.model.execute_stream";
|
||||
let started = if let Some(client_key_id) = request.client_key_id.take() {
|
||||
let bound = self.binding(&port, context, call, client_key_id).await?;
|
||||
check_size(bound.request_body_limit())?;
|
||||
port.start_bound(self.bound_request(request, payload, stream, bound)?)
|
||||
.await
|
||||
.map_err(super::error::gateway)?
|
||||
} else {
|
||||
check_size(self.request_limits.body_bytes(call)?)?;
|
||||
let request = self.request(context, request, payload, stream)?;
|
||||
port.start(request).await.map_err(super::error::gateway)?
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user