Compare commits

...
37 Commits
Author SHA1 Message Date
ZyyandCodex 5205c07d60 chore: bump version to 3.4.1
Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:24:21 +08:00
ZyyandCodex 60eb08cb96 docs(release): prepare v3.4.1 notes
Summarize quota reset, downstream transport isolation, compressed request decoding, and weekly/monthly usage window fixes since v3.4.0.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:24:04 +08:00
ZyyandCodex fd4a49e64f test(openai): merge websocket proxy header regression (#67)
Preserve the original contributor commit and its two-frame Cloudflare header regression. Resolve overlap with the existing transport-header fix while keeping the identity-encoding fixture and ingress decompression documentation from main.

Validation: 385 API, protocol, and application tests, Rustfmt, and strict workspace Clippy passed. The new regression also passed independently. No new live Cloudflare/OpenAI end-to-end run was performed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:17:39 +08:00
ZyyandCodex be2ee7f3b8 fix(openai): bound request decompression and validate encodings
Stream all supported encodings through a bounded decoder, cap buffer growth and the zstd window, and consume every gzip member. Borrow uncompressed bodies, reject stacked encodings, and remove the unused error variant.

Add decoding and HTTP boundary regressions, preserve downstream transport-header isolation, and document the ingress compression contract.

Validation: 384 API, protocol, and application tests, Rustfmt, and strict workspace Clippy passed. Twenty real HTTP requests passed with synthetic upstream execution. An 8,210-byte zstd probe now stops at the 64 MiB output limit, with a largest Rust allocation of 64 MiB instead of 256 MiB. Official Codex client fallback and real upstream execution were not exercised.

Co-authored-by: Codex <noreply@openai.com>
2026-09-12 00:10:23 +08:00
xbz123andCodex 757abdb4d1 fix(openai): strip downstream proxy headers from upstream requests
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:55:11 +02:00
ZyyandCodex cb5e0ba4c0 fix(openai): merge request content encoding support (#66)
Preserve the original contribution before hardening its decoding limits and regression coverage.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 23:54:42 +08:00
ZyyandCodex d2b418f8cc fix(admin): use weekly and monthly quota windows for usage
Prefer account-wide weekly usage windows, falling back to monthly windows when weekly usage is unavailable. Require complete window boundaries and do not fall back to short-term or historical totals.

Expose the selected window label in account responses, update usage panels and Token tooltips, and align summary text baselines. Preserve quota bar and Dashboard selection behavior.

Validation: 421 related and application tests, Rustfmt, strict workspace Clippy, and frontend lint/type checks/build passed. Browser checks with synthetic weekly, monthly, and empty fixtures passed in dark, light, and narrow layouts.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 23:27:34 +08:00
zhangwentian 2390783900 fix(openai): decode gzip/deflate/zstd request bodies on POST /v1/responses
官方 Codex 客户端对较大的 /v1/responses 请求体默认 zstd 压缩(level 3)并
携带 Content-Encoding: zstd,也会使用 gzip。网关此前只在出站方向按官方指纹
做 zstd 压缩(client_sse.rs),入站方向却不接受压缩正文,压缩请求一律命中
400 invalid_json("Request body must be valid JSON.")。官方后端接受压缩
请求,因此直连官方可用的会话经网关转发时会在 HTTP 回退路径稳定失败。

现在 POST /v1/responses 入站解码按 Content-Encoding 解压后再进入 JSON
解析:

- 支持 gzip、deflate、zstd;未压缩与 identity 原样透传
- 解压结果超过 64 MiB 上限时拒绝,防御解压炸弹
- 未知 Content-Encoding 返回 400 unsupported_content_encoding
- 损坏的压缩载荷统一按 invalid_json 返回,不泄露内部细节

WebSocket 帧路径不受影响(permessage-deflate 由传输层协商处理)。
2026-09-11 23:21:45 +08:00
ZyyandCodex c75f0e7fd9 fix(openai): isolate downstream transport headers
Share transport-header classification between API ingress and OpenAI encoding. Strip proxy metadata and downstream compression negotiation while preserving opaque business extensions and upstream response diagnostics.

Add protocol, decoder, HTTP, and WebSocket regressions and document the transport boundary.

Validation: 946 related and application tests, Rustfmt, and strict workspace Clippy passed. Application tests passed with incremental compilation disabled after a compiler cache panic. Real-account HTTP/WebSocket checks and a two-turn WebSocket session through Cloudflare Tunnel passed.

Refs #63, #64

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 22:58:38 +08:00
ZyyandCodex d5e9360ed7 fix(admin): support quota resets over plain HTTP
Generate canonical UUIDv4 redeem IDs with crypto.getRandomValues so reset
confirmation works on non-secure HTTP origins. Preserve the pending
operation ID when retrying uncertain consumption.

Validation: frontend lint/type checks/build, browser composable checks
with mocked APIs on HTTP/localhost/HTTPS, and 10 related backend tests
passed. No real reset credits were consumed.

Refs #62

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 20:41:32 +08:00
ZyyandCodex 3fcb8e5f2d docs(release): translate v3.4.0 notes into Chinese
Translate the release summary and upgrade instructions while preserving
the version, technical identifiers, and documented behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:25:57 +08:00
ZyyandCodex 8cdaf8b2e6 chore: add contribution workflows and repository skills
Define shared contribution and review standards, add a YAML Issue Form,
and guard AGENTS.md changes using PR metadata.

Move project skills into .agents/skills and add PR, Issue, and release
workflows with consistent names and scoped triggers. Keep generic
engineering guidance in the personal skill directory.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 17:25:57 +08:00
ZyyandCodex 68f05a4481 chore: bump version to 3.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:59 +08:00
ZyyandCodex bb4d178b5d docs(release): prepare v3.4.0 release notes
Document managed proxies, connection tests before saving, credential and
certificate fixes, account and interface improvements, and migration
behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:26 +08:00
ZyyandCodex 73487fd98b fix(api-keys): trim trailing zeros from budget amounts
Display budget amounts with up to two decimal places without padding
trailing zeros. Keep exact amounts in the details popover and preserve
accounting precision.

Validation: frontend lint/type checks/build and browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:20 +08:00
ZyyandCodex 66fd09eba6 feat(proxies): test connections before saving and refine controls
Separate connection testing from saving in proxy forms. Probe unsaved URLs
with the shared certificate configuration and test concurrency limit,
without changing saved proxy settings or linked accounts.

Keep saved authentication when testing an unchanged connection, use
clearer placeholders, fix action column widths, and remove the redundant
list refresh button.

Validation: 5 proxy API tests, scoped strict Clippy, rustfmt, frontend
lint/type checks/build, and browser checks passed. Codex CLI requests
through real HTTP and SOCKS5 proxies completed with matching upstream
text while Clash TUN remained enabled.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 16:08:14 +08:00
ZyyandCodex bd152dec00 feat: integrate managed proxies and WebSocket fixes (#60)
Integrate managed account proxies and the non-streaming WebSocket fix.
Include shared CA trust, credential import protection, paginated linked
account management, and the reviewed interface refinements.

Preserve the original contribution and the separate proxy and UI commits.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:54:24 +08:00
ZyyandCodex 7090dca502 fix(ui): correct modal dragging and sidebar scrolling
Use positional offsets for modal dragging to prevent seams between table
cells. Scroll sidebar navigation independently while keeping the brand
and footer fixed, with an auto-hiding scrollbar at the outer edge.

Validation: frontend lint/type checks/build and browser checks for
modal dragging, sidebar overflow, alignment, and auto-hide passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:45 +08:00
ZyyandCodex 029f3124bf feat(proxies): paginate linked accounts and support removal
Load linked accounts on demand with shared plan metadata and guarded
removal to direct connections. Preserve credentials, scheduling settings,
and groups when clearing a proxy binding.

Align proxy forms and tables with shared components, integrate endpoint
details into outbound selectors, and consolidate probe results into the
latency column.

Validation: 11 proxy regressions, strict workspace Clippy, rustfmt,
frontend lint/type checks/build, and local browser checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 14:43:31 +08:00
ZyyandCodex 840707e5a1 fix(proxies): share CA trust and preserve credentials during import
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 13:07:31 +08:00
Yanshan Chen 704b652f86 feat: add managed account proxies and fix non-streaming WebSocket requests 2026-09-11 11:55:09 +08:00
ZyyandCodex 2b98839c29 fix(admin): unify plan labels and refine budget popover
Normalize provider plan display names to PascalCase while preserving raw
plan values. Replace native budget tooltips with the shared borderless
popover, retaining exact amounts and daily/weekly reset information.
Reuse the dayjs date helper for explicit Beijing time formatting.

Validation: 371 Admin/API tests, strict Clippy, rustfmt, frontend lint and
type checks passed. Local frontend and backend health checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:47:34 +08:00
ZyyandCodex 90dd5b5a57 chore: bump version to 3.3.32
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:12 +08:00
ZyyandCodex 5ad9378b33 docs(release): prepare 3.3.32 release notes
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:15:08 +08:00
ZyyandCodex 4a413f5bb7 fix(budgets): remove manual charge reconciliation
Settle only observed USD costs and treat missing costs as zero without
blocking client keys. Preserve known retry costs and idempotent settlement.
Remove reconciliation controls, endpoints, pending records, and obsolete
schema fields; migrate legacy unknown charges to zero without changing
recorded usage.

Display daily and weekly amounts with two decimal places while retaining
full precision for accounting and tooltips. Distinguish missing WebSocket
closing handshakes from TCP resets in connection diagnostics.

Validation: workspace tests with PostgreSQL/Redis, targeted regressions,
strict Clippy, rustfmt, frontend lint/build, and migration checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 05:13:06 +08:00
ZyyandCodex 0bab566d04 fix(xai): correct subscription plans and Codex tool handling
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 04:49:22 +08:00
ZyyandCodex 9b19cf083a feat(release): publish release notes from Markdown
Require committed, non-empty notes matching the target release version before
creating a tag. Read notes from the tagged source and place them before the
installation guide and generated release changelog.

Add the 3.3.31 feature summary and keep maintainer instructions in the release
script help and repository skill instead of the deployment README.

Validation: shell syntax and workflow lint passed. Nine isolated checks cover
local publication, stale or missing notes, and release-body rendering.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:40:00 +08:00
ZyyandCodex 8b22788746 chore: bump version to 3.3.31
Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:34:04 +08:00
ZyyandCodex e973fc516e feat(accounts): separate setup from account import
Share account settings across import, single-account editing, and batch editing.
Commit credentials and import settings in the same transaction. Improve modal
actions and fix the flash at the end of light/dark theme transitions.

Reauthenticate each execution against the current key policy so existing
WebSocket connections apply updated RPM, concurrency limits, and enabled state.
Add regression coverage for policy updates and unavailable snapshots.

Validation: all 1,950 backend tests, workspace Clippy, frontend lint/typecheck/build,
migration checks, Compose validation, and workflow lint passed. Completed 24 real
requests with a free account using gpt-5.6-luna to verify daily/weekly budgets,
RPM, concurrency, reconciliation, WebSocket policy updates, and budget persistence
across restarts and usage-log cleanup.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:26:48 +08:00
ZyyandCodex 12ecbf574a fix(accounts): fill missing plans from provider quota snapshots
Use explicit plan types from OpenAI quota snapshots to fill missing or
unknown account plans before building account and dashboard display names.
Preserve known plan subtypes and reuse cached quota without extra requests
or changes to persisted credential facts.

Cover quota projection and account list/detail fallback behavior, and
document the backend display contract.

Validation: gateway-admin, OpenAI, and xAI tests (1049 passed), strict
Clippy for affected backend crates, gateway build, and diff whitespace
checks passed. A real account JSON import and upstream quota refresh
confirmed free/Free in list, detail, and cached quota responses.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:27:45 +08:00
ZyyandCodex 443bd6ad17 feat: integrate account proxies and daily key budgets (#58)
Enforce daily and seven-day client key budgets with a durable charge ledger,
and route provider requests through each account's configured proxy.

Preserve backend plan display names and Codex personal access token imports.
Keep PAT validation on the selected proxy, preserve credential refreshes
when account egress changes, and include the upstream SOCKS IPv6 fix.

Use the PR migration layout, keep the account proxy and key forms aligned
with existing controls, and document the ownership and settlement rules
in the architecture guide.

Validation: 1942 workspace tests passed with isolated PostgreSQL/Redis;
strict Clippy, rustfmt, frontend lint/build, and migration checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:07:28 +08:00
ZyyandCodex c795098a87 feat(openai): support Codex personal access token imports
Accept at- personal access tokens through existing AT and JSON imports,
including the official personal_access_token field. Verify identity with
auth whoami instead of trusting document metadata, preserve optional email,
and exclude PAT credentials from OAuth refresh scheduling.

Expose explicit static PAT validation errors without leaking credentials
or raw upstream responses. Cover rejection, invalid identity, timeouts,
redirect protection, export round trips, and unchanged JWT import behavior.

Validation: OpenAI provider tests (562 passed), gateway-admin tests
(100 passed), strict Clippy for both crates with all targets and features,
workspace rustfmt, and diff whitespace checks passed.
Upstream behavior was tested with mocks; no live PAT was used.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:02:51 +08:00
ZyyandCodex 1f0009341d fix(accounts): resolve plan display names in the backend
Map OpenAI plan identifiers to official desktop labels in the provider.
Expose planTypeDisplay in account and dashboard responses while preserving
raw planType values, and render backend labels directly in the frontend.
Show missing plans as unknown and retain raw identifiers in badge tooltips.

Validation: Admin/API tests (368 passed), OpenAI admin tests (29 passed),
strict Clippy for affected backend crates with all targets and features,
frontend production build, targeted ESLint, and diff whitespace checks.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 02:02:51 +08:00
Codex 1359eaa894 feat: add account egress and client key USD budgets
Import sub2api proxy bindings, enforce durable daily and seven-day budgets alongside existing key concurrency, and integrate matching admin controls and audited reconciliation.
2026-09-10 23:41:35 +08:00
ZyyandCodex 74318af570 fix(routing): report missing models with explicit errors
Return model_not_found with HTTP 404 when all scoped provider catalogs
confirm the requested or mapped model is absent. Explain missing mapping
targets separately and preserve unavailable-provider errors for circuit
blocks, empty account scopes, and unsupported operations.

Cover missing models, aliases, empty and unknown catalogs, provider
fallback, and streaming and buffered HTTP error responses.

Validation: gateway-core and gateway-api tests (541 passed), strict Clippy
for both crates with all targets and features, workspace rustfmt, and diff
whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-10 22:23:42 +08:00
Zyy ddc10d7c1a docs: add Apache 2.0 license and community link 2026-09-10 16:14:44 +08:00
ZyyandCodex 6bc2189e6f refactor(frontend): streamline quota reset dialog and spacing
Show remaining usage limits and an always-visible reset credit list with per-credit reset actions. Use backend-provided credit titles and quota window labels while preserving confirmation and ambiguous-result recovery.

Display credit expiration in UTC+8 without a timezone suffix. Balance modal title and reset section spacing, and remove inline baseline whitespace from account quota summary cells without changing row heights or the borderless design.

Validation: browser layout and quota popover interaction checks, expiration timezone checks, frontend typecheck, targeted ESLint, and diff whitespace checks passed.

Co-authored-by: Codex <noreply@openai.com>
2026-09-10 15:33:43 +08:00
255 changed files with 14429 additions and 2708 deletions
+34
View File
@@ -0,0 +1,34 @@
---
name: dev-guide
description: 当前仓库开发指南。修改或排查本仓库的 Rust 网关、Vue 管理端、账号与出站代理、接口协议、部署、CI、发布或文档时使用,定位权威规范和验证入口。Use for development in the repository containing this skill; not for generic Rust/Vue questions in unrelated repositories.
---
# 开发指南
先读根目录的 [AGENTS.md](../../../AGENTS.md) 和 [CONTRIBUTING.md](../../../CONTRIBUTING.md)。
本技能只组织工作入口,架构、协议和视觉规则从下列权威文档读取,不维护第二份副本。
## 按任务读取
| 任务 | 文档入口 |
| --- | --- |
| 用户能力、快速开始 | [README](../../../README.md) |
| 模块职责、调用链、状态归属 | [系统架构](../../../docs/architecture.md) |
| HTTP 路由、请求与响应、导入导出 | [API 文档](../../../docs/api.md) |
| 管理端组件、主题 Token、视觉验证 | [管理端主题](../../../docs/theme.md) |
| 部署、客户端配置、备份与恢复 | [部署说明](../../../deploy/README.md) |
| 数据迁移、冻结清单与本地测试库 | [迁移说明](../../../backend/migrations/README.md) |
| 验证命令与提交规范 | [贡献与审查](../../../CONTRIBUTING.md) |
| 创建或审查 PR | [github-pr](../github-pr/SKILL.md) |
| 整理或提交 Issue | [github-issue](../github-issue/SKILL.md) |
| 发布说明、版本规划与发版 | [release](../release/SKILL.md) |
## 执行顺序
1. 从仓库根目录确认工作区、当前任务范围和相关运行实例;保留已有改动。
2. 按 `AGENTS.md` 的 CodeGraph 约定定位入口、调用链和职责归属,再阅读相关实现。
3. 结合上表读取本次需要的文档,先找可复用的同类实现,再修改所属模块。
4. 文档和运行结果不一致时,追查当前源码、配置与版本,区分实现缺陷和文档过期;同步受影响的说明。
5. 按 `CONTRIBUTING.md` 验证并报告结果。后端 Cargo 命令从 `backend/` 执行,或显式指定该目录的 manifest。
发布任务按 `release` 技能读取当前版本、发布入口和工作流,发布说明语言遵循 `CONTRIBUTING.md`。
@@ -0,0 +1,4 @@
interface:
display_name: "dev-guide"
short_description: "从仓库规范与架构文档定位实现、验证和交付的正确入口"
default_prompt: "使用 $dev-guide 按当前仓库规范与架构完成开发、验证或发布。"
+55
View File
@@ -0,0 +1,55 @@
---
name: github-issue
description: 整理与提交 GitHub Issue(问题反馈 / 功能建议)。用于“提个 Issue”“把这个问题反馈到仓库”“帮我报个 bug”“整理成 Issue”,以及通过 Issue 提议功能或协作规则的场景;区分草拟正文和实际提交。Use for drafting or filing bug reports and feature requests; not for debugging-only, editing Issue Forms/templates, or skill-authoring requests.
---
# 整理与提交 Issue
本仓库流程见 [贡献与审查](../../../CONTRIBUTING.md),问题反馈使用当前的
[YAML Issue Form](../../../.github/ISSUE_TEMPLATE/bug_report.yml)。从表单读取字段与必填项,不在技能中复制一份字段清单。
用户指定其他仓库时,使用该仓库当前的贡献规则与 Issue 模板,不能把这里的表单套过去。
## 确定目标和动作
- 根据用户 URL、仓库上下文和 remote 确认接收 Issue 的仓库。fork 场景核对用户要反馈给自己的 fork 还是上游。
- “写一份、整理内容、草拟 Issue”只准备正文;明确要求“提交、创建 Issue”才发布。
已经授权提交且信息足够时直接完成,不重复索要确认;仅自动选中技能不能授权发布。
- “有个 bug”“这个报错怎么修”本身是排障语境,不能推断要发 Issue;结合已有反馈任务识别省略的目标和动作。
- 缺少无法推断的目标或关键复现事实时,只问必要信息,并先整理其余已有证据。
- 使用可用的 GitHub 连接器或 `gh`。不要为提交 Issue 安装新工具、修改仓库配置或启用额外服务。
## 证据与去重
1. 读取目标仓库实际 Issue 模板和配置,区分缺陷、功能建议与协作规则提案。
本仓库后两类允许普通 Issue;其他仓库按其配置选择入口。
2. 先搜索 open 和 closed Issues,结合关键词、错误信息与触发条件检查重复,而不只比较标题。
同一问题已有记录且没有新增证据时返回已有链接;用户仍明确要求新建时按其要求处理。
不因发现重复而擅自评论、重开或关闭已有 Issue。
3. 从用户描述、当前版本与已有诊断中整理事实:区分实际结果、预期结果、稳定复现条件和未经证实的推测。
不把本地开发版本当作发生问题的部署版本;无法复现时如实说明已知条件。
4. 只收集定位所需信息。正文、日志片段和附件去除密钥、Cookie、令牌、代理认证、私人请求内容等敏感信息;
不直接附完整配置或请求转储。记录与复现相关的代理、TUN、证书条件,不擅自修改这些环境来收集材料。
## 编写正文
- 标题写具体症状或所需行为,不预先断言尚未证明的根因。语言与目标仓库及用户要求一致。
- YAML Issue Form 按字段顺序使用 `attributes.label` 作为 Markdown 小标题,填写相应答案。
`gh issue create --body-file` 直接提交正文,不会替你完成网页表单校验;手动核对必填字段、选项和必选确认项。
不把原始 YAML、占位文字或尚未确认的勾选项当成答案提交。
- 无法获知的信息明确写未提供或不适用,并说明理由;如果该事实关系到能否理解或复现问题,发布前补齐。
功能或规则提案说明当前场景、缺少的能力、建议结果和必要取舍,不强行填写缺陷复现步骤。
- 附件只使用用户授权发布且实际可访问的地址;本机 `/tmp/...` 路径不能作为 GitHub 附件。
没有可用附件时保留操作描述并说明缺口,不声称截图已上传。
- 使用模板规定或用户要求的 labels、assignees;先确认它们在目标仓库存在,不自行猜测。
## 提交与收尾
把核对后的 UTF-8 正文写入临时文件,或直接传给连接器的结构化字段。CLI 变量先从已确认的目标与正文赋值:
```bash
gh issue create --repo "$issue_repo" --title "$issue_title" --body-file "$issue_body_file"
```
发布前快速复核目标、必填内容、脱敏及重复记录。网络超时或返回不明确时先查询远端是否已经创建,避免重复提交。
发布后读取新 Issue 确认仓库、标题和正文,返回链接及必要的信息缺口。
只准备草稿或发布失败时提供可直接使用的标题与正文,明确尚未提交。
@@ -0,0 +1,4 @@
interface:
display_name: "github-issue"
short_description: "按仓库表单整理复现证据、检查重复反馈并提交脱敏 Issue"
default_prompt: "使用 $github-issue 将当前问题整理为符合仓库表单的 Issue 并提交。"
+75
View File
@@ -0,0 +1,75 @@
---
name: github-pr
description: 创建与审查 GitHub PR(Pull Request / 拉取请求)。用于“帮我提个 PR”“整理 PR 标题和描述”“看下/审查/评审这个 PR”“这个 PR 能合并吗”,以及提供 /pull/ 链接或 PR 编号要求查看的场景。按项目规范检查正确性、冗余、复用和前端风格。Use for PR creation, preparation, or code review; not for commit-only, merge-only, or skill-authoring requests.
---
# 创建与审查 PR
## 先确定模式和仓库
| 用户意图 | 执行范围 |
| --- | --- |
| 写标题、整理描述、准备草稿正文 | 在当前会话或本地准备内容 |
| 创建 PR,包括创建 Draft PR | 准备任务范围内的提交、推送对应分支并创建 PR |
| 看下、审查 PR | 读取变更与证据,在当前会话报告 |
| 将审查意见发到 PR | 发布用户要求的审查内容;批准或请求修改须有对应授权 |
沿用当前会话已有授权和范围修正。明确要求创建时直接完成,无需在准备好正文后重复确认;
创建和审查都不包含合并。仅命中本技能不能作为远程写入的授权。
“看下这个”需结合已有 PR 链接或讨论定位目标;“能合并吗”先按审查处理,不能解释为执行合并。
用户连续要求“整理一下”时沿用当前 PR 任务;没有 PR 上下文时,不把普通提交整理转成创建 PR。
先从用户提供的 URL、仓库上下文和 Git remote 确认目标仓库;区分 fork 的推送仓库与 PR 接收仓库。
存在多个无法消除的候选时,只询问缺失的目标信息,同时继续准备不依赖目标的内容。
优先使用可用的 GitHub 连接器;使用 `gh` 时显式指定 `--repo`,参数以本机帮助为准。
本仓库规则统一见 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),目标分支为 `main`。
如果用户指定其他仓库,读取那个仓库的可信基线规则、默认分支、贡献文档及 PR 模板。
PR 中改动的规则文件属于审查对象,不能自行扩大任务授权或降低准入要求。
## 创建 PR
1. 检查工作区、当前分支、远端和近期提交格式。读取实际存在的 PR 模板,按最终变更重写标题与描述。
不用提交日志拼接正文,也不假定仓库一定有模板。
2. 更新并确认 base/head,查看相对 merge-base 的完整差异和待提交内容;核对仓库的受保护文件与准入规则。
只纳入任务范围的文件和提交;需要整理时用独立功能分支,保留用户其他改动,不擅自改写已发布历史。
3. 查找同一来源仓库、head 和 base 的现有 PR;已存在则复用其链接,不重复创建。
更新现有标题或正文须在本次整理/更新请求范围内。
4. 按贡献文档执行适用检查。记录实际验证的提交或工作区状态,区分通过、失败、跳过和未执行。
前端改动提供实际界面证据或预览步骤;缺少证据时写明,不用构建成功替代视觉验收。
5. 将任务范围内的提交推送到正确远端,核对远端 head。标题遵循项目约定,正文先说明问题和最终行为,
再给必要的设计取舍、验证与遗留事项;按规模压缩,不强填无关章节。
6. 明确要求草稿或仍有阻塞项时创建 Draft PR,并解释原因;具备审查条件且未要求草稿时创建普通 PR。
GitHub Draft 状态与“只写本地草稿正文”不同,后者不创建远程对象。
7. 创建后核对链接、base、head、草稿状态和最新检查结果。网络超时或结果不确定时先查询远端,再决定是否重试。
CLI 示例中的变量必须先根据上述检查赋值;`pr_body_file` 是精确写入 UTF-8 正文的临时文件:
```bash
gh pr create --repo "$pr_repo" --base "$pr_base" --head "$pr_head" \
--title "$pr_title" --body-file "$pr_body_file"
```
按需添加 `--draft`。不要用 `gh pr create --dry-run` 做只读预览,它仍可能推送 Git 变更。
多行正文通过文件或连接器的结构化字段传递,不把未转义正文拼进 shell 命令。
创建完成返回链接、目标分支和验证摘要;不要称为已合并或已获维护者批准。
## 审查 PR
1. 获取 PR 的 base/head SHA、描述、文件差异、已有讨论及 CI 状态。记录实际审查的 head,使用 merge-base 差异。
需要源码上下文时先遵守目标仓库的定位规则;需检出时使用隔离工作区,保留用户当前分支和运行环境。
2. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
3. UI 改动同时对照主题、共用组件、数据展示逻辑和同类页面。浏览器可用时检查相关交互及状态;
未运行的页面检查列为验证缺口,不从截图或源码推断所有状态都正确。
4. 核对 CI 与本地证据是否覆盖当前 head;根据风险补充必要验证,不触发无关部署或真实账号业务请求。
工具不可用、集成测试跳过或环境不足时具体说明影响范围。
5. 依照贡献文档用中文输出必须修复项、可选建议和验证缺口;没有的类别可省略。
每个问题给变更中的文件位置、触发条件或规范依据、影响和最小修复方向。
复用建议指出具体已有实现,不把个人偏好写成缺陷;未发现问题时直接说明并交代覆盖范围。
6. 结束前核对 head 是否变化。发生变化时检查新增差异并更新结论,或明确结论只适用于原 SHA。
审查本身不修改源码、不发表评论、不提交 GitHub 的批准或请求修改状态。
用户明确要求发布时,先复核当前 head 和重复评论,再通过结构化字段或 `--body-file` 发布对应内容;
需要绑定具体提交时使用支持 commit ID 的审查接口。不要在旧提交结论上批准新的 head。
@@ -0,0 +1,4 @@
interface:
display_name: "github-pr"
short_description: "按项目规范创建拉取请求,或基于明确提交审查代码与界面"
default_prompt: "使用 $github-pr 审查当前 PR,按仓库规范报告问题与验证缺口。"
+79
View File
@@ -0,0 +1,79 @@
---
name: release
description: 准备或执行当前仓库发版,维护中文发布说明。用于“发版”“发个新版本”“准备 vX.Y.Z”“推荐版本号”“整理更新日志/发布说明”“更新 release/notes.md”“打版本 tag”等发行任务。Use for version planning, release notes, and publishing a repository release; not for deploying/restarting an instance, publishing application data, or authoring a release skill.
---
# 发版
## 识别任务范围
| 用户意图 | 执行范围 |
| --- | --- |
| 推荐版本号 | 根据本次变更与已有版本给出建议及理由 |
| 整理更新日志、写 note、准备发版 | 核对变更、编写说明、检查发布条件;未要求提交时保留本地改动 |
| 正式发布、发布指定版本、打版本 tag | 在已授权范围内执行对应步骤;仅打 tag 的请求不自动扩展为完整发版 |
| 重试失败的发版 | 核对上次执行状态,从未完成的步骤恢复 |
沿用当前会话明确的版本、范围与授权;用户已要求正式发版且条件满足时直接执行,不重复确认。
“准备好了”“可以发版吗”属于准备或检查,不能解释成正式发布;创建本技能也不触发发版。
发行版发布与运行实例升级分开判断,“上线”需结合上下文确定用户指哪一种。
## 读取当前流程
先读 [AGENTS.md](../../../AGENTS.md) 与 [贡献与审查](../../../CONTRIBUTING.md),再按任务检查:
| 事实 | 权威入口 |
| --- | --- |
| 当前版本 | [release/version.yaml](../../../release/version.yaml) |
| 本次发布说明及语言约定 | [release/notes.md](../../../release/notes.md)、贡献文档的项目约定 |
| 版本提交、tag、推送与工作流触发 | [release/publish](../../../release/publish) |
| 构建、检查、GitHub Release 和镜像发布 | [发布工作流](../../../.github/workflows/release.yml) |
| 平台与产物清单 | [release/platforms.yaml](../../../release/platforms.yaml) |
命令从仓库根目录执行;每次以当前脚本和工作流为准,不另写发布脚本或绕过已有质量检查。
用户明确指定其他仓库时先读取其发布流程,不能直接套用这里的路径和命令。
## 准备版本与说明
1. 确认发布分支、upstream、目标仓库、工作区和完整待推送范围。本仓库沿用 `main`;
未提交或未跟踪内容分别核对,不因脚本只检查已跟踪文件就认定所有内容都已纳入发布。
2. 读取版本文件、已有 tag 和实际发行记录,确认上一发布基线与本次目标提交。
根据基线到目标的提交和最终差异归纳变更,剔除回退、重复和未进入发布范围的内容。
不能只用当前版本文件或最近几条提交判断“本次更新”。
3. 使用用户指定或当前会话已确定的目标版本;需要建议时按兼容性、能力新增和修复范围说明理由。
目标 tag 必须未被占用;遇到已有同名版本先判断是重试还是新发版,不自动递增版本掩盖冲突。
4. 按贡献文档维护 `release/notes.md`。正文写中文,保留版本号和必要技术标识;
按用户可感知的最终行为归并,不逐条翻译 commit,也不把排查过程或未验证承诺写入说明。
可按新增功能、问题修复、体验优化、升级说明组织,省略没有内容的章节。
5. 替换为本次版本说明,首行与目标 tag 一致且正文非空。升级事项应能追溯到实际迁移、配置或兼容变化;
敏感信息脱敏,未合入功能不宣称已发布。已有准确说明仅需翻译时保持其事实和范围。
6. 按贡献规范检查变更与现有 CI 证据,明确通过、失败、跳过和未执行。
只修改说明时无需重建前后端;正式发版仍要跟踪发布工作流针对目标提交的检查与产物。
## 执行正式发版
调用入口前重新核对当前脚本的副作用、目标版本和待推送提交,保留无关工作区内容,不自动清理或暂存用户文件。
当前脚本要求先提交 `release/notes.md`,再由脚本更新 `release/version.yaml` 并创建版本提交,
不要提前把版本文件改成目标版本,否则脚本会判定该版本已设置。
本任务需要的提交按贡献文档保留英文 Conventional Commits 与相应署名。
在准备完成且用户已授权正式发布后,将 `release_version` 设为已确定的版本,执行:
```bash
release/publish "$release_version"
```
该命令会创建版本提交与 annotated tag,原子推送分支和 tag,再从长期分支手动触发 `release.yml`。
它没有只读预演模式;不能为了检查或演练而执行。发布工作流读取 tag 中的说明,修改 tag 之后的本地文件不会改变该次构建。
## 恢复与验收
- 脚本中断或网络超时时,先核对本地提交与 tag、远端分支与 tag、对应工作流 run 及 Release,确定已完成到哪一步。
若本地已创建版本提交或 tag,不直接重跑入口,也不通过删 tag、回退版本或强推来消除报错。
- 提交和 tag 已推送但工作流未触发时,核对目标后使用脚本提示的 dispatch 命令补触发;
工作流已存在则跟踪或恢复对应 run,避免重复发布。发现 tag 指向不同提交时报告冲突,不移动已发布 tag。
- 跟踪与本次 tag 对应的 run ID,核对工作流解析的目标提交。触发工作流成功只表示已开始,不能当作发布完成。
- 发布完成后核对 GitHub Release 的版本、正式版/预发行状态和中文变更正文;对照平台清单与工作流确认归档、校验和及镜像产物。
工作流附加的安装说明和自动提交列表按其当前配置处理,不用它们替代手写的中文变更说明。
- 返回版本、tag 对应提交、Release/工作流链接及实际结果。失败、仍在构建或缺失产物时明确说明,不能称为发布成功;
Release 和镜像已生成也不表示用户的运行实例已经升级。
@@ -0,0 +1,4 @@
interface:
display_name: "release"
short_description: "按仓库流程整理中文发布说明、核对版本并完成发行与验证"
default_prompt: "使用 $release 准备下一个版本的中文发布说明,并核对发版条件。"
+111
View File
@@ -0,0 +1,111 @@
name: 问题反馈
description: 提交可复现的问题,帮助定位和修复
body:
- type: markdown
attributes:
value: |
请提供最小复现信息。请求、日志和截图发布前请移除密钥、令牌、Cookie、代理认证及私人内容。
- type: dropdown
id: issue-type
attributes:
label: 问题类型
options:
- 接口请求或响应异常
- 页面或交互异常
- 配置或部署异常
- 其他问题
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 说明发生了什么、实际结果以及出现的频率。
validations:
required: true
- type: input
id: version
attributes:
label: 服务版本
description: 填写具体版本号;从源码运行时填写提交号。
placeholder: 版本号或提交号
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: 部署方式
options:
- Docker Compose
- 其他容器部署
- 二进制运行
- 源码运行
- 其他方式(请在环境信息中说明)
validations:
required: true
- type: textarea
id: environment
attributes:
label: 环境信息
description: 填写操作系统与版本;页面问题同时填写浏览器与版本。
placeholder: |
操作系统与版本:
浏览器与版本(页面问题):
validations:
required: true
- type: textarea
id: account-client
attributes:
label: 账号与客户端
description: 接口问题请填写平台、认证类型、模型和客户端信息,不要提供账号凭据。
placeholder: |
平台与认证类型:
模型名称:
客户端名称与版本:
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 描述触发问题的操作;无法稳定复现时,说明已知触发条件。
placeholder: |
1. 使用什么配置或输入
2. 执行什么操作
3. 出现什么结果
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 说明原本期望得到的结果。
validations:
required: true
- type: textarea
id: request-response
attributes:
label: 请求与响应
description: |
接口问题请提供脱敏后的 cURL 或最小请求体、HTTP 状态码、响应片段,以及请求 ID / 发生时间。
如需详细报文,可按部署文档开启 `host.logging.request_dump`,仅摘取定位所需片段并脱敏,不要直接上传完整转储。
render: text
- type: textarea
id: screenshots
attributes:
label: 截图或录屏
description: 页面或交互问题可在此拖入相关截图、录屏,并说明对应的操作。
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 最近的升级或配置变更;网络问题补充系统代理、TUN 和自定义证书等相关环境。
+75
View File
@@ -0,0 +1,75 @@
name: Agent Instructions Guard
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- edited
permissions:
contents: read
pull-requests: write
concurrency:
group: agents-md-guard-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
guard:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
- name: Enforce agent instructions policy
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const params = { owner, repo, pull_number: pr.number };
const files = await github.paginate(github.rest.pulls.listFiles, {
...params,
per_page: 100,
});
const isInstructions = path => typeof path === 'string'
&& (path === 'AGENTS.md' || path.endsWith('/AGENTS.md'));
// 同时检查重命名前后的路径,避免通过移走规则文件绕过检查。
if (!files.some(file => isInstructions(file.filename) || isInstructions(file.previous_filename))) {
core.info('No agent instruction changes detected.');
return;
}
// 旧事件不处理后续提交,也不重复处理已关闭的 PR。
const { data: current } = await github.rest.pulls.get(params);
if (current.state !== 'open' || current.head.sha !== pr.head.sha) {
core.info('Pull request state has changed; skipping this event.');
return;
}
const marker = '<!-- agents-md-guard -->';
const body = [
marker,
'此 PR 修改了 `AGENTS.md`,已按贡献规则关闭。',
'',
'PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。',
'请撤销相关变更后重新打开 PR;如需调整协作规则,请先提交 Issue 说明原因和建议,由维护者处理。',
].join('\n');
try {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const notified = comments.some(comment => comment.user?.login === 'github-actions[bot]'
&& comment.body?.includes(marker));
if (!notified) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
} catch (error) {
core.warning(`Unable to post policy explanation: ${error.message}`);
}
await github.rest.pulls.update({ ...params, state: 'closed' });
core.setFailed('Pull requests must not change AGENTS.md.');
+31 -9
View File
@@ -88,6 +88,13 @@ jobs:
fail("Release tag #{release_tag} does not match #{version_path} tag #{expected_tag}") unless release_tag == expected_tag
fail("#{version_path}: version #{version} is not a valid semver") unless version.match?(SEMVER)
notes_path = "release/notes.md"
fail("#{notes_path}: release notes are required") unless File.file?(notes_path)
notes = File.readlines(notes_path)
unless notes.first&.strip == "# #{release_tag}" && !notes.drop(1).join.strip.empty?
fail("#{notes_path}: expected non-empty notes starting with '# #{release_tag}'")
end
platforms = platforms_data["platforms"]
fail("#{platforms_path}: platforms must be a non-empty array") unless platforms.is_a?(Array) && !platforms.empty?
@@ -643,15 +650,10 @@ jobs:
cosign sign --yes "${images[@]}"
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body: |
- name: Prepare release notes
shell: bash
env:
INSTALLATION_NOTES: |
> [!WARNING]
> **Upgrading across major versions?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
@@ -672,6 +674,26 @@ jobs:
- [GitHub Repository](https://github.com/${{ github.repository }})
- [Installation Guide](https://github.com/${{ github.repository }}/blob/main/deploy/README.md)
run: |
python3 <<'PY'
import os
from pathlib import Path
notes = "\n".join(Path("release/notes.md").read_text().splitlines()[1:]).strip()
installation = os.environ["INSTALLATION_NOTES"].strip()
output = Path(os.environ["RUNNER_TEMP"]) / "release-notes.md"
output.write_text(f"{notes}\n\n{installation}\n")
PY
- name: Publish GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
tag_name: ${{ needs.release-info.outputs.tag }}
generate_release_notes: true
previous_tag: ${{ needs.release-info.outputs.previous_tag }}
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
body_path: ${{ runner.temp }}/release-notes.md
fail_on_unmatched_files: true
files: |
dist/release/*
-1
View File
@@ -33,7 +33,6 @@ Thumbs.db
# Local tools and worktrees
/.codegraph/
/.codex-ws-audit/
/.agents/
/.reasonix/
/.claude
/.worktrees/
+45
View File
@@ -0,0 +1,45 @@
# 项目协作约定
开发和审查先阅读 [CONTRIBUTING.md](CONTRIBUTING.md),使用其中的协作流程和审查标准。
模块职责以 [系统架构](docs/architecture.md) 为准,界面约定以 [管理端主题](docs/theme.md) 为准;
没有明确约定时保持与相邻实现一致,不把单次页面反馈扩展成全仓规则。
<!-- CODEGRAPH_START -->
## CodeGraph
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
- **MCP tools** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them. `codegraph_node` returns one symbol's source + callers, or reads a whole file with line numbers. If the tools are listed but deferred, load them by name via tool search.
- **Shell** (always works): `codegraph explore "<symbol names or question>"` and `codegraph node <symbol-or-file>` print the same output.
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
<!-- CODEGRAPH_END -->
## 工作方式
- 先定位变更所属模块、同类实现和上下游调用关系,再选择最小合理改动。复用已有能力,也避免为尚不存在的需求增加抽象。
- 代码注释使用中文,解释原因与边界;提交信息使用英文,沿用历史中的 Conventional Commits 格式。
- 按变更范围执行验证,记录命令、结果和缺口。跳过的测试不算通过;界面与集成行为需要对应运行证据,构建通过不能替代实际验收。
- 审查请求默认只读。修复、提交、推送和合并按用户当前授权执行;“本地验证通过”不等于用户已经审阅批准。
- 不读取或输出无关凭据,不把真实密钥、代理认证、账号令牌或请求转储放入提交、截图及审查报告。
## Code Review Rules
### 审查范围
- 确认目标分支与当前变更范围,结合调用方、被调用方及已有测试审查;结论对应实际审查的提交或工作区状态。
- 按 CONTRIBUTING.md 的维度检查设计、正确性、安全、复杂度、性能、可读性、验证、界面和文档,深度与变更风险相称。
- 重点核对现有架构和数据合同:共享业务事实是否重复解释,异步与并发操作是否破坏状态,权限和敏感数据是否越界,协议适配是否影响兼容性。
- 修改既有特殊分支时先查场景和历史;放宽检查、修改测试或新增例外需要业务依据,不能仅以让检查通过为理由。
### 代码与界面一致性
- 对重复逻辑、冗余状态和多余抽象给出具体位置及影响;建议复用时指出已有实现,不能仅凭行数或语法相似判定质量。
- UI 变更对照项目基础组件、主题和同类页面,检查交互、状态、响应式与可访问性。局部问题是否应在共用组件修复,按职责判断,不一律禁止局部样式。
- 核对截图、预览和实际交互证据;未检查的状态明确列为验证缺口,不推断视觉验收已经通过。
### 审查输出
- 用中文报告本次变更新引入或加重的、可定位的问题,包含文件位置、触发场景或规范依据、影响和最小修复方向。
- 区分必须修复的问题、可选建议和验证缺口。个人偏好标为建议,不作为必须修复项;没有问题时不要凑数。
- 严重程度按实际影响判断,不为绕过工具的优先级过滤而升级普通规范问题。说明审查和验证的实际覆盖范围,AI 未报告问题不等于维护者批准。
+114
View File
@@ -0,0 +1,114 @@
# 贡献与审查
贡献应解决明确的问题,并保持或改善项目的可维护性。提交者、自动检查和审查者使用同一套标准,让重复问题在提交和初审阶段得到处理。
本文定义协作流程与审查标准;模块职责以 [系统架构](docs/architecture.md) 为准,视觉规则以 [管理端主题](docs/theme.md) 为准。没有明确约定时遵循相邻实现;具体页面的布局决定不自动成为全项目限制。
## 提交 Issue
问题反馈使用仓库的 [Issue 表单](.github/ISSUE_TEMPLATE/bug_report.yml),填写问题类型、描述、版本与环境、复现步骤和预期结果。接口问题补充平台与认证类型、模型、客户端,以及脱敏后的请求、响应和请求 ID;页面问题附相关截图或录屏。
日志和配置只提供定位所需片段,不上传真实凭据或完整请求转储。功能建议与协作规则调整可直接提交 Issue,说明场景、原因和建议方案。
## PR 流程
1. **准备变更。** 找到问题所属模块、相关调用链和最接近的现有实现,在功能分支完成修改与自查。涉及架构、公开合同或较大交互变化时,先说明方案与取舍。
2. **发起 PR。** 目标分支使用 `main`。说明触发场景、改动后的行为和实际验证结果;界面改动提供截图、录屏或可复现的预览方式。一个 PR 围绕一个明确目标,必要重构说明关联原因。
3. **自动检查与初审。** CI 执行适用的格式、静态分析、测试和构建检查;启用 AI 审查后,由它按本规范辅助检查语义、复用和一致性。提交者先处理明确问题,维护者重点审阅方案、风险与用户体验。
4. **修改与合并。** 更新代码后重新执行受影响的检查,确认审查意见已处理,验证证据对应当前变更。维护者审阅批准后再合并;需要升级说明的变更同步相关文档。
AI 开发和审查入口是根目录的 [AGENTS.md](AGENTS.md)。在仓库上下文中要求“审查 PR #编号”时,应按下列标准执行,无需再次列举要求。
### 项目技能
仓库技能位于 `.agents/skills/`,随代码共享。技能提供执行流程,协作与审查标准仍以本文和 `AGENTS.md` 为准。
| 技能 | 用途 | 调用示例 |
| --- | --- | --- |
| [dev-guide](.agents/skills/dev-guide/SKILL.md) | 项目开发与文档导航 | `$dev-guide 修复当前问题` |
| [github-pr](.agents/skills/github-pr/SKILL.md) | 创建或审查 PR | `$github-pr 创建当前分支的 PR` / `$github-pr 审查 PR #编号` |
| [github-issue](.agents/skills/github-issue/SKILL.md) | 整理或提交 Issue | `$github-issue 提交当前问题` |
| [release](.agents/skills/release/SKILL.md) | 准备发布说明或正式发版 | `$release 整理本次发布说明` / `$release 发布 vX.Y.Z` |
审查请求默认在当前会话报告;明确要求创建 PR、提交 Issue 或正式发版时才执行对应远程操作。技能安装不等于启用 GitHub 自动审查。
### 规则文件保护
PR 不接受根目录或子目录中 `AGENTS.md` 的新增、修改、删除或重命名。调整这些文件时先提交 Issue 说明原因和建议,由维护者处理;[规则保护工作流](.github/workflows/agents-md-guard.yml) 会提示并关闭包含此类变更的 PR。撤销相关变更后可以重新打开 PR,继续正常检查。
源码变更遵守现有架构边界与迁移冻结规则,由对应 CI 检查执行;不额外设立整块业务目录的 PR 禁改清单。构建、测试和安全扫描复用现有工作流。
## 审查标准
按改动涉及的范围选择检查深度,无需为每个 PR 填写所有维度的报告。
| 维度 | 检查重点 |
| --- | --- |
| 设计与架构 | 职责归属、依赖方向、公开接口是否合理;是否融入已有体系,有无绕过边界的旁路 |
| 正确性与兼容性 | 正常、异常和边界输入;并发、取消、重试与副作用;是否改变既有 API、协议或数据合同 |
| 安全与数据 | 权限、输入校验、敏感信息、状态隔离和持久化一致性;日志与诊断是否符合脱敏边界 |
| 简洁性与复用 | 重复业务规则、冗余状态、无效代码和过早抽象;能否用更直接的实现解决当前问题 |
| 性能与资源 | 实际数据规模下的查询、请求、内存与连接成本;资源释放、背压和异常时的生命周期 |
| 可读性与风格 | 命名是否准确,控制流是否易懂,格式是否一致;注释解释约束与原因而非复述代码 |
| 测试与验证 | 是否覆盖真实触发条件和结果,能否发现回归;证据是否对应本次变更,有无跳过或缺失项 |
| 界面与交互 | 共用组件和主题、信息层级、状态反馈、响应式、键盘操作及可访问性;是否有实际页面证据 |
| 文档与升级 | 行为、配置、API 和部署说明是否同步;数据迁移和兼容影响是否交代清楚 |
审查意见区分三类:
- **必须修复:** 有具体触发条件的缺陷、违反现有合同或明确项目规范的问题。说明位置、依据、影响和最小修复方向。
- **建议:** 不影响合同或规范的替代实现、命名偏好和局部优化。个人偏好不能单独阻止合并,也不应强求与本次目标无关的重构。
- **验证缺口:** 尚未检查的行为、状态或运行环境。说明缺少什么证据,由维护者评估是否需要补齐,不能把未验证当作已通过或已发现缺陷。
复杂度不按行数判断,复用不只看语法相似。建议合并重复逻辑时应指出共享的业务含义;建议保留特殊分支时应能说明它服务的场景。例外应有依据,不能仅为绕过检查而放宽规则。
## 项目约定
- Rust 模块、可见性、依赖方向和测试目录按架构文档执行,使用现有 Rustfmt 与 Clippy 配置。
- Vue 使用 TypeScript、`<script setup>` 和现有 ESLint 配置;API 请求通过已有模块及 `request` 封装,沿用逐行请求配置格式。
- 业务数据的解析、校验和展示优先复用已有逻辑。新增抽象放在拥有该职责的模块,避免重复事实源或无实际需求的通用层。
- 新增代码注释使用中文。提交信息使用英文 Conventional Commits,参考近期历史,按完整功能归并;确由 Codex 参与的提交保留 `Co-authored-by: Codex <noreply@openai.com>`。
- 发布说明写入 `release/notes.md`,标题首行为目标版本的 `# vX.Y.Z`,章节标题和正文使用中文;命令、配置键、协议名等保留原文。只说明本次版本的实际变化,按影响交代升级事项。
- PR 聚焦目标;无关格式化、重构和依赖升级单独处理。行为或合同变更同步到架构文档指定的文档位置,发布说明按维护者的发布安排更新。
## 界面验证
开发前查找同类页面及 `components/base` 中的基础组件,优先使用已有主题 Token、交互和数据展示逻辑。需要扩展时在对应职责层修改;局部样式的适用范围遵循主题文档,不一律禁止,也不靠页面覆盖掩盖共用组件的问题。
列表列宽、序号、按钮、分页和弹窗尺寸由实际任务与数据规模决定,以同类页面为参照。新增或调整交互时说明用户需要完成什么操作,不把某个页面的取舍推广成全局禁令。
提交者在浏览器中验证受影响路径,提供前后截图、录屏或预览步骤,并标明实际覆盖的状态。按变更选择浅色/深色、窄窗口、空/加载/错误状态、大量数据及键盘操作;共用主题或基础组件变更按主题文档扩大验证范围。模拟数据与预览材料不包含真实凭据。
## 验证
选择与变更相关的检查,纯文档修改无需重建前后端。行为修复补充能覆盖触发条件和结果的测试,不增加仅复述实现的断言。
| 范围 | 现有检查 | 仍需提供的证据 |
| --- | --- | --- |
| Rust | Rustfmt、严格 Clippy、后端测试、架构边界检查 | 相关失败场景和集成行为 |
| 前端 | ESLint、类型检查与构建 | 页面一致性、交互和实际数据状态 |
| 数据库迁移 | 冻结清单校验与 PR 基线比对 | 升级行为及兼容说明 |
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
前端从仓库根目录执行:
```bash
pnpm --dir frontend format:check
pnpm --dir frontend build
git diff --check
```
`build` 已包含类型检查;按当前架构约定,不维护独立前端测试代码。后端的工具链、命令与线程栈设置统一见 [修改与验收](docs/architecture.md#12-修改与验收)。
依赖 PostgreSQL / Redis 的测试使用 [专用测试环境](backend/migrations/README.md#本地测试库),未配置导致跳过时明确记录。CI 中已有对应服务和检查,见 [质量工作流](.github/workflows/_quality.yml) 与 [安全扫描](.github/workflows/security-scan.yml)。
集成变更应验证实际业务路径及结果,不能只凭健康检查或探测成功认定全链路正常。协议和网络改动按需核对认证、证书、代理、流式交付及客户端结果;记录影响复现的环境条件。缺少运行条件时说明缺口,不用模拟结果代替真实集成结论。
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 已按变更范围调用现有质量检查。新增自动规则优先使用现有工具,要求依据明确、结果稳定、误报可控;无法可靠判定的设计与视觉问题保留审查判断,避免把具体源码写法锁成测试。
AI 自动审查需要在所用工具中单独启用。其支持的检查范围和优先级过滤以工具说明为准;即使能读取 `AGENTS.md`,也不能保证所有可维护性和视觉问题都会被报告。配置参考 [Codex GitHub 审查文档](https://learn.chatgpt.com/docs/third-party/github)。
要由 GitHub 强制执行合并条件,维护者需配置对应分支的必需状态检查、审阅批准和讨论解决要求,并按需要设置新提交后的重新审阅。配置前核对实际检查名称与触发范围,见 [GitHub 分支保护](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches)。规则文档本身不会启用 AI 服务或更改分支设置。
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+13 -3
View File
@@ -10,8 +10,9 @@
[![CI](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/zyycn/codex-proxy-rs?display_name=tag&sort=semver&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/releases)
[![GHCR](https://img.shields.io/badge/GHCR-codex--proxy--rs-2496ED?logo=docker&logoColor=white&style=flat-square)](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=flat-square)](LICENSE)
[功能](#功能) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档)
[功能](#功能) · [快速开始](#快速开始) · [客户端接入](#客户端接入) · [文档](#文档) · [社区](#社区) · [许可证](#许可证)
</div>
@@ -20,8 +21,8 @@
## 功能
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看。
- **访问控制**:通过账号分组分配密钥权限,设置并发和请求频率。
- **账号管理**:OAuth 授权、账号导入、凭据刷新与额度查看;代理管理支持独立保存、连接测试和账号绑定,以及 sub2api OpenAI 账号代理导入。
- **访问控制**:通过账号分组分配密钥权限,设置日/周 USD 限额、并发和请求频率。
- **客户端接入**:支持 Codex CLI、桌面端与 Responses API 客户端,提供 HTTP 流式响应和 WebSocket。
- **请求诊断**:查看请求记录、用量、费用、延迟和错误详情。
- **部署运维**:Docker Compose 部署、S3/R2 数据库备份、同大版本在线更新与回滚。
@@ -122,3 +123,12 @@ curl http://127.0.0.1:8080/v1/models \
- [系统架构](docs/architecture.md)
- [管理端主题](docs/theme.md)
- [数据库迁移](backend/migrations/README.md)
- [贡献与审查](CONTRIBUTING.md)
## 社区
感谢 [LINUX DO](https://linux.do) 社区提供开放、友善的技术交流平台。
## 许可证
本项目基于 [Apache License 2.0](LICENSE) 开源。
+13 -7
View File
@@ -744,6 +744,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-rustls",
]
[[package]]
@@ -1191,7 +1192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1433,6 +1434,7 @@ dependencies = [
"cron",
"futures",
"gateway-core",
"heck",
"hex",
"rand_core 0.6.4",
"secrecy",
@@ -1456,6 +1458,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"flate2",
"futures",
"gateway-admin",
"gateway-core",
@@ -1471,6 +1474,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"zstd",
]
[[package]]
@@ -1488,6 +1492,7 @@ dependencies = [
"sha2 0.11.0",
"thiserror",
"tracing",
"url",
"uuid",
]
@@ -1877,10 +1882,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
source = "git+https://github.com/hyperium/hyper-util?rev=191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6#191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"futures-channel",
"futures-util",
@@ -2610,6 +2614,7 @@ dependencies = [
"hex",
"hyper",
"hyper-rustls",
"hyper-util",
"reqwest",
"roxmltree",
"rustls",
@@ -2627,6 +2632,7 @@ dependencies = [
"tokio-rustls",
"tokio-tungstenite 0.28.0",
"tokio-util",
"tower-service",
"tracing",
"tracing-subscriber",
"tungstenite 0.27.0",
@@ -2735,7 +2741,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3005,7 +3011,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3707,7 +3713,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5 -1
View File
@@ -15,7 +15,7 @@ resolver = "3"
[workspace.package]
version = "0.0.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
@@ -38,3 +38,7 @@ strip = "symbols"
lto = "thin"
codegen-units = 1
panic = "abort"
[patch.crates-io]
# Upstream SOCKS IPv6 fix; remove once a release includes hyperium/hyper-util#302.
hyper-util = { git = "https://github.com/hyperium/hyper-util", rev = "191b1acf0a6b18e807e4abf5a6d4a7c47979ecd6" }
+1
View File
@@ -28,6 +28,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] }
serde_json = "1"
syn = { version = "3.0.3", features = ["full", "visit"] }
tempfile = "3.27.0"
tokio-rustls = { version = "=0.26.4", default-features = false }
[lints]
workspace = true
+4 -1
View File
@@ -78,7 +78,10 @@ pub async fn run() -> Result<(), BootstrapError> {
store.admin_ports(),
vec![openai.admin_provider(), xai.admin_provider()],
core.snapshot_control(),
core.account_probe(),
(
core.account_probe(),
host.proxy_probe(provider_openai::build_reqwest_client_with_custom_ca),
),
host.client_distribution_resolver(),
host.system_operations(),
)
@@ -182,6 +182,7 @@ const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
&[
"client_distribution",
"config",
"proxy_probe",
"serve",
"system_update",
"workers",
+145
View File
@@ -9,6 +9,151 @@ const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
const ADMIN_PASSWORD: &str = "test-admin-password";
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
#[tokio::test]
async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use std::{sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_rustls::{
TlsAcceptor,
rustls::{
ServerConfig,
pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
},
};
const CHILD_ENV: &str = "CPR_TEST_PROXY_TLS_DIRECTORY";
let Ok(directory) = std::env::var(CHILD_ENV) else {
let directory = tempfile::tempdir().unwrap();
generate_proxy_test_certificates(directory.path());
// 使用子进程隔离环境变量,避免并行测试读取到临时 CA 配置。
for ca_env in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
let mut child = Command::new(std::env::current_exe().unwrap());
child
.args([
"--exact",
"bootstrap::proxy_probe_should_use_provider_custom_ca_for_https_proxies",
"--nocapture",
])
.env(CHILD_ENV, directory.path())
.env_remove("CODEX_CA_CERTIFICATE")
.env_remove("SSL_CERT_FILE")
.env(ca_env, directory.path().join("ca.pem"));
if ca_env == "CODEX_CA_CERTIFICATE" {
child.env(
"SSL_CERT_FILE",
directory.path().join("missing-fallback.pem"),
);
}
let output = child.output().unwrap();
assert!(
output.status.success(),
"{ca_env}: {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
return;
};
provider_openai::ensure_rustls_provider();
let directory = std::path::Path::new(&directory);
let certificate = CertificateDer::from_pem_file(directory.join("server.pem")).unwrap();
let key = PrivateKeyDer::from_pem_file(directory.join("server.key")).unwrap();
let acceptor = TlsAcceptor::from(Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![certificate], key)
.unwrap(),
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy =
OutboundProxy::parse(&format!("https://{}", listener.local_addr().unwrap())).unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut stream = acceptor.accept(socket).await.unwrap();
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") {
assert!(request.len() < 8192);
request.push(stream.read_u8().await.unwrap());
}
assert!(request.starts_with(b"GET http://unresolvable.invalid/ip "));
let body = "{\"ip\":\"203.0.113.8\"}";
stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
stream.shutdown().await.unwrap();
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy).await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
fn generate_proxy_test_certificates(directory: &std::path::Path) {
let openssl = |args: &[&str]| {
let output = Command::new("openssl")
.args(args)
.current_dir(directory)
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
};
openssl(&[
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"ca.key",
"-out",
"ca.pem",
"-days",
"1",
"-subj",
"/CN=Proxy Test CA",
]);
openssl(&[
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
"server.key",
"-out",
"server.csr",
"-subj",
"/CN=localhost",
]);
fs::write(directory.join("extensions"), "subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n").unwrap();
openssl(&[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.pem",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.pem",
"-days",
"1",
"-extfile",
"extensions",
]);
}
#[test]
fn config_loader_should_load_complete_terminal_example() {
parse_config(&valid_config()).expect("terminal config example");
+1
View File
@@ -19,6 +19,7 @@ bytes = "1.11.1"
chrono = "0.4.45"
futures = "0.3.32"
gateway-core = { path = "../gateway-core" }
heck = "0.5.0"
hex = "0.4.3"
rand_core = { version = "0.6.4", features = ["getrandom"] }
serde = { version = "1.0.228", features = ["derive"] }
+17 -2
View File
@@ -25,7 +25,7 @@ pub use use_case::{
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
backup::BackupService, client_distribution::ClientDistributionService,
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
settings::SettingsService, system::SystemService, xai::XaiService,
proxies::ProxiesService, settings::SettingsService, system::SystemService, xai::XaiService,
};
use model::{AdminError, AdminErrorKind};
@@ -149,6 +149,7 @@ pub enum AdminConfigError {
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
#[derive(Clone)]
pub struct AdminServices {
proxies: Arc<dyn ProxiesService>,
auth: Arc<dyn AuthService>,
accounts: Arc<dyn AccountsService>,
account_groups: Arc<dyn AccountGroupService>,
@@ -163,6 +164,11 @@ pub struct AdminServices {
}
impl AdminServices {
#[must_use]
pub fn proxies(&self) -> &dyn ProxiesService {
self.proxies.as_ref()
}
#[must_use]
pub fn auth(&self) -> &dyn AuthService {
self.auth.as_ref()
@@ -247,10 +253,11 @@ pub async fn initialize(
store: AdminStorePorts,
providers: Vec<Arc<dyn ProviderAdmin>>,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
probes: (Arc<dyn AccountProbe>, Arc<dyn ports::proxy::ProxyProbe>),
client_distribution: Arc<dyn ClientDistributionResolver>,
system: Arc<dyn SystemOperations>,
) -> Result<AdminBundle, AdminError> {
let (probe, proxy_probe) = probes;
config
.resolve_and_validate(Path::new("."))
.map_err(|error| AdminError::invalid(error.to_string()))?;
@@ -290,6 +297,12 @@ pub async fn initialize(
backup_ports.object_store(),
);
let services = AdminServices {
proxies: Arc::new(use_case::proxies::DefaultProxiesService::new(
store.proxies(),
proxy_probe,
snapshot.clone(),
registry.clone(),
)),
auth,
accounts,
account_groups: Arc::new(DefaultAccountGroupService::new(
@@ -316,11 +329,13 @@ pub async fn initialize(
openai: Arc::new(DefaultOpenAiService::new(
openai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
xai: Arc::new(DefaultXaiService::new(
xai,
store.accounts(),
store.proxies(),
snapshot.clone(),
)),
backups,
@@ -18,6 +18,15 @@ pub use gateway_core::account::{
QuotaState, resolve_account_status,
};
/// 导入时统一应用的账号调度与分组设置;缺省时保留原有导入语义。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountImportSettings {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
}
/// 账号列表排序字段。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountSortField {
@@ -89,6 +98,7 @@ pub struct AccountRecord {
pub enabled: bool,
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub credential_state: CredentialState,
pub credential_observed_at: DateTime<Utc>,
pub quota: QuotaState,
@@ -211,6 +221,7 @@ pub struct UpdateAccount {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 账号更新结果。
@@ -228,6 +239,7 @@ pub struct BatchUpdateAccounts {
pub concurrency_limit: Option<AccountConcurrencyLimit>,
pub weight: AccountWeight,
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// 批量账号更新结果。
@@ -5,6 +5,7 @@ use std::{fmt, num::NonZeroU16};
use chrono::{DateTime, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
routing::{AccountGroupId, ProviderKind},
};
@@ -93,6 +94,7 @@ pub struct ClientKeyRecord {
pub prefix: String,
pub enabled: bool,
pub limits: RateLimits,
pub budget: ClientBudgetStatus,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -146,6 +148,7 @@ pub struct CreateClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub budget: ClientBudgetLimits,
}
/// 管理用例生成 ID 与明文后的持久化命令。
@@ -156,6 +159,7 @@ pub struct NewClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub budget: ClientBudgetLimits,
pub plaintext: String,
}
@@ -179,6 +183,8 @@ pub struct UpdateClientKey {
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
pub limits: RateLimits,
pub daily_limit_usd: Option<gateway_core::metering::Decimal>,
pub weekly_limit_usd: Option<gateway_core::metering::Decimal>,
}
/// 修改 Client Key 启用状态。
@@ -10,6 +10,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod provider_credentials;
pub mod proxies;
pub mod settings;
pub mod system;
@@ -471,6 +471,8 @@ pub struct DashboardAccountUsage {
pub name: String,
pub email: Option<String>,
pub plan_type: Option<String>,
/// Admin 按 Provider 补齐的套餐展示名称。
pub plan_type_display: Option<String>,
pub request_count: u64,
pub success_count: u64,
pub input_tokens: Option<u64>,
@@ -16,7 +16,9 @@ use uuid::Uuid;
use super::{
AdminError, MutationActor, MutationContext, PageSize, Revision,
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
accounts::{
AccountImportSettings, AccountRecord, AccountSummary, AccountUsage, CredentialState,
},
};
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
@@ -106,6 +108,8 @@ pub struct CredentialDetails {
/// Provider 正式文档批量导入命令。
pub struct ImportCredentials {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub document: ProviderDocument,
}
@@ -129,6 +133,7 @@ pub struct CredentialImportResult {
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
pub struct PrepareCredentialImport {
pub default_outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub document: ProviderDocument,
}
@@ -144,6 +149,7 @@ impl fmt::Debug for PrepareCredentialImport {
/// Provider 已验证、可由 Store 原子创建的一份 credential。
#[derive(Debug, Clone, PartialEq)]
pub struct PreparedCredentialCreate {
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
pub account_id: ProviderAccountId,
pub provider_kind: ProviderKind,
pub name: String,
@@ -171,6 +177,8 @@ pub struct PreparedCredentialImport {
/// Admin 交给 Store 的导入事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialImportCommit {
pub outbound_proxy: Option<super::proxies::ImportProxyBinding>,
pub settings: Option<AccountImportSettings>,
pub prepared: PreparedCredentialImport,
}
@@ -249,6 +257,8 @@ pub struct PendingAuthorizationMutation {
provider_kind: ProviderKind,
target: AuthorizationMutationTarget,
owner_binding: AuthorizationOwnerBinding,
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
outbound_proxy_id: Option<String>,
}
impl PendingAuthorizationMutation {
@@ -262,6 +272,8 @@ impl PendingAuthorizationMutation {
provider_kind,
target,
owner_binding,
outbound_proxy: None,
outbound_proxy_id: None,
}
}
@@ -279,6 +291,29 @@ impl PendingAuthorizationMutation {
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
&self.owner_binding
}
#[must_use]
pub fn with_outbound_proxy(
mut self,
proxy: Option<gateway_core::account::OutboundProxy>,
) -> Self {
self.outbound_proxy = proxy;
self
}
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy.as_ref()
}
#[must_use]
pub fn with_outbound_proxy_id(mut self, id: Option<String>) -> Self {
self.outbound_proxy_id = id;
self
}
pub fn outbound_proxy_id(&self) -> Option<&str> {
self.outbound_proxy_id.as_deref()
}
}
impl PendingAuthorizationMutation {
@@ -300,7 +335,7 @@ impl PendingAuthorizationMutation {
AuthorizationOwner::AdminApiKey => serde_json::json!({"kind": "admin_api_key"}),
AuthorizationOwner::System => serde_json::json!({"kind": "system"}),
};
Map::from_iter([
let mut document = Map::from_iter([
(
"provider_kind".to_owned(),
Value::String(self.provider_kind.as_str().to_owned()),
@@ -311,7 +346,17 @@ impl PendingAuthorizationMutation {
"started_request_id".to_owned(),
Value::String(self.owner_binding.started_request_id().to_owned()),
),
])
]);
if let Some(proxy) = &self.outbound_proxy {
document.insert(
"outbound_proxy_url".to_owned(),
Value::String(proxy.expose_url().to_owned()),
);
}
if let Some(id) = &self.outbound_proxy_id {
document.insert("outbound_proxy_id".to_owned(), Value::String(id.clone()));
}
document
}
/// 恢复 adapter 已确认版本为 v1 的中立事务字段。
@@ -341,6 +386,12 @@ impl PendingAuthorizationMutation {
StoredAuthorizationOwnerV1::AdminApiKey => AuthorizationOwner::AdminApiKey,
StoredAuthorizationOwnerV1::System => AuthorizationOwner::System,
};
let proxy = document
.outbound_proxy_url
.as_deref()
.map(gateway_core::account::OutboundProxy::parse)
.transpose()
.map_err(|_| invalid())?;
Ok(Self::new(
provider_kind,
target,
@@ -348,7 +399,9 @@ impl PendingAuthorizationMutation {
owner,
started_request_id: document.started_request_id,
},
))
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(document.outbound_proxy_id))
}
}
@@ -359,6 +412,10 @@ struct StoredAuthorizationMutationV1 {
target: StoredAuthorizationTargetV1,
owner: StoredAuthorizationOwnerV1,
started_request_id: String,
#[serde(default)]
outbound_proxy_url: Option<String>,
#[serde(default)]
outbound_proxy_id: Option<String>,
}
#[derive(Deserialize)]
@@ -382,6 +439,7 @@ pub struct StartAuthorization {
pub context: MutationContext,
pub name: String,
pub reauthorization: Option<ProviderAccountId>,
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
}
/// OAuth 流程启动结果。
@@ -447,6 +505,7 @@ pub enum AuthorizationCredentialCommit {
/// Admin 交给 Store 的 OAuth 原子事务命令。
#[derive(Debug, Clone, PartialEq)]
pub struct AuthorizationCommit {
pub settings: Option<AccountImportSettings>,
pub pending: PendingAuthorizationMutation,
pub credential: AuthorizationCredentialCommit,
}
@@ -481,7 +540,10 @@ impl PreparedAuthorizationCommit {
self
}
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
pub(crate) fn into_commit(
self,
settings: Option<AccountImportSettings>,
) -> AuthorizationCommitSettlement {
let (credential, guard) = match self.credential {
PreparedAuthorizationCredential::Create(credential) => {
(AuthorizationCredentialCommit::Create(credential), None)
@@ -496,6 +558,7 @@ impl PreparedAuthorizationCommit {
};
AuthorizationCommitSettlement {
command: AuthorizationCommit {
settings,
pending: self.pending,
credential,
},
@@ -509,7 +572,7 @@ impl PreparedAuthorizationCommit {
credential_guard,
authorization_guard,
..
} = self.into_commit();
} = self.into_commit(None);
drop(credential_guard);
if let Some(guard) = authorization_guard {
guard.abort().await?;
@@ -521,6 +584,7 @@ impl PreparedAuthorizationCommit {
/// 完成 Provider OAuth 流程。
#[derive(Clone, PartialEq, Eq)]
pub struct CompleteAuthorization {
pub settings: Option<AccountImportSettings>,
pub context: MutationContext,
pub flow_id: String,
pub callback_url: String,
@@ -707,6 +771,13 @@ pub struct ProviderQuotaWindow {
pub provider_data: Option<ProviderDocument>,
}
/// 账号用量统计周期,按周优先、月次之选择;短期限流窗口不参与统计面板。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AccountUsagePeriod {
Weekly,
Monthly,
}
/// Provider 解释 quota 所需的公共请求事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderQuotaRequest {
@@ -718,6 +789,8 @@ pub struct ProviderQuotaRequest {
/// Provider 已解析的 quota 结果及其不透明差异字段。
#[derive(Debug, Clone, PartialEq)]
pub struct ProviderQuota {
/// 上游额度响应明确提供的套餐,可用于补全账号展示。
pub plan_type: Option<String>,
pub observed_at: Option<DateTime<Utc>>,
pub refresh_token_expires_at: Option<DateTime<Utc>>,
pub windows: Vec<ProviderQuotaWindow>,
@@ -726,6 +799,14 @@ pub struct ProviderQuota {
pub provider_data: Option<ProviderDocument>,
}
/// 空值和 `unknown` 代表未提供套餐;新的套餐标识仍按明确值保留。
pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> {
value.filter(|value| {
let value = value.trim();
!value.is_empty() && !value.eq_ignore_ascii_case("unknown")
})
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
@@ -840,23 +921,41 @@ pub struct ProviderResetCreditResult {
}
impl ProviderQuota {
/// 返回账号展开面板使用的当前额度窗口用量。
/// 保留账号已有的套餐子类型,仅在缺失时使用上游额度快照补全。
pub(crate) fn fill_missing_plan_type(&self, account_plan_type: &mut Option<String>) {
if explicit_plan_type(account_plan_type.as_deref()).is_none() {
*account_plan_type = explicit_plan_type(self.plan_type.as_deref()).map(str::to_owned);
}
}
/// 选择账号用量统计的周/月窗口,同时返回展示所需的周期事实。
///
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
/// 同一优先级保持 Provider 投影顺序。
/// 只使用边界完整、可归属到整个账号的窗口;周优先于月,同周期保持
/// Provider 投影顺序。没有符合条件的窗口时不回退到短期或历史累计。
#[must_use]
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
pub fn usage_window(&self) -> Option<(&ProviderQuotaWindow, AccountUsagePeriod)> {
self.windows
.iter()
.enumerate()
.filter(|(_, window)| {
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
&& window.window_seconds.is_some()
&& window.local_usage.is_some()
.filter_map(|(index, window)| {
if window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|| window.reset_at.is_none()
|| window.local_usage.is_none()
{
return None;
}
let seconds = window.window_seconds?;
let period = if is_week_window(seconds) {
AccountUsagePeriod::Weekly
} else if window.group == "monthly" && seconds >= 7 * 24 * 60 * 60 {
AccountUsagePeriod::Monthly
} else {
return None;
};
Some((index, window, period))
})
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
.and_then(|(_, window)| window.local_usage.as_ref())
.min_by_key(|(index, _, period)| (*period, *index))
.map(|(_, window, period)| (window, period))
}
/// 返回 Dashboard 使用的代表性额度比例。
@@ -1019,6 +1118,8 @@ impl fmt::Debug for ProviderExport {
#[derive(Debug, Clone, PartialEq)]
pub struct AccountDirectoryItem {
pub account: AccountRecord,
/// Provider 提供的套餐展示名称;未识别到套餐时为空。
pub plan_type_display: Option<String>,
pub projection: gateway_core::account::AccountStatusProjection,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
@@ -0,0 +1,105 @@
//! 可复用的账号出口配置,以及脱敏后的连通性测试结果。
use chrono::{DateTime, Utc};
use gateway_core::account::OutboundProxy;
use super::{PageSize, Revision, account_groups::AccountGroupRef};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AccountProxySelection {
Direct,
Url(OutboundProxy),
Saved(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportProxyBinding {
pub id: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct ProxyListQuery {
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyAccountRef {
pub id: String,
pub name: String,
pub email: Option<String>,
pub provider_kind: String,
pub authentication_kind: String,
pub plan_type: Option<String>,
pub plan_type_display: Option<String>,
pub groups: Vec<AccountGroupRef>,
pub enabled: bool,
}
/// 按代理查询关联账号,分页与搜索均在存储层执行。
#[derive(Debug, Clone)]
pub struct ProxyAccountListQuery {
pub proxy_id: String,
pub page: u32,
pub page_size: PageSize,
pub search: String,
}
#[derive(Debug, Clone)]
pub struct ProxyAccountPage {
pub items: Vec<ProxyAccountRef>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyTestResult {
pub success: bool,
pub latency_ms: u64,
pub exit_ip: Option<std::net::IpAddr>,
pub message: String,
}
#[derive(Debug, Clone)]
pub struct ProxyRecord {
pub id: String,
pub name: String,
pub proxy: OutboundProxy,
pub revision: Revision,
pub account_count: u64,
pub last_test_at: Option<DateTime<Utc>>,
pub last_test: Option<ProxyTestResult>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ProxyPage {
pub items: Vec<ProxyRecord>,
pub total: u64,
pub page: u32,
pub page_size: u16,
}
#[derive(Debug, Clone)]
pub struct NewProxy {
pub name: String,
pub proxy: OutboundProxy,
}
#[derive(Debug, Clone)]
pub struct UpdateProxy {
pub id: String,
pub revision: Revision,
pub name: String,
pub proxy: Option<OutboundProxy>,
}
#[derive(Debug, Clone)]
pub struct ProxyMutation {
pub config_revision: Revision,
pub record: ProxyRecord,
}
@@ -3,5 +3,6 @@
pub mod backup;
pub mod client_distribution;
pub mod provider;
pub mod proxy;
pub mod store;
pub mod system;
@@ -8,6 +8,7 @@ use gateway_core::{
operation::Operation,
routing::{ProviderKind, UpstreamModelId},
};
use heck::ToUpperCamelCase;
use crate::model::observability::{
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
@@ -18,7 +19,7 @@ use crate::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits,
ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type,
};
/// Provider 管理失败的稳定分类。
@@ -40,11 +41,13 @@ pub enum ProviderAdminErrorKind {
///
/// `message` 是 Provider 局部诊断,可能包含原始上游正文;通用管理用例不得自动把它作为公开文案。
/// 只有明确拥有原始诊断合同的调用方才能读取,`Debug` 始终只记录是否存在。
/// `public_message` 则是明确标记为可公开的静态提示,不允许携带动态上游材料。
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
#[error("provider admin operation failed: {kind:?}")]
pub struct ProviderAdminError {
kind: ProviderAdminErrorKind,
message: Option<String>,
public_message: Option<&'static str>,
}
impl std::fmt::Debug for ProviderAdminError {
@@ -53,6 +56,7 @@ impl std::fmt::Debug for ProviderAdminError {
.debug_struct("ProviderAdminError")
.field("kind", &self.kind)
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
.field("public_message", &self.public_message)
.finish()
}
}
@@ -63,6 +67,7 @@ impl ProviderAdminError {
Self {
kind,
message: None,
public_message: None,
}
}
@@ -72,6 +77,18 @@ impl ProviderAdminError {
self
}
/// Provider 明确允许公开的静态提示;不得承载上游响应或凭据材料。
#[must_use]
pub const fn with_public_message(mut self, message: &'static str) -> Self {
self.public_message = Some(message);
self
}
#[must_use]
pub const fn public_message(&self) -> Option<&'static str> {
self.public_message
}
#[must_use]
pub const fn kind(&self) -> ProviderAdminErrorKind {
self.kind
@@ -91,6 +108,11 @@ impl ProviderAdminError {
pub trait ProviderAdmin: Send + Sync {
fn provider_kind(&self) -> &ProviderKind;
/// 将原始套餐值投影为展示名称;默认保留未知 Provider 的原始名称。
fn plan_type_display(&self, plan_type: &str) -> String {
plan_type.to_owned()
}
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
///
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
@@ -230,6 +252,37 @@ impl ProviderAdminRegistry {
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
/// 账号目录与关联列表共用套餐补全和展示规则,已知账号套餐优先于额度快照。
pub(crate) fn resolve_account_plan(
&self,
provider_kind: &str,
plan_type: &mut Option<String>,
quota: Option<&ProviderQuota>,
) -> Option<String> {
if let Some(quota) = quota {
quota.fill_missing_plan_type(plan_type);
}
self.plan_type_display(provider_kind, plan_type.as_deref())
}
/// 账号页和 Dashboard 共用的大驼峰套餐展示名称,不修改原始套餐值。
pub(crate) fn plan_type_display(
&self,
provider_kind: &str,
plan_type: Option<&str>,
) -> Option<String> {
let plan_type = explicit_plan_type(plan_type)?.trim();
let provider = ProviderKind::new(provider_kind.to_owned())
.ok()
.and_then(|kind| self.providers.get(&kind));
let display = provider.map_or_else(
|| plan_type.to_owned(),
|provider| provider.plan_type_display(plan_type),
);
// 所有 Provider 及未知套餐统一排版,保留 Plus 后缀的含义。
Some(display.replace('+', " Plus ").to_upper_camel_case())
}
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
self.providers
@@ -0,0 +1,66 @@
use async_trait::async_trait;
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use super::store::AdminStoreResult;
use crate::model::{
MutationContext, Revision,
proxies::{
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
},
};
#[async_trait]
pub trait ProxyStore: Send + Sync {
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果。
async fn reserve_import(&self, id: &str) -> AdminStoreResult<ProxyImportReservation>;
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage>;
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord>;
/// 仅在账号仍绑定指定代理时解除关联,并清除账号保存的连接地址。
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> AdminStoreResult<ProxyMutation>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> AdminStoreResult<Revision>;
async fn record_test(
&self,
id: &str,
revision: Revision,
result: ProxyTestResult,
context: &MutationContext,
) -> AdminStoreResult<ProxyRecord>;
}
/// 离开作用域时释放保护,错误返回和请求取消也遵循相同规则。
pub trait ProxyImportGuard: Send + Sync {}
pub struct ProxyImportReservation {
pub binding: ImportProxyBinding,
pub guard: Box<dyn ProxyImportGuard>,
}
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult;
}
@@ -382,6 +382,7 @@ pub struct AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
}
impl AdminAccountStorePorts {
@@ -390,11 +391,13 @@ impl AdminAccountStorePorts {
accounts: Arc<dyn AccountStore>,
runtime: Arc<dyn AccountRuntimeStore>,
groups: Arc<dyn AccountGroupStore>,
proxies: Arc<dyn super::proxy::ProxyStore>,
) -> Self {
Self {
accounts,
runtime,
groups,
proxies,
}
}
}
@@ -447,6 +450,11 @@ impl AdminStorePorts {
self.accounts.groups.clone()
}
#[must_use]
pub fn proxies(&self) -> Arc<dyn super::proxy::ProxyStore> {
self.accounts.proxies.clone()
}
#[must_use]
pub fn auth(&self) -> Arc<dyn AuthStore> {
self.auth.clone()
@@ -269,7 +269,7 @@ impl DefaultAccountsService {
})
.await
.map_err(|error| map_provider_error(error, "provider quota"))?;
let stored = if refresh_quota {
let mut stored = if refresh_quota {
self.load_account(account_id).await?
} else {
stored
@@ -279,8 +279,15 @@ impl DefaultAccountsService {
std::slice::from_mut(&mut quota),
)
.await?;
let usage = quota.representative_window_usage().cloned();
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
&mut stored.account.plan_type,
Some(&quota),
),
projection: stored.projection,
usage,
account: stored.account,
@@ -366,9 +373,16 @@ impl AccountsService for DefaultAccountsService {
.items
.into_iter()
.zip(quotas)
.map(|(item, quota)| {
let usage = quota.representative_window_usage().cloned();
.map(|(mut item, quota)| {
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
&mut item.account.plan_type,
Some(&quota),
),
usage,
account: item.account,
projection: item.projection,
@@ -735,6 +749,7 @@ fn map_reset_credits_error_after_refresh(
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
fn empty_quota() -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -99,6 +99,7 @@ impl ClientKeyService for DefaultClientKeyService {
label: command.label,
group_ids: command.group_ids,
limits: command.limits,
budget: command.budget,
plaintext: plaintext.clone(),
},
context,
@@ -8,6 +8,7 @@ pub mod client_distribution;
pub mod client_keys;
pub mod observability;
pub mod openai;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod xai;
@@ -83,7 +84,7 @@ fn map_provider_error(
AdminErrorKind::Internal => "服务内部错误",
_ => "Provider 操作失败",
};
AdminError::new(kind, message)
AdminError::new(kind, error.public_message().unwrap_or(message))
}
async fn publish_committed(
@@ -109,22 +110,58 @@ async fn required_credential(
.ok_or_else(|| AdminError::not_found("Provider 凭据不存在"))
}
async fn import_proxy_binding(
proxies: &dyn crate::ports::proxy::ProxyStore,
id: Option<&str>,
) -> Result<Option<crate::ports::proxy::ProxyImportReservation>, AdminError> {
let Some(id) = id else { return Ok(None) };
let reservation = proxies
.reserve_import(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
Ok(Some(reservation))
}
async fn pending_authorization(
accounts: &dyn AccountStore,
proxies: &dyn crate::ports::proxy::ProxyStore,
provider_kind: &ProviderKind,
command: &StartAuthorization,
resource: &'static str,
) -> Result<PendingAuthorizationMutation, AdminError> {
let proxy;
let mut proxy_id = None;
let target = match &command.reauthorization {
Some(account_id) => {
required_credential(accounts, provider_kind, account_id, resource).await?;
proxy = required_credential(accounts, provider_kind, account_id, resource)
.await?
.credential
.outbound_proxy;
AuthorizationMutationTarget::Reauthorize {
account_id: account_id.clone(),
}
}
None => AuthorizationMutationTarget::Create {
name: command.name.clone(),
},
None => {
use crate::model::proxies::AccountProxySelection;
proxy = match &command.outbound_proxy {
Some(AccountProxySelection::Url(value)) => Some(value.clone()),
Some(AccountProxySelection::Saved(id)) => {
let record = proxies
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.last_test.is_some_and(|test| test.success) {
return Err(AdminError::conflict("请先测试代理连接"));
}
proxy_id = Some(record.id);
Some(record.proxy)
}
Some(AccountProxySelection::Direct) | None => None,
};
AuthorizationMutationTarget::Create {
name: command.name.clone(),
}
}
};
Ok(PendingAuthorizationMutation::new(
provider_kind.clone(),
@@ -132,7 +169,9 @@ async fn pending_authorization(
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
&command.context,
),
))
)
.with_outbound_proxy(proxy)
.with_outbound_proxy_id(proxy_id))
}
fn validate_prepared_import(
@@ -217,14 +256,24 @@ async fn validate_authorization_commit(
async fn commit_authorization(
accounts: &dyn AccountStore,
prepared: PreparedAuthorizationCommit,
settings: Option<crate::model::accounts::AccountImportSettings>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
if settings.is_some()
&& matches!(
&prepared.credential,
PreparedAuthorizationCredential::Reauthorize(_)
)
{
prepared.abort().await?;
return Err(AdminError::invalid("重新授权不能修改账号设置"));
}
let crate::model::provider_credentials::AuthorizationCommitSettlement {
command,
credential_guard,
authorization_guard,
} = prepared.into_commit();
} = prepared.into_commit(settings);
match accounts.commit_authorization(command, context).await {
Ok(result) => {
if let Some(guard) = credential_guard {
@@ -164,6 +164,11 @@ impl DefaultObservabilityService {
self.enrich_list_billing(&mut observation.recent_requests);
self.enrich_dashboard_quotas(&mut observation.account_usage)
.await;
for account in &mut observation.account_usage {
account.plan_type_display = self
.providers
.plan_type_display(&account.provider_kind, account.plan_type.as_deref());
}
let today_start = china_day_start(observation.range.end);
let yesterday_start = today_start - Duration::days(1);
let today =
@@ -770,6 +775,7 @@ impl DefaultObservabilityService {
let quotas = futures::future::join_all(quota_reads).await;
for (account, quota) in accounts.iter_mut().zip(quotas) {
if let Some(mut quota) = quota {
quota.fill_missing_plan_type(&mut account.plan_type);
quota.apply_limit_reached_display();
account.quota_used_percent = quota.representative_used_percent();
account.quota_window = quota.representative_window().cloned();
@@ -58,6 +58,7 @@ pub trait OpenAiService: Send + Sync {
pub(crate) struct DefaultOpenAiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -66,11 +67,13 @@ impl DefaultOpenAiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -128,9 +131,20 @@ impl OpenAiService for DefaultOpenAiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -142,9 +156,17 @@ impl OpenAiService for DefaultOpenAiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
drop(proxy_reservation);
self.provider
.account_facts_changed(&result.credential_ids)
.await;
@@ -160,6 +182,7 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"OpenAI credential",
@@ -173,9 +196,10 @@ impl OpenAiService for DefaultOpenAiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -191,6 +215,7 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"OpenAI authorization",
)
@@ -0,0 +1,261 @@
use std::sync::Arc;
use async_trait::async_trait;
use futures::{StreamExt, stream};
use gateway_core::{
account::{OutboundProxy, ProviderAccountId},
routing::ProviderKind,
runtime::SnapshotControl,
};
use tokio::sync::Semaphore;
use super::{map_store_error, publish_committed};
use crate::{
model::{
AdminError, AdminErrorKind, MutationContext, Revision,
provider_credentials::{ProviderQuotaRequest, explicit_plan_type},
proxies::*,
},
ports::{
provider::ProviderAdminRegistry,
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError>;
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn create(
&self,
command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn update(
&self,
command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError>;
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError>;
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
}
pub(crate) struct DefaultProxiesService {
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
test_slots: Semaphore,
}
impl DefaultProxiesService {
pub(crate) fn new(
store: Arc<dyn ProxyStore>,
probe: Arc<dyn ProxyProbe>,
snapshot: Arc<dyn SnapshotControl>,
providers: ProviderAdminRegistry,
) -> Self {
Self {
store,
probe,
snapshot,
providers,
test_slots: Semaphore::new(4),
}
}
}
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &str,
context: &MutationContext,
) -> Result<Revision, AdminError> {
if proxy_id.is_empty() || proxy_id.len() > 128 || proxy_id.chars().any(char::is_control) {
return Err(AdminError::invalid("代理 ID 不合法"));
}
let account_id = ProviderAccountId::new(account_id.to_owned())
.map_err(|_| AdminError::invalid("账号 ID 不合法"))?;
let revision = self
.store
.remove_account(proxy_id, &account_id, context)
.await
.map_err(|error| {
if error.kind() == AdminStoreErrorKind::Conflict {
AdminError::conflict("账号的代理绑定已变化,请刷新后重试")
} else {
map_store_error(error, "proxy account")
}
})?;
publish_committed(self.snapshot.as_ref(), revision).await?;
Ok(revision)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> Result<ProxyAccountPage, AdminError> {
if query.proxy_id.is_empty()
|| query.proxy_id.len() > 128
|| query.proxy_id.chars().any(char::is_control)
|| query.page == 0
|| query.search.len() > 256
|| query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("关联账号查询参数不合法"));
}
let mut page = self
.store
.list_accounts(query)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
page.items = stream::iter(page.items)
.map(|mut account| async {
// 与账号目录一致,缺失套餐时读取已有额度快照;限制并发且不触发上游刷新。
let mut cached_quota = None;
if explicit_plan_type(account.plan_type.as_deref()).is_none()
&& let Ok(kind) = ProviderKind::new(account.provider_kind.clone())
&& let Ok(provider) = self.providers.require(&kind)
&& let Ok(account_id) = ProviderAccountId::new(account.id.clone())
&& let Ok(quota) = provider
.quota(ProviderQuotaRequest {
account_id,
refresh: false,
rolling_usage: None,
})
.await
{
cached_quota = Some(quota);
}
account.plan_type_display = self.providers.resolve_account_plan(
&account.provider_kind,
&mut account.plan_type,
cached_quota.as_ref(),
);
account
})
.buffered(8)
.collect()
.await;
Ok(page)
}
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
{
return Err(AdminError::invalid("代理查询参数不合法"));
}
self.store
.list(query)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
async fn create(
&self,
mut command: NewProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.create(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn update(
&self,
mut command: UpdateProxy,
context: &MutationContext,
) -> Result<ProxyMutation, AdminError> {
command.name = validate_name(&command.name)?;
let result = self
.store
.update(command, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
Ok(result)
}
async fn delete(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<Revision, AdminError> {
let result = self
.store
.delete(id, revision, context)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
publish_committed(self.snapshot.as_ref(), result).await?;
Ok(result)
}
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
Ok(self.probe.test(proxy).await)
}
async fn test(
&self,
id: &str,
revision: Revision,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
})?;
let record = self
.store
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if record.revision != revision {
return Err(AdminError::conflict("代理已被修改,请刷新后重新测试"));
}
let result = self.probe.test(&record.proxy).await;
self.store
.record_test(id, revision, result, context)
.await
.map_err(|error| map_store_error(error, "proxy"))
}
}
fn validate_name(value: &str) -> Result<String, AdminError> {
let value = value.trim();
if value.is_empty() || value.chars().count() > 100 || value.chars().any(char::is_control) {
return Err(AdminError::invalid("代理名称需要 1 至 100 个字符"));
}
Ok(value.to_owned())
}
@@ -49,6 +49,7 @@ pub trait XaiService: Send + Sync {
pub(crate) struct DefaultXaiService {
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
}
@@ -57,11 +58,13 @@ impl DefaultXaiService {
pub(crate) fn new(
provider: Arc<dyn ProviderAdmin>,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn crate::ports::proxy::ProxyStore>,
snapshot: Arc<dyn SnapshotControl>,
) -> Self {
Self {
provider,
accounts,
proxies,
snapshot,
}
}
@@ -81,9 +84,20 @@ impl XaiService for DefaultXaiService {
command: ImportCredentials,
) -> Result<CredentialImportResult, AdminError> {
let context = command.context;
let proxy_reservation = super::import_proxy_binding(
self.proxies.as_ref(),
command.outbound_proxy_id.as_deref(),
)
.await?;
let outbound_proxy = proxy_reservation
.as_ref()
.map(|reservation| reservation.binding.clone());
let prepared = self
.provider
.prepare_import(PrepareCredentialImport {
default_outbound_proxy: outbound_proxy
.as_ref()
.map(|binding| binding.proxy.clone()),
document: command.document,
})
.await
@@ -95,9 +109,17 @@ impl XaiService for DefaultXaiService {
)?;
let result = self
.accounts
.commit_credential_import(CredentialImportCommit { prepared }, &context)
.commit_credential_import(
CredentialImportCommit {
outbound_proxy,
prepared,
settings: command.settings,
},
&context,
)
.await
.map_err(|error| map_store_error(error, "xAI credential import"))?;
drop(proxy_reservation);
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
for account_id in &result.credential_ids {
@@ -119,6 +141,7 @@ impl XaiService for DefaultXaiService {
) -> Result<AuthorizationStarted, AdminError> {
let pending = pending_authorization(
self.accounts.as_ref(),
self.proxies.as_ref(),
self.provider.provider_kind(),
&command,
"xAI credential",
@@ -132,9 +155,10 @@ impl XaiService for DefaultXaiService {
async fn complete_authorization(
&self,
command: CompleteAuthorization,
mut command: CompleteAuthorization,
) -> Result<CredentialMutationResult, AdminError> {
let context = command.context.clone();
let settings = command.settings.take();
let prepared = self
.provider
.complete_authorization(command)
@@ -150,6 +174,7 @@ impl XaiService for DefaultXaiService {
let result = commit_authorization(
self.accounts.as_ref(),
prepared,
settings,
&context,
"xAI authorization",
)
+8 -3
View File
@@ -121,6 +121,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
#[test]
fn representative_quota_should_prefer_short_window_and_highest_usage() {
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -141,6 +142,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -155,7 +157,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
}
#[test]
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
fn usage_window_should_not_use_daily_rolling_usage_for_weekly_statistics() {
let usage = AccountUsage {
account_id: "acct_xai".to_owned(),
request_count: 5,
@@ -177,6 +179,7 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
models: Vec::new(),
};
let quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -191,19 +194,20 @@ fn representative_window_usage_should_accept_account_wide_rolling_window_without
used_percent: None,
reset_at: None,
limit_reached: false,
local_usage: Some(usage.clone()),
local_usage: Some(usage),
provider_data: None,
}],
limit_reached: false,
provider_data: None,
};
assert_eq!(quota.representative_window_usage(), Some(&usage));
assert_eq!(quota.usage_window(), None);
}
#[test]
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![
@@ -231,6 +235,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
reached.limit_reached = true;
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
@@ -1,15 +1,139 @@
use gateway_admin::model::provider_credentials::{
AuthorizationMutationTarget, AuthorizationOwnerBinding, PendingAuthorizationMutation,
ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole, QuotaLocalUsageAttribution,
AccountUsagePeriod, AuthorizationMutationTarget, AuthorizationOwnerBinding,
PendingAuthorizationMutation, ProviderQuota, ProviderQuotaWindow, ProviderQuotaWindowRole,
QuotaLocalUsageAttribution,
};
use gateway_admin::model::{MutationActor, MutationContext};
use gateway_core::account::ProviderAccountId;
use gateway_core::routing::ProviderKind;
use serde_json::{Value, json};
#[test]
fn usage_window_should_prefer_weekly_without_changing_dashboard_selection() {
let quota = usage_quota(vec![
usage_window("short", "shortTerm", 18_000),
usage_window("month", "monthly", 30 * 86_400),
usage_window("week", "shortTerm", 7 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("week", AccountUsagePeriod::Weekly))
);
assert_eq!(
quota
.representative_window()
.map(|window| window.key.as_str()),
Some("short")
);
}
#[test]
fn usage_window_should_use_monthly_when_weekly_is_unavailable() {
let mut model_week = usage_window("model-week", "shortTerm", 7 * 86_400);
model_week.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let mut incomplete_week = usage_window("incomplete-week", "shortTerm", 7 * 86_400);
incomplete_week.reset_at = None;
let mut unknown_usage = usage_window("unknown-week", "shortTerm", 7 * 86_400);
unknown_usage.local_usage = None;
let quota = usage_quota(vec![
model_week,
incomplete_week,
unknown_usage,
usage_window("month", "monthly", 30 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("month", AccountUsagePeriod::Monthly))
);
}
#[test]
fn usage_window_should_not_fallback_to_short_daily_or_incomplete_windows() {
let mut missing_duration = usage_window("unknown-month", "monthly", 30 * 86_400);
missing_duration.window_seconds = None;
let quota = usage_quota(vec![
usage_window("short", "shortTerm", 18_000),
usage_window("day", "shortTerm", 86_400),
usage_window("invalid-month", "monthly", 86_400),
missing_duration,
]);
assert_eq!(quota.usage_window(), None);
}
#[test]
fn usage_window_should_preserve_provider_order_for_equal_periods() {
let quota = usage_quota(vec![
usage_window("first-week", "shortTerm", 7 * 86_400),
usage_window("second-week", "shortTerm", 7 * 86_400),
]);
assert_eq!(
selected_usage_window(&quota),
Some(("first-week", AccountUsagePeriod::Weekly))
);
}
fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePeriod)> {
quota
.usage_window()
.map(|(window, period)| (window.key.as_str(), period))
}
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows,
limit_reached: false,
provider_data: None,
}
}
fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
ProviderQuotaWindow {
key: key.to_owned(),
group: group.to_owned(),
label: key.to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(seconds),
used_percent: Some(50.0),
reset_at: Some(chrono::Utc::now()),
limit_reached: false,
local_usage: Some(gateway_admin::model::accounts::AccountUsage {
account_id: "acct_test".to_owned(),
request_count: 0,
success_count: 0,
input_tokens: None,
output_tokens: None,
cached_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
image_input_tokens: None,
image_output_tokens: None,
image_request_count: 0,
image_request_failed_count: 0,
total_tokens: None,
cost_coverage: Default::default(),
costs: Vec::new(),
last_used_at: None,
request_buckets: Vec::new(),
models: Vec::new(),
}),
provider_data: None,
}
}
#[test]
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
let mut quota = ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -96,7 +220,21 @@ fn pending_mutation_v1_should_round_trip_all_targets_and_owners() {
PendingAuthorizationMutation::from_storage_v1(Value::Object(
expected.to_storage_v1()
)),
Ok(expected),
Ok(expected.clone()),
);
let with_proxy = expected
.with_outbound_proxy(Some(
gateway_core::account::OutboundProxy::parse(
"http://user:secret@proxy.example:8080",
)
.unwrap(),
))
.with_outbound_proxy_id(Some("proxy_saved".to_owned()));
assert_eq!(
PendingAuthorizationMutation::from_storage_v1(Value::Object(
with_proxy.to_storage_v1()
)),
Ok(with_proxy)
);
}
}
@@ -105,6 +105,18 @@ impl FakeProviderAdmin {
Some(ProviderAdminError::new(kind).with_message(message));
}
pub(super) fn fail_next_with_public_message(
&self,
kind: ProviderAdminErrorKind,
message: &'static str,
) {
*self.failure.lock().expect("provider failure") = Some(
ProviderAdminError::new(kind)
.with_message("upstream body containing a secret token")
.with_public_message(message),
);
}
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
*self.quota_failure.lock().expect("provider quota failure") = Some(kind);
}
@@ -201,6 +213,10 @@ impl ProviderAdmin for FakeProviderAdmin {
&self.kind
}
fn plan_type_display(&self, plan_type: &str) -> String {
format!("{} display: {plan_type}", self.kind)
}
async fn account_unavailable(&self, _: &ProviderAccountId) {
self.record("provider.account_unavailable");
}
@@ -425,6 +441,7 @@ pub(super) struct FakeAccountStore {
account_after_probe: Mutex<Option<AccountRecord>>,
fail_commit: Mutex<bool>,
audit_requests: Mutex<Vec<String>>,
import_settings: Mutex<Vec<Option<gateway_admin::model::accounts::AccountImportSettings>>>,
quota_window_usage: Mutex<Vec<AccountUsageWindowResult>>,
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
}
@@ -441,6 +458,7 @@ impl FakeAccountStore {
account_after_probe: Mutex::new(None),
fail_commit: Mutex::new(false),
audit_requests: Mutex::new(Vec::new()),
import_settings: Mutex::new(Vec::new()),
quota_window_usage: Mutex::new(Vec::new()),
quota_window_queries: Mutex::new(Vec::new()),
})
@@ -450,6 +468,15 @@ impl FakeAccountStore {
*self.fail_commit.lock().expect("store failure") = true;
}
pub(super) fn import_settings(
&self,
) -> Vec<Option<gateway_admin::model::accounts::AccountImportSettings>> {
self.import_settings
.lock()
.expect("import settings")
.clone()
}
pub(super) fn audit_requests(&self) -> Vec<String> {
self.audit_requests.lock().expect("audit requests").clone()
}
@@ -656,6 +683,10 @@ impl AccountStore for FakeAccountStore {
command: CredentialImportCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialImportResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_import");
self.record_context(context);
self.require_commit()?;
@@ -675,6 +706,10 @@ impl AccountStore for FakeAccountStore {
command: AuthorizationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.import_settings
.lock()
.expect("import settings")
.push(command.settings);
self.record("store.commit_authorization");
self.record_context(context);
self.require_commit()?;
@@ -1213,6 +1248,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
.update(
&context("update-request"),
UpdateAccount {
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
@@ -1249,6 +1285,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
.update(
&context("update-failure"),
UpdateAccount {
outbound_proxy: None,
account_id: "acct_test".to_owned(),
enabled: false,
concurrency_limit: None,
@@ -1290,6 +1327,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
.batch_update(
&context("batch-update-request"),
BatchUpdateAccounts {
outbound_proxy: None,
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
enabled: false,
concurrency_limit: None,
@@ -1320,9 +1358,11 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
#[tokio::test]
async fn accounts_list_should_return_complete_directory_semantics() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
let page = accounts_service(provider, store)
.await
let mut stored = account_record("openai");
stored.plan_type = Some(" self_serve_business_prolite ".to_owned());
let store = FakeAccountStore::with_account(stored, events());
let services = accounts_service(provider, store).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
@@ -1344,6 +1384,91 @@ async fn accounts_list_should_return_complete_directory_semantics() {
account.projection.status,
gateway_admin::model::accounts::AccountStatus::Normal
);
assert_eq!(
(
account.account.plan_type.as_deref(),
account.plan_type_display.as_deref(),
),
(
Some(" self_serve_business_prolite "),
Some("OpenaiDisplaySelfServeBusinessProlite"),
)
);
let detail = services
.accounts()
.quota(
&ProviderAccountId::new("acct_test").expect("account ID"),
false,
)
.await
.expect("account detail");
assert_eq!(detail.plan_type_display, account.plan_type_display);
assert_eq!(detail.account.plan_type, account.account.plan_type);
}
#[tokio::test]
async fn accounts_should_fill_missing_plan_from_quota_without_overriding_known_subtypes() {
for (stored_plan, quota_plan, expected, expected_display) in [
(None, Some("free"), Some("free"), Some("OpenaiDisplayFree")),
(
Some(" "),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("unknown"),
Some("free"),
Some("free"),
Some("OpenaiDisplayFree"),
),
(
Some("self_serve_business_prolite"),
Some("team"),
Some("self_serve_business_prolite"),
Some("OpenaiDisplaySelfServeBusinessProlite"),
),
(None, None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: quota_plan.map(str::to_owned),
..empty_quota()
});
let mut stored = account_record("openai");
stored.plan_type = stored_plan.map(str::to_owned);
let store = FakeAccountStore::with_account(stored, events());
let services = accounts_service(provider, store).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.expect("account list");
let account = page.items.first().expect("account item");
assert_eq!(account.account.plan_type.as_deref(), expected);
assert_eq!(account.plan_type_display.as_deref(), expected_display);
for refresh in [false, true] {
let detail = services
.accounts()
.quota(
&ProviderAccountId::new("acct_test").expect("account ID"),
refresh,
)
.await
.expect("account quota detail");
assert_eq!(detail.account.plan_type.as_deref(), expected);
assert_eq!(detail.plan_type_display, account.plan_type_display);
}
}
}
#[tokio::test]
@@ -1351,6 +1476,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
let events = events();
let openai = FakeProviderAdmin::new("openai", events.clone());
openai.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1482,6 +1608,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1529,6 +1656,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1577,6 +1705,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::hours(1);
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![ProviderQuotaWindow {
@@ -1625,10 +1754,8 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
.as_ref()
.expect("quota window local usage");
assert_eq!(usage.total_tokens, Some(4_330_000));
assert_eq!(
item.usage.as_ref().and_then(|usage| usage.total_tokens),
Some(4_330_000),
);
// 短期额度条保留自己的本地用量,但不作为周/月统计面板的回退。
assert!(item.usage.is_none());
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
@@ -1638,11 +1765,104 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
assert_eq!(queries[0].range.end, reset_at);
}
#[tokio::test]
async fn accounts_list_and_quota_refresh_should_select_the_same_weekly_or_monthly_usage() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").expect("account id");
let reset_at = Utc::now() + TimeDelta::days(1);
let short = ProviderQuotaWindow {
key: "short".to_owned(),
group: "shortTerm".to_owned(),
label: "5小时限额".to_owned(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(18_000),
used_percent: Some(50.0),
reset_at: Some(reset_at),
limit_reached: false,
local_usage: None,
provider_data: None,
};
let week = ProviderQuotaWindow {
key: "week".to_owned(),
label: "周额度".to_owned(),
window_seconds: Some(7 * 86_400),
..short.clone()
};
let month = ProviderQuotaWindow {
key: "month".to_owned(),
group: "monthly".to_owned(),
label: "月额度".to_owned(),
window_seconds: Some(30 * 86_400),
..short.clone()
};
for (windows, selected_key, tokens, duration) in [
(vec![short.clone(), month.clone(), week], "week", 700, 7),
(vec![short, month], "month", 3000, 30),
] {
provider.set_quota(ProviderQuota {
windows,
..empty_quota()
});
store.set_quota_window_usage(
[("short", 50), ("week", 700), ("month", 3000)]
.into_iter()
.map(|(key, total)| AccountUsageWindowResult {
account_id: account_id.to_string(),
key: key.to_owned(),
usage: quota_local_usage(account_id.as_str(), total),
})
.collect(),
);
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).expect("page size"),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.expect("list accounts");
let refreshed = services
.accounts()
.quota(&account_id, true)
.await
.expect("refresh quota");
let expected = quota_local_usage(account_id.as_str(), tokens);
for item in [&page.items[0], &refreshed] {
let usage = item.usage.as_ref().expect("selected window usage");
assert_eq!(usage.total_tokens, expected.total_tokens);
assert_eq!(
item.quota
.usage_window()
.map(|(window, _)| window.key.as_str()),
Some(selected_key)
);
}
let queries = store.quota_window_queries();
let selected = queries
.iter()
.find(|query| query.key == selected_key)
.expect("selected query");
assert_eq!(selected.range.start, reset_at - TimeDelta::days(duration));
assert_eq!(selected.range.end, reset_at);
}
}
#[tokio::test]
async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let reset_at = Utc::now() + TimeDelta::days(7);
provider.set_quota(ProviderQuota {
plan_type: None,
observed_at: Some(Utc::now()),
refresh_token_expires_at: None,
windows: vec![
@@ -1897,6 +2117,7 @@ pub(super) fn document() -> ProviderDocument {
fn empty_quota() -> ProviderQuota {
ProviderQuota {
plan_type: None,
observed_at: None,
refresh_token_expires_at: None,
windows: Vec::new(),
@@ -1931,6 +2152,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: None,
id: "acct_test".to_owned(),
provider_kind: ProviderKind::new(kind).expect("provider kind"),
groups: Vec::new(),
@@ -1972,6 +2194,7 @@ fn prepared_create_with_id(
) -> PreparedCredentialCreate {
let now = Utc::now();
PreparedCredentialCreate {
outbound_proxy: None,
account_id: ProviderAccountId::new(account_id).expect("prepared account ID"),
provider_kind,
name: name.to_owned(),
@@ -2098,3 +2321,17 @@ fn store_unavailable() -> AdminStoreError {
fn unsupported() -> ProviderAdminError {
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
}
pub(super) fn import_settings() -> gateway_admin::model::accounts::AccountImportSettings {
gateway_admin::model::accounts::AccountImportSettings {
enabled: false,
concurrency_limit: Some(
gateway_core::account::AccountConcurrencyLimit::new(3).expect("concurrency"),
),
weight: gateway_core::account::AccountWeight::new(7).expect("weight"),
group_ids: vec![
gateway_core::routing::AccountGroupId::new("grp_00000000000000000000000000000091")
.expect("group ID"),
],
}
}
@@ -5,6 +5,7 @@ mod backup;
mod client_keys;
mod observability;
mod openai;
mod proxies;
mod settings;
mod system;
mod xai;
@@ -82,6 +83,7 @@ pub(super) struct AdminHarness {
default_password: String,
session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
@@ -101,6 +103,7 @@ impl AdminHarness {
default_password: "strong-test-password".to_owned(),
session_ttl_minutes: 60,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
@@ -179,6 +182,14 @@ impl AdminHarness {
self
}
pub(super) fn proxies(
mut self,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
) -> Self {
self.proxies = proxies;
self
}
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
self.system = system;
self
@@ -196,6 +207,7 @@ impl AdminHarness {
self.accounts,
self.account_runtime,
self.account_groups,
self.proxies,
),
self.auth,
self.client_keys,
@@ -205,7 +217,7 @@ impl AdminHarness {
),
self.providers,
Arc::new(NoopSnapshot),
self.probe,
(self.probe, Arc::new(proxies::TestProxies::default())),
Arc::new(NoopClientDistribution),
self.system,
)
@@ -50,6 +50,8 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-openai"),
document: document(),
})
@@ -75,9 +77,52 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-openai"]);
}
#[tokio::test]
async fn openai_import_should_expose_only_explicit_public_errors_without_committing() {
use gateway_admin::model::AdminErrorKind;
for (provider_kind, admin_kind) in [
(ProviderAdminErrorKind::Invalid, AdminErrorKind::Invalid),
(
ProviderAdminErrorKind::Unavailable,
AdminErrorKind::Unavailable,
),
(
ProviderAdminErrorKind::BadGateway,
AdminErrorKind::BadGateway,
),
] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
provider
.fail_next_with_public_message(provider_kind, "Codex PAT 验证失败,请检查令牌后重试");
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
let error = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-pat-failure"),
document: document(),
})
.await
.expect_err("validation must fail before committing accounts");
assert_eq!(error.kind(), admin_kind);
assert_eq!(error.to_string(), "Codex PAT 验证失败,请检查令牌后重试");
assert!(!format!("{error:?}").contains("secret token"));
assert_eq!(recorded(&events), ["provider.prepare_import"]);
assert!(store.audit_requests().is_empty());
}
}
#[tokio::test]
async fn openai_import_should_refresh_quota_for_every_imported_account() {
let events = events();
@@ -89,6 +134,8 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-batch"),
document: document(),
})
@@ -132,6 +179,8 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-quota-failure"),
document: document(),
})
@@ -157,6 +206,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-create"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -166,6 +216,7 @@ async fn openai_authorization_create_should_observe_initial_quota() {
services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("oauth-start-openai-create"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -193,6 +244,10 @@ async fn openai_authorization_create_should_observe_initial_quota() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
}
@@ -207,6 +262,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai-quota-failure"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -216,6 +272,7 @@ async fn openai_authorization_create_should_remain_successful_when_initial_quota
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-start-openai-quota-failure"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -238,6 +295,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
store.fail_next_commit();
let services = service(provider.clone(), store).await;
let command = || CompleteAuthorization {
settings: None,
context: context("oauth-store-retry"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -246,6 +304,7 @@ async fn openai_authorization_store_failure_should_release_claim_for_retry() {
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-store-retry"),
name: "new OpenAI credential".to_owned(),
reauthorization: None,
@@ -293,6 +352,8 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
services
.openai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-openai-error"),
document: document(),
})
@@ -311,6 +372,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("oauth-start-openai"),
name: "reauthorize".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
@@ -336,6 +398,7 @@ async fn openai_reauthorization_should_commit_after_credential_revision_advances
let result = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: None,
context: context("oauth-complete-openai"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
@@ -387,3 +450,34 @@ fn deletion(account_id: &str) -> CredentialDeletion {
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
}
}
#[tokio::test]
async fn reauthorization_with_import_settings_releases_claim_without_committing() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events.clone());
let services = service(provider, store.clone()).await;
services
.openai()
.start_authorization(StartAuthorization {
outbound_proxy: None,
context: context("reauthorization-settings"),
name: "existing account".to_owned(),
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
})
.await
.expect("start reauthorization");
let error = services
.openai()
.complete_authorization(CompleteAuthorization {
settings: Some(super::accounts::import_settings()),
context: context("reauthorization-settings"),
flow_id: "flow-test".to_owned(),
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
})
.await
.expect_err("reauthorization must preserve settings");
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Invalid);
assert!(!recorded(&events).contains(&"store.commit_authorization"));
assert!(store.import_settings().is_empty());
}
@@ -0,0 +1,227 @@
use async_trait::async_trait;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::AdminStoreResult,
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
#[derive(Default)]
pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
}
struct ImportGuard(super::accounts::EventLog);
impl gateway_admin::ports::proxy::ProxyImportGuard for ImportGuard {}
impl Drop for ImportGuard {
fn drop(&mut self) {
self.0.lock().unwrap().push("proxy.release");
}
}
#[async_trait]
impl ProxyStore for TestProxies {
async fn remove_account(
&self,
_: &str,
_: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn reserve_import(
&self,
id: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
let events = self
.events
.as_ref()
.ok_or_else(|| super::unavailable("proxy"))?;
events.lock().unwrap().push("proxy.reserve");
Ok(gateway_admin::ports::proxy::ProxyImportReservation {
binding: ImportProxyBinding {
id: id.to_owned(),
proxy: OutboundProxy::parse("http://127.0.0.1:8080").unwrap(),
},
guard: Box::new(ImportGuard(events.clone())),
})
}
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
Err(super::unavailable("proxy"))
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
let items = self
.accounts
.clone()
.ok_or_else(|| super::unavailable("proxy"))?;
Ok(ProxyAccountPage {
total: items.len() as u64,
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
Err(super::unavailable("proxy"))
}
async fn record_test(
&self,
_: &str,
_: Revision,
_: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
Err(super::unavailable("proxy"))
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: &OutboundProxy) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
#[tokio::test]
async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
use super::accounts::{FakeProviderAdmin, events};
use gateway_admin::model::{PageSize, provider_credentials::ProviderQuota};
use std::sync::Arc;
for (stored, cached, expected) in [
(None, Some("free"), Some("free")),
(Some("unknown"), Some("free"), Some("free")),
(Some("plus"), Some("free"), Some("plus")),
(None, None, None),
] {
let provider = FakeProviderAdmin::new("openai", events());
provider.set_quota(ProviderQuota {
plan_type: cached.map(str::to_owned),
observed_at: None,
refresh_token_expires_at: None,
windows: vec![],
limit_reached: false,
provider_data: None,
});
let services = super::AdminHarness::new()
.provider(provider.clone())
.proxies(Arc::new(TestProxies {
accounts: Some(vec![ProxyAccountRef {
id: "acct_plan".to_owned(),
name: "套餐测试".to_owned(),
email: None,
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: stored.map(str::to_owned),
plan_type_display: None,
groups: vec![],
enabled: true,
}]),
..Default::default()
}))
.build()
.await;
let result = services
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: "proxy_plan".to_owned(),
page: 1,
page_size: PageSize::new(20).unwrap(),
search: String::new(),
})
.await
.unwrap();
assert_eq!(result.items[0].plan_type.as_deref(), expected);
assert_eq!(
result.items[0].plan_type_display.as_deref(),
match expected {
Some("free") => Some("OpenaiDisplayFree"),
Some("plus") => Some("OpenaiDisplayPlus"),
_ => None,
}
);
let requests = provider.quota_requests();
assert_eq!(requests.len(), usize::from(stored != Some("plus")));
assert!(requests.iter().all(|request| !request.refresh));
}
}
#[tokio::test]
async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_errors() {
use super::accounts::{
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded,
};
use gateway_admin::model::provider_credentials::ImportCredentials;
use gateway_admin::ports::provider::ProviderAdminErrorKind;
use std::sync::Arc;
for kind in ["openai", "xai"] {
for failure in [None, Some("prepare"), Some("commit")] {
let events = events();
let provider = FakeProviderAdmin::new(kind, events.clone());
let store = FakeAccountStore::new(kind, events.clone());
if failure == Some("prepare") {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
if failure == Some("commit") {
store.fail_next_commit();
}
let services = super::AdminHarness::new()
.provider(provider)
.accounts(store)
.proxies(Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
let command = ImportCredentials {
outbound_proxy_id: Some("proxy_import".to_owned()),
settings: None,
context: context("reserved-import"),
document: document(),
};
let result = if kind == "openai" {
services.openai().import_document(command).await
} else {
services.xai().import_document(command).await
};
assert_eq!(result.is_err(), failure.is_some());
let events = recorded(&events);
let expected = if failure == Some("prepare") {
vec!["proxy.reserve", "provider.prepare_import", "proxy.release"]
} else {
vec![
"proxy.reserve",
"provider.prepare_import",
"store.commit_import",
"proxy.release",
]
};
assert_eq!(&events[..expected.len()], expected);
}
}
}
@@ -20,6 +20,8 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: Some(super::accounts::import_settings()),
context: context("import-xai"),
document: document(),
})
@@ -42,6 +44,10 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
rolling_usage: None,
}]
);
assert_eq!(
store.import_settings(),
[Some(super::accounts::import_settings())]
);
assert_eq!(store.audit_requests(), ["import-xai"]);
}
@@ -56,6 +62,8 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-batch"),
document: document(),
})
@@ -90,6 +98,8 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
let result = services
.xai()
.import_document(ImportCredentials {
outbound_proxy_id: None,
settings: None,
context: context("import-xai-quota-failure"),
document: document(),
})
+3 -1
View File
@@ -2,7 +2,7 @@
name = "gateway-api"
version = "0.1.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
autotests = false
@@ -16,6 +16,7 @@ axum = { version = "0.8.9", default-features = false, features = ["json", "query
base64 = "0.23.0"
bytes = "1.11.1"
chrono = { version = "0.4.45", features = ["serde"] }
flate2 = "1.1.9"
futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
@@ -29,6 +30,7 @@ tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeo
tracing = "0.1.44"
url = "2.5.8"
uuid = { version = "1.23.3", features = ["v7"] }
zstd = "=0.13.3"
[dev-dependencies]
async-trait = "0.1.89"
@@ -18,16 +18,55 @@ impl AccountProvider {
}
}
/// 导入统一设置,复用编辑账号的调度和分组约束。
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportSettingsRequest {
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
pub concurrency_limit: Option<u64>,
pub weight: u64,
pub group_ids: Vec<String>,
}
impl AccountImportSettingsRequest {
fn validate(&self) -> Result<(), WireValidationError> {
parse_concurrency_limit(self.concurrency_limit)?;
parse_account_weight(self.weight)?;
validate_wire_group_ids(&self.group_ids)?;
Ok(())
}
fn into_settings(
self,
) -> Result<gateway_admin::model::accounts::AccountImportSettings, WireValidationError> {
Ok(gateway_admin::model::accounts::AccountImportSettings {
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
weight: parse_account_weight(self.weight)?,
group_ids: validate_wire_group_ids(&self.group_ids)?,
})
}
}
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountImportRequest {
pub outbound_proxy_id: Option<String>,
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub data: Value,
}
impl AccountImportRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(id) = &self.outbound_proxy_id {
require_wire_id(id, "outboundProxyId")?;
}
if let Some(settings) = &self.settings {
settings.validate()?;
}
AccountProvider::parse(&self.provider)?;
if !self.data.is_object()
|| serde_json::to_vec(&self.data)
@@ -47,6 +86,11 @@ impl AccountImportRequest {
Ok((
provider,
ImportCredentials {
outbound_proxy_id: self.outbound_proxy_id,
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
document: provider_document(self.data, "data")?,
},
@@ -57,9 +101,11 @@ impl AccountImportRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StartAccountAuthorizationRequest {
pub outbound_proxy_id: Option<String>,
pub provider: String,
pub name: String,
pub account_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
}
impl StartAccountAuthorizationRequest {
@@ -89,6 +135,10 @@ impl StartAccountAuthorizationRequest {
context,
name: self.name,
reauthorization,
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
},
))
}
@@ -97,6 +147,7 @@ impl StartAccountAuthorizationRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CompleteAccountAuthorizationRequest {
pub settings: Option<AccountImportSettingsRequest>,
pub provider: String,
pub flow_id: String,
pub callback_url: String,
@@ -104,6 +155,9 @@ pub struct CompleteAccountAuthorizationRequest {
impl CompleteAccountAuthorizationRequest {
pub fn validate(&self) -> Result<(), WireValidationError> {
if let Some(settings) = &self.settings {
settings.validate()?;
}
let provider = AccountProvider::parse(&self.provider)?;
match provider {
AccountProvider::OpenAi => {
@@ -128,6 +182,10 @@ impl CompleteAccountAuthorizationRequest {
Ok((
provider,
CompleteAuthorization {
settings: self
.settings
.map(AccountImportSettingsRequest::into_settings)
.transpose()?,
context,
flow_id: self.flow_id,
callback_url: self.callback_url,
@@ -139,6 +197,8 @@ impl CompleteAccountAuthorizationRequest {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateAccountRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<super::wire::AccountProxyUpdate>,
pub account_id: String,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -159,6 +219,10 @@ impl UpdateAccountRequest {
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
self.validate()?;
Ok(UpdateAccount {
outbound_proxy: super::wire::proxy_selection(
self.outbound_proxy_id,
self.outbound_proxy_url,
)?,
account_id: self.account_id,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -26,13 +26,13 @@ use gateway_admin::model::{
},
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, RotateCredential, StartAuthorization,
AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -59,6 +59,7 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
let AccountDirectoryItem {
account,
plan_type_display,
projection,
usage,
quota,
@@ -68,6 +69,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
AccountView {
id: account.id.clone(),
@@ -89,6 +91,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
user_id: account.upstream_user_id,
label: None,
plan_type: account.plan_type,
plan_type_display: plan_type_display.unwrap_or_else(|| "未知套餐".to_owned()),
authentication_kind: account.authentication_kind,
has_refresh_token: account.has_refresh_token,
status,
@@ -99,6 +102,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
enabled: account.enabled,
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
weight: account.weight.get(),
outbound_proxy_endpoint: account
.outbound_proxy
.as_ref()
.map(|proxy| proxy.endpoint()),
access_token_expires_at: expires_at,
access_token_expires_at_display: account
.access_token_expires_at
@@ -111,7 +118,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, now),
usage: account_usage_view(usage, usage_period, now),
}
}
@@ -205,6 +212,7 @@ pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
pub(super) fn account_usage_view(
usage: Option<AccountUsage>,
period: Option<AccountUsagePeriod>,
now: DateTime<Utc>,
) -> AccountUsageView {
let Some(usage) = usage else {
@@ -222,6 +230,12 @@ pub(super) fn account_usage_view(
"known"
};
AccountUsageView {
window_label_display: match period {
Some(AccountUsagePeriod::Weekly) => "周额度窗口",
Some(AccountUsagePeriod::Monthly) => "月额度窗口",
None => "周/月额度窗口",
}
.to_owned(),
request_count: Some(usage.request_count),
request_count_display: format_number(usage.request_count),
input_tokens: usage.input_tokens,
@@ -341,6 +355,7 @@ pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
pub(super) fn empty_account_usage() -> AccountUsageView {
AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
request_count: None,
request_count_display: "—".to_owned(),
input_tokens: None,
@@ -2,6 +2,48 @@
use super::*;
#[derive(Debug, Clone)]
pub struct AccountProxyUpdate(pub Option<gateway_core::account::OutboundProxy>);
impl<'de> Deserialize<'de> for AccountProxyUpdate {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
if value.is_empty() {
return Ok(Self(None));
}
gateway_core::account::OutboundProxy::parse(&value)
.map(|proxy| Self(Some(proxy)))
.map_err(serde::de::Error::custom)
}
}
pub(super) fn proxy_selection(
id: Option<String>,
url: Option<AccountProxyUpdate>,
) -> Result<Option<gateway_admin::model::proxies::AccountProxySelection>, WireValidationError> {
use gateway_admin::model::proxies::AccountProxySelection;
match (id, url) {
(Some(_), Some(_)) => Err(WireValidationError::new("outboundProxyId")),
(Some(id), None) if id.is_empty() => Ok(Some(AccountProxySelection::Direct)),
(Some(id), None) => {
if id.len() > 128
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(WireValidationError::new("outboundProxyId"));
}
Ok(Some(AccountProxySelection::Saved(id)))
}
(None, Some(value)) => Ok(Some(
value
.0
.map_or(AccountProxySelection::Direct, AccountProxySelection::Url),
)),
(None, None) => Ok(None),
}
}
/// 账号列表查询参数。
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -19,6 +61,8 @@ pub struct ListQuery {
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct BatchUpdateAccountsRequest {
pub outbound_proxy_id: Option<String>,
pub outbound_proxy_url: Option<AccountProxyUpdate>,
pub account_ids: Vec<String>,
pub enabled: bool,
#[serde(deserialize_with = "deserialize_required_nullable")]
@@ -68,6 +112,7 @@ impl BatchUpdateAccountsRequest {
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
self.validate()?;
Ok(BatchUpdateAccounts {
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
account_ids: self.account_ids,
enabled: self.enabled,
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
@@ -192,6 +237,7 @@ pub struct AccountSummaryView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountView {
pub outbound_proxy_endpoint: Option<String>,
pub id: String,
pub name: String,
pub provider: String,
@@ -202,6 +248,8 @@ pub struct AccountView {
pub user_id: Option<String>,
pub label: Option<String>,
pub plan_type: Option<String>,
/// 后端生成的套餐展示名称;缺失套餐时为“未知套餐”。
pub plan_type_display: String,
pub authentication_kind: String,
pub has_refresh_token: bool,
pub status: String,
@@ -268,6 +316,7 @@ pub struct AccountQuotaWindowView {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountUsageView {
pub window_label_display: String,
pub request_count: Option<u64>,
pub request_count_display: String,
pub input_tokens: Option<u64>,
@@ -11,6 +11,8 @@ use gateway_admin::model::client_keys::{
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
};
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
routing::AccountGroupId,
};
@@ -33,6 +35,15 @@ const MAX_CURSOR_BYTES: usize = 512;
const MAX_SEARCH_BYTES: usize = 256;
const DEFAULT_PAGE_SIZE: u16 = 50;
fn parse_budget(
value: Option<String>,
field: &'static str,
) -> Result<Option<Decimal>, WireValidationError> {
value
.map(|value| value.parse().map_err(|_| WireValidationError::new(field)))
.transpose()
}
/// Client Key 列表查询。
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -140,6 +151,8 @@ pub struct CreateClientKeyRequest {
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: Option<String>,
weekly_limit_usd: Option<String>,
}
impl CreateClientKeyRequest {
@@ -154,6 +167,11 @@ impl CreateClientKeyRequest {
name: self.name,
label: self.label,
group_ids,
budget: ClientBudgetLimits {
daily_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?.unwrap_or_default(),
weekly_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?
.unwrap_or_default(),
},
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -172,6 +190,8 @@ pub struct UpdateClientKeyRequest {
group_ids: Vec<String>,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: Option<String>,
weekly_limit_usd: Option<String>,
}
impl UpdateClientKeyRequest {
@@ -188,6 +208,8 @@ impl UpdateClientKeyRequest {
name: self.name,
label: self.label,
group_ids,
daily_limit_usd: parse_budget(self.daily_limit_usd, "dailyLimitUsd")?,
weekly_limit_usd: parse_budget(self.weekly_limit_usd, "weeklyLimitUsd")?,
limits: RateLimits {
max_concurrency: self.max_concurrency,
requests_per_minute: self.requests_per_minute,
@@ -247,6 +269,12 @@ pub struct ClientKeyView {
enabled: bool,
max_concurrency: u64,
requests_per_minute: u64,
daily_limit_usd: String,
weekly_limit_usd: String,
daily_used_usd: String,
weekly_used_usd: String,
daily_resets_at: Option<DateTime<Utc>>,
weekly_resets_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
@@ -292,6 +320,12 @@ impl From<ClientKeyRecord> for ClientKeyView {
enabled: record.enabled,
max_concurrency: record.limits.max_concurrency,
requests_per_minute: record.limits.requests_per_minute,
daily_limit_usd: record.budget.limits.daily_usd.canonical(),
weekly_limit_usd: record.budget.limits.weekly_usd.canonical(),
daily_used_usd: record.budget.daily_used_usd.canonical(),
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
created_at: record.created_at,
updated_at: record.updated_at,
last_used_at: record.last_used_at,
@@ -16,6 +16,7 @@ pub mod client_keys;
mod extract;
pub mod observability;
pub mod presenter;
pub mod proxies;
pub mod settings;
pub mod system;
pub mod wire;
@@ -34,6 +35,7 @@ where
{
Router::new()
.merge(account_groups::router::<S>())
.merge(proxies::router::<S>())
.merge(accounts::router::<S>())
.merge(auth::router::<S>())
.merge(backups::router::<S>())
@@ -900,6 +900,10 @@ pub(crate) fn dashboard_view(
.clone()
.unwrap_or_else(|| credential.name.clone()),
plan_type: credential.plan_type.clone(),
plan_type_display: credential
.plan_type_display
.clone()
.unwrap_or_else(|| "未知套餐".to_owned()),
tokens: credential
.total_tokens
.map_or_else(|| "—".to_owned(), format_compact_number),
@@ -373,6 +373,7 @@ pub struct DashboardAccountUsageView {
pub authentication_kind: String,
pub email: String,
pub plan_type: Option<String>,
pub plan_type_display: String,
pub tokens: String,
pub request_count: u64,
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
@@ -0,0 +1,452 @@
use axum::{
Router,
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{get, post},
};
use gateway_admin::model::{
PageSize, Revision,
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
ProxyTestResult, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
use super::{
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
PageMeta,
accounts::{AccountGroupRefView, AccountProxyUpdate},
};
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ListQuery {
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AccountsQuery {
proxy_id: String,
page: Option<u32>,
page_size: Option<u16>,
search: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RemoveAccountRequest {
proxy_id: String,
account_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
name: String,
proxy_url: AccountProxyUpdate,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
id: String,
revision: u64,
name: String,
proxy_url: Option<AccountProxyUpdate>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct IdRequest {
id: String,
revision: u64,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProbeRequest {
proxy_url: AccountProxyUpdate,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyTestView {
success: bool,
latency_ms: u64,
exit_ip: Option<String>,
message: String,
}
impl From<ProxyTestResult> for ProxyTestView {
fn from(result: ProxyTestResult) -> Self {
Self {
success: result.success,
latency_ms: result.latency_ms,
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
message: result.message,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyAccountView {
id: String,
name: String,
email: Option<String>,
provider: String,
authentication_kind: String,
plan_type: Option<String>,
plan_type_display: Option<String>,
groups: Vec<AccountGroupRefView>,
enabled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
id: String,
name: String,
endpoint: String,
has_authentication: bool,
revision: u64,
account_count: u64,
last_test_at: Option<String>,
last_test: Option<ProxyTestView>,
created_at: String,
updated_at: String,
}
impl From<ProxyRecord> for ProxyView {
fn from(record: ProxyRecord) -> Self {
let endpoint = record.proxy.endpoint();
Self {
id: record.id,
name: record.name,
has_authentication: record.proxy.expose_url() != endpoint,
endpoint,
revision: record.revision.get(),
account_count: record.account_count,
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
last_test: record.last_test.map(Into::into),
created_at: record.created_at.to_rfc3339(),
updated_at: record.updated_at.to_rfc3339(),
}
}
}
#[derive(Serialize)]
struct ProxyPageView {
items: Vec<ProxyView>,
page: PageMeta,
}
#[derive(Serialize)]
struct ProxyAccountPageView {
items: Vec<ProxyAccountView>,
page: PageMeta,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MutationView {
record: ProxyView,
config_revision: u64,
}
impl From<ProxyMutation> for MutationView {
fn from(value: ProxyMutation) -> Self {
Self {
record: value.record.into(),
config_revision: value.config_revision.get(),
}
}
}
pub fn router<S>() -> Router<S>
where
S: AdminSessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/admin/proxies", get(list::<S>))
.route("/api/admin/proxies/accounts", get(list_accounts::<S>))
.route(
"/api/admin/proxies/accounts/remove",
post(remove_account::<S>),
)
.route("/api/admin/proxies/create", post(create::<S>))
.route("/api/admin/proxies/update", post(update::<S>))
.route("/api/admin/proxies/delete", post(delete::<S>))
.route("/api/admin/proxies/test", post(test::<S>))
.route("/api/admin/proxies/probe", post(probe::<S>))
}
fn revision(value: u64) -> Result<Revision, AdminError> {
if value > i64::MAX as u64 {
return Err(AdminError::bad_request("代理版本不合法"));
}
Revision::new(value).map_err(|_| AdminError::bad_request("代理版本不合法"))
}
fn map_error(error: gateway_admin::model::AdminError) -> AdminError {
super::wire::map_admin_service_error(error)
}
async fn list<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<ListQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list(ProxyListQuery {
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyPageView {
items: result.items.into_iter().map(Into::into).collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn list_accounts<S>(
_: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountsQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.list_accounts(ProxyAccountListQuery {
proxy_id: query.proxy_id,
page: query.page.unwrap_or(1),
page_size: PageSize::new(query.page_size.unwrap_or(20))
.map_err(|_| AdminError::bad_request("分页大小不合法"))?,
search: query.search.unwrap_or_default().trim().to_owned(),
})
.await
.map_err(map_error)?;
let total_pages = result.total.div_ceil(u64::from(result.page_size));
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyAccountPageView {
items: result
.items
.into_iter()
.map(|account| ProxyAccountView {
id: account.id,
name: account.name,
email: account.email,
provider: account.provider_kind,
authentication_kind: account.authentication_kind,
plan_type: account.plan_type,
plan_type_display: account.plan_type_display,
groups: account
.groups
.into_iter()
.map(|group| AccountGroupRefView {
id: group.id.to_string(),
name: group.name,
color: group.color.as_str().to_owned(),
enabled: group.enabled,
})
.collect(),
enabled: account.enabled,
})
.collect(),
page: PageMeta::new(
result.page,
u32::from(result.page_size),
result.total,
u32::try_from(total_pages).unwrap_or(u32::MAX),
),
}),
))
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.create(
NewProxy {
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn remove_account<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<RemoveAccountRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let revision = state
.admin_services()
.proxies()
.remove_account(
&request.proxy_id,
&request.account_id,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": revision.get()})),
))
}
async fn update<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<UpdateRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.map(|value| {
value
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))
})
.transpose()?;
let result = state
.admin_services()
.proxies()
.update(
UpdateProxy {
id: request.id,
revision: revision(request.revision)?,
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(MutationView::from(result)),
))
}
async fn delete<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.delete(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({"configRevision": result.get()})),
))
}
async fn probe<S>(
_: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<ProbeRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.probe(&proxy)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyTestView::from(result)),
))
}
async fn test<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<IdRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: AdminSessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.test(
&request.id,
revision(request.revision)?,
&auth.context().mutation_context(),
)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from(result)),
))
}
+16 -2
View File
@@ -143,13 +143,27 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
/// Gateway 错误的 OpenAI HTTP 表达。
pub fn gateway_error_response(error: &GatewayError) -> Response {
let (status, default_type, default_code) = gateway_error_contract(error.kind());
openai_error_response(
let mut response = openai_error_response(
status,
error.client_message(),
error.client_error_type().unwrap_or(default_type),
error.client_error_code().unwrap_or(default_code),
)
.into_response()
.into_response();
if let Some(delay) = error.retry_after()
&& let Ok(value) = HeaderValue::from_str(
&delay
.as_secs()
.saturating_add(u64::from(delay.subsec_nanos() > 0))
.max(1)
.to_string(),
)
{
response
.headers_mut()
.insert(axum::http::header::RETRY_AFTER, value);
}
response
}
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
@@ -40,6 +40,15 @@ pub enum RequestDecodeError {
/// 请求不是合法 JSON。
#[error("request body must be valid JSON")]
MalformedJson,
/// 请求体解压后超过允许上限。
#[error("decompressed request body is too large")]
DecompressedBodyTooLarge,
/// 请求使用了网关不支持的 `Content-Encoding`。
#[error("request content-encoding `{encoding}` is not supported")]
UnsupportedContentEncoding {
/// Content-Encoding 标识。
encoding: String,
},
/// 顶层不是 object。
#[error("request body must be a JSON object")]
ExpectedObject,
@@ -99,6 +108,16 @@ impl RequestDecodeError {
"Request body must be valid JSON.".to_owned(),
None,
),
Self::DecompressedBodyTooLarge => (
"request_too_large",
"Decompressed request body exceeds the allowed size.".to_owned(),
None,
),
Self::UnsupportedContentEncoding { encoding } => (
"unsupported_content_encoding",
format!("Content-Encoding `{encoding}` is not supported."),
None,
),
Self::ExpectedObject => (
"invalid_request_body",
"Request body must be a JSON object.".to_owned(),
@@ -1,12 +1,13 @@
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。
use std::{fmt, net::IpAddr};
use std::{borrow::Cow, fmt, net::IpAddr};
use axum::http::HeaderMap;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
use gateway_protocol::openai::{
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
is_transport_managed_request_header,
};
use serde_json::{Map, Value};
@@ -322,12 +323,87 @@ impl fmt::Debug for DecodedResponsesRequest {
///
/// # Errors
///
/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。
/// 编码不支持、压缩正文损坏或超限、JSON 非法,或路由字段无效时返回安全错误。
pub fn decode_request_with_headers(
body: &[u8],
headers: &HeaderMap,
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
let body = decompress_request_body(body, headers)?;
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
}
/// 下游请求体解压后的最大字节数;防御解压炸弹。
const MAX_DECOMPRESSED_REQUEST_BYTES: usize = 64 * 1024 * 1024;
/// 按 `Content-Encoding` 解压下游请求体。
///
/// 未压缩与 `identity` 借用原始正文;压缩正文在读取过程中限制展开大小,
/// 避免先完整分配再检查。只接受单一编码,重复头和叠加编码不能只解释第一项。
fn decompress_request_body<'a>(
body: &'a [u8],
headers: &HeaderMap,
) -> Result<Cow<'a, [u8]>, RequestDecodeError> {
let encoding = headers
.get_all(axum::http::header::CONTENT_ENCODING)
.iter()
.map(|value| {
value
.to_str()
.map_err(|_| RequestDecodeError::MalformedJson)
})
.collect::<Result<Vec<_>, _>>()?
.join(",");
match encoding.trim().to_ascii_lowercase().as_str() {
"" | "identity" => Ok(Cow::Borrowed(body)),
"gzip" => {
let decoder = flate2::read::MultiGzDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
}
"deflate" => {
let decoder = flate2::read::ZlibDecoder::new(body);
read_bounded(decoder).map(Cow::Owned)
}
"zstd" => {
let mut decoder = zstd::stream::read::Decoder::with_buffer(body)
.map_err(|_| RequestDecodeError::MalformedJson)?;
// 输出有界之外,也限制压缩帧声明的回溯窗口,防止解码器内部过量分配。
decoder
.window_log_max(MAX_DECOMPRESSED_REQUEST_BYTES.ilog2())
.map_err(|_| RequestDecodeError::MalformedJson)?;
read_bounded(decoder).map(Cow::Owned)
}
other => Err(RequestDecodeError::UnsupportedContentEncoding {
encoding: other.to_owned(),
}),
}
}
fn read_bounded<R: std::io::Read>(mut reader: R) -> Result<Vec<u8>, RequestDecodeError> {
let mut decoded = Vec::new();
let mut chunk = [0; 8192];
loop {
let remaining = MAX_DECOMPRESSED_REQUEST_BYTES - decoded.len();
let read_limit = chunk.len().min(remaining + 1);
let read = reader
.read(&mut chunk[..read_limit])
.map_err(|_| RequestDecodeError::MalformedJson)?;
if read == 0 {
return Ok(decoded);
}
if read > remaining {
return Err(RequestDecodeError::DecompressedBodyTooLarge);
}
// 默认 Vec 扩容和 read_to_end 的 EOF 探测可能突破输出上限;
// 容量增长也受相同边界约束,越界探测只使用栈上分块缓冲区。
let required = decoded.len() + read;
if required > decoded.capacity() {
let capacity = (decoded.capacity() * 2)
.max(required)
.min(MAX_DECOMPRESSED_REQUEST_BYTES);
decoded.reserve_exact(capacity - decoded.len());
}
decoded.extend_from_slice(&chunk[..read]);
}
}
pub(super) fn decode_request_inner(
@@ -458,7 +534,7 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
if connection_headers
.iter()
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|| name.starts_with("sec-websocket-")
|| is_transport_managed_request_header(name)
|| name.starts_with("x-grok-")
|| name.starts_with("x-xai-")
{
@@ -467,30 +543,8 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
!matches!(
name,
// 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
// 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。
| "forwarded"
| "x-forwarded-for"
| "x-forwarded-host"
| "x-forwarded-proto"
| "x-forwarded-port"
| "x-real-ip"
| "true-client-ip"
| "cf-connecting-ip"
| "x-request-id"
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
| "authorization"
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
"authorization"
| "x-api-key"
// Codex 的服务端托管认证标记只用于客户端能力判断,不代表上游身份。
| "x-openai-actor-authorization"
@@ -340,6 +340,7 @@ mod response {
#[test]
fn account_usage_view_should_keep_unobserved_numbers_null() {
let view = AccountUsageView {
window_label_display: "周/月额度窗口".to_owned(),
request_count: None,
request_count_display: "-".to_owned(),
input_tokens: None,
@@ -374,6 +375,7 @@ mod response {
models: Vec::new(),
};
let value = serde_json::to_value(view).expect("serialize account usage");
assert_eq!(value["windowLabelDisplay"], "周/月额度窗口");
assert!(value["inputTokens"].is_null());
assert!(value["totalTokens"].is_null());
assert!(value["reasoningTokens"].is_null());
@@ -756,3 +758,60 @@ mod actions {
);
}
}
mod import_settings {
use gateway_api::admin::accounts::{AccountImportRequest, CompleteAccountAuthorizationRequest};
use serde_json::json;
#[test]
fn import_and_oauth_apply_the_same_settings_validation() {
for (field, value) in [
("concurrencyLimit", json!(0)),
("concurrencyLimit", json!(4_294_967_296_u64)),
("weight", json!(0)),
("weight", json!(101)),
("groupIds", json!(["invalid-group"])),
] {
let mut settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 1, "groupIds": []});
settings[field] = value;
let import: AccountImportRequest = serde_json::from_value(
json!({"provider": "openai", "data": {}, "settings": settings}),
)
.expect("import request");
let oauth: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({"provider": "xai", "flowId": "flow-test", "callbackUrl": "code", "settings": settings})).expect("OAuth request");
assert_eq!(
import.validate().expect_err("invalid settings").field(),
field
);
assert_eq!(
oauth.validate().expect_err("invalid settings").field(),
field
);
}
}
#[test]
fn import_settings_require_a_complete_explicit_configuration() {
let settings =
json!({"enabled": false, "concurrencyLimit": null, "weight": 100, "groupIds": []});
let request: AccountImportRequest =
serde_json::from_value(json!({"provider": "openai", "data": {}, "settings": settings}))
.expect("settings");
assert!(request.validate().is_ok());
for field in ["enabled", "concurrencyLimit", "weight", "groupIds"] {
let mut incomplete = settings.clone();
incomplete
.as_object_mut()
.expect("settings object")
.remove(field);
assert!(
serde_json::from_value::<AccountImportRequest>(
json!({"provider": "openai", "data": {}, "settings": incomplete})
)
.is_err(),
"missing {field}"
);
}
}
}
@@ -10,6 +10,46 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
"requestsPerMinute": 0, "dailyLimitUsd": "0.1234567891", "weeklyLimitUsd": "15"});
let command = serde_json::from_value::<CreateClientKeyRequest>(payload.clone())
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.budget.daily_usd.canonical(), "0.1234567891");
assert_eq!(command.budget.weekly_usd.canonical(), "15");
assert_eq!(command.limits.max_concurrency, 3);
for field in ["dailyLimitUsd", "weeklyLimitUsd"] {
for invalid in ["-1", "NaN", "1e3", "10000000000", "0.00000000001", ""] {
let mut payload = payload.clone();
payload[field] = json!(invalid);
assert_eq!(
serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap_err()
.field(),
field
);
}
}
let mut update = payload;
update["id"] = json!("key_budget");
update.as_object_mut().unwrap().remove("dailyLimitUsd");
update["weeklyLimitUsd"] = json!("0");
let command = serde_json::from_value::<UpdateClientKeyRequest>(update)
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.daily_limit_usd, None);
assert_eq!(
command.weekly_limit_usd,
Some(gateway_core::metering::Decimal::ZERO)
);
}
#[test]
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
@@ -207,6 +247,7 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
.single()
.expect("valid time");
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
budget: Default::default(),
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
name: "visible".to_owned(),
label: None,
@@ -238,6 +279,10 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
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"]
@@ -82,6 +82,7 @@ mod auth;
mod client_keys;
mod errors;
mod observability;
mod proxies;
mod settings;
mod system;
mod wire;
@@ -124,7 +125,12 @@ impl AdminTestFixture {
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
});
let stores = AdminStorePorts::new(
AdminAccountStorePorts::new(unused.clone(), unused.clone(), account_groups.clone()),
AdminAccountStorePorts::new(
unused.clone(),
unused.clone(),
account_groups.clone(),
Arc::new(proxies::MemoryProxies::default()),
),
auth.clone(),
client_keys.clone(),
unused,
@@ -144,7 +150,7 @@ impl AdminTestFixture {
stores,
providers,
Arc::new(NoopSnapshot),
Arc::new(NoopProbe),
(Arc::new(NoopProbe), Arc::new(proxies::SuccessfulProbe)),
Arc::new(StaticClientDistribution),
system,
)
@@ -640,6 +646,7 @@ impl ClientKeyStore for MemoryClientKeyStore {
let now = Utc::now();
Ok(Some(ClientKeySecret::new(
ClientKeyRecord {
budget: Default::default(),
id: id.clone(),
name: "revealed".to_owned(),
label: None,
@@ -64,6 +64,7 @@ fn dashboard_account_usage_should_keep_daily_request_timeline() {
authentication_kind: "oauth".to_owned(),
email: "account@example.com".to_owned(),
plan_type: Some("free".to_owned()),
plan_type_display: "Free".to_owned(),
tokens: "—".to_owned(),
request_count: 3,
request_buckets: vec![DashboardAccountRequestBucketView {
@@ -0,0 +1,520 @@
use std::sync::Mutex;
use async_trait::async_trait;
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Utc;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use serde_json::{Value, json};
use tower::ServiceExt as _;
use super::{AdminTestFixture, AdminTestState};
#[derive(Default)]
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
fn missing() -> AdminStoreError {
AdminStoreError::new(AdminStoreErrorKind::NotFound, "proxy", "missing proxy")
}
#[async_trait]
impl ProxyStore for MemoryProxies {
async fn remove_account(
&self,
proxy_id: &str,
account_id: &ProviderAccountId,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
if proxy_id != "proxy_test" || account_id.as_str() != "acct_linked" {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy account",
"changed binding",
));
}
Ok(Revision::new(4).unwrap())
}
async fn reserve_import(
&self,
_: &str,
) -> AdminStoreResult<gateway_admin::ports::proxy::ProxyImportReservation> {
Err(missing())
}
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect();
Ok(ProxyPage {
total: u64::try_from(items.len()).unwrap(),
items,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
self.0.lock().unwrap().clone().ok_or_else(missing)
}
async fn list_accounts(
&self,
query: ProxyAccountListQuery,
) -> AdminStoreResult<ProxyAccountPage> {
if !self
.0
.lock()
.unwrap()
.as_ref()
.is_some_and(|record| record.id == query.proxy_id)
{
return Err(missing());
}
Ok(ProxyAccountPage {
items: vec![ProxyAccountRef {
id: "acct_linked".to_owned(),
name: "工作账号".to_owned(),
email: Some("work@example.invalid".to_owned()),
provider_kind: "openai".to_owned(),
authentication_kind: "oauth".to_owned(),
plan_type: Some("plus".to_owned()),
plan_type_display: None,
groups: vec![],
enabled: true,
}],
total: 21,
page: query.page,
page_size: query.page_size.get(),
})
}
async fn create(
&self,
command: NewProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let record = ProxyRecord {
id: "proxy_test".to_owned(),
name: command.name,
proxy: command.proxy,
revision: Revision::new(1).unwrap(),
account_count: 0,
last_test: None,
last_test_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
*self.0.lock().unwrap() = Some(record.clone());
Ok(ProxyMutation {
config_revision: record.revision,
record,
})
}
async fn update(
&self,
command: UpdateProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.name = command.name;
if let Some(proxy) = command.proxy {
record.proxy = proxy;
}
record.revision = Revision::new(record.revision.get() + 1).unwrap();
Ok(ProxyMutation {
config_revision: record.revision,
record: record.clone(),
})
}
async fn delete(
&self,
_: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
self.0.lock().unwrap().take().ok_or_else(missing)?;
Ok(Revision::new(3).unwrap())
}
async fn record_test(
&self,
_: &str,
_: Revision,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyRecord> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
Ok(record.clone())
}
}
pub(super) struct SuccessfulProbe;
#[async_trait]
impl ProxyProbe for SuccessfulProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
ProxyTestResult {
success: true,
latency_ms: 15,
exit_ip: Some("203.0.113.2".parse().unwrap()),
message: "Connected".to_owned(),
}
}
}
async fn request(
fixture: &AdminTestFixture,
path: &str,
body: Option<Value>,
authenticated: bool,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.header("x-request-id", "req_proxy_tests")
.uri(path)
.method(if body.is_some() { "POST" } else { "GET" });
if authenticated {
builder = builder.header(header::COOKIE, "cpr_admin_session=valid-session");
}
let body = body.map_or_else(Body::empty, |body| Body::from(body.to_string()));
let response = gateway_api::admin::proxies::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
builder
.header(header::CONTENT_TYPE, "application/json")
.body(body)
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(!text.contains("private-password"));
assert!(!text.contains("test-user"));
(status, serde_json::from_slice(&body).unwrap())
}
#[tokio::test]
async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_credentials() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
"name": " Office ", "proxyUrl": "http://test-user:private-password@proxy.example:8080"
})),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["name"], "Office");
assert_eq!(created["data"]["record"]["hasAuthentication"], true);
assert_eq!(
created["data"]["record"]["endpoint"],
"http://proxy.example:8080/"
);
assert!(created["data"]["record"].get("proxyUrl").is_none());
assert_eq!(created["data"]["record"]["accountCount"], 0);
assert!(created["data"]["record"].get("accounts").is_none());
let (status, linked) = request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test&page=2&pageSize=20&search=work",
None,
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
linked["data"]["page"],
json!({"page":2,"pageSize":20,"total":21,"totalPages":2})
);
assert_eq!(
linked["data"]["items"],
json!([{
"id":"acct_linked", "name":"工作账号", "email":"work@example.invalid",
"provider":"openai", "enabled":true, "authenticationKind":"oauth",
"planType":"plus", "planTypeDisplay":"Plus", "groups":[]
}])
);
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2");
let (_, listed) = request(
&fixture,
"/api/admin/proxies?page=1&pageSize=20",
None,
true,
)
.await;
assert_eq!(listed["data"]["items"][0], tested["data"]);
let (status, _) = request(
&fixture,
"/api/admin/proxies/update",
Some(json!({"id": "proxy_test", "revision": 1, "name": "Renamed"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
&fixture,
"/api/admin/proxies/delete",
Some(json!({"id": "proxy_test", "revision": 2})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
}
#[tokio::test]
async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let draft = json!({
"proxyUrl": "http://test-user:private-password@proxy.example:8080"
});
let (status, probed) = request(
&fixture,
"/api/admin/proxies/probe",
Some(draft.clone()),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
probed["data"],
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
let (_, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
true,
)
.await;
let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
assert_eq!(status, StatusCode::OK);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
}
#[tokio::test]
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/probe", Some(body), true)
.await
.0,
expected
);
}
}
#[tokio::test]
async fn proxy_routes_require_auth_and_reject_invalid_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
assert_eq!(
request(&fixture, "/api/admin/proxies", None, false).await.0,
StatusCode::UNAUTHORIZED
);
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=proxy_test",
None,
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
for query in [
"",
"proxyId=",
"proxyId=proxy_test&page=0",
"proxyId=proxy_test&pageSize=0",
"proxyId=proxy_test&pageSize=201",
"proxyId=proxy_test&search=%00",
"proxyId=proxy_test&unknown=1",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/accounts?{query}"),
None,
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
}
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts?proxyId=missing",
None,
true
)
.await
.0,
StatusCode::NOT_FOUND
);
for action in [
"create",
"update",
"delete",
"test",
"probe",
"accounts/remove",
] {
assert_eq!(
request(
&fixture,
&format!("/api/admin/proxies/{action}"),
Some(json!({})),
false
)
.await
.0,
StatusCode::UNAUTHORIZED
);
}
for query in ["page=0", "pageSize=0", "pageSize=201", "unknown=1"] {
assert_eq!(
request(&fixture, &format!("/api/admin/proxies?{query}"), None, true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
for body in [
json!({"name":" ","proxyUrl":"http://proxy.example:8080"}),
json!({"name":"Invalid","proxyUrl":""}),
] {
assert_eq!(
request(&fixture, "/api/admin/proxies/create", Some(body), true)
.await
.0,
StatusCode::BAD_REQUEST
);
}
let (status, _) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})),
true,
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn proxy_account_removal_validates_binding_and_input() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(
json!({"proxyId":"", "accountId":"acct_linked"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":""}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"invalid"}),
StatusCode::BAD_REQUEST,
),
(
json!({"proxyId":"proxy_test", "accountId":"acct_linked", "enabled":false}),
StatusCode::UNPROCESSABLE_ENTITY,
),
] {
assert_eq!(
request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(body),
true
)
.await
.0,
expected
);
}
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_test", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"], json!({"configRevision":4}));
let (status, result) = request(
&fixture,
"/api/admin/proxies/accounts/remove",
Some(json!({"proxyId":"proxy_previous", "accountId":"acct_linked"})),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(result["message"], "账号的代理绑定已变化,请刷新后重试");
}
@@ -50,6 +50,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
"src/admin/observability/routes.rs",
"src/admin/observability/wire.rs",
"src/admin/presenter.rs",
"src/admin/proxies.rs",
"src/admin/settings.rs",
"src/admin/system.rs",
"src/admin/wire.rs",
@@ -96,6 +97,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/admin/observability/mod.rs",
"tests/admin/observability/query.rs",
"tests/admin/observability/response.rs",
"tests/admin/proxies.rs",
"tests/admin/settings.rs",
"tests/admin/system.rs",
"tests/admin/wire.rs",
@@ -110,6 +112,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
"tests/openai/endpoint.rs",
"tests/openai/responses/http.rs",
"tests/openai/responses/mod.rs",
"tests/openai/responses/request.rs",
"tests/openai/responses/websocket/connection.rs",
"tests/openai/responses/websocket/mod.rs",
"tests/openai/responses/websocket/protocol.rs",
@@ -11,6 +11,21 @@ use gateway_api::openai::error::{
openai_error_response,
};
#[tokio::test]
async fn key_budget_errors_preserve_limit_code_and_retry_after() {
for code in ["key_daily_budget_exceeded", "key_weekly_budget_exceeded"] {
let error = GatewayError::new(GatewayErrorKind::RateLimited, "key budget exhausted")
.with_client_code(code)
.with_retry_after(std::time::Duration::from_millis(1501));
let response = gateway_error_response(&error);
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(response.headers()["retry-after"], "2");
let body: serde_json::Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap();
assert_eq!(body["error"]["code"], code);
}
}
#[test]
fn invalid_request_error_should_map_to_openai_bad_request() {
assert_eq!(
@@ -433,3 +448,151 @@ async fn continuation_recovery_should_preserve_the_official_retry_signal() {
})
);
}
mod model_routing {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode},
};
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
DefaultExecutionService, ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
};
use gateway_core::engine::provider::ProviderRegistry;
use gateway_core::routing::{ProviderKind, RuntimeSnapshot};
use gateway_core::runtime::RuntimeSnapshotHandle;
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::openai::{
IgnoredClientApiKeyUsage, UnusedAdmissions, UnusedContinuation, UnusedExecutionStore,
};
struct TestCircuits {
blocked: bool,
}
impl ProviderCircuitPort for TestCircuits {
fn decision<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
Box::pin(async move {
Ok(if self.blocked {
ProviderCircuitDecision::BlockedUntil(
SystemTime::now() + Duration::from_secs(30),
)
} else {
ProviderCircuitDecision::Allow
})
})
}
fn observe_failure<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
fn observe_success<'a>(
&'a self,
_: &'a ProviderKind,
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
Box::pin(async { unreachable!("routing rejection must not execute a provider") })
}
}
async fn request_model(
snapshot: RuntimeSnapshot,
model: &str,
stream: bool,
blocked: bool,
) -> (StatusCode, Value) {
let execution = DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot),
Arc::new(UnusedExecutionStore),
ProviderRegistry::default(),
Arc::new(UnusedAdmissions),
Arc::new(TestCircuits { blocked }),
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
);
let response = crate::openai::api_router(Arc::new(execution))
.await
.oneshot(
Request::post("/v1/responses")
.header("authorization", "Bearer sk_model_routing")
.header("content-type", "application/json")
.body(Body::from(
json!({"model": model, "input": "hello", "stream": stream}).to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), 16 * 1024)
.await
.expect("error body");
(status, serde_json::from_slice(&body).expect("error JSON"))
}
#[tokio::test]
async fn missing_models_should_return_404_with_a_specific_explanation() {
for (model, mappings, message) in [
(
"missing-model",
BTreeMap::new(),
"the requested model was not found in the provider catalogs available to this API key; check the model name",
),
(
"model-a",
BTreeMap::from([("model-a".to_owned(), "missing-target".to_owned())]),
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping",
),
] {
for stream in [false, true] {
let snapshot = crate::openai::snapshot("sk_model_routing", "openai")
.with_model_mappings(mappings.clone());
let response = request_model(snapshot, model, stream, false).await;
assert_eq!(
response,
(
StatusCode::NOT_FOUND,
json!({"error": {
"type": "invalid_request_error",
"code": "model_not_found",
"message": message,
}}),
),
"model={model}, stream={stream}",
);
}
}
}
#[tokio::test]
async fn an_existing_model_with_a_blocked_provider_should_remain_service_unavailable() {
let response = request_model(
crate::openai::snapshot("sk_model_routing", "openai"),
"model-a",
true,
true,
)
.await;
assert_eq!(
(response.0, response.1["error"]["code"].as_str()),
(
StatusCode::SERVICE_UNAVAILABLE,
Some("no_available_provider")
),
);
}
}
@@ -1,4 +1,5 @@
use std::collections::VecDeque;
use std::io::Read;
use std::net::{IpAddr, SocketAddr};
use std::sync::{
Arc, Mutex,
@@ -11,7 +12,7 @@ use axum::{
extract::connect_info::ConnectInfo,
http::{
HeaderMap, HeaderValue, Request, StatusCode,
header::{AUTHORIZATION, CONTENT_TYPE},
header::{AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
},
};
use bytes::Bytes;
@@ -451,9 +452,26 @@ async fn captured_client_context(
async fn captured_http_request(
provider_name: &str,
body: Value,
mut headers: HeaderMap,
headers: HeaderMap,
peer_address: Option<SocketAddr>,
) -> CapturedClientContext {
let (response, observed) = http_request_with_body(
provider_name,
body.to_string().into(),
headers,
peer_address,
)
.await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
observed.expect("captured request context")
}
async fn http_request_with_body(
provider_name: &str,
body: Bytes,
mut headers: HeaderMap,
peer_address: Option<SocketAddr>,
) -> (axum::response::Response, Option<CapturedClientContext>) {
headers.insert(
AUTHORIZATION,
"Bearer sk_context_test".parse().expect("authorization"),
@@ -464,7 +482,7 @@ async fn captured_http_request(
client: authenticated_client_for_provider("sk_context_test", provider_name),
});
let mut request = Request::post("/v1/responses")
.body(Body::from(body.to_string()))
.body(Body::from(body))
.expect("context request");
*request.headers_mut() = headers;
if let Some(peer_address) = peer_address {
@@ -475,12 +493,75 @@ async fn captured_http_request(
.oneshot(request)
.await
.expect("context response");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
observed
.lock()
.expect("context capture lock")
.clone()
.expect("captured request context")
let observed = observed.lock().expect("context capture lock").clone();
(response, observed)
}
#[tokio::test]
async fn compressed_http_requests_should_preserve_execution_context_without_transport_headers() {
let body = json!({
"model": "model-a", "input": "hello", "previous_response_id": "resp_previous",
"prompt_cache_key": "cache-key", "client_metadata": {"source": "test"}
});
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.insert("session_id", HeaderValue::from_static("session-test"));
let expected = captured_http_request("openai", body.clone(), headers.clone(), None).await;
for encoding in ["gzip", "deflate", "zstd"] {
let compressed = super::request::encode_body(encoding, body.to_string().as_bytes());
let mut headers = headers.clone();
headers.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
headers.insert(
CONTENT_LENGTH,
HeaderValue::from_str(&compressed.len().to_string()).expect("compressed length"),
);
let (response, observed) =
http_request_with_body("openai", compressed.into(), headers, None).await;
// 捕获执行器在解码成功后主动返回 500;正文和业务头应与未压缩请求一致,
// 压缩编码及长度不能泄漏到已解压正文的上游协议上下文。
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(observed.as_ref(), Some(&expected), "{encoding}");
}
}
#[tokio::test]
async fn invalid_compressed_http_requests_should_fail_before_execution() {
for (encoding, body, code, message) in [
(
"gzip",
Bytes::from_static(b"broken gzip"),
"invalid_json",
"Request body must be valid JSON.",
),
(
"br",
Bytes::from_static(b"{}"),
"unsupported_content_encoding",
"Content-Encoding `br` is not supported.",
),
(
"zstd",
super::request::encode_body("zstd", std::io::repeat(0).take(64 * 1024 * 1024 + 1))
.into(),
"request_too_large",
"Decompressed request body exceeds the allowed size.",
),
] {
let headers = HeaderMap::from_iter([
(CONTENT_ENCODING, HeaderValue::from_static(encoding)),
(CONTENT_TYPE, HeaderValue::from_static("application/json")),
]);
let (response, observed) = http_request_with_body("openai", body, headers, None).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{encoding}");
assert!(observed.is_none(), "invalid body must not start execution");
let body = to_bytes(response.into_body(), 4096)
.await
.expect("error body");
assert_eq!(
serde_json::from_slice::<Value>(&body).expect("error JSON"),
json!({"error": {"type": "invalid_request_error", "code": code, "message": message}})
);
}
}
async fn captured_http_compaction(provider_name: &str) -> CapturedClientContext {
@@ -1,4 +1,5 @@
mod http;
mod request;
mod websocket;
use axum::http::{HeaderMap, HeaderValue};
@@ -369,6 +370,47 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
}
}
#[test]
fn decoder_should_exclude_downstream_transport_headers_from_opaque_context() {
let mut headers = HeaderMap::new();
for name in [
"cf-visitor",
"cf-connecting-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"cf-ray",
"cf-ipcountry",
"cf-warp-tag-id",
"cf-worker",
"cf-ew-via",
"cdn-loop",
"via",
"forwarded",
"x-forwarded-for",
"x-forwarded-prefix",
"accept-encoding",
] {
headers.insert(name, HeaderValue::from_static("downstream-only"));
}
headers.insert(
"CF-Visitor",
HeaderValue::from_static(r#"{"scheme":"https"}"#),
);
headers.insert("accept-encoding", HeaderValue::from_static("br, gzip"));
// 正文未压缩;入口现在消费 Content-Encoding,但仍不得向上游透传。
headers.insert("content-encoding", HeaderValue::from_static("identity"));
headers.insert("x-openai-future-mode", HeaderValue::from_static("keep"));
let decoded =
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
.expect("decode request behind a reverse proxy");
assert_eq!(
openai_protocol_context(&decoded).get("opaque_request_headers"),
Some(&json!([["x-openai-future-mode", STANDARD.encode(b"keep")]])),
);
}
#[test]
fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
let secret = "nested-private-value";
@@ -0,0 +1,174 @@
use std::io::{Read, Write, copy, repeat};
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_ENCODING};
use gateway_api::openai::responses::{RequestDecodeError, decode_request_with_headers};
use serde_json::json;
use super::openai_wire_body;
const ENCODINGS: [&str; 3] = ["gzip", "deflate", "zstd"];
const REQUEST: &[u8] = br#"{"model":"gpt-test","input":"hello","future_field":{"value":1}}"#;
const DECOMPRESSED_LIMIT: u64 = 64 * 1024 * 1024;
pub(super) fn encode_body(encoding: &str, mut data: impl Read) -> Vec<u8> {
match encoding {
"gzip" => {
let mut encoder =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
copy(&mut data, &mut encoder).expect("gzip encode");
encoder.finish().expect("gzip finish")
}
"deflate" => {
let mut encoder =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
copy(&mut data, &mut encoder).expect("deflate encode");
encoder.finish().expect("deflate finish")
}
"zstd" => zstd::stream::encode_all(data, 3).expect("zstd encode"),
_ => panic!("unsupported fixture encoding"),
}
}
fn headers(encoding: &str) -> HeaderMap {
HeaderMap::from_iter([(
CONTENT_ENCODING,
HeaderValue::from_str(encoding).expect("encoding header"),
)])
}
#[test]
fn http_decode_should_preserve_compressed_request_fields() {
let expected = serde_json::from_slice::<serde_json::Value>(REQUEST).expect("fixture JSON");
for encoding in ENCODINGS {
let compressed = encode_body(encoding, REQUEST);
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
assert_eq!(
openai_wire_body(&decoded),
expected.as_object().expect("object")
);
}
}
#[test]
fn http_decode_should_accept_plain_identity_and_case_insensitive_encodings() {
for request_headers in [HeaderMap::new(), headers(""), headers(" identity ")] {
assert!(decode_request_with_headers(REQUEST, &request_headers).is_ok());
}
let compressed = encode_body("gzip", REQUEST);
assert!(decode_request_with_headers(&compressed, &headers(" GZip ")).is_ok());
}
#[test]
fn http_decode_should_reject_unknown_and_stacked_encodings() {
let mut repeated = headers("identity");
repeated.append(CONTENT_ENCODING, HeaderValue::from_static("gzip"));
for request_headers in [headers("br"), headers("gzip, zstd"), repeated] {
let error = decode_request_with_headers(REQUEST, &request_headers)
.expect_err("unsupported encoding");
assert!(matches!(
error,
RequestDecodeError::UnsupportedContentEncoding { .. }
));
assert_eq!(
error.protocol_body().into_value()["error"]["code"],
"unsupported_content_encoding"
);
}
}
#[test]
fn http_decode_should_reject_corrupted_and_truncated_compressed_bodies() {
for encoding in ENCODINGS {
let mut truncated = encode_body(encoding, REQUEST);
truncated.truncate(truncated.len() - 4);
for body in [b"not-a-compressed-stream".as_slice(), truncated.as_slice()] {
let error = decode_request_with_headers(body, &headers(encoding)).expect_err(encoding);
assert_eq!(
error.protocol_body().into_value(),
json!({"error": {
"type": "invalid_request_error", "code": "invalid_json",
"message": "Request body must be valid JSON."
}})
);
}
}
}
#[test]
fn http_decode_should_read_all_gzip_members_and_zstd_frames() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, &REQUEST[..12]);
compressed.extend(encode_body(encoding, &REQUEST[12..]));
let decoded = decode_request_with_headers(&compressed, &headers(encoding)).expect(encoding);
assert_eq!(openai_wire_body(&decoded)["input"], "hello");
}
}
#[test]
fn http_decode_should_not_ignore_invalid_trailing_members() {
for encoding in ["gzip", "zstd"] {
let mut compressed = encode_body(encoding, REQUEST);
compressed.extend(encode_body(encoding, b"invalid trailing JSON".as_slice()));
assert_eq!(
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding),
RequestDecodeError::MalformedJson
);
}
}
#[test]
fn http_decode_should_reject_zstd_frames_with_an_excessive_window() {
let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).expect("zstd encoder");
encoder.window_log(27).expect("128 MiB window");
encoder.write_all(REQUEST).expect("write JSON");
// 提前输出帧头,避免编码器根据最终小正文缩减窗口。
encoder.flush().expect("flush frame header");
let compressed = encoder.finish().expect("finish frame");
assert_eq!(
zstd::stream::decode_all(compressed.as_slice()).expect("valid zstd frame"),
REQUEST
);
assert_eq!(
decode_request_with_headers(&compressed, &headers("zstd")).expect_err("oversized window"),
RequestDecodeError::MalformedJson
);
}
#[test]
fn http_decode_should_accept_the_exact_decompressed_limit() {
let padding = repeat(b' ').take(DECOMPRESSED_LIMIT - REQUEST.len() as u64);
let compressed = encode_body("zstd", REQUEST.chain(padding));
let decoded = decode_request_with_headers(&compressed, &headers("zstd")).expect("exact limit");
assert_eq!(decoded.metadata().requested_model(), "gpt-test");
}
#[test]
fn http_decode_should_reject_bodies_above_the_decompressed_limit() {
for encoding in ENCODINGS {
let compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
"{encoding}"
);
}
}
#[test]
fn http_decode_should_stop_at_limit_before_reading_invalid_trailing_frames() {
for encoding in ["zstd", "gzip"] {
// 尾部故意损坏:若先完整解压再检查长度,就会错误地返回 invalid_json,
// 这个断言验证越界时已经停止读取,而不只是最终返回了某个超限错误。
let mut compressed = encode_body(encoding, repeat(0).take(DECOMPRESSED_LIMIT + 1));
compressed.extend_from_slice(b"invalid trailing frame");
let error =
decode_request_with_headers(&compressed, &headers(encoding)).expect_err(encoding);
assert_eq!(
error,
RequestDecodeError::DecompressedBodyTooLarge,
"{encoding}"
);
}
}
@@ -1,8 +1,53 @@
use axum::http::{HeaderMap, HeaderValue};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_api::openai::responses::{OpenAiRequestHeaders, decode_response_create_with_context};
use gateway_core::operation::Operation;
use serde_json::json;
use super::decode_response_create;
#[test]
fn response_create_should_exclude_cloudflare_headers_on_every_frame() {
let mut headers = HeaderMap::new();
for (name, value) in [
("CF-Visitor", r#"{"scheme":"https"}"#),
("cdn-loop", "cloudflare; loops=1"),
("cf-warp-tag-id", "downstream-warp"),
("cf-ipcountry", "US"),
("cf-worker", "worker.example"),
("cf-ew-via", "15"),
("cf-future-proxy-field", "downstream-only"),
] {
headers.insert(name, HeaderValue::from_static(value));
}
headers.append("x-openai-future-mode", HeaderValue::from_static("first"));
headers.append("x-openai-future-mode", HeaderValue::from_static("second"));
let request_headers = OpenAiRequestHeaders::from_headers(&headers);
// 连接级头会用于同一 WebSocket 的每一帧,后续帧也不能恢复下游链路元数据。
for input in ["hello", "continue"] {
let decoded = decode_response_create_with_context(
&json!({"type": "response.create", "model": "smart-code", "input": input}).to_string(),
&request_headers,
)
.expect("decode response.create behind Cloudflare");
let Operation::Generate(request) = decoded.operation() else {
panic!("Responses must map to Generate");
};
assert_eq!(
request
.protocol_payload()
.context()
.get("opaque_request_headers"),
Some(&json!([
["x-openai-future-mode", STANDARD.encode(b"first")],
["x-openai-future-mode", STANDARD.encode(b"second")],
])),
);
assert_eq!(request.protocol_payload().body()["input"], input);
}
}
#[test]
fn latest_official_codex_response_create_fixture_decodes_unchanged() {
// Audited against openai/codex main 94cbbddafc1776d5e377bca1b05932c697e82238.
+2 -1
View File
@@ -2,7 +2,7 @@
name = "gateway-core"
version = "0.1.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
autotests = false
@@ -24,6 +24,7 @@ semver = "1.0.27"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
url = "2.5.8"
[lints]
workspace = true
@@ -2,6 +2,8 @@
mod error;
mod model;
mod proxy;
pub use proxy::{InvalidOutboundProxy, OutboundProxy};
pub mod scope;
mod selection;
mod store;
@@ -654,7 +654,7 @@ const fn status_projection(status: AccountStatus) -> AccountStatusProjection {
}
}
/// 不含 secret 的账号持久事实。
/// 账号持久事实;代理认证信息只通过显式 secret accessor 读取。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderAccount {
id: ProviderAccountId,
@@ -676,6 +676,7 @@ pub struct ProviderAccount {
access_token_expires_at: Option<SystemTime>,
next_refresh_at: Option<SystemTime>,
has_refresh_token: bool,
outbound_proxy: Option<super::OutboundProxy>,
}
impl ProviderAccount {
@@ -710,6 +711,7 @@ impl ProviderAccount {
access_token_expires_at,
next_refresh_at: None,
has_refresh_token: false,
outbound_proxy: None,
}
}
@@ -726,6 +728,17 @@ impl ProviderAccount {
self
}
#[must_use]
pub fn with_outbound_proxy(mut self, proxy: Option<super::OutboundProxy>) -> Self {
self.outbound_proxy = proxy;
self
}
#[must_use]
pub const fn outbound_proxy(&self) -> Option<&super::OutboundProxy> {
self.outbound_proxy.as_ref()
}
#[must_use]
pub fn with_account_facts(
mut self,
@@ -0,0 +1,49 @@
//! Explicit account egress. Credentials never appear in Debug or ordinary admin projections.
use std::fmt;
use url::Url;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct OutboundProxy(Url);
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid outbound proxy; expected http, https, socks5 or socks5h URL with a host and port")]
pub struct InvalidOutboundProxy;
impl OutboundProxy {
pub fn parse(value: &str) -> Result<Self, InvalidOutboundProxy> {
if value.len() > 4096 || value.chars().any(char::is_control) {
return Err(InvalidOutboundProxy);
}
let url = Url::parse(value).map_err(|_| InvalidOutboundProxy)?;
if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h")
|| url.host_str().is_none()
|| url.port_or_known_default().is_none_or(|port| port == 0)
|| !matches!(url.path(), "" | "/")
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(InvalidOutboundProxy);
}
Ok(Self(url))
}
#[must_use]
pub fn expose_url(&self) -> &str {
self.0.as_str()
}
#[must_use]
pub fn endpoint(&self) -> String {
let mut url = self.0.clone();
let _ = url.set_username("");
let _ = url.set_password(None);
url.to_string()
}
}
impl fmt::Debug for OutboundProxy {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("OutboundProxy(<redacted>)")
}
}
@@ -0,0 +1,52 @@
//! 按 Key 持久化 USD 限额与已取得费用,不依赖尽力写入的请求观测。
use std::time::SystemTime;
use futures::future::BoxFuture;
use crate::{error::GatewayError, metering::Decimal, policy::ClientApiKeyId};
use super::ModelRequestId;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ClientBudgetLimits {
pub daily_usd: Decimal,
pub weekly_usd: Decimal,
}
impl ClientBudgetLimits {
#[must_use]
pub fn is_limited(self) -> bool {
self.daily_usd != Decimal::ZERO || self.weekly_usd != Decimal::ZERO
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClientBudgetStatus {
pub limits: ClientBudgetLimits,
pub daily_used_usd: Decimal,
pub weekly_used_usd: Decimal,
pub daily_resets_at: Option<SystemTime>,
pub weekly_resets_at: Option<SystemTime>,
}
#[derive(Debug, Clone)]
pub struct ClientBudgetCharge {
pub key_id: ClientApiKeyId,
pub request_id: ModelRequestId,
/// 已取得的 USD 费用,包含重试;缺少费用的尝试按零累计。
pub amount_usd: Decimal,
pub completed_at: SystemTime,
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
#[error("client budget store is unavailable")]
pub struct ClientBudgetError;
pub trait ClientBudgetPort: Send + Sync {
/// 原子检查当前限额与已用金额,不创建预扣费或待结算记录。
fn admit(&self, key_id: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>>;
/// 按网关请求 ID 幂等累计已取得费用;写入失败由 Store 重试。
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>>;
}
@@ -30,6 +30,7 @@ use crate::event::{
GatewayEvent, ProviderEvent, ProviderResponseHeader, ProviderResponseObservation,
};
use crate::lifecycle::CancellationToken;
use crate::metering::Decimal;
use crate::operation::{Operation, ProviderSessionState};
use crate::routing::RoutingPlan;
use futures::future::Fuse;
@@ -166,6 +167,8 @@ where
request_id,
client_api_key_ref,
observation: ResponseObservation::new(timing_started_at),
budget_prior_attempts_usd: Decimal::ZERO,
budget_attempt_already_counted: false,
trace,
deadline,
deadline_timer: Delay::new(
@@ -197,6 +200,7 @@ where
delivery_pending: false,
upstream_complete: false,
finalized: false,
finalized_at: None,
image_generation_requested,
last_retryable_failure: None,
last_retryable_failure_events: Vec::new(),
@@ -253,6 +257,8 @@ pub struct ResponseExecutionSession<S: ?Sized> {
request_id: ModelRequestId,
client_api_key_ref: crate::policy::ClientApiKeyId,
observation: ResponseObservation,
budget_prior_attempts_usd: Decimal,
budget_attempt_already_counted: bool,
trace: TraceContext,
deadline: SystemTime,
/// 会话级 deadline 计时器;deadline 固定,帧循环内复用而非逐事件新建。
@@ -288,6 +294,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
delivery_pending: bool,
upstream_complete: bool,
finalized: bool,
finalized_at: Option<SystemTime>,
image_generation_requested: bool,
/// 最近一次为无感恢复而被丢弃的原始上游错误;只在后续空选路时成为终态。
last_retryable_failure: Option<ProviderError>,
@@ -475,6 +482,35 @@ where
self.finalized
}
#[must_use]
pub fn budget_charge(&self) -> super::budget::ClientBudgetCharge {
// 超出数据库可表示范围时保留最大金额,避免溢出后误记为零。
let amount_usd = self
.budget_prior_attempts_usd
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
super::budget::ClientBudgetCharge {
key_id: self.client_api_key_ref.clone(),
request_id: self.request_id.clone(),
amount_usd,
completed_at: self.finalized_at.unwrap_or_else(SystemTime::now),
}
}
fn budget_attempt_usd(&self) -> Decimal {
if self.budget_attempt_already_counted {
return Decimal::ZERO;
}
// Key 只累计已取得的 USD 费用;缺少费用的请求按零结算。
// 发送状态仍用于判断重放是否安全并保留在诊断中,不能据此推断存在欠费。
self.observation
.cost
.total()
.filter(|money| money.currency().as_str() == "USD")
.map(crate::metering::Money::amount)
.unwrap_or(Decimal::ZERO)
}
/// 返回截至当前已完成的实际上游调用结果。
///
/// 调用方可保存已消费下标;该切片在会话生命周期内只追加、不改写。
@@ -800,6 +836,7 @@ where
let mut events = std::mem::take(&mut self.last_retryable_failure_events);
if !events.is_empty() {
self.observe_atomic_terminal_events(&mut events);
self.budget_attempt_already_counted = true;
return Ok(Some(PullOutcome::TerminalFailure {
events,
error: last_failure,
@@ -1143,6 +1180,11 @@ where
"sendState": format!("{attempt_send_state:?}"),
}));
if retryable {
for event in &atomic_client_events {
for fact in event.canonical_facts() {
self.observation.observe_event(fact);
}
}
// 普通 clone 只保留稳定事实;原始 wire/HTTP response 由 request-local
// 所有权保留到下一次 attempt 成功,或最终空选路时返回客户端。
let persistence_error = self.request_persisted.then(|| error.clone());
@@ -1317,6 +1359,12 @@ where
}
fn reset_uncommitted_observations(&mut self) {
// 响应观测按尝试隔离;被丢弃尝试中已取得的费用仍计入 Key。
self.budget_prior_attempts_usd = self
.budget_prior_attempts_usd
.checked_add(self.budget_attempt_usd())
.unwrap_or(Decimal::MAX);
self.budget_attempt_already_counted = false;
self.observation.reset_for_attempt();
self.upstream_complete = false;
}
@@ -1326,6 +1374,7 @@ where
return Ok(());
}
let completed_at = SystemTime::now();
self.finalized_at = Some(completed_at);
self.observation.finish();
let upstream_request_id = self.current.as_ref().and_then(|current| {
current
@@ -1505,11 +1554,12 @@ where
"sendState": format!("{:?}", finalization.send_state), "attempts": self.attempts,
}),
);
let completed_at = SystemTime::now();
self.finalized_at = Some(completed_at);
if !self.request_persisted {
self.finalized = true;
return Ok(());
}
let completed_at = SystemTime::now();
self.observation.finish();
let upstream_request_id = self.current.as_ref().and_then(|current| {
current
@@ -14,6 +14,7 @@ use uuid::Uuid;
use crate::engine::admission::{
ClientAdmissionDecision, ClientAdmissionPort, ClientAdmissionRejection, ClientAdmissionRequest,
};
use crate::engine::budget::{ClientBudgetCharge, ClientBudgetPort};
use crate::engine::continuation::{
ContinuationBinding, NativeContinuationPin, NativeContinuationPort,
NativeContinuationStoreErrorKind, PreviousResponseId,
@@ -256,6 +257,7 @@ pub struct DefaultExecutionService {
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
budget: Option<Arc<dyn ClientBudgetPort>>,
}
impl DefaultExecutionService {
@@ -283,9 +285,16 @@ impl DefaultExecutionService {
circuits,
continuation,
client_api_key_usage,
budget: None,
}
}
#[must_use]
pub fn with_budget(mut self, budget: Arc<dyn ClientBudgetPort>) -> Self {
self.budget = Some(budget);
self
}
async fn start_inner(&self, request: StartExecution) -> Result<StartedExecution, GatewayError> {
let StartExecution {
client,
@@ -325,9 +334,18 @@ impl DefaultExecutionService {
&self,
mut request: PendingStartExecution,
) -> Result<StartedExecution, GatewayError> {
request.client.policy.authorize().map_err(|_| {
GatewayError::new(GatewayErrorKind::PolicyDenied, "client API key is disabled")
})?;
// 长连接每次执行都重新鉴权并冻结当前策略,确保限额和授权变更对新请求生效。
request.client = self
.authenticate(request.client.policy.plaintext_key().expose_for_auth())
.map_err(|error| match error {
ClientAuthenticationError::InvalidKey => {
GatewayError::new(GatewayErrorKind::Unauthorized, "client API key is invalid")
}
ClientAuthenticationError::SnapshotUnavailable => GatewayError::new(
GatewayErrorKind::Internal,
"runtime snapshot is unavailable",
),
})?;
let started_at = SystemTime::now();
let deadline_at = started_at
.checked_add(MODEL_REQUEST_DEADLINE)
@@ -504,6 +522,12 @@ impl DefaultExecutionService {
client_api_key_id: client.policy.key_id().clone(),
model_request_id: request_id.clone(),
};
if let Some(budget) = &self.budget
&& let Err(error) = budget.admit(client.policy.key_id().clone()).await
{
admission.release().await;
return Err(error);
}
let observation = plan
.candidates()
.first()
@@ -552,6 +576,18 @@ impl DefaultExecutionService {
{
Ok(core) => core,
Err(error) => {
if let Some(budget) = &self.budget {
settle_budget(
budget.as_ref(),
ClientBudgetCharge {
key_id: client.policy.key_id().clone(),
request_id: request_id.clone(),
amount_usd: crate::metering::Decimal::ZERO,
completed_at: SystemTime::now(),
},
)
.await;
}
admission.release().await;
return Err(gateway_error_from_engine(&error));
}
@@ -565,6 +601,7 @@ impl DefaultExecutionService {
admission,
Arc::clone(&self.circuits),
Arc::clone(&self.continuation),
self.budget.clone(),
)),
})
}
@@ -922,6 +959,12 @@ struct AdmissionLease {
model_request_id: ModelRequestId,
}
async fn settle_budget(port: &dyn ClientBudgetPort, charge: ClientBudgetCharge) {
if let Err(error) = port.settle(charge).await {
tracing::error!(%error, "Client budget settlement failed; storage will retry on the next request");
}
}
impl AdmissionLease {
async fn release(self) {
if let Err(error) = self
@@ -941,6 +984,7 @@ struct DefaultExecutionSession {
continuation: Arc<dyn NativeContinuationPort>,
observed_provider_outcomes: usize,
continuation_recorded: bool,
budget: Option<Arc<dyn ClientBudgetPort>>,
}
impl DefaultExecutionSession {
@@ -949,6 +993,7 @@ impl DefaultExecutionSession {
admission: AdmissionLease,
circuits: Arc<dyn ProviderCircuitPort>,
continuation: Arc<dyn NativeContinuationPort>,
budget: Option<Arc<dyn ClientBudgetPort>>,
) -> Self {
Self {
core: Some(core),
@@ -957,6 +1002,7 @@ impl DefaultExecutionSession {
continuation,
observed_provider_outcomes: 0,
continuation_recorded: false,
budget,
}
}
@@ -973,6 +1019,11 @@ impl DefaultExecutionSession {
.is_some_and(ResponseExecutionSession::is_finalized)
&& let Some(admission) = self.admission.take()
{
if let Some(budget) = self.budget.take()
&& let Some(core) = &self.core
{
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
admission.release().await;
}
}
@@ -1023,6 +1074,9 @@ impl DefaultExecutionSession {
.get(self.observed_provider_outcomes..)
.unwrap_or_default();
publish_provider_attempt_outcomes(self.circuits.as_ref(), pending).await;
if let Some(budget) = self.budget.take() {
settle_budget(budget.as_ref(), core.budget_charge()).await;
}
if let Some(admission) = self.admission.take() {
admission.release().await;
}
@@ -1213,6 +1267,17 @@ fn new_request_id() -> Result<ModelRequestId, GatewayError> {
fn map_routing_error(error: crate::validation::RoutingError) -> GatewayError {
match error {
crate::validation::RoutingError::ModelNotFound {
model,
mapped_model,
} => GatewayError::new(
GatewayErrorKind::ModelNotFound,
if model == mapped_model {
"the requested model was not found in the provider catalogs available to this API key; check the model name"
} else {
"the requested model maps to an upstream model that was not found in the provider catalogs available to this API key; check the configured model mapping"
},
),
crate::validation::RoutingError::NoCapableProvider { .. }
| crate::validation::RoutingError::NoCapableProviderEndpoint { .. }
| crate::validation::RoutingError::EmptyAccountScope => GatewayError::new(
@@ -1,6 +1,7 @@
//! 模型请求生命周期、单行持久化 port 与 commit/send/cancellation 边界。
pub mod admission;
pub mod budget;
pub mod continuation;
pub mod coordinator;
pub mod execution;
+36
View File
@@ -975,6 +975,13 @@ pub struct GatewayError {
message: &'static str,
diagnostic: Option<Box<ProviderDiagnostic>>,
client_visible_upstream_error: Option<ClientVisibleUpstreamError>,
client_details: Option<Box<GatewayClientErrorDetails>>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct GatewayClientErrorDetails {
code: Option<&'static str>,
retry_after: Option<Duration>,
}
impl GatewayError {
@@ -986,9 +993,33 @@ impl GatewayError {
message,
diagnostic: None,
client_visible_upstream_error: None,
client_details: None,
}
}
#[must_use]
pub fn with_client_code(mut self, code: &'static str) -> Self {
self.client_details
.get_or_insert_with(Default::default)
.code = Some(code);
self
}
#[must_use]
pub fn with_retry_after(mut self, delay: Duration) -> Self {
self.client_details
.get_or_insert_with(Default::default)
.retry_after = Some(delay);
self
}
#[must_use]
pub fn retry_after(&self) -> Option<Duration> {
self.client_details
.as_ref()
.and_then(|details| details.retry_after)
}
/// 将 Provider 错误归一为客户端无关错误。
#[must_use]
pub fn from_provider(error: &ProviderError) -> Self {
@@ -1103,6 +1134,11 @@ impl GatewayError {
self.client_visible_upstream_error
.as_ref()
.and_then(ClientVisibleUpstreamError::code)
.or_else(|| {
self.client_details
.as_ref()
.and_then(|details| details.code)
})
}
}
+14 -2
View File
@@ -52,6 +52,7 @@ pub struct CoreStorePorts {
snapshots: Arc<dyn SnapshotStorePort>,
snapshot_subscriptions: Arc<dyn SnapshotSubscriptionPort>,
client_api_key_usage: Arc<dyn ClientApiKeyUsageSink>,
budget: Option<Arc<dyn engine::budget::ClientBudgetPort>>,
}
impl CoreStorePorts {
@@ -79,8 +80,15 @@ impl CoreStorePorts {
snapshots,
snapshot_subscriptions,
client_api_key_usage,
budget: None,
}
}
#[must_use]
pub fn with_budget(mut self, budget: Arc<dyn engine::budget::ClientBudgetPort>) -> Self {
self.budget = Some(budget);
self
}
}
pub struct CoreBundle {
@@ -145,7 +153,7 @@ pub async fn initialize(
Arc::clone(&ports.snapshot_subscriptions),
));
let worker_contributions = publisher.worker_contributions()?;
let service = Arc::new(DefaultExecutionService::new(
let mut service = DefaultExecutionService::new(
snapshots.clone(),
ports.execution,
providers,
@@ -153,7 +161,11 @@ pub async fn initialize(
ports.circuits,
ports.continuation,
ports.client_api_key_usage,
));
);
if let Some(budget) = ports.budget {
service = service.with_budget(budget);
}
let service = Arc::new(service);
let execution: Arc<dyn ExecutionService> = service.clone();
let account_probe: Arc<dyn AccountProbe> = service;
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
@@ -14,6 +14,8 @@ pub struct Decimal(u128);
impl Decimal {
pub const ZERO: Self = Self(0);
/// 数据库可表示的最大非负金额。
pub const MAX: Self = Self(MAX_SCALED_DECIMAL);
/// 从按十位小数缩放的整数创建。
///
@@ -778,6 +778,19 @@ impl RuntimeSnapshot {
}
if candidates.is_empty() {
// 模型存在性必须包含被熔断的 Provider;目录未知时不能断言模型不存在。
let mut scoped_providers = providers.intersection(&self.providers).peekable();
if scoped_providers.peek().is_some()
&& scoped_providers.all(|provider| {
self.known_provider_catalogs.contains(provider)
&& !self.contains_public_model_for_provider(public_model, provider)
})
{
return Err(RoutingError::ModelNotFound {
model: public_model.as_str().to_owned(),
mapped_model: self.mapped_model(public_model.as_str()),
});
}
return Err(RoutingError::NoCapableProvider {
model: public_model.as_str().to_owned(),
});
@@ -85,6 +85,16 @@ pub enum RoutingError {
/// 实体 ID。
id: String,
},
/// 账号范围内的 Provider 目录均已知,且均没有请求或映射后的模型。
#[error(
"model `{model}` (mapped to `{mapped_model}`) was not found in the scoped provider catalogs"
)]
ModelNotFound {
/// 客户端提交的模型名称。
model: String,
/// 应用模型映射后的名称。
mapped_model: String,
},
/// 固定平台内没有可执行本次请求的 Provider。
#[error("no provider can execute model `{model}`")]
NoCapableProvider {
@@ -1,3 +1,4 @@
mod proxy;
mod selection;
mod selection_diagnostics;
@@ -0,0 +1,33 @@
use gateway_core::account::OutboundProxy;
#[test]
fn proxy_endpoints_support_explicit_schemes_and_redact_credentials() {
for scheme in ["http", "https", "socks5", "socks5h"] {
let proxy =
OutboundProxy::parse(&format!("{scheme}://user:p%40ss%3Aword@[::1]:1080")).unwrap();
assert!(proxy.expose_url().contains("p%40ss%3Aword"));
assert!(!proxy.endpoint().contains("user"));
assert!(!proxy.endpoint().contains("p%40ss"));
assert!(!format!("{proxy:?}").contains("user"));
assert!(proxy.endpoint().contains("[::1]:1080"));
}
}
#[test]
fn invalid_proxy_never_silently_becomes_direct() {
for url in [
"",
"localhost:1080",
"file:///etc/passwd",
"ftp://host:21",
"http://host:0",
"socks5://host",
"http://host:80/path",
"http://host:80?secret=1",
"http://host:80#fragment",
"http://user:secret@host:80\n",
] {
let error = OutboundProxy::parse(url).unwrap_err();
assert!(!error.to_string().contains("secret"));
}
}
@@ -2158,6 +2158,146 @@ fn discarded_attempt_cost_never_leaks_into_retry_result() {
let state = store.state.lock().expect("store lock");
assert_eq!(state.finalizations[0].cost_source, CostSource::Unavailable);
assert_eq!(state.finalizations[0].cost_ticks, None);
assert_eq!(
session.budget_charge().amount_usd.scaled(),
999,
"missing final usage cannot erase a charged retry"
);
}
#[test]
fn key_budget_accumulates_only_observed_retry_costs() {
for (first_cost, first_send, expected) in [
(Some(999), UpstreamSendState::Sent, 1122),
(
Some(gateway_core::metering::Decimal::MAX.scaled()),
UpstreamSendState::Sent,
gateway_core::metering::Decimal::MAX.scaled(),
),
(None, UpstreamSendState::Sent, 123),
(None, UpstreamSendState::NotSent, 123),
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut first = Vec::new();
if first_send != UpstreamSendState::NotSent {
first.push(Ok(GatewayEvent::Started(ResponseMeta::new(
"resp_first",
"gpt-5",
))));
}
if let Some(cost) = first_cost {
first.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
first.push(Err(ProviderError::new(
ProviderErrorKind::Unavailable,
first_send,
)
.with_replay_safe()));
let (coordinator, _, _) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: first,
},
Script::Stream {
account_id: "acct_second",
items: vec![
Ok(GatewayEvent::Started(ResponseMeta::new(
"resp_done",
"gpt-5",
))),
Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(123).unwrap(),
)),
Ok(GatewayEvent::Completed(ResponseMeta::new(
"resp_done",
"gpt-5",
))),
],
},
]);
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 charge = session.budget_charge();
assert_eq!(charge.amount_usd.scaled(), expected);
assert_eq!(
session.budget_charge().completed_at,
charge.completed_at,
"final settlement time stays stable"
);
}
}
#[test]
fn key_budget_settles_post_send_failures_from_observed_cost_without_replaying() {
for send_state in [UpstreamSendState::Sent, UpstreamSendState::Ambiguous] {
for cost in [None, Some(123)] {
for committed in [false, true] {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut items = vec![Ok(GatewayEvent::Started(ResponseMeta::new(
"response-interrupted",
"gpt-5",
)))];
if let Some(cost) = cost {
items.push(Ok(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(cost).unwrap(),
)));
}
items.push(Err(ProviderError::new(
ProviderErrorKind::Transport,
send_state,
)));
let (coordinator, _, provider) = coordinator(vec![Script::Stream {
account_id: "acct_first",
items,
}]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.unwrap();
if committed {
block_on(session.next_event()).unwrap().unwrap();
block_on(session.commit_downstream(Some(200))).unwrap();
loop {
match block_on(session.next_event()) {
Ok(Some(_)) => {}
Err(EngineError::Provider(_)) => break,
other => panic!("expected terminal transport error, got {other:?}"),
}
}
} else {
assert!(matches!(
block_on(session.collect_uncommitted()),
Err(EngineError::Provider(_))
));
}
assert!(session.is_finalized());
assert_eq!(
session.budget_charge().amount_usd.scaled(),
cost.unwrap_or(0),
"send={send_state:?}, committed={committed}, cost={cost:?}"
);
assert_eq!(provider.contexts.lock().unwrap().len(), 1);
}
}
}
}
#[test]
@@ -2688,10 +2828,16 @@ fn exhausted_atomic_response_failed_should_deliver_only_the_last_failure_once()
fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire() {
let operation = generate_operation();
let route_plan = plan(&operation);
let mut failure = bare_atomic_response_failed("response-only-failed");
let mut events = failure.take_atomic_client_events();
events.push(ProviderEvent::canonical(GatewayEvent::ProviderCost(
ProviderReportedCost::from_usd_ticks(500).unwrap(),
)));
let failure = failure.with_atomic_client_events(events);
let (coordinator, store, provider) = coordinator(vec![
Script::ObservedStream {
account_id: "acct_only",
items: vec![Err(bare_atomic_response_failed("response-only-failed"))],
items: vec![Err(failure)],
},
Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
@@ -2731,6 +2877,11 @@ fn empty_selection_after_atomic_failure_should_deliver_the_last_upstream_wire()
));
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
assert_eq!(
session.budget_charge().amount_usd.scaled(),
500,
"restoring a discarded failure must not charge its usage twice"
);
let state = store.state.lock().expect("store lock");
assert_eq!(state.attempts.len(), 1);
assert_eq!(state.intermediate_failures, 1);
@@ -2868,6 +3019,7 @@ fn ambiguous_send_state_stops_retry() {
let finalization = &state.finalizations[0];
assert_eq!(finalization.outcome, ExecutionOutcome::Failed);
assert_eq!(finalization.send_state, UpstreamSendState::Ambiguous);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
assert_eq!(finalization.attempt_count, 1);
assert!(!finalization.committed);
assert!(finalization.latency_ms.is_some());
@@ -3342,6 +3494,7 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
assert_eq!(state.commits, 0);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Cancelled);
assert!(!state.finalizations[0].committed);
assert_eq!(session.budget_charge().amount_usd.scaled(), 0);
}
#[test]
@@ -7,6 +7,231 @@ use std::{
time::{Duration, Instant, SystemTime},
};
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
use gateway_core::error::GatewayError;
#[derive(Default)]
struct Admissions {
active: Arc<AtomicBool>,
limits: Mutex<Vec<RateLimits>>,
}
impl ClientAdmissionPort for Admissions {
fn admit(
&self,
request: ClientAdmissionRequest,
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
Box::pin(async move {
self.limits.lock().unwrap().push(request.limits);
assert!(!self.active.swap(true, Ordering::SeqCst));
Ok(ClientAdmissionDecision::Granted)
})
}
fn release<'a>(
&'a self,
_: &'a ClientApiKeyId,
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async { Ok(self.active.swap(false, Ordering::SeqCst)) })
}
fn restore(
&self,
_: ClientAdmissionRecovery,
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
Box::pin(async { Ok(ClientAdmissionRestoreResult::default()) })
}
}
struct Budget {
reject: bool,
active: Arc<AtomicBool>,
charges: Mutex<Vec<ClientBudgetCharge>>,
}
impl ClientBudgetPort for Budget {
fn admit(&self, _: ClientApiKeyId) -> BoxFuture<'_, Result<(), GatewayError>> {
Box::pin(async {
assert!(self.active.load(Ordering::SeqCst));
if self.reject {
Err(
GatewayError::new(GatewayErrorKind::RateLimited, "budget exhausted")
.with_client_code("key_daily_budget_exceeded"),
)
} else {
Ok(())
}
})
}
fn settle(&self, charge: ClientBudgetCharge) -> BoxFuture<'_, Result<(), ClientBudgetError>> {
Box::pin(async move {
assert!(
self.active.load(Ordering::SeqCst),
"settle before releasing the concurrent request slot"
);
self.charges.lock().unwrap().push(charge);
Ok(())
})
}
}
fn service(admissions: Arc<Admissions>, budget: Arc<Budget>) -> DefaultExecutionService {
DefaultExecutionService::new(
RuntimeSnapshotHandle::new(start_snapshot()),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions,
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
)
.with_budget(budget)
}
fn request(service: &DefaultExecutionService, transport: ClientTransport) -> StartExecution {
StartExecution {
client: service.authenticate("sk_start_test").unwrap(),
public_model: PublicModelId::new("gpt-start").unwrap(),
operation: start_operation(),
metadata: ExecutionRequestMetadata {
protocol: "openai".to_owned(),
endpoint: "/v1/responses".to_owned(),
transport,
stream: transport != ClientTransport::HttpJson,
client_ip: None,
user_agent: None,
previous_response_id: None,
},
}
}
#[test]
fn budget_rejection_releases_client_concurrency_without_creating_a_charge() {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
reject: true,
active: admissions.active.clone(),
charges: Mutex::default(),
});
let service = service(admissions.clone(), budget.clone());
for transport in [
ClientTransport::HttpJson,
ClientTransport::HttpSse,
ClientTransport::WebSocket,
] {
let result = block_on(service.start(request(&service, transport)));
assert!(
matches!(result, Err(error) if error.client_error_code() == Some("key_daily_budget_exceeded"))
);
assert!(!admissions.active.load(Ordering::SeqCst));
assert!(budget.charges.lock().unwrap().is_empty());
}
}
#[test]
fn reused_client_uses_updated_limits_for_each_execution() {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let client = service.authenticate("sk_start_test").unwrap();
let limited = RateLimits {
max_concurrency: 1,
requests_per_minute: 1,
};
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
let mut next = request(&service, ClientTransport::WebSocket);
next.client = client.clone();
let started = block_on(service.start(next)).expect("new execution");
block_on(started.session.detach_finalize());
}
assert_eq!(
*admissions.limits.lock().unwrap(),
vec![limited, RateLimits::unlimited()]
);
}
#[test]
fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
for suspend in [false, true] {
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
let admissions = Arc::new(Admissions::default());
let service = DefaultExecutionService::new(
snapshots.clone(),
Arc::new(TrackingExecutionStore::default()),
ProviderRegistry::default(),
admissions.clone(),
Arc::new(UnusedCircuits),
Arc::new(UnusedContinuation),
Arc::new(RecordingClientApiKeyUsage::default()),
);
let next = request(&service, ClientTransport::WebSocket);
if suspend {
snapshots.suspend();
} else {
snapshots.publish(start_snapshot_with_policy(
2,
false,
RateLimits::unlimited(),
));
}
let result = block_on(service.start(next));
let expected = if suspend {
GatewayErrorKind::Internal
} else {
GatewayErrorKind::Unauthorized
};
assert!(matches!(result, Err(error) if error.kind() == expected));
assert!(admissions.limits.lock().unwrap().is_empty());
}
}
#[test]
fn cancellation_and_pre_send_failure_settle_zero_and_release_concurrency_for_all_transports() {
let admissions = Arc::new(Admissions::default());
let budget = Arc::new(Budget {
reject: false,
active: admissions.active.clone(),
charges: Mutex::default(),
});
let service = service(admissions.clone(), budget.clone());
for transport in [
ClientTransport::HttpJson,
ClientTransport::HttpSse,
ClientTransport::WebSocket,
] {
for detach in [true, false] {
let mut started = block_on(service.start(request(&service, transport))).unwrap();
assert!(admissions.active.load(Ordering::SeqCst));
if detach {
block_on(started.session.detach_finalize());
} else {
assert!(block_on(started.session.next_event()).is_err());
block_on(started.session.detach_finalize());
}
assert!(!admissions.active.load(Ordering::SeqCst));
}
}
let charges = budget.charges.lock().unwrap();
assert_eq!(
charges.len(),
6,
"one settlement per request, including detached finalized sessions"
);
assert!(
charges
.iter()
.all(|charge| charge.amount_usd == gateway_core::metering::Decimal::ZERO)
);
}
use async_trait::async_trait;
use bytes::Bytes;
use futures::{executor::block_on, future::BoxFuture};
@@ -495,7 +720,13 @@ fn known_catalog_should_reject_a_model_that_the_provider_did_not_publish() {
panic!("a known provider catalog must be authoritative for model availability");
};
assert_eq!(error.kind(), GatewayErrorKind::NoAvailableProvider);
assert_eq!(
(error.kind(), error.client_message()),
(
GatewayErrorKind::ModelNotFound,
"the requested model was not found in the provider catalogs available to this API key; check the model name",
)
);
}
#[test]
@@ -917,11 +1148,15 @@ fn client_snapshot() -> RuntimeSnapshot {
}
fn start_snapshot() -> RuntimeSnapshot {
start_snapshot_with_policy(1, true, RateLimits::unlimited())
}
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
let provider = ProviderKind::new("openai").expect("provider kind");
let capabilities =
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
RuntimeSnapshot::new(
ConfigRevision::new(1).expect("config revision"),
ConfigRevision::new(revision).expect("config revision"),
AccountSelectionPolicy::new(
RotationStrategy::Smart,
std::num::NonZeroU32::new(1).expect("concurrency"),
@@ -937,8 +1172,8 @@ fn start_snapshot() -> RuntimeSnapshot {
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
account_scope(&provider, "acct_start"),
true,
RateLimits::unlimited(),
enabled,
limits,
)],
)
.expect("start snapshot")
+127 -13
View File
@@ -326,12 +326,123 @@ fn known_provider_catalog_missing_mapped_model_should_be_filtered() {
)
.expect_err("known xAI catalog does not contain the mapped OpenAI model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "gpt-5.4".to_owned(),
mapped_model: "gpt-5.5".to_owned(),
}
);
}
#[test]
fn missing_model_should_be_reported_when_all_scoped_catalogs_are_known() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect_err("neither provider publishes the requested model");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "missing-model".to_owned(),
mapped_model: "missing-model".to_owned(),
}
);
}
#[test]
fn missing_model_in_one_provider_should_still_route_to_another() {
let snapshot = snapshot();
let plan = snapshot
.plan(
&PublicModelId::new("grok-4.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.expect("xAI publishes the model missing from OpenAI");
assert_eq!(plan.candidates()[0].provider().as_str(), "xai");
}
#[test]
fn blocked_model_provider_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let error = snapshot
.plan(
&PublicModelId::new("gpt-5.5").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("OpenAI is blocked and xAI does not publish this model");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn blocked_unknown_catalog_should_not_prove_model_absence() {
let snapshot = RuntimeSnapshot::new(
ConfigRevision::new(1).expect("revision"),
scheduling(),
vec![
ProviderKind::new("openai").expect("provider"),
ProviderKind::new("xai").expect("provider"),
],
vec![model("xai", "grok-4.5", capabilities())],
Vec::new(),
)
.expect("snapshot")
.with_account_directory(account_directory());
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
blocked_providers: BTreeSet::from([ProviderKind::new("openai").expect("provider")]),
..RoutingContext::default()
},
)
.expect_err("the blocked provider's catalog cannot establish model absence");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
fn empty_account_scope_should_not_be_misreported_as_model_not_found() {
let snapshot = snapshot();
let empty_scope = Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::default()),
ClientRoutingScope::all_accounts(),
));
let error = snapshot
.plan(
&PublicModelId::new("missing-model").expect("model"),
&operation(),
empty_scope,
&RoutingContext::default(),
)
.expect_err("the API key has no accounts");
assert_eq!(error, gateway_core::error::RoutingError::EmptyAccountScope);
}
#[test]
fn provider_endpoint_plan_should_not_consult_or_publish_the_text_model_catalog() {
let snapshot = snapshot();
@@ -463,19 +574,22 @@ fn known_unsupported_operation_should_not_be_bypassed() {
)
.expect("snapshot");
assert!(
snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.is_err()
);
let error = snapshot
.plan(
&PublicModelId::new("gpt-known-unsupported").expect("model"),
&operation(),
snapshot.all_account_scope(),
&RoutingContext {
required_provider: Some(ProviderKind::new("openai").expect("provider")),
..RoutingContext::default()
},
)
.expect_err("the model exists but cannot perform this operation");
assert!(matches!(
error,
gateway_core::error::RoutingError::NoCapableProvider { .. }
));
}
#[test]
@@ -56,10 +56,13 @@ struct PublishingCatalogProvider {
queries: AtomicUsize,
}
struct UnavailableCatalogProvider;
enum TestCatalogProvider {
Unavailable,
Empty,
}
#[async_trait]
impl Provider for UnavailableCatalogProvider {
impl Provider for TestCatalogProvider {
fn name(&self) -> &'static str {
"alpha"
}
@@ -71,10 +74,13 @@ impl Provider for UnavailableCatalogProvider {
async fn query_model_capabilities(
&self,
) -> Result<Vec<ProviderModelCapabilities>, ProviderError> {
Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
))
match self {
Self::Unavailable => Err(ProviderError::new(
ProviderErrorKind::Unavailable,
UpstreamSendState::NotSent,
)),
Self::Empty => Ok(Vec::new()),
}
}
async fn execute(
@@ -145,7 +151,7 @@ fn compiler_should_reject_revision_changed_during_consistent_read() {
#[test]
fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
let providers =
ProviderRegistry::new([Arc::new(UnavailableCatalogProvider) as Arc<dyn Provider>])
ProviderRegistry::new([Arc::new(TestCatalogProvider::Unavailable) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
@@ -162,6 +168,35 @@ fn compiler_should_preserve_passthrough_when_provider_catalog_is_unavailable() {
assert_eq!(snapshot.client_policies().count(), 1);
}
#[test]
fn known_empty_catalog_should_report_model_not_found() {
let providers =
ProviderRegistry::new([Arc::new(TestCatalogProvider::Empty) as Arc<dyn Provider>])
.expect("provider registry");
let compiler =
RuntimeSnapshotCompiler::new(Arc::new(TestSnapshotStore::new(Ok(facts(3, 3)))), providers);
let snapshot = block_on(compiler.compile()).expect("compile empty catalog");
let error = snapshot
.plan(
&PublicModelId::new("public-model").expect("model"),
&super::operation(),
snapshot.all_account_scope(),
&gateway_core::routing::RoutingContext {
required_provider: Some(ProviderKind::new("alpha").expect("provider")),
..gateway_core::routing::RoutingContext::default()
},
)
.expect_err("a successfully published empty catalog proves model absence");
assert_eq!(
error,
gateway_core::error::RoutingError::ModelNotFound {
model: "public-model".to_owned(),
mapped_model: "upstream-model".to_owned(),
},
);
}
#[test]
fn compiler_retries_when_provider_publishes_catalog_during_compilation() {
let provider = Arc::new(PublishingCatalogProvider {
+1 -1
View File
@@ -17,7 +17,7 @@ futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-core = { path = "../gateway-core" }
hex = "0.4.3"
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
scraper = "0.27.0"
semver = "1.0.27"
serde = { version = "1.0.228", features = ["derive"] }
+12
View File
@@ -3,6 +3,7 @@
pub mod client_distribution;
pub mod config;
mod logging;
pub mod proxy_probe;
pub mod serve;
pub mod system_update;
pub mod workers;
@@ -81,6 +82,17 @@ impl HostBundle {
self.client_distribution.clone()
}
#[must_use]
pub fn proxy_probe<E>(
&self,
build_client: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E>
+ Send
+ Sync
+ 'static,
) -> Arc<dyn gateway_admin::ports::proxy::ProxyProbe> {
Arc::new(proxy_probe::HttpProxyProbe::default().with_client_builder(build_client))
}
#[must_use]
pub fn connection_lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
self.connections.clone()
@@ -0,0 +1,105 @@
//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试。
use std::{
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
use async_trait::async_trait;
use gateway_admin::{model::proxies::ProxyTestResult, ports::proxy::ProxyProbe};
use gateway_core::account::OutboundProxy;
use serde::Deserialize;
pub struct HttpProxyProbe {
endpoint: String,
build_client: Arc<ProxyClientBuilder>,
}
type ProxyClientBuilder =
dyn Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, &'static str> + Send + Sync;
impl Default for HttpProxyProbe {
fn default() -> Self {
Self::new("https://api.ipify.org?format=json")
}
}
impl HttpProxyProbe {
#[must_use]
pub fn new(endpoint: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
build_client: Arc::new(|builder| builder.build().map_err(|_| "无法创建代理连接")),
}
}
/// 由组合根注入与 Provider 请求一致的证书信任策略。
#[must_use]
pub fn with_client_builder<E>(
mut self,
build: impl Fn(reqwest::ClientBuilder) -> Result<reqwest::Client, E> + Send + Sync + 'static,
) -> Self {
self.build_client = Arc::new(move |builder| {
build(builder).map_err(|_| "无法创建代理连接,请检查证书信任配置")
});
self
}
async fn exit_ip(&self, proxy: &OutboundProxy) -> Result<IpAddr, &'static str> {
let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?;
let builder = reqwest::Client::builder()
.no_proxy()
.proxy(proxy)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(reqwest::redirect::Policy::none());
let client = (self.build_client)(builder)?;
let mut response = client.get(&self.endpoint).send().await.map_err(|error| {
if error.is_timeout() {
"代理连接超时"
} else {
"代理连接失败,请检查地址、认证和网络"
}
})?;
if !response.status().is_success() {
return Err(
if response.status() == reqwest::StatusCode::PROXY_AUTHENTICATION_REQUIRED {
"代理认证失败"
} else {
"出口检测服务返回错误状态"
},
);
}
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| "出口检测响应读取失败")?
{
if body.len() + chunk.len() > 1024 {
return Err("出口检测响应过大");
}
body.extend_from_slice(&chunk);
}
#[derive(Deserialize)]
struct Response {
ip: IpAddr,
}
serde_json::from_slice::<Response>(&body)
.map(|response| response.ip)
.map_err(|_| "出口检测响应不合法")
}
}
#[async_trait]
impl ProxyProbe for HttpProxyProbe {
async fn test(&self, proxy: &OutboundProxy) -> ProxyTestResult {
let started = Instant::now();
let result = tokio::time::timeout(Duration::from_secs(15), self.exit_ip(proxy)).await;
let result = result.unwrap_or(Err("代理连接超时"));
ProxyTestResult {
success: result.is_ok(),
latency_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
exit_ip: result.as_ref().ok().copied(),
message: result.map_or_else(str::to_owned, |_| "连接成功".to_owned()),
}
}
}
@@ -1,6 +1,7 @@
mod client_distribution;
mod config;
mod logging;
mod proxy_probe;
mod serve;
mod system_update;
mod workers;
@@ -0,0 +1,78 @@
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use serde_json::json;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header},
};
#[tokio::test]
async fn proxy_probe_sends_authentication_through_explicit_proxy() {
let proxy_server = MockServer::start().await;
Mock::given(header("proxy-authorization", "Basic dXNlcjpwYXNzd29yZA=="))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"})))
.expect(1)
.mount(&proxy_server)
.await;
let proxy =
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address())).unwrap();
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&proxy)
.await;
assert!(result.success);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
}
#[tokio::test]
async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_responses() {
for response in [
ResponseTemplate::new(407),
ResponseTemplate::new(302).insert_header("Location", "http://127.0.0.1/"),
ResponseTemplate::new(200).set_body_json(json!({"ip": "not-an-ip"})),
ResponseTemplate::new(200).set_body_string("a".repeat(1025)),
] {
let proxy_server = MockServer::start().await;
Mock::given(any())
.respond_with(response)
.expect(1)
.mount(&proxy_server)
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap())
.await;
assert!(!result.success);
assert!(result.exit_ip.is_none());
}
}
#[tokio::test]
async fn unavailable_proxy_never_falls_back_to_direct_connection() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip":"203.0.113.8"})))
.expect(0)
.mount(&target)
.await;
let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap();
drop(unused);
let result = HttpProxyProbe::new(target.uri()).test(&proxy).await;
assert!(!result.success);
}
#[tokio::test]
async fn invalid_certificate_configuration_should_not_fall_back_or_expose_details() {
let target = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&target)
.await;
let result = HttpProxyProbe::new(target.uri())
.with_client_builder(|_| Err("private-certificate-path"))
.test(&OutboundProxy::parse(&target.uri()).unwrap())
.await;
assert!(!result.success);
assert!(!result.message.contains("private-certificate-path"));
}
+1 -1
View File
@@ -2,7 +2,7 @@
name = "gateway-protocol"
version = "0.0.0"
edition = "2024"
license = "MIT"
license = "Apache-2.0"
publish = false
rust-version = "1.97"
autotests = false
@@ -0,0 +1,36 @@
//! OpenAI 请求头的业务协议与传输边界。
/// 判断小写请求头是否属于传输层管理的字段,不得作为业务扩展头透传。
///
/// API 入站和 Provider 编码共用此分类;`Connection` 动态声明的逐跳头由入站额外剥离。
/// 只用于请求方向,不影响上游响应中的代理诊断信息。
#[must_use]
pub fn is_transport_managed_request_header(name: &str) -> bool {
// 代理命名空间描述的是下游链路;包括未知扩展也不能冒充上游连接事实。
name.starts_with("cf-")
|| name.starts_with("x-forwarded-")
|| name.starts_with("sec-websocket-")
|| matches!(
name,
"connection"
| "keep-alive"
| "proxy-connection"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "host"
| "content-length"
// 请求实体与响应压缩能力属于各段 transport,不能继承下游协商。
| "content-encoding"
| "accept-encoding"
| "forwarded"
| "via"
| "cdn-loop"
| "x-real-ip"
| "true-client-ip"
| "x-request-id"
)
}
@@ -13,6 +13,7 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request";
pub mod codex;
/// OpenAI/Codex 事件语义、用量与限流字段编解码。
pub mod events;
mod headers;
/// Server-Sent Events 帧的解析与编码。
pub mod sse;
@@ -20,3 +21,4 @@ pub use codex::{
CodexResponsesRequestSemantics, codex_responses_request_semantics,
codex_responses_request_semantics_with_turn_metadata, codex_session_id, codex_thread_id,
};
pub use headers::is_transport_managed_request_header;
@@ -0,0 +1,68 @@
use gateway_protocol::openai::is_transport_managed_request_header;
#[test]
fn transport_headers_should_include_proxy_namespaces_and_compression() {
for name in [
"cf-visitor",
"cf-connecting-ip",
"cf-connecting-ipv6",
"cf-pseudo-ipv4",
"cf-ray",
"cf-ipcountry",
"cf-warp-tag-id",
"cf-worker",
"cf-ew-via",
"cf-future-proxy-field",
"cdn-loop",
"via",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"x-forwarded-port",
"x-forwarded-prefix",
"x-forwarded-future-field",
"x-real-ip",
"true-client-ip",
"x-request-id",
"accept-encoding",
"content-encoding",
"content-length",
"host",
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"sec-websocket-key",
"sec-websocket-extensions",
] {
assert!(is_transport_managed_request_header(name), "missing {name}");
}
}
#[test]
fn transport_headers_should_leave_business_extensions_to_the_protocol_owner() {
for name in [
"x-openai-future-mode",
"x-custom-extension",
"x-client-request-id",
"session-id",
"thread-id",
"x-codex-turn-state",
"x-codex-beta-features",
"openai-beta",
"accept",
"content-type",
"x-cf-business-field",
] {
assert!(
!is_transport_managed_request_header(name),
"unexpected {name}"
);
}
}

Some files were not shown because too many files have changed in this diff Show More