Compare commits

...
23 Commits
Author SHA1 Message Date
ZyyandCodex 45954899e5 chore(release): v3.29.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:31:14 +08:00
ZyyandCodex 78f533d9d3 chore(db): consolidate unreleased migrations for v3.29.0
Merge migrations 0033-0035 into one migration while preserving published migrations 0001-0032. Verified PostgreSQL 18 fresh installation, upgrade from v3.28.0, existing data defaults, and schema equivalence with the original migration sequence.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:30:59 +08:00
ZyyandCodex 376305fed4 docs: align reference documentation with current behavior
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:20:33 +08:00
a82bcdb8f8 fix(providers): preserve upstream failures across account selection (#462)
* fix(openai): distinguish session policy exclusions from unavailable accounts

Co-authored-by: Codex <noreply@openai.com>

* fix(providers): preserve upstream failures after selection exhaustion

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: 2233224230 <111681546+2233224230@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-17 21:19:00 +08:00
ZyyandCodex 3d072fedcf fix(deploy): preserve runtime ownership on installer reruns
Limit application data and log ownership changes to their top-level directories. Add a container check that reapplies installer permissions and verifies application startup.

Validation: first-install and repeated-install filesystem permission checks, Bash syntax, ShellCheck, Actionlint, and diff checks passed. Full local container validation was blocked by Docker Desktop bind-mount permission mapping.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 21:10:39 +08:00
ZyyandCodex a0e708071a perf(plugins)!: optimize RPC and simplify host boundaries
Move business JSON out of control metadata, encode request snapshot headers
compactly, and reuse encoded frames. Cache compiled configuration schemas
and prepare plugin instances with bounded concurrency.

Ignore unused plugin result fields and remove redundant business validation
while preserving host authorization, resource ownership, and capacity limits.
Keep response decoding failures local to their calls.

Seal callback side effects before completing or cancelling RPC calls, and
recover abandoned state migrations without allowing old owners to promote.

Validation: SDK 112 tests and its doctest, Runtime 203 tests, Core 542 tests,
40 PostgreSQL plugin tests, and 16 architecture checks passed. Strict Clippy,
formatting, and diff checks passed.

BREAKING CHANGE: RPC protocol v4 changes frame payload layout and request
snapshot header encoding. Plugins must update their SDK and protocol
manifest version and be rebuilt.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:54:41 +08:00
ZyyandCodex e9ad1811dd feat(usage): add bounded date range picker
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:38:02 +08:00
ZyyandCodex 615e8d66f4 fix(openai): enforce account model access for standalone search
Apply model allowlists and denylists before selecting search accounts.
Use unrestricted accounts when the request model cannot be determined,
and preserve plugin body rewrites after selection.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:36:58 +08:00
ZyyandCodex d79c76a105 feat(accounts): add optional weekly quota limit for reset credits
Skip new automatic redemptions above the configured remaining weekly
quota limit, leaving it unset by default and preserving pending retries.
Keep temporarily ineligible credits scheduled and expose the setting
beside the redemption lead time.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:15:21 +08:00
ZyyandCodex 1bf47f71b6 chore(deps): align workbench with UI v0.5.0
Keep the host, UI submodule, and plugin workbench lockfile on the same UI release for source development.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:09:14 +08:00
ZyyandCodex 2f3f2975ef feat(ui): upgrade UI to v0.5.0 and simplify health popovers
Use shared hover and dismissal handling for health timeline popovers, remove host workarounds and redundant footer copy, and pin the UI dependency and submodule to v0.5.0.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 20:03:36 +08:00
759935b75d feat(accounts): add concurrency details and SDK request context (#461)
* feat(accounts): add concurrency details and SDK request context

Co-authored-by: Codex <noreply@openai.com>

* fix(plugins): version request-context frames and preserve failed snapshots

Co-authored-by: Codex <noreply@openai.com>

* fix(dev): sync workbench with request-context contracts

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine concurrency and request context handling

Preserve entry context and recursion scopes in retry and public management callbacks. Encode queued RPC frames once and use a dedicated account existence query for concurrency polling. Sync workbench packaging tooling and CI through the plugin submodule.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 19:45:49 +08:00
ZyyandCodex fcf4af0ddd fix(ui): let key usage page fill viewport width
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 19:27:59 +08:00
ZyyandCodex 8fb48dd6c3 fix(plugins): expose reset credit automation controls
Register typed account automation operations for server and CLI plugins, reusing the native account service. Preserve the initiating actor across service continuations and generate the SDK contract from host declarations.

Co-authored-by: Codex <noreply@openai.com>
2026-10-17 16:53:54 +08:00
5a81d61979 feat(accounts): automatically use expiring reset credits (#460)
* feat(accounts): automate expiring reset credits

Persist per-account plans and redemption keys, stagger daily discovery, and verify availability before consuming expiring credits.

Co-authored-by: Codex <noreply@openai.com>

* fix(ui): remove reset credit timing button background

Use the existing link variant to keep the control transparent in both idle and hover states.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 16:25:12 +08:00
ZyyandCodex 1fadc75ba2 fix(ui): refine table labels and status alignment
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 15:17:16 +08:00
83f5d71853 fix(openai): bound WebSocket connections per outbound proxy (#458)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 14:56:30 +08:00
f4eceb076b fix(openai): adapt multipart image edits to native JSON (#457)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 14:52:13 +08:00
ZyyandCodex 0b02d0d98a fix(deps): upgrade zip to resolve security advisories
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 13:27:38 +08:00
a014da6916 feat(proxies): add batch APIs and streamline proxy management (#450)
* feat(proxies): add batch creation and geolocated testing

Co-authored-by: Codex <noreply@openai.com>

* feat(proxies): unify batch APIs and simplify management UI

Accept arrays on the existing create and test routes with ordered per-item
results, partial success, bounded concurrency and no automatic replay.
Streamline selected-proxy testing and creation forms, combine location
presentation, and generate bundled licenses from the npm flag package.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-10-17 13:21:11 +08:00
ZyyandCodex 9d4c58f967 docs: prioritize official Codex behavior and clarify comments
Co-authored-by: Codex <noreply@openai.com>
2026-10-17 12:36:15 +08:00
ZyyandCodex c67b081ec1 feat(settings): detect request location from server egress
Reuse proxy egress detection for global request location drafts and preserve cached client identity previews.

Co-authored-by: Codex <noreply@openai.com>
2026-10-16 20:02:12 +08:00
ZyyandCodex 1ce9710afa chore(deps): update plugins submodule to v0.4.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-16 19:35:40 +08:00
302 changed files with 12112 additions and 1994 deletions
+11 -7
View File
@@ -5,6 +5,17 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
# 开发指南
## 参考仓库的优先级
**Codex 兼容性优先保持官方行为,以透明转发和最小必要适配为默认方案;这是开发、排障与审查的前置约束,不只是引用资料的要求**
- 以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 先对照官方与 CPR 的实际请求、响应及状态生命周期,确认本项目是否改写、遗漏或破坏官方合同;真实上游报错不自动等于 CPR 缺陷,源码推断、代理链路复现与官方直连结果分别说明
- 官方路径已能满足场景时,优先复用已有能力或正确透传合同,不额外解释请求意图、扩展工具权限或改变续接语义;补偿后请求成功、测试通过或第三方采用同一做法,都不能单独证明代理应改变行为
- 偏离官方行为必须说明官方路径为何不足、本项目的实际约束、最小必要改动及行为代价,并提供对应验证;依据不足时保持现有合同并报告缺口,不先加兼容分支再以通过测试作为必要性依据;用户明确要求的行为变更限定在授权范围内
- 网关独有的调度、隔离和资源管理按本项目职责评估,不能机械照搬单客户端实现,也不能借此任意改变客户端可观察行为;新增默认限制或回收阈值须有实际容量依据,并验证对续接、重试与延迟的影响
- 三方仓库只作线索或实现参考,不能替代官方行为依据;结论注明适用版本或提交,区分官方事实、三方参考与本项目推断,不把自定义策略表述为官方要求
## 加载边界
- 遵守已提供的 [AGENTS.md](../../../AGENTS.md),上下文已有的规则不重复读取
@@ -26,13 +37,6 @@ description: Codex Proxy RS 仓库的开发、修复、排障、代码审查和
| 普通代码审查 | [审查标准](../../../CONTRIBUTING.md#审查标准);审查默认只读 |
| PR / Issue / 发版 | 按当前意图选 [PR](../cpr-github-pr/SKILL.md)、[Issue](../cpr-github-issue/SKILL.md)或[发版](../cpr-release/SKILL.md),普通提交不创建 PR |
## 参考仓库的优先级
- 涉及 Codex 行为、协议或兼容性时,以官方 [openai/codex](https://github.com/openai/codex) 对应版本的源码与调用链为第一手资料,先查官方实现
- 优先使用用户提供或本机已有的官方仓库,核对来源、版本或提交;资料不足时再查官方对应版本的源码、测试与文档
- 三方仓库只能提供排查线索或实现参考,不能代替官方行为依据;与官方不一致时回到官方源码核实,不直接照搬三方结论
- 结论注明适用版本或提交,区分官方事实、三方参考与本项目推断;官方证据不足时保留不确定性
## 执行与收尾
1. 确认工作区、任务范围与已有改动,先定位事实归属、同类实现和调用链,再修改;编码前只读[项目约定](../../../CONTRIBUTING.md#项目约定)的对应条目
+1 -1
View File
@@ -20,7 +20,7 @@ description: 草拟、创建、补充或回复 GitHub Issue,包括缺陷、功
- 其他仓库读取它自己的相关规则与实际模板,长文档先定位标题再截取,不预读全部模板或 CONTRIBUTING
- 搜索同场景 open/closed Issues、相关 PR 和发布记录,区分主分支修复与已发布修复,不凭标题判断重复;没有新证据时返回已有链接,用户仍要求新建则按其要求处理
- 仅当需要额外排查或修改时使用 [cpr-dev-guide](../cpr-dev-guide/SKILL.md),提交症状反馈不以找到根因为前提
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;注明适用版本、来源与缺口
- 引用上游行为或提出方案时读取[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据),涉及 Codex 时执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级);注明适用版本、来源与缺口,不把上游报错直接定性为 CPR 缺陷,也不把第三方补偿方案当成预期行为;症状反馈仍不以证明根因为前提
## 正文与证据
+3 -1
View File
@@ -15,6 +15,8 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
本仓库目标分支为 `main`,其他仓库使用其可信基线规则与模板,PR 内修改的规则不能降低审查要求
涉及 Codex 行为时,优先执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级),先判断是否保持官方合同、偏离是否必要,再评价实现;引用源码和验证通过不能替代方案依据
## 按模式读取
只加载当前模式,已有上下文不重读;长文档先定位标题再截取对应章节,不因链接存在而加载全文或所有 references
@@ -40,7 +42,7 @@ description: 准备、创建、更新或审查 GitHub PR,含回复讨论与处
## 创建与审查的公共核对
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状;涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考
- 按[问题与方案依据](../../../CONTRIBUTING.md#问题与方案依据)核实触发条件、发生版本与目标分支现状,分别判断问题是否成立、是否属于目标项目的职责;涉及 Codex 时核对方案是否满足上述官方行为优先约束
- 核对最新目标分支及同场景 open/closed PR、Issue,按实际行为和测试判断重复,不凭标题下结论,也不擅自关闭他人 PR
- 自审或审查覆盖完整差异与调用链,文档逐段检查必要性、职责、重复和当前状态表达,构建与链接检查不能代替内容判断
- 核对[AI 使用披露](../../../CONTRIBUTING.md#ai-使用披露),填写实际工具与模型,不猜型号;必需截图或型号待补时保留 Draft
@@ -7,10 +7,12 @@
2. 先核实 PR 要解决的问题是否成立:提取触发场景、预期行为和声称的原因,在目标分支基线检查实际行为。
按入口的公共核对收集一手证据,区分原有问题、环境或配置差异与 PR 新引入的问题,说明主分支已覆盖的部分。
3. 从官方方案评估解决方向:说明官方如何处理同一场景,检查 PR 是否遵循其协议语义和职责归属、
是否复用已有能力或正确传递官方合同;核对偏离官方方案的理由与运行证据。
是否复用已有能力或正确传递官方合同;按入口的官方行为优先约束核对偏离的必要性、最小范围、行为代价与运行证据。
审查 OpenAI 官方上游与 Codex 的对接时,默认官方返回与 Codex 兼容、直接对接正常,重点检查本项目是否误解、
改写或破坏官方合同。不得仅凭自行构造的异常上游数据揣测官方不兼容,也不据此报告缺陷、提出改进建议,
或要求增加防御分支与测试。讨论特定版本或场景的对接例外须有真实响应或实际复现证据,并区分代理链路与官方直连。
真实复现上游错误或补偿后成功,也不能单独证明 CPR 应改写请求;缺少必要性依据的行为偏离须在解决方向中明确指出,
不因测试通过而给出方案合理的结论。网关资源策略单独核对容量依据与客户端影响,不以官方客户端没有同类池策略就判错。
4. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
+1 -1
View File
@@ -14,7 +14,7 @@ description: 开发、排查或打包 Codex Proxy RS 独立网关插件;仅改
- 确认用途、工程位置、目标宿主版本与平台,沿用已有工程;位置无法推断时只补齐该信息,不把插件源码塞进宿主 workspace
- 核对运行宿主、[SDK 版本](../../../backend/crates/gateway-plugin/sdk/Cargo.toml)、[宿主支持清单](../../../backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json)及所用 CLI,清单、进程和能力版本分别判断
- `contributes` 声明处理器,`bindings` 选择挂载与匹配范围;安装成功不等于功能生效。当前清单拒绝 `permissions`,安装者完整信任插件,`trustedProcess` 不提供操作系统沙箱或访问域隔离,见[完整信任](../../../backend/crates/gateway-plugin/sdk/docs/manifest.md#完整信任)
- 涉及 Codex 时按[参考仓库优先级](../cpr-dev-guide/SKILL.md#参考仓库的优先级)先查官方 Codex 源码,三方仓库仅作参考;宿主能力以目标版本的 SDK 为准
- 涉及 Codex 兼容性适配时执行[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级),不因实现位于插件中扩大请求改写或补偿范围;用户明确要求的业务改写限定在授权范围,宿主能力以目标版本的 SDK 为准
## 按当前步骤读取
+1
View File
@@ -31,6 +31,7 @@ description: Codex Proxy RS 的版本规划、发布说明、正式或预发行
## 必须保持的边界
- 发行范围涉及 Codex 兼容性变化时,按[官方行为优先约束](../cpr-dev-guide/SKILL.md#参考仓库的优先级)核对已有方案依据和实际验证范围;缺少依据如实列为发行风险,不以 CI 通过代替,不把网关自定义策略写成官方要求
- `release/publish` 会提交、打 tag、推送并触发工作流,没有只读预演模式,不能用于探测或演练
- 同名版本、超时或中断先查本地与远端状态,不删已发布 tag、不移动 tag、不强推掩盖冲突
- 预发行不得覆盖 GitHub Latest 或镜像 `latest`,完成后回读核对
@@ -154,6 +154,21 @@ runs:
"${compose[@]}" exec -T codex-proxy-rs sh -c \
'touch /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write && rm /app/.runtime/data/.ci-write /app/.runtime/logs/.ci-write'
- name: Verify application restart after installer permissions
shell: bash
run: |
set -euo pipefail
compose=(docker compose --project-name "$COMPOSE_PROJECT_NAME" \
-f deploy/compose.yaml)
"${compose[@]}" stop --timeout 30 codex-proxy-rs
# 使用安装器原函数,验证已有备份暂存目录与日志不会因所有权变更而阻止启动
(
export INSTALL_DIR="$PWD"
source deploy/install.sh
fix_runtime_permissions
)
"${compose[@]}" up -d --no-build --wait codex-proxy-rs
- name: Verify clean logs and graceful shutdown
shell: bash
run: |
+16 -2
View File
@@ -1878,6 +1878,7 @@ dependencies = [
name = "gateway-plugin-sdk"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"semver",
"serde",
"serde_json",
@@ -2779,6 +2780,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -5631,14 +5644,15 @@ dependencies = [
[[package]]
name = "zip"
version = "8.6.0"
version = "9.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"nix",
"typed-path",
]
@@ -67,6 +67,7 @@ async fn execute(
let Environment {
host,
mut store,
timezone,
plugin_cache,
version,
plugin_http,
@@ -115,6 +116,16 @@ async fn execute(
&plugin_runtime,
));
or_shutdown!(plugin_runtime, services.register_settings(&settings));
let accounts = gateway_admin::initialize_accounts(
store.admin_ports().accounts(),
store.admin_ports().account_runtime(),
store.admin_ports().settings(),
timezone,
providers.admin.clone(),
core.snapshot_control(),
core.account_probe(),
);
or_shutdown!(plugin_runtime, services.register_accounts(&accounts));
let usage_data = gateway_admin::initialize_usage_data(
store.admin_ports().usage_data(),
store.admin_ports().settings(),
@@ -78,7 +78,7 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(&proxy, false).await;
let result = probe.test(Some(&proxy), false).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)
+2 -2
View File
@@ -250,8 +250,8 @@ fn source_manifest() -> &'static [u8] {
"main":"bin/plugin",
"runtime":"trustedProcess",
"contributes":{
"middleware":{"version":4,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{}
"middleware":{"version":5,"stages":["request"],"inputFormats":["openai"],"outputFormats":["openai"]},
"management":{"version":2}
},
"icon":"assets/icon.png",
+1 -1
View File
@@ -31,7 +31,7 @@ sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
uuid = { version = "1.23.3", features = ["v4", "v7"] }
cron = "0.17"
chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
+55 -2
View File
@@ -21,6 +21,7 @@ pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod public_service;
mod reset_credit_automation;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -420,13 +421,15 @@ async fn initialize_inner(
auth.ensure_default_admin(config.default_password.expose())
.await?;
let accounts = Arc::new(DefaultAccountsService::new(
let accounts = initialize_accounts(
store.accounts(),
store.account_runtime(),
store.settings(),
timezone,
registry.clone(),
snapshot.clone(),
probe.clone(),
));
);
let backup_ports = store.backup();
let backups = Arc::new(DefaultBackupService::new(
backup_ports.repository(),
@@ -477,6 +480,7 @@ async fn initialize_inner(
let usage_data = initialize_usage_data(store.usage_data(), store.settings(), registry.clone());
let mut public_services = public_service::Registry::new(service_middleware);
public_services.register_settings(&settings)?;
public_services.register_accounts(&accounts)?;
public_services.register_usage_data(&usage_data)?;
let services = AdminServices {
timezone,
@@ -553,6 +557,33 @@ async fn initialize_inner(
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(60),
Duration::from_secs(90),
Duration::from_secs(30),
)
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
worker_contributions.push(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
accounts,
store: store.accounts(),
settings: store.settings(),
}),
},
)
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
));
Ok(AdminBundle {
services,
worker_contributions,
@@ -641,6 +672,28 @@ fn freeze_recovery_worker_contribution(
Ok(vec![WorkerContribution::Registration(registration)])
}
/// 服务端与 CLI 复用账号用例,不随命令启动后台调度
#[must_use]
pub fn initialize_accounts(
accounts: Arc<dyn ports::store::AccountStore>,
account_runtime: Arc<dyn ports::store::AccountRuntimeStore>,
settings: Arc<dyn ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
) -> Arc<dyn AccountsService> {
Arc::new(DefaultAccountsService::new(
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
))
}
/// 设置服务不依赖 Web 管理会话,CLI 与服务器通过同一用例执行事务和发布
pub fn initialize_settings(
store: Arc<dyn ports::store::SettingsStore>,
@@ -294,3 +294,24 @@ pub enum AccountConnectionTestEvent {
/// 每次连接测试独占的有限事件流
pub type AccountConnectionTestEventStream =
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
/// 单个账号普通并发池的实时租约明细,不关联请求历史
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountConcurrencyEntry {
pub lease_id: String,
pub request_id: Option<String>,
pub client_key_id: Option<String>,
pub client_key_name: Option<String>,
pub model: Option<String>,
pub client_ip: Option<String>,
pub started_at: Option<DateTime<Utc>>,
pub acquired_at: Option<DateTime<Utc>>,
}
/// 同一次 Redis 读取中的当前占用数和分页明细
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountConcurrencyPage {
pub entries: Vec<AccountConcurrencyEntry>,
pub total: u64,
pub observed_at: DateTime<Utc>,
}
@@ -22,6 +22,7 @@ pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod reset_credit_automation;
pub mod retention;
pub mod settings;
pub mod system;
@@ -76,6 +76,7 @@ pub struct PluginManagementResource {
/// 原始正文可能含业务数据,不能派生内容型 Debug 或写入诊断日志
pub struct PluginManagementRequest {
pub request_context: Option<std::sync::Arc<gateway_core::engine::extensions::RequestContext>>,
pub method: String,
pub path: String,
pub query: String,
@@ -1,6 +1,6 @@
//! 插件私有状态的归属、代次隔离与迁移数据合同
use std::{collections::BTreeMap, fmt};
use std::{collections::BTreeMap, fmt, sync::Arc};
use serde::{Deserialize, Serialize};
@@ -173,6 +173,8 @@ pub struct PluginStateTransition {
pub instance_id: String,
pub artifact_sha256: String,
pub namespaces: Vec<PluginStateMigrationNamespace>,
/// Store 持有的迁移所有权;最后一个调用持有者释放后允许回收遗留 staging
pub guard: Arc<dyn Send + Sync>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -104,6 +104,8 @@ pub struct NewProxy {
#[derive(Debug, Clone)]
pub struct UpdateProxy {
/// 省略保留配置,零值取消限制
pub max_websocket_connections: Option<u32>,
pub enabled: Option<bool>,
pub auto_location: Option<bool>,
pub test: Option<ProxyTestResult>,
@@ -121,6 +123,21 @@ pub struct ProxyMutation {
pub record: ProxyRecord,
}
pub const MAX_PROXY_BATCH_SIZE: usize = 100;
#[derive(Debug)]
pub struct TestProxy {
pub id: String,
pub revision: Revision,
pub detect_location: bool,
}
#[derive(Debug)]
pub enum ProxyBatchResult<T> {
Completed(Result<T, super::AdminError>),
Skipped,
}
/// 检测失败与连通性分开,双栈冲突不得继续应用旧位置
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
@@ -0,0 +1,45 @@
//! 自动重置卡的持久计划与查询投影,不保存上游凭据或完整卡片
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedResetCredit {
pub id: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingResetCredit {
pub credit: PlannedResetCredit,
pub redeem_request_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResetCreditAutomationState {
pub next_discovery_at: DateTime<Utc>,
pub credits: Vec<PlannedResetCredit>,
pub pending: Option<PendingResetCredit>,
pub retry_at: Option<DateTime<Utc>>,
pub failures: u32,
pub last_result: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetCreditAutomation {
pub account_id: String,
pub enabled: bool,
/// 零表示尚未创建;每次持久状态写入都递增版本
pub revision: u64,
pub next_run_at: DateTime<Utc>,
pub state: ResetCreditAutomationState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResetCreditAutomationStatus {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at: Option<DateTime<Utc>>,
pub next_consume_at: Option<DateTime<Utc>>,
pub last_result: Option<String>,
}
@@ -75,6 +75,8 @@ pub struct RuntimeSettingsValues {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
/// 完整运行设置事实
@@ -189,6 +191,15 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
if !(1..=1440).contains(&self.reset_credit_lead_minutes) {
return Err("reset_credit_lead_minutes");
}
if self
.reset_credit_max_weekly_remaining_percent
.is_some_and(|percent| percent > 100)
{
return Err("reset_credit_max_weekly_remaining_percent");
}
self.request_location
.validate()
.map_err(|_| "request_location")?;
@@ -11,6 +11,7 @@ pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod reset_credit_automation;
pub mod retention;
pub mod store;
pub mod system;
@@ -64,5 +64,6 @@ pub struct ProxyImportReservation {
#[async_trait]
pub trait ProxyProbe: Send + Sync {
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult;
/// 未指定代理时探测服务器直连出口,不继承进程代理环境变量
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult;
}
@@ -0,0 +1,29 @@
//! 自动重置卡计划的持久化端口,配置变更沿用管理事务与审计
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::store::AdminStoreResult;
use crate::model::{MutationContext, reset_credit_automation::ResetCreditAutomation};
#[async_trait]
pub trait ResetCreditAutomationStore: Send + Sync {
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<Option<ResetCreditAutomation>>;
/// 只读取到期的本地计划,按到期时间稳定排序
async fn due_reset_credit_automations(
&self,
now: DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>>;
/// 按 record.revision 比较写入,成功返回新版本;配置变更传入审计上下文
async fn save_reset_credit_automation(
&self,
record: &ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64>;
}
@@ -109,7 +109,9 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作
#[async_trait]
pub trait AccountStore: Send + Sync {
pub trait AccountStore:
super::reset_credit_automation::ResetCreditAutomationStore + Send + Sync
{
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
async fn list_plugin_accounts(
&self,
@@ -128,6 +130,8 @@ pub trait AccountStore: Send + Sync {
runtime: AccountRuntimeSnapshot,
) -> AdminStoreResult<Option<AccountPageItem>>;
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool>;
async fn load_account_usage(
&self,
range: TimeRange,
@@ -241,6 +245,13 @@ pub trait AccountStore: Send + Sync {
/// 可丢失账号运行态的管理读端口;跨存储编排由 Admin application service 拥有
#[async_trait]
pub trait AccountRuntimeStore: Send + Sync {
async fn account_concurrency(
&self,
account_id: &str,
offset: u64,
limit: u32,
) -> AdminStoreResult<crate::model::accounts::AccountConcurrencyPage>;
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot>;
async fn account_runtime(
@@ -0,0 +1,37 @@
//! 账号自动重置的公开操作声明,复用账号用例及宿主签发的审计上下文
use crate::{AccountsService, model::AdminError};
use gateway_core::account::ProviderAccountId;
use std::sync::Arc;
pub(super) fn register(
registry: &mut super::Registry,
service: &Arc<dyn AccountsService>,
) -> Result<(), AdminError> {
macro_rules! register {
($sdk:ident, $method:ident, $input:ty, $pattern:pat $(, $argument:expr)* $(,)?) => {{
let service = service.clone();
registry.register(concat!("accounts.", stringify!($method)), move |$pattern: $input| {
let service = service.clone();
Box::pin(async move { service.$method($($argument),*).await })
})?;
}};
}
register!(
GetResetCreditAutomation,
reset_credit_automation_status,
String,
account_id,
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?
);
register!(
SetResetCreditAutomation,
set_reset_credit_automation,
(String, bool),
(account_id, enabled),
&super::actor::mutation_context()?,
ProviderAccountId::new(account_id).map_err(|_| AdminError::invalid("账号 ID 无效"))?,
enabled
);
Ok(())
}
@@ -1,21 +1,55 @@
//! 公开服务写入主体由受信宿主边界设置,插件输入不能覆盖
use crate::model::usage_data::UsageActor;
use crate::model::{AdminError, MutationActor, MutationContext};
use std::future::Future;
tokio::task_local! {
static ACTOR: UsageActor;
static ACTOR: Option<UsageActor>;
}
/// 与 middleware Origin 独立传递,嵌套服务不会丢失真实调用主体
pub async fn scope_actor<F: Future>(actor: UsageActor, future: F) -> F::Output {
scope_captured(Some(actor), future).await
}
pub(super) async fn scope_captured<F: Future>(actor: Option<UsageActor>, future: F) -> F::Output {
ACTOR.scope(actor, future).await
}
pub(super) fn current_actor() -> Result<UsageActor, crate::model::usage_data::UsageDataError> {
ACTOR.try_with(Clone::clone).map_err(|_| {
ACTOR.try_with(Clone::clone).ok().flatten().ok_or_else(|| {
crate::model::usage_data::UsageDataError::invalid(
"usage mutation requires a trusted caller identity",
)
})
}
pub(super) fn mutation_context() -> Result<MutationContext, AdminError> {
let actor = ACTOR
.try_with(Clone::clone)
.ok()
.flatten()
.ok_or_else(|| AdminError::invalid("服务写入缺少可信调用主体"))?;
super::CURRENT
.try_with(|origin| {
let (actor, request_id) = match actor {
UsageActor::Plugin { instance_id, .. } => (
MutationActor::System,
format!(
"plugin:{instance_id}:request:{}:call:{}",
origin.request_id, origin.call_id
),
),
UsageActor::Admin { subject } => (
MutationActor::AdminSession {
admin_user_id: subject,
},
origin.request_id.clone(),
),
UsageActor::System => (MutationActor::System, origin.request_id.clone()),
};
MutationContext { actor, request_id }
})
.map_err(|_| AdminError::invalid("服务写入缺少调用上下文"))
}
@@ -1,5 +1,6 @@
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
mod accounts;
mod actor;
mod registry;
mod settings;
@@ -101,8 +102,10 @@ pub(crate) async fn invoke(
plan: plan.clone(),
};
let inner = context.clone();
// next 可在另一回调任务中执行,终端必须保留发起主体及其缺席状态
let actor = actor::current_actor().ok();
let next = compose(Vec::new(), move |input| {
Box::pin(scope(inner, terminal(input)))
Box::pin(actor::scope_captured(actor, scope(inner, terminal(input))))
});
tokio::select! {
biased;
@@ -29,6 +29,13 @@ impl Registry {
super::settings::register(self, service)
}
pub fn register_accounts(
&mut self,
service: &Arc<dyn crate::AccountsService>,
) -> Result<(), AdminError> {
super::accounts::register(self, service)
}
pub(crate) fn register<I, O>(
&mut self,
name: &'static str,
@@ -0,0 +1,125 @@
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
use crate::{
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
ports::store::{AccountStore, SettingsStore},
use_case::accounts::AccountsService,
};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
time::DeploymentTimeZone,
};
use sha2::{Digest as _, Sha256};
use std::{sync::Arc, time::Duration};
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
pub(crate) fn next_discovery(
now: DateTime<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
let start = timezone.days_after(now, 1)?;
let end = timezone.days_after(now, 2)?;
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
if seconds == 0 {
return None;
}
start.checked_add_signed(chrono::Duration::seconds(
i64::try_from(random % seconds).ok()?,
))
}
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
pub(crate) fn consume_at(
account_id: &str,
credit: &PlannedResetCredit,
lead_minutes: u32,
) -> DateTime<Utc> {
let lead_seconds = i64::from(lead_minutes) * 60;
let mut hash = Sha256::new();
hash.update(account_id.as_bytes());
hash.update([0]);
hash.update(credit.id.as_bytes());
let digest = hash.finalize();
let offset =
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
}
pub(crate) fn next_consumption(
record: &ResetCreditAutomation,
lead_minutes: u32,
) -> Option<DateTime<Utc>> {
record
.state
.credits
.iter()
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
.min()
}
pub(crate) fn reschedule(
record: &mut ResetCreditAutomation,
lead_minutes: u32,
now: DateTime<Utc>,
) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
})
};
}
pub(crate) struct ResetCreditAutomationTask {
pub(crate) accounts: Arc<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ScheduledTask for ResetCreditAutomationTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
let due = self
.store
.due_reset_credit_automations(Utc::now(), 100)
.await
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
let work = futures::stream::iter(due)
.map(|account_id| async move {
let id = ProviderAccountId::new(account_id.clone())
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
tracing::warn!(
account_id,
error_kind = error.kind().as_str(),
"自动重置卡任务失败"
);
}
Ok::<_, WorkerTaskError>(())
})
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
.collect::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -1,5 +1,7 @@
//! 统一账号目录与原生 Provider 分派
mod reset_credit_automation;
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
@@ -52,6 +54,13 @@ const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
/// 统一账号页消费的服务
#[async_trait]
pub trait AccountsService: Send + Sync {
async fn concurrency(
&self,
account_id: ProviderAccountId,
page: u32,
page_size: u32,
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError>;
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
async fn export(
@@ -123,6 +132,29 @@ pub trait AccountsService: Send + Sync {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
}
async fn reset_credit_automation_status(
&self,
_account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn set_reset_credit_automation(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
_enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reconcile_reset_credit_automation(
&self,
_account_id: ProviderAccountId,
) -> Result<(), AdminError> {
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -160,6 +192,8 @@ pub trait AccountsService: Send + Sync {
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -172,6 +206,8 @@ impl DefaultAccountsService {
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -179,6 +215,8 @@ impl DefaultAccountsService {
Self {
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
@@ -381,6 +419,31 @@ impl DefaultAccountsService {
#[async_trait]
impl AccountsService for DefaultAccountsService {
async fn concurrency(
&self,
account_id: ProviderAccountId,
page: u32,
page_size: u32,
) -> Result<crate::model::accounts::AccountConcurrencyPage, AdminError> {
let offset = page
.checked_sub(1)
.and_then(|page| page.checked_mul(page_size))
.filter(|_| (1..=100).contains(&page_size))
.ok_or_else(|| AdminError::invalid("并发明细分页参数无效"))?;
if !self
.accounts
.account_exists(account_id.as_str())
.await
.map_err(|error| map_store_error(error, "account concurrency"))?
{
return Err(AdminError::not_found("账号不存在"));
}
self.account_runtime
.account_concurrency(account_id.as_str(), u64::from(offset), page_size)
.await
.map_err(|error| map_store_error(error, "account concurrency"))
}
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
let providers = &self.providers;
let runtime = self
@@ -891,27 +954,48 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider profile avatar"))
}
async fn reset_credit_automation_status(
&self,
account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.automation_status(&account_id).await
}
async fn set_reset_credit_automation(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.configure_automation(context, &account_id, enabled)
.await
}
async fn reconcile_reset_credit_automation(
&self,
account_id: ProviderAccountId,
) -> Result<(), AdminError> {
self.reconcile_automation(&account_id).await
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let credits = self
.reset_credits_upstream(context, account_id.clone())
.await?;
if let Err(error) = self.remember_reset_credits(&account_id, &credits).await {
tracing::warn!(
account_id = account_id.as_str(),
error_kind = error.kind().as_str(),
"保存重置卡计划失败"
);
}
Ok(credits)
}
async fn consume_reset_credit(
@@ -919,27 +1003,26 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费
let lock = self.reset_credit_lock(&account_id).await;
let lock = self.reset_credit_lock(&command.account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
if self
.accounts
.reset_credit_automation(command.account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.is_some_and(|record| {
record
.state
.pending
.is_some_and(|pending| pending.credit.expires_at > Utc::now())
})
{
return Err(AdminError::new(
crate::model::AdminErrorKind::Conflict,
"自动重置结果待确认,请稍后刷新",
));
}
self.consume_reset_credit_upstream(context, command).await
}
async fn models(
@@ -0,0 +1,513 @@
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
use super::*;
impl DefaultAccountsService {
pub(super) async fn reset_credits_upstream(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
pub(super) async fn consume_reset_credit_upstream(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
}
use crate::{
model::{MutationActor, reset_credit_automation::*},
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
};
use chrono::{DateTime, Utc};
use rand_core::{OsRng, RngCore as _};
impl DefaultAccountsService {
async fn automation_settings(
&self,
) -> Result<crate::model::settings::RuntimeSettingsValues, AdminError> {
Ok(self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?
.values)
}
pub(super) async fn automation_status(
&self,
account_id: &ProviderAccountId,
) -> Result<ResetCreditAutomationStatus, AdminError> {
self.require_reset_credit_automation(account_id).await?;
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
let record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(ResetCreditAutomationStatus {
enabled: record.as_ref().is_some_and(|r| r.enabled),
lead_minutes,
next_discovery_at: record
.as_ref()
.filter(|r| r.enabled)
.map(|r| r.state.next_discovery_at),
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
next_consumption(r, lead_minutes)
.or_else(|| {
r.state
.pending
.as_ref()
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
})
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
}),
last_result: record.and_then(|r| {
if r.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
{
Some("expired_unknown".to_owned())
} else {
r.state.last_result
}
}),
})
}
async fn require_reset_credit_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<bool, AdminError> {
let (stored, provider) = self.provider_for_account(account_id).await?;
let capabilities =
provider.account_capabilities(account_id, &stored.account.authentication_kind);
if !capabilities.reset_credits || !capabilities.consume_reset_credit {
return Err(AdminError::invalid("当前账号不支持自动重置卡"));
}
Ok(stored.account.enabled)
}
pub(super) async fn configure_automation(
&self,
context: &MutationContext,
account_id: &ProviderAccountId,
enabled: bool,
) -> Result<ResetCreditAutomationStatus, AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
self.require_reset_credit_automation(account_id).await?;
let now = Utc::now();
let mut record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.unwrap_or_else(|| ResetCreditAutomation {
account_id: account_id.as_str().to_owned(),
enabled: false,
revision: 0,
next_run_at: now,
state: ResetCreditAutomationState {
next_discovery_at: now,
credits: vec![],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
});
if record.enabled != enabled {
record.enabled = enabled;
// 首次开启错开一分钟;重新开启保留已知到期时间及未决幂等键
if enabled && record.revision == 0 {
record.state.next_discovery_at =
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
}
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
reschedule(&mut record, lead, now);
self.save_automation(&mut record, Some(context)).await?;
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?;
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
}
self.automation_status(account_id).await
}
async fn save_automation(
&self,
record: &mut ResetCreditAutomation,
context: Option<&MutationContext>,
) -> Result<(), AdminError> {
record.revision = self
.accounts
.save_reset_credit_automation(record, context)
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(())
}
fn observe_reset_credits(
&self,
record: &mut ResetCreditAutomation,
credits: &ProviderResetCredits,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
record.state.credits = credits
.credits
.iter()
.filter(|credit| credit.status.as_deref() == Some("available"))
.filter_map(|credit| {
credit
.expires_at
.filter(|expiry| *expiry > now)
.map(|expires_at| PlannedResetCredit {
id: credit.id.clone(),
expires_at,
})
})
.collect();
record.state.credits.sort_by_key(|credit| credit.expires_at);
record.state.credits.dedup_by(|a, b| a.id == b.id);
if record.state.next_discovery_at <= now
|| self
.timezone
.local(record.state.next_discovery_at)
.date_naive()
== self.timezone.local(now).date_naive()
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if record.state.pending.is_none() {
record.state.retry_at = None;
record.state.failures = 0;
}
Ok(())
}
pub(super) async fn remember_reset_credits(
&self,
account_id: &ProviderAccountId,
credits: &ProviderResetCredits,
) -> Result<(), AdminError> {
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
let now = Utc::now();
self.observe_reset_credits(&mut record, credits, now)?;
reschedule(
&mut record,
self.automation_settings().await?.reset_credit_lead_minutes,
now,
);
self.save_automation(&mut record, None).await
}
async fn automation_failed(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
if record.state.pending.is_none()
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
record.state.retry_at = None;
record.state.last_result = Some("discovery_failed".to_owned());
reschedule(record, lead, now);
return self.save_automation(record, None).await;
}
record.state.failures = record.state.failures.saturating_add(1);
let delay = (30_i64 * 2_i64.pow(record.state.failures.min(4) - 1)).min(300)
+ i64::from(OsRng.next_u32() % 30);
record.state.retry_at = Some(now + chrono::Duration::seconds(delay));
record.state.last_result = Some(
if record.state.pending.is_some() {
"pending"
} else {
"discovery_failed"
}
.to_owned(),
);
reschedule(record, lead, now);
self.save_automation(record, None).await
}
/// 暂不满足条件不丢弃卡片,也不创建未决消费,允许到期前再次判断
async fn defer_automation(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
reason: &str,
) -> Result<(), AdminError> {
let now = Utc::now();
let retry = now + chrono::Duration::seconds(30 + i64::from(OsRng.next_u32() % 30));
record.state.retry_at = Some(
record
.state
.credits
.iter()
.map(|credit| credit.expires_at)
.min()
.map_or(retry, |expiry| retry.min(expiry)),
);
record.state.last_result = Some(reason.to_owned());
record.state.failures = 0;
reschedule(record, lead, now);
self.save_automation(record, None).await
}
async fn weekly_reset_credit_gate(
&self,
account_id: &ProviderAccountId,
max_remaining: u32,
) -> Result<Option<&'static str>, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
let quota = provider
.quota(ProviderQuotaRequest {
account_id: account_id.clone(),
refresh: true,
rolling_usage: None,
})
.await
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
let remaining = quota
.usage_windows()
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
.and_then(|(window, _)| window.used_percent)
.filter(|used| used.is_finite())
.map(|used| 100.0 - used.clamp(0.0, 100.0));
Ok(match remaining {
Some(remaining) if remaining > f64::from(max_remaining) => {
Some("weekly_quota_above_threshold")
}
Some(_) => None,
None => Some("weekly_quota_unavailable"),
})
}
pub(super) async fn reconcile_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<(), AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
match self.require_reset_credit_automation(account_id).await {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(error) => {
record.next_run_at = next_discovery(Utc::now(), self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
self.save_automation(&mut record, None).await?;
return Err(error);
}
}
let now = Utc::now();
let settings = self.automation_settings().await?;
let lead = settings.reset_credit_lead_minutes;
record
.state
.credits
.retain(|credit| credit.expires_at > now);
if record
.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= now)
{
record.state.pending = None;
record.state.retry_at = None;
record.state.last_result = Some("expired_unknown".to_owned());
}
if record.state.retry_at.is_some_and(|retry| retry > now) {
reschedule(&mut record, lead, now);
return self.save_automation(&mut record, None).await;
}
let context = MutationContext {
actor: MutationActor::System,
request_id: "reset-credit-automation".to_owned(),
};
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
if record.state.pending.is_none()
&& (due_credit
|| record.state.next_discovery_at <= now
|| record.state.retry_at.is_some())
{
// 先保存下一次发现,查询被取消或进程重启也不会重复刷普通发现接口
if record.state.next_discovery_at <= now {
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if due_credit || record.state.retry_at.is_some() {
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
}
reschedule(&mut record, lead, now);
self.save_automation(&mut record, None).await?;
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
match self
.reset_credits_upstream(&context, account_id.clone())
.await
{
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
if let Some(credit) = record
.state
.credits
.iter()
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
.cloned()
{
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
match self
.weekly_reset_credit_gate(account_id, max_remaining)
.await
{
Ok(Some(reason)) => {
return self.defer_automation(&mut record, lead, reason).await;
}
Ok(None) => {}
Err(error) => {
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
.await?;
return Err(error);
}
}
}
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
if credit.expires_at <= Utc::now() {
record
.state
.credits
.retain(|credit| credit.expires_at > Utc::now());
reschedule(&mut record, lead, Utc::now());
return self.save_automation(&mut record, None).await;
}
record.state.pending = Some(PendingResetCredit {
credit,
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
record.state.last_result = Some("pending".to_owned());
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
reschedule(&mut record, lead, Utc::now());
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
self.save_automation(&mut record, None).await?;
}
}
let Some(pending) = record.state.pending.clone() else {
reschedule(&mut record, lead, Utc::now());
return self.save_automation(&mut record, None).await;
};
let command = ConsumeProviderResetCredit {
account_id: account_id.clone(),
credit_id: Some(pending.credit.id.clone()),
redeem_request_id: uuid::Uuid::parse_str(&pending.redeem_request_id)
.map_err(|_| AdminError::internal("重置卡幂等键不合法"))?,
};
match self.consume_reset_credit_upstream(&context, command).await {
Ok(result) => {
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
record.state.pending = None;
if result.code == "nothing_to_reset" {
return self
.defer_automation(&mut record, lead, "nothing_to_reset")
.await;
}
record
.state
.credits
.retain(|credit| credit.id != pending.credit.id);
record.state.retry_at = None;
record.state.failures = 0;
record.state.last_result = Some(if confirmed {
result.code
} else {
"not_redeemed".to_owned()
});
reschedule(&mut record, lead, Utc::now());
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
self.save_automation(&mut record, None).await?;
if confirmed {
let _ = self.quota(account_id, true).await;
}
}
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
Ok(())
}
}
@@ -1,6 +1,9 @@
//! 账号出站代理的管理、账号解绑与连通性测试用例
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use async_trait::async_trait;
use futures::{StreamExt, stream};
@@ -27,6 +30,16 @@ use crate::{
#[async_trait]
pub trait ProxiesService: Send + Sync {
async fn create_batch(
&self,
commands: Vec<Result<NewProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError>;
async fn test_batch(
&self,
commands: Vec<Result<TestProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError>;
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
async fn list_accounts(
&self,
@@ -61,10 +74,10 @@ pub trait ProxiesService: Send + Sync {
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定
/// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定
async fn probe(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError>;
}
@@ -96,6 +109,75 @@ impl DefaultProxiesService {
#[async_trait]
impl ProxiesService for DefaultProxiesService {
async fn create_batch(
&self,
commands: Vec<Result<NewProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyMutation>>, AdminError> {
validate_batch_size(commands.len())?;
let mut stopped = false;
let mut results = Vec::with_capacity(commands.len());
for command in commands {
if stopped {
results.push(ProxyBatchResult::Skipped);
continue;
}
let result = match command {
Ok(command) => self.create(command, context).await,
Err(error) => Err(error),
};
stopped = result.as_ref().is_err_and(batch_should_stop);
results.push(ProxyBatchResult::Completed(result));
}
Ok(results)
}
async fn test_batch(
&self,
commands: Vec<Result<TestProxy, AdminError>>,
context: &MutationContext,
) -> Result<Vec<ProxyBatchResult<ProxyRecord>>, AdminError> {
validate_batch_size(commands.len())?;
let mut ids = std::collections::HashSet::with_capacity(commands.len());
if commands
.iter()
.filter_map(|command| command.as_ref().ok())
.any(|command| !ids.insert(&command.id))
{
return Err(AdminError::invalid("批量测试不能包含重复代理"));
}
let stopped = AtomicBool::new(false);
// 与单条测试共用全局容量,批次最多占用两槽,保留交互操作的容量
Ok(stream::iter(commands)
.map(|command| {
let stopped = &stopped;
async move {
if stopped.load(Ordering::Relaxed) {
return ProxyBatchResult::Skipped;
}
let result = match command {
Ok(command) => {
self.test(
&command.id,
command.revision,
command.detect_location,
context,
)
.await
}
Err(error) => Err(error),
};
if result.as_ref().is_err_and(batch_should_stop) {
stopped.store(true, Ordering::Relaxed);
}
ProxyBatchResult::Completed(result)
}
})
.buffered(2)
.collect()
.await)
}
async fn remove_account(
&self,
proxy_id: &str,
@@ -194,7 +276,7 @@ impl ProxiesService for DefaultProxiesService {
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
command.test = if command.auto_location {
Some(self.probe(&command.proxy, true).await?)
Some(self.probe(Some(&command.proxy), true).await?)
} else {
None
};
@@ -233,7 +315,7 @@ impl ProxiesService for DefaultProxiesService {
if command.auto_location.unwrap_or(current.auto_location)
&& (!current.auto_location || proxy != &current.proxy)
{
command.test = Some(self.probe(proxy, true).await?);
command.test = Some(self.probe(Some(proxy), true).await?);
}
}
let result = self
@@ -262,7 +344,7 @@ impl ProxiesService for DefaultProxiesService {
async fn probe(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError> {
let _permit = self.test_slots.try_acquire().map_err(|_| {
@@ -292,7 +374,7 @@ impl ProxiesService for DefaultProxiesService {
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置
let result = self
.probe
.test(&record.proxy, record.auto_location || detect_location)
.test(Some(&record.proxy), record.auto_location || detect_location)
.await;
let mutation = self
.store
@@ -313,3 +395,22 @@ fn validate_name(value: &str) -> Result<String, AdminError> {
}
Ok(value.to_owned())
}
fn validate_batch_size(size: usize) -> Result<(), AdminError> {
if size == 0 || size > MAX_PROXY_BATCH_SIZE {
return Err(AdminError::invalid("一次需要提交 1 至 100 条代理"));
}
Ok(())
}
fn batch_should_stop(error: &AdminError) -> bool {
matches!(
error.kind(),
AdminErrorKind::Unauthorized
| AdminErrorKind::Forbidden
| AdminErrorKind::BadGateway
| AdminErrorKind::UpstreamResultUnknown
| AdminErrorKind::Unavailable
| AdminErrorKind::Internal
)
}
@@ -273,6 +273,19 @@ struct FakeRuntimeStore {
#[async_trait]
impl AccountRuntimeStore for FakeRuntimeStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -1,5 +1,7 @@
//! 账号管理用例测试及共享 Provider、存储替身
mod reset_credit_automation;
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
@@ -87,6 +89,11 @@ pub(super) struct FakeProviderAdmin {
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
reset_credits: Mutex<Vec<gateway_admin::model::provider_credentials::ProviderResetCredit>>,
reset_credit_failure: Mutex<Option<ProviderAdminErrorKind>>,
reset_credit_result: Mutex<String>,
reset_credit_query_started: tokio::sync::Notify,
reset_credit_query_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
@@ -112,6 +119,11 @@ impl FakeProviderAdmin {
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
reset_credits: Mutex::default(),
reset_credit_failure: Mutex::default(),
reset_credit_result: Mutex::new("reset".into()),
reset_credit_query_started: tokio::sync::Notify::new(),
reset_credit_query_gate: Mutex::new(None),
profile_result: Mutex::new(Ok(empty_profile_statistics())),
subscription_result: Mutex::new(Ok(None)),
personal_info_barrier: Mutex::new(None),
@@ -498,6 +510,28 @@ impl ProviderAdmin for FakeProviderAdmin {
Ok(self.quota.lock().expect("provider quota").clone())
}
async fn reset_credits(
&self,
_: &ProviderAccountId,
) -> Result<gateway_admin::model::provider_credentials::ProviderResetCredits, ProviderAdminError>
{
self.record("provider.reset_credits");
self.require_available()?;
self.reset_credit_query_started.notify_one();
let gate = self.reset_credit_query_gate.lock().unwrap().take();
if let Some(gate) = gate {
let _ = gate.await;
}
let credits = self.reset_credits.lock().unwrap().clone();
Ok(
gateway_admin::model::provider_credentials::ProviderResetCredits {
available_count: credits.len() as u64,
credits,
},
)
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
@@ -508,8 +542,11 @@ impl ProviderAdmin for FakeProviderAdmin {
.expect("provider reset-credit commands")
.push(command);
self.require_available()?;
if let Some(kind) = self.reset_credit_failure.lock().unwrap().take() {
return Err(ProviderAdminError::new(kind));
}
Ok(ProviderResetCreditResult {
code: "reset".to_owned(),
code: self.reset_credit_result.lock().unwrap().clone(),
credit: None,
})
}
@@ -577,6 +614,9 @@ pub(super) struct FakeAccountStore {
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
update_commands: Mutex<Vec<UpdateAccount>>,
automations: Mutex<
BTreeMap<String, gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
@@ -599,6 +639,7 @@ impl FakeAccountStore {
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
automations: Mutex::default(),
})
}
@@ -693,6 +734,16 @@ impl FakeAccountStore {
#[async_trait]
impl AccountStore for FakeAccountStore {
async fn account_exists(&self, account_id: &str) -> AdminStoreResult<bool> {
self.record("store.account_exists");
Ok(self
.accounts
.lock()
.expect("accounts")
.iter()
.any(|account| account.id == account_id))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -1086,7 +1137,10 @@ impl AccountStore for FakeAccountStore {
}
}
struct StaticSettingsStore;
#[derive(Default)]
struct StaticSettingsStore {
reset_credit_max_weekly_remaining_percent: Option<u32>,
}
#[async_trait]
impl SettingsStore for StaticSettingsStore {
@@ -1145,6 +1199,9 @@ impl SettingsStore for StaticSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
},
})
}
@@ -1840,7 +1897,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
store.set_accounts(vec![openai_account, xai_account]);
let services = super::AdminHarness::new()
.accounts(store.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(openai)
.provider(xai)
.probe(Arc::new(SuccessfulAccountProbe))
@@ -2048,7 +2105,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
store.set_accounts(vec![account_record("openai"), xai_account]);
let services = super::AdminHarness::new()
.accounts(store.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(openai)
.provider(failing)
.probe(Arc::new(SuccessfulAccountProbe))
@@ -3405,7 +3462,7 @@ async fn accounts_service_with_probe(
) -> AdminServices {
super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(provider)
.probe(probe)
.build()
@@ -3574,6 +3631,19 @@ struct CapacityRuntime {
#[async_trait]
impl AccountRuntimeStore for CapacityRuntime {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -3636,7 +3706,7 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
let services = super::AdminHarness::new()
.accounts(store)
.account_runtime(runtime.clone())
.settings(Arc::new(StaticSettingsStore))
.settings(Arc::new(StaticSettingsStore::default()))
.provider(FakeProviderAdmin::new("openai", events()))
.build()
.await;
@@ -3667,3 +3737,56 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
}
}
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for FakeAccountStore
{
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(self.automations.lock().unwrap().get(account_id).cloned())
}
async fn due_reset_credit_automations(
&self,
now: chrono::DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>> {
let records = self.automations.lock().unwrap();
let mut due = records
.values()
.filter(|r| r.enabled && r.next_run_at <= now)
.collect::<Vec<_>>();
due.sort_by_key(|r| r.next_run_at);
Ok(due
.into_iter()
.take(limit as usize)
.map(|r| r.account_id.clone())
.collect())
}
async fn save_reset_credit_automation(
&self,
record: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64> {
let mut records = self.automations.lock().unwrap();
if records.get(&record.account_id).map_or(0, |r| r.revision) != record.revision {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"automation",
"stale revision",
));
}
let mut next = record.clone();
next.revision += 1;
let revision = next.revision;
records.insert(next.account_id.clone(), next);
if let Some(context) = context {
self.record_context(context);
}
Ok(revision)
}
}
@@ -0,0 +1,969 @@
//! 自动重置的持久幂等恢复、低频发现与账号独立错峰
use super::*;
use gateway_admin::{
model::reset_credit_automation::*,
ports::reset_credit_automation::ResetCreditAutomationStore as _,
};
use gateway_core::{
engine::middleware::{self, FrozenMiddlewarePlan, MiddlewarePlan},
routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference},
};
#[derive(Debug)]
struct ServiceForwarder;
impl ExtensionSetLease for ServiceForwarder {
fn is_ready(&self) -> bool {
true
}
}
impl MiddlewarePlan for ServiceForwarder {
fn has_service(&self) -> bool {
true
}
fn handle_service(
&self,
_: middleware::service::Context,
input: middleware::service::Value,
next: middleware::service::Next,
) -> BoxFuture<'static, Result<middleware::service::Value, middleware::service::Error>> {
Box::pin(async move {
// 模拟 RPC 回调跨任务执行 next,且该任务带有另一插件的身份
tokio::spawn(gateway_admin::public_service::scope_actor(
gateway_admin::model::usage_data::UsageActor::Plugin {
publisher: "test".into(),
name: "forwarder".into(),
instance_id: "forwarding-plugin".into(),
},
next.run(input),
))
.await
.unwrap()
})
}
fn handle(
&self,
_: middleware::MiddlewareContext,
request: middleware::MiddlewareRequest,
next: middleware::MiddlewareNext,
) -> BoxFuture<'static, Result<middleware::MiddlewareResponse, middleware::MiddlewareError>>
{
next.run(request)
}
}
#[tokio::test]
async fn public_service_reads_and_updates_reset_credit_automation_with_host_identity() {
use gateway_admin::{
model::usage_data::UsageActor,
public_service::{Origin, Plan, scope, scope_actor},
};
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider, store.clone()).await;
let registry = services.public_services();
let read = "accounts.reset_credit_automation_status";
let write = "accounts.set_reset_credit_automation";
let initial = registry.call(read, json!("acct_test")).await.unwrap();
assert_eq!(initial["enabled"], false);
assert_eq!(initial["lead_minutes"], 10);
let actor = UsageActor::Plugin {
publisher: "test".into(),
name: "automation".into(),
instance_id: "installed-plugin".into(),
};
let middleware = Arc::new(ServiceForwarder);
let plan = FrozenMiddlewarePlan::new(
middleware.clone(),
ExtensionSetReference::new(
ExtensionSetId::new("service-forwarder".into()).unwrap(),
middleware,
),
);
let origin = Origin {
request_id: "plugin-request".into(),
call_id: "plugin-parent".into(),
cancellation: Default::default(),
extensions: Default::default(),
plan: Plan::Frozen(Some(plan)),
};
let error = scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let enabled = scope_actor(
actor.clone(),
scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap();
let status: ResetCreditAutomationStatus = serde_json::from_value(enabled.clone()).unwrap();
assert!(status.enabled);
assert!(status.next_discovery_at.is_some());
assert_eq!(
registry.call(read, json!("acct_test")).await.unwrap(),
enabled
);
assert!(store.automations.lock().unwrap()["acct_test"].enabled);
let audit = store.audit_requests();
assert_eq!(audit.len(), 1);
assert!(audit[0].starts_with("plugin:installed-plugin:request:plugin-request:call:"));
let repeated = scope_actor(
actor.clone(),
scope(
origin.clone(),
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap();
assert_eq!(repeated, enabled);
assert_eq!(store.audit_requests(), audit);
let disabled = scope_actor(
actor,
scope(origin, registry.call(write, json!(["acct_test", false]))),
)
.await
.unwrap();
assert_eq!(disabled["enabled"], false);
assert!(disabled["next_discovery_at"].is_null());
assert!(disabled["next_consume_at"].is_null());
assert!(!store.automations.lock().unwrap()["acct_test"].enabled);
assert_eq!(store.audit_requests().len(), 2);
assert!(!recorded(&events).contains(&"provider.reset_credits"));
}
#[tokio::test]
async fn public_service_automation_rejects_invalid_and_cancelled_writes_without_side_effects() {
use gateway_admin::{
model::usage_data::UsageActor,
public_service::{Origin, Plan, scope, scope_actor},
};
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
let store = FakeAccountStore::new("openai", events);
let services = accounts_service(provider.clone(), store.clone()).await;
let registry = services.public_services();
let write = "accounts.set_reset_credit_automation";
// 直接服务调用也必须有可信主体,输入不能自报管理员身份
let error = registry
.call(write, json!(["acct_test", true]))
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let error = scope_actor(
UsageActor::System,
registry.call(
write,
json!([{"actor": "admin_api_key"}, "acct_test", true]),
),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
let error = scope_actor(
UsageActor::System,
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "invalid");
assert_eq!(error.message, "当前账号不支持自动重置卡");
enable_capability(&provider);
store.set_accounts(Vec::new());
let error = scope_actor(
UsageActor::System,
registry.call(write, json!(["acct_test", true])),
)
.await
.unwrap_err();
assert_eq!(error.kind, "not_found");
let cancellation = gateway_core::lifecycle::CancellationToken::new();
cancellation.cancel();
let error = scope_actor(
UsageActor::System,
scope(
Origin {
request_id: "cancelled".into(),
call_id: "parent".into(),
cancellation,
extensions: Default::default(),
plan: Plan::Frozen(None),
},
registry.call(write, json!(["acct_test", true])),
),
)
.await
.unwrap_err();
assert_eq!(error.kind, "unavailable");
assert!(store.automations.lock().unwrap().is_empty());
assert!(store.audit_requests().is_empty());
}
fn enable_capability(provider: &FakeProviderAdmin) {
let mut capabilities = provider.capabilities.lock().unwrap();
capabilities.reset_credits = true;
capabilities.consume_reset_credit = true;
}
fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
ResetCreditAutomation {
account_id: "acct_test".into(),
enabled: true,
revision: 1,
next_run_at: Utc::now(),
state: ResetCreditAutomationState {
next_discovery_at: Utc::now() + TimeDelta::days(1),
credits: vec![PlannedResetCredit {
id: "card_1".into(),
expires_at,
}],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
}
}
fn card(
expires_at: chrono::DateTime<Utc>,
) -> gateway_admin::model::provider_credentials::ProviderResetCredit {
gateway_admin::model::provider_credentials::ProviderResetCredit {
id: "card_1".into(),
status: Some("available".into()),
expires_at: Some(expires_at),
title: None,
reset_type: None,
}
}
#[tokio::test]
async fn automation_restores_pending_idempotency_after_service_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
provider.reset_credits.lock().unwrap().push(card(expiry));
*provider.reset_credit_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Ambiguous);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let account_id = ProviderAccountId::new("acct_test").unwrap();
let services = accounts_service(provider.clone(), store.clone()).await;
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.expect_err("unknown consume result");
let persisted = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
let pending = persisted.state.pending.as_ref().unwrap();
let command = provider.reset_credit_commands()[0].clone();
assert_eq!(
pending.redeem_request_id,
command.redeem_request_id.to_string()
);
assert_eq!(command.redeem_request_id.get_version_num(), 4);
assert_eq!(provider.quota_requests().len(), 0);
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
1,
"wait until persisted retry time"
);
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
drop(services);
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
restarted
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.pending.is_none());
assert!(record.state.credits.is_empty());
assert_eq!(record.state.last_result.as_deref(), Some("reset"));
assert!(provider.quota_requests().iter().any(|q| q.refresh));
}
#[tokio::test]
async fn automation_only_queries_due_accounts_and_rechecks_credit_before_consume() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::hours(6));
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(!recorded(&events).contains(&"provider.reset_credits"));
record.state.credits[0].expires_at = Utc::now() + TimeDelta::minutes(5);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert!(recorded(&events).contains(&"provider.reset_credits"));
assert!(
provider.reset_credit_commands().is_empty(),
"disappeared credit must never be consumed"
);
}
#[tokio::test]
async fn automation_defaults_off_and_disabled_pending_survives_reenable() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
assert!(
!services
.accounts()
.reset_credit_automation_status(account_id.clone())
.await
.unwrap()
.enabled
);
services
.accounts()
.set_reset_credit_automation(&context("enable"), account_id.clone(), true)
.await
.unwrap();
let first = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(first.state.next_discovery_at >= Utc::now() - TimeDelta::seconds(1));
let pending = PendingResetCredit {
credit: PlannedResetCredit {
id: "pending".into(),
expires_at: Utc::now() + TimeDelta::hours(1),
},
redeem_request_id: uuid::Uuid::new_v4().to_string(),
};
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.pending = Some(pending.clone());
services
.accounts()
.set_reset_credit_automation(&context("disable"), account_id.clone(), false)
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(provider.reset_credit_commands().is_empty());
services
.accounts()
.set_reset_credit_automation(&context("reenable"), account_id, true)
.await
.unwrap();
let next = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(next.state.pending, Some(pending));
assert_eq!(next.state.next_discovery_at, first.state.next_discovery_at);
}
#[tokio::test]
async fn automation_schedules_each_account_inside_the_lead_window_and_next_local_day() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let now = Utc::now();
let expiry = now + TimeDelta::days(2);
provider.reset_credits.lock().unwrap().push(card(expiry));
let accounts = (0..100)
.map(|n| {
let mut account = account_record("openai");
account.id = format!("acct_{n}");
let mut record = plan(expiry);
record.account_id = account.id.clone();
record.state.next_discovery_at = now - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert(account.id.clone(), record);
account
})
.collect::<Vec<_>>();
store.set_accounts(accounts.clone());
let services = accounts_service(provider, store.clone()).await;
let timezone = gateway_core::time::DeploymentTimeZone::default();
let start = timezone.days_after(now, 1).unwrap();
let end = timezone.days_after(now, 2).unwrap();
let mut times = std::collections::BTreeSet::new();
for account in accounts {
let id = ProviderAccountId::new(account.id.clone()).unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let status = services
.accounts()
.reset_credit_automation_status(id)
.await
.unwrap();
let discovery = status.next_discovery_at.unwrap();
assert!(discovery >= start && discovery < end);
times.insert(status.next_consume_at.unwrap());
}
assert!(
times.len() > 30,
"accounts must not all fire at the same instant"
);
assert!(times.iter().all(
|due| *due >= expiry - TimeDelta::minutes(10) && *due < expiry - TimeDelta::minutes(9)
));
}
#[tokio::test]
async fn automation_discards_externally_used_credit_and_stops_on_already_redeemed() {
for already_redeemed_after_query in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
let mut remote_credit = card(expiry);
if !already_redeemed_after_query {
remote_credit.status = Some("redeemed".into());
}
provider.reset_credits.lock().unwrap().push(remote_credit);
*provider.reset_credit_result.lock().unwrap() = "already_redeemed".into();
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
usize::from(already_redeemed_after_query)
);
let stored = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(stored.state.credits.is_empty());
assert!(stored.state.pending.is_none());
if already_redeemed_after_query {
assert_eq!(
stored.state.last_result.as_deref(),
Some("already_redeemed")
);
}
}
}
#[tokio::test]
async fn automation_daily_discovery_is_not_polled_again_after_success_or_failure() {
for failed in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
if failed {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let services = accounts_service(provider.clone(), store.clone()).await;
let id = ProviderAccountId::new("acct_test").unwrap();
let first = services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await;
assert_eq!(first.is_err(), failed);
let scheduled = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at;
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
assert_eq!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at,
scheduled
);
assert!(scheduled > Utc::now());
}
}
#[tokio::test]
async fn automation_cancelled_discovery_preserves_next_random_time_across_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let (release, gate) = tokio::sync::oneshot::channel();
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
let services = accounts_service(provider.clone(), store.clone()).await;
let task = tokio::spawn(async move {
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
});
tokio::time::timeout(
std::time::Duration::from_secs(2),
provider.reset_credit_query_started.notified(),
)
.await
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
drop(release);
let before = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(before.state.next_discovery_at > Utc::now());
let restarted = accounts_service(provider.clone(), store.clone()).await;
restarted
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
let after = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
before.state.next_discovery_at,
after.state.next_discovery_at
);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
}
#[tokio::test]
async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_expires() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let mut record = plan(Utc::now() + TimeDelta::minutes(5));
record.enabled = false;
record.state.pending = Some(PendingResetCredit {
credit: record.state.credits[0].clone(),
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").unwrap(),
credit_id: Some("another_card".into()),
redeem_request_id: uuid::Uuid::new_v4(),
};
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command.clone())
.await
.expect_err("unresolved automation is still active");
assert!(provider.reset_credit_commands().is_empty());
record.state.pending.as_mut().unwrap().credit.expires_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
}
async fn threshold_services(
provider: Arc<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
max_remaining: Option<u32>,
) -> AdminServices {
super::super::AdminHarness::new()
.accounts(store)
.settings(Arc::new(StaticSettingsStore {
reset_credit_max_weekly_remaining_percent: max_remaining,
}))
.provider(provider)
.probe(Arc::new(SuccessfulAccountProbe))
.build()
.await
}
fn weekly_quota(used_percent: f64) -> ProviderQuota {
ProviderQuota {
windows: vec![ProviderQuotaWindow {
key: "week".into(),
group: "shortTerm".into(),
label: "周额度".into(),
limit_id: None,
limit_name: None,
role: None,
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
window_seconds: Some(7 * 86_400),
used_percent: Some(used_percent),
reset_at: Some(Utc::now() + TimeDelta::days(1)),
limit_reached: false,
local_usage: None,
provider_data: None,
}],
..empty_quota()
}
}
fn schedule_due_credit(provider: &FakeProviderAdmin, store: &FakeAccountStore) {
enable_capability(provider);
let expiry = Utc::now() + TimeDelta::minutes(5);
provider.reset_credits.lock().unwrap().push(card(expiry));
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
}
fn advance_retry(store: &FakeAccountStore) {
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
}
#[tokio::test]
async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_quota() {
for (threshold, used, consume) in [
(None, 0.0, true),
(Some(70), 29.9, false),
(Some(70), 30.0, true),
(Some(0), 99.9, false),
(Some(0), 100.0, true),
(Some(100), 0.0, true),
] {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(used));
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
assert_eq!(
!provider.reset_credit_commands().is_empty(),
consume,
"threshold={threshold:?}, used={used}"
);
let quota_requests = provider.quota_requests();
// 消费成功已有一次回读;未设置上限时不增加消费前查询
assert_eq!(
quota_requests.len(),
usize::from(threshold.is_some()) + usize::from(consume)
);
assert!(quota_requests.iter().all(|request| request.refresh));
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.is_empty(), consume);
if !consume {
assert_eq!(
record.state.last_result.as_deref(),
Some("weekly_quota_above_threshold")
);
assert!(record.state.retry_at.unwrap() > Utc::now());
assert!(record.state.retry_at.unwrap() <= record.state.credits[0].expires_at);
}
}
}
#[tokio::test]
async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(weekly_quota(10.0));
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
assert!(provider.reset_credit_commands().is_empty());
assert_eq!(provider.quota_requests().len(), 1);
provider.set_quota(weekly_quota(40.0));
advance_retry(&store);
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.credits.is_empty());
assert!(record.state.retry_at.is_none());
}
#[tokio::test]
async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
let weekly = weekly_quota(90.0).windows.remove(0);
let mut short = weekly.clone();
short.window_seconds = Some(18_000);
let mut model = weekly.clone();
model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
let mut month = weekly.clone();
month.group = "monthly".into();
month.window_seconds = Some(30 * 86_400);
let mut missing_percent = weekly.clone();
missing_percent.used_percent = None;
for (quota, failure) in [
(empty_quota(), None),
(
ProviderQuota {
windows: vec![short, model, month],
..empty_quota()
},
None,
),
(
ProviderQuota {
windows: vec![missing_percent],
..empty_quota()
},
None,
),
(weekly_quota(f64::NAN), None),
(
weekly_quota(90.0),
Some(ProviderAdminErrorKind::Unavailable),
),
] {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
provider.set_quota(quota);
*provider.quota_failure.lock().unwrap() = failure;
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
let result = services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await;
assert_eq!(result.is_err(), failure.is_some());
assert!(provider.reset_credit_commands().is_empty());
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
record.state.last_result.as_deref(),
Some("weekly_quota_unavailable")
);
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.len(), 1);
assert!(record.state.retry_at.is_some());
}
}
#[tokio::test]
async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key() {
let provider = FakeProviderAdmin::new("openai", events());
let store = FakeAccountStore::new("openai", events());
schedule_due_credit(&provider, &store);
*provider.reset_credit_result.lock().unwrap() = "nothing_to_reset".into();
let services = accounts_service(provider.clone(), store.clone()).await;
let id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
record.state.last_result.as_deref(),
Some("nothing_to_reset")
);
assert!(record.state.pending.is_none());
assert_eq!(record.state.credits.len(), 1);
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
advance_retry(&store);
*provider.reset_credit_result.lock().unwrap() = "reset".into();
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
let commands = provider.reset_credit_commands();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].credit_id, commands[1].credit_id);
assert_ne!(commands[0].redeem_request_id, commands[1].redeem_request_id);
assert!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.credits
.is_empty()
);
}
@@ -62,6 +62,8 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
}
}
@@ -158,6 +160,19 @@ impl FreezeRuntimeStore {
#[async_trait]
impl AccountRuntimeStore for FreezeRuntimeStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -96,6 +96,7 @@ pub(super) struct AdminHarness {
client_session_ttl_minutes: u64,
accounts: Arc<dyn AccountStore>,
proxies: Arc<dyn gateway_admin::ports::proxy::ProxyStore>,
proxy_probe: Arc<dyn gateway_admin::ports::proxy::ProxyProbe>,
account_runtime: Arc<dyn AccountRuntimeStore>,
account_groups: Arc<dyn AccountGroupStore>,
auth: Arc<dyn AuthStore>,
@@ -121,6 +122,7 @@ impl AdminHarness {
client_session_ttl_minutes: 1_440,
accounts: unavailable.clone(),
proxies: Arc::new(proxies::TestProxies::default()),
proxy_probe: Arc::new(proxies::TestProxies::default()),
account_runtime: unavailable.clone(),
account_groups: Arc::new(UnavailableAccountGroupStore),
auth: Arc::new(BootstrapAuthStore::default()),
@@ -291,7 +293,7 @@ impl AdminHarness {
.unwrap(),
snapshot: Arc::new(NoopSnapshot),
account_probe: self.probe,
proxy_probe: Arc::new(proxies::TestProxies::default()),
proxy_probe: self.proxy_probe,
client_distribution: Arc::new(NoopClientDistribution),
system: self.system,
client_key_verifier: self.client_key_verifier,
@@ -476,6 +478,10 @@ impl AccountGroupStore for UnavailableAccountGroupStore {
#[async_trait]
impl AccountStore for UnavailableStore {
async fn account_exists(&self, _: &str) -> AdminStoreResult<bool> {
Err(unavailable("account"))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -645,6 +651,19 @@ impl AccountStore for UnavailableStore {
#[async_trait]
impl AccountRuntimeStore for UnavailableStore {
async fn account_concurrency(
&self,
_: &str,
_: u64,
_: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
Err(gateway_admin::ports::store::AdminStoreError::new(
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
"account concurrency",
"not configured in this fixture",
))
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot::default())
}
@@ -1160,3 +1179,31 @@ fn unavailable_system() -> SystemOperationError {
)
}
mod service_contract;
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for UnavailableStore
{
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
@@ -1140,6 +1140,8 @@ impl SettingsStore for FixtureSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
})
}
@@ -434,6 +434,7 @@ impl PluginStateStore for LifecycleFixture {
from_schema_version: 1,
to_schema_version: 2,
}],
guard: Arc::new(()),
})
}
@@ -15,6 +15,7 @@ pub(super) struct TestProxies {
pub events: Option<super::accounts::EventLog>,
pub accounts: Option<Vec<ProxyAccountRef>>,
pub record: Option<ProxyRecord>,
pub records: Vec<ProxyRecord>,
}
struct ImportGuard(super::accounts::EventLog);
@@ -77,9 +78,13 @@ impl ProxyStore for TestProxies {
self.record
.clone()
.filter(|record| record.id == id)
.or_else(|| self.records.iter().find(|record| record.id == id).cloned())
.ok_or_else(|| super::unavailable("proxy"))
}
async fn create(&self, _: NewProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
if let Some(events) = &self.events {
events.lock().unwrap().push("proxy.create");
}
Err(super::unavailable("proxy"))
}
async fn update(&self, _: UpdateProxy, _: &MutationContext) -> AdminStoreResult<ProxyMutation> {
@@ -95,18 +100,23 @@ impl ProxyStore for TestProxies {
}
async fn record_test(
&self,
_: &str,
id: &str,
_: Revision,
_: ProxyTestResult,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
Err(super::unavailable("proxy"))
let mut record = self.get(id).await?;
record.last_test = Some(result);
Ok(ProxyMutation {
config_revision: record.revision,
record,
})
}
}
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: &OutboundProxy, _: bool) -> ProxyTestResult {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
@@ -310,3 +320,163 @@ async fn credential_import_keeps_proxy_reserved_until_commit_and_releases_on_err
}
}
}
#[tokio::test]
async fn batch_create_stops_after_uncertain_store_failure_without_replaying() {
use super::accounts::{context, events, recorded};
use gateway_admin::model::AdminErrorKind;
let events = events();
let services = super::AdminHarness::new()
.proxies(std::sync::Arc::new(TestProxies {
events: Some(events.clone()),
..Default::default()
}))
.build()
.await;
let commands = [" ", "First", "Skipped"]
.into_iter()
.map(|name| {
Ok(NewProxy {
name: name.to_owned(),
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
auto_location: false,
location: None,
test: None,
})
})
.collect();
let results = services
.proxies()
.create_batch(commands, &context("batch-create"))
.await
.unwrap();
assert!(
matches!(&results[0], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Invalid)
);
assert!(
matches!(&results[1], ProxyBatchResult::Completed(Err(error)) if error.kind() == AdminErrorKind::Unavailable)
);
assert!(matches!(&results[2], ProxyBatchResult::Skipped));
assert_eq!(recorded(&events), vec!["proxy.create"]);
}
struct GatedProxyProbe {
started: tokio::sync::mpsc::UnboundedSender<()>,
release: tokio::sync::Semaphore,
active: std::sync::atomic::AtomicUsize,
}
struct ActiveProbe<'a>(&'a std::sync::atomic::AtomicUsize);
impl Drop for ActiveProbe<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
#[async_trait]
impl ProxyProbe for GatedProxyProbe {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
self.active
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _active = ActiveProbe(&self.active);
self.started.send(()).unwrap();
self.release.acquire().await.unwrap().forget();
ProxyTestResult {
location: Default::default(),
success: true,
latency_ms: 1,
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "ok".to_owned(),
}
}
}
#[tokio::test]
async fn batch_tests_share_capacity_preserve_order_and_release_slots_on_cancellation() {
use super::accounts::context;
use std::sync::{Arc, atomic::Ordering};
let (started, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let probe = Arc::new(GatedProxyProbe {
started,
release: tokio::sync::Semaphore::new(0),
active: Default::default(),
});
let now = chrono::Utc::now();
let records = (0..4)
.map(|i| ProxyRecord {
id: format!("proxy_{i}"),
name: format!("Proxy {i}"),
proxy: OutboundProxy::parse("http://proxy.example:8080").unwrap(),
revision: Revision::new(1).unwrap(),
enabled: true,
auto_location: false,
location: None,
detected_location: None,
account_count: 0,
last_test_at: None,
last_test: None,
created_at: now,
updated_at: now,
})
.collect();
let mut harness = super::AdminHarness::new().proxies(Arc::new(TestProxies {
records,
..Default::default()
}));
harness.proxy_probe = probe.clone();
let services = harness.build().await;
let commands = || {
(0..4)
.map(|i| {
Ok(TestProxy {
id: format!("proxy_{i}"),
revision: Revision::new(1).unwrap(),
detect_location: true,
})
})
.collect()
};
let context = context("batch-test");
let mut batch = Box::pin(services.proxies().test_batch(commands(), &context));
tokio::select! {
_ = &mut batch => panic!("batch finished before probes were released"),
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
}
assert_eq!(probe.active.load(Ordering::Relaxed), 2);
let mut single_a = Box::pin(services.proxies().probe(None, false));
let mut single_b = Box::pin(services.proxies().probe(None, false));
tokio::select! {
_ = &mut batch => panic!("batch unexpectedly finished"),
_ = &mut single_a => panic!("single probe unexpectedly finished"),
_ = &mut single_b => panic!("single probe unexpectedly finished"),
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
}
assert_eq!(probe.active.load(Ordering::Relaxed), 4);
assert_eq!(
services
.proxies()
.probe(None, false)
.await
.unwrap_err()
.kind(),
gateway_admin::model::AdminErrorKind::RateLimited
);
drop((batch, single_a, single_b));
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
probe.release.add_permits(4);
let results = services
.proxies()
.test_batch(commands(), &context)
.await
.unwrap();
for (index, result) in results.into_iter().enumerate() {
let ProxyBatchResult::Completed(Ok(record)) = result else {
panic!("probe failed after cancellation")
};
assert_eq!(record.id, format!("proxy_{index}"));
assert!(record.last_test.unwrap().success);
}
assert_eq!(probe.active.load(Ordering::Relaxed), 0);
}
@@ -124,6 +124,89 @@ fn contract_type(mut item: Item, name: &str) -> Item {
item
}
fn service_operations(
root: &Path,
namespace: &str,
service_path: &str,
service_name: &str,
) -> (Vec<syn::File>, usize) {
let service = source(root, service_path)
.items
.into_iter()
.find_map(|item| match item {
Item::Trait(item) if item.ident == service_name => Some(item),
_ => None,
})
.unwrap();
let outputs: BTreeMap<_, _> = service
.items
.into_iter()
.filter_map(|item| match item {
syn::TraitItem::Fn(method) => {
let syn::ReturnType::Type(_, output) = method.sig.output else {
panic!("服务必须返回 Result")
};
let Type::Path(result) = *output else {
panic!("服务必须返回 Result")
};
let segment = result.path.segments.last().unwrap();
assert_eq!(segment.ident, "Result");
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
panic!("Result 缺少类型参数")
};
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
else {
panic!("Result 缺少输出类型")
};
WireTypes.visit_type_mut(&mut output);
Some((method.sig.ident.to_string(), output))
}
_ => None,
})
.collect();
let register = source(root, &format!("src/public_service/{namespace}.rs"))
.items
.into_iter()
.find_map(|item| match item {
Item::Fn(item) if item.sig.ident == "register" => Some(item),
_ => None,
})
.unwrap();
let operations: Vec<syn::File> = register
.block
.stmts
.into_iter()
.filter_map(|stmt| {
let syn::Stmt::Macro(stmt) = stmt else {
return None;
};
if !stmt.mac.path.is_ident("register") {
return None;
}
let Operation {
name,
method,
mut input,
} = syn::parse2(stmt.mac.tokens).unwrap();
WireTypes.visit_type_mut(&mut input);
let output = outputs.get(&method.to_string()).unwrap();
let operation = format!("{namespace}.{method}");
Some(
syn::parse2(quote! {
pub struct #name;
impl Operation for #name {
const NAME: &'static str = #operation;
type Input = #input;
type Output = #output;
}
})
.unwrap(),
)
})
.collect();
(operations, outputs.len())
}
#[test]
fn sdk_settings_contract_matches_host_declarations() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
@@ -240,78 +323,13 @@ fn sdk_settings_contract_matches_host_declarations() {
}
}
let service = source(root, "src/use_case/settings.rs")
.items
.into_iter()
.find_map(|item| match item {
Item::Trait(item) if item.ident == "SettingsService" => Some(item),
_ => None,
})
.unwrap();
let outputs: BTreeMap<_, _> = service
.items
.into_iter()
.filter_map(|item| match item {
syn::TraitItem::Fn(method) => {
let syn::ReturnType::Type(_, output) = method.sig.output else {
panic!("服务必须返回 Result")
};
let Type::Path(result) = *output else {
panic!("服务必须返回 Result")
};
let segment = result.path.segments.last().unwrap();
assert_eq!(segment.ident, "Result");
let syn::PathArguments::AngleBracketed(args) = &segment.arguments else {
panic!("Result 缺少类型参数")
};
let syn::GenericArgument::Type(mut output) = args.args.first().unwrap().clone()
else {
panic!("Result 缺少输出类型")
};
WireTypes.visit_type_mut(&mut output);
Some((method.sig.ident.to_string(), output))
}
_ => None,
})
.collect();
let register = source(root, "src/public_service/settings.rs")
.items
.into_iter()
.find_map(|item| match item {
Item::Fn(item) if item.sig.ident == "register" => Some(item),
_ => None,
})
.unwrap();
let operations: Vec<_> = register
.block
.stmts
.into_iter()
.filter_map(|stmt| {
let syn::Stmt::Macro(stmt) = stmt else {
return None;
};
if !stmt.mac.path.is_ident("register") {
return None;
}
let Operation {
name,
method,
mut input,
} = syn::parse2(stmt.mac.tokens).unwrap();
WireTypes.visit_type_mut(&mut input);
let output = outputs.get(&method.to_string()).unwrap();
let operation = format!("settings.{method}");
Some(quote! {
pub struct #name;
impl Operation for #name {
const NAME: &'static str = #operation;
type Input = #input;
type Output = #output;
}
})
})
.collect();
assert_eq!(operations.len(), outputs.len(), "公开服务方法必须完整声明");
let (operations, method_count) = service_operations(
root,
"settings",
"src/use_case/settings.rs",
"SettingsService",
);
assert_eq!(operations.len(), method_count, "公开服务方法必须完整声明");
let assignments = replacement_fields.iter().map(|field| match field.to_string().as_str() {
"expected_revision" => quote!(expected_revision: settings.config_revision),
"request_profile_updates" => quote!(request_profile_updates: settings.request_profiles.into_iter().map(|(provider, value)| (provider, Some(value))).collect()),
@@ -340,14 +358,48 @@ fn sdk_settings_contract_matches_host_declarations() {
}
}
};
let output = root.join("../gateway-plugin/sdk/src/call/services/settings.rs");
let expected = prettyplease::unparse(&syn::parse2::<syn::File>(generated).unwrap());
check_contract(root, "settings", syn::parse2(generated).unwrap());
}
#[test]
fn sdk_accounts_contract_matches_host_declarations() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let status = source(root, "src/model/reset_credit_automation.rs")
.items
.into_iter()
.find(|item| matches!(item, Item::Struct(item) if item.ident == "ResetCreditAutomationStatus"))
.unwrap();
let status = contract_type(status, "ResetCreditAutomationStatus");
let (operations, _) = service_operations(
root,
"accounts",
"src/use_case/accounts/mod.rs",
"AccountsService",
);
let generated = quote! {
//! 账号自动重置的类型化操作与状态合同
//!
//! 从宿主状态类型与 public_service/accounts.rs 生成;更新命令见 SDK 维护说明
use super::Operation;
use serde::{Deserialize, Serialize};
#status
#(#operations)*
};
check_contract(root, "accounts", syn::parse2(generated).unwrap());
}
fn check_contract(root: &Path, namespace: &str, generated: syn::File) {
let output = root.join(format!(
"../gateway-plugin/sdk/src/call/services/{namespace}.rs"
));
let expected = prettyplease::unparse(&generated);
if std::env::var_os("CPR_UPDATE_SERVICE_CONTRACT").is_some() {
std::fs::write(&output, &expected).unwrap();
}
let actual = syn::parse_file(&std::fs::read_to_string(output).unwrap()).unwrap();
assert!(
prettyplease::unparse(&actual) == expected,
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_settings_contract_matches_host_declarations"
"SDK 服务合同已过期:CPR_UPDATE_SERVICE_CONTRACT=1 cargo test -p gateway-admin --test main sdk_{namespace}_contract_matches_host_declarations"
);
}
@@ -355,6 +355,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
},
)
@@ -0,0 +1,100 @@
//! 账号普通池并发明细的有界实时查询
use super::*;
use crate::auth::SessionState;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct Query {
account_id: String,
#[serde(default = "first_page")]
page: u32,
#[serde(default = "page_size")]
page_size: u32,
}
const fn first_page() -> u32 {
1
}
const fn page_size() -> u32 {
20
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Entry {
lease_id: String,
request_id: Option<String>,
client_key_id: Option<String>,
client_key_name: Option<String>,
model: Option<String>,
client_ip: Option<String>,
started_at: Option<DateTime<Utc>>,
started_at_display: Option<String>,
occupied_ms: Option<u64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Data {
account_id: String,
entries: Vec<Entry>,
total: u64,
page: u32,
page_size: u32,
observed_at: DateTime<Utc>,
}
pub(super) async fn list<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<Query>,
) -> Result<Response, AdminError>
where
S: SessionState + Send + Sync,
{
require_account_id(&query.account_id, "accountId").map_err(map_wire_error)?;
let account_id = ProviderAccountId::new(query.account_id)
.map_err(|_| map_wire_error(WireValidationError::new("accountId")))?;
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.accounts()
.concurrency(account_id.clone(), query.page, query.page_size)
.await
.map_err(map_service_error)?;
let observed_at = result.observed_at;
let entries = result
.entries
.into_iter()
.map(|entry| Entry {
lease_id: entry.lease_id,
request_id: entry.request_id,
client_key_id: entry.client_key_id,
client_key_name: entry.client_key_name,
model: entry.model,
client_ip: entry.client_ip,
started_at: entry.started_at,
started_at_display: entry.started_at.map(|at| time.datetime(&at)),
occupied_ms: entry
.acquired_at
.map(|at| u64::try_from((observed_at - at).num_milliseconds()).unwrap_or(0)),
})
.collect();
let mut response = AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(Data {
account_id: account_id.as_str().to_owned(),
entries,
total: result.total,
page: query.page,
page_size: query.page_size,
observed_at,
}),
)
.into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
@@ -12,6 +12,10 @@ where
Router::new()
.merge(super::import_tasks::router::<S>())
.route("/api/admin/accounts", get(list_accounts::<S>))
.route(
"/api/admin/accounts/concurrency",
get(super::concurrency::list::<S>),
)
.route("/api/admin/accounts/detail", get(account_detail::<S>))
.route("/api/admin/accounts/export", get(export_accounts::<S>))
.route("/api/admin/accounts/import", post(import_accounts::<S>))
@@ -36,6 +40,11 @@ where
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits/automation",
get(account_reset_credit_automation::<S>)
.post(set_account_reset_credit_automation::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -625,3 +634,54 @@ where
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn account_reset_credit_automation<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.accounts()
.reset_credit_automation_status(query.into_id().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
async fn set_account_reset_credit_automation<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditAutomationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = AccountIdQuery {
account_id: request.account_id,
}
.into_id()
.map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.set_reset_credit_automation(
&auth.context().mutation_context(),
account_id,
request.enabled,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
@@ -61,6 +61,7 @@ const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
const MAX_AVATAR_VERSION_BYTES: usize = 32;
mod concurrency;
mod credentials;
mod handlers;
mod import_tasks;
@@ -1192,3 +1192,49 @@ fn profile_activity_calendar(
weeks,
})
}
/// 账号自动使用临期重置卡开关
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditAutomationRequest {
pub account_id: String,
pub enabled: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditAutomationData {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at_display: Option<String>,
pub next_consume_at_display: Option<String>,
pub last_result: Option<String>,
}
impl
From<(
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
)> for AccountResetCreditAutomationData
{
fn from(
(status, time): (
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
),
) -> Self {
Self {
enabled: status.enabled,
lead_minutes: status.lead_minutes,
next_discovery_at_display: status
.next_discovery_at
.as_ref()
.map(|value| time.datetime(value)),
next_consume_at_display: status
.next_consume_at
.as_ref()
.map(|value| time.datetime(value)),
last_result: status.last_result,
}
}
}
@@ -251,6 +251,8 @@ async fn callback<S: SessionState + Send + Sync>(
&path.target(),
&nonce,
PluginManagementRequest {
request_context: crate::middleware::current()
.and_then(|context| context.extensions.shared_request()),
method: "GET".into(),
path: path.path,
query,
@@ -323,6 +325,8 @@ async fn handle<S: SessionState + Send + Sync>(
.handle(
&target,
PluginManagementRequest {
request_context: crate::middleware::current()
.and_then(|context| context.extensions.shared_request()),
method,
path: path.path,
query,
+97 -34
View File
@@ -10,10 +10,10 @@ use axum::{
routing::{get, post},
};
use gateway_admin::model::{
PageSize, Revision,
AdminError as ServiceError, PageSize, Revision,
proxies::{
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
ProxyTestResult, UpdateProxy,
NewProxy, ProxyAccountListQuery, ProxyBatchResult, ProxyListQuery, ProxyMutation,
ProxyRecord, ProxyTestResult, TestProxy, UpdateProxy,
},
};
use serde::{Deserialize, Serialize};
@@ -50,6 +50,8 @@ struct RemoveAccountRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CreateRequest {
#[serde(default)]
max_websocket_connections: u32,
#[serde(default)]
auto_location: bool,
location: Option<gateway_core::account::RequestLocation>,
@@ -60,6 +62,7 @@ struct CreateRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct UpdateRequest {
max_websocket_connections: Option<u32>,
enabled: Option<bool>,
auto_location: Option<bool>,
#[serde(default, deserialize_with = "deserialize_location_update")]
@@ -146,6 +149,7 @@ struct ProxyAccountView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ProxyView {
max_websocket_connections: u32,
enabled: bool,
auto_location: bool,
detected_location: Option<gateway_admin::model::proxies::DetectedProxyLocation>,
@@ -170,6 +174,7 @@ impl From<(ProxyRecord, crate::time::TimePresenter)> for ProxyView {
let endpoint = record.proxy.endpoint();
Self {
auto_location: record.auto_location,
max_websocket_connections: record.proxy.max_websocket_connections(),
enabled: record.enabled,
detected_location: record.detected_location,
location: record.location,
@@ -342,38 +347,88 @@ where
))
}
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
enum BatchView<T> {
Succeeded {
data: T,
},
Failed {
#[serde(rename = "httpStatus")]
http_status: u16,
#[serde(flatten)]
error: super::wire::AdminErrorBody,
},
Skipped,
}
fn batch_response<T, V: Serialize>(
results: Vec<ProxyBatchResult<T>>,
project: impl Fn(T) -> V,
) -> axum::response::Response {
let diagnostic = results.iter().find_map(|result| match result {
ProxyBatchResult::Completed(Err(error)) if error.error_details().is_some() => {
Some(error.clone())
}
_ => None,
});
let items: Vec<BatchView<V>> = results
.into_iter()
.map(|result| match result {
ProxyBatchResult::Completed(Ok(value)) => BatchView::Succeeded {
data: project(value),
},
ProxyBatchResult::Completed(Err(error)) => {
let (http_status, error) = map_error(error).into_batch_error();
BatchView::Failed { http_status, error }
}
ProxyBatchResult::Skipped => BatchView::Skipped,
})
.collect();
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(items)).into_response();
if let Some(diagnostic) = diagnostic {
response.extensions_mut().insert(diagnostic);
}
response
}
async fn create<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<CreateRequest>,
AdminJson(requests): AdminJson<Vec<serde_json::Value>>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let proxy = request
.proxy_url
.0
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
let result = state
.admin_services()
.proxies()
.create(
NewProxy {
let commands = requests
.into_iter()
.map(|value| {
// 每项单独解码,格式错误只拒绝当前项,错误文案不能回显代理认证
let request: CreateRequest = serde_json::from_value(value)
.map_err(|_| ServiceError::invalid("代理参数格式不正确"))?;
let proxy = request
.proxy_url
.0
.ok_or_else(|| ServiceError::invalid("代理 URL 不能为空"))?;
Ok(NewProxy {
auto_location: request.auto_location,
test: None,
location: request.location,
name: request.name,
proxy,
},
&auth.context().mutation_context(),
)
proxy: proxy.with_max_websocket_connections(request.max_websocket_connections),
})
})
.collect();
let results = state
.admin_services()
.proxies()
.create_batch(commands, &auth.context().mutation_context())
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::CREATED,
AdminEnvelope::ok(MutationView::from((result, time))),
))
Ok(batch_response(results, |value| {
MutationView::from((value, time))
}))
}
async fn remove_account<S>(
@@ -422,6 +477,7 @@ where
.proxies()
.update(
UpdateProxy {
max_websocket_connections: request.max_websocket_connections,
enabled: request.enabled,
auto_location: request.auto_location,
test: None,
@@ -480,7 +536,7 @@ where
let result = state
.admin_services()
.proxies()
.probe(&proxy, request.detect_location)
.probe(Some(&proxy), request.detect_location)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
@@ -492,25 +548,32 @@ where
async fn test<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<TestRequest>,
AdminJson(requests): AdminJson<Vec<serde_json::Value>>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
let commands = requests
.into_iter()
.map(|value| {
let request: TestRequest = serde_json::from_value(value)
.map_err(|_| ServiceError::invalid("代理参数格式不正确"))?;
Ok(TestProxy {
id: request.id,
revision: revision(request.revision)
.map_err(|error| ServiceError::invalid(error.to_string()))?,
detect_location: request.detect_location,
})
})
.collect();
let results = state
.admin_services()
.proxies()
.test(
&request.id,
revision(request.revision)?,
request.detect_location,
&auth.context().mutation_context(),
)
.test_batch(commands, &auth.context().mutation_context())
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(ProxyView::from((result, time))),
))
Ok(batch_response(results, |value| {
ProxyView::from((value, time))
}))
}
@@ -66,6 +66,8 @@ pub struct RuntimeSettingsFields {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
}
/// 运行配置投影与设置页字段的聚合响应
@@ -275,6 +277,10 @@ where
)
.route("/api/admin/settings/pricing/sync", post(sync_pricing::<S>))
.route("/api/admin/settings", get(settings::<S>))
.route(
"/api/admin/settings/location/detect",
post(detect_location::<S>),
)
.route(
"/api/admin/settings/client-profiles/{provider}",
get(client_profile_options::<S>),
@@ -648,6 +654,25 @@ fn deserialize_profile_update<'de, D: serde::Deserializer<'de>>(
serde_json::Map::<String, serde_json::Value>::deserialize(deserializer).map(Some)
}
async fn detect_location<S>(
_auth: AdminAuth,
State(state): State<S>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.proxies()
.probe(None, true)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result.location),
))
}
async fn client_profile_options<S>(
_auth: AdminAuth,
Path(provider): Path<String>,
@@ -762,6 +787,9 @@ impl RuntimeSettingsFields {
account_warmup_enabled: self.account_warmup_enabled,
account_warmup_schedule_time: self.account_warmup_schedule_time,
account_warmup_model: self.account_warmup_model,
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: self
.reset_credit_max_weekly_remaining_percent,
};
values.validate().map_err(settings_validation_error)?;
Ok(values)
@@ -802,6 +830,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
account_warmup_enabled: settings.account_warmup_enabled,
account_warmup_schedule_time: settings.account_warmup_schedule_time,
account_warmup_model: settings.account_warmup_model,
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
reset_credit_max_weekly_remaining_percent: settings
.reset_credit_max_weekly_remaining_percent,
}
}
}
@@ -817,6 +848,8 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
@@ -262,6 +262,10 @@ const SERVICE_UNAVAILABLE: AdminErrorSpec = AdminErrorSpec::new(
);
impl AdminError {
pub(super) fn into_batch_error(self) -> (u16, AdminErrorBody) {
(self.status.as_u16(), self.body)
}
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
Self {
status,
+1
View File
@@ -34,6 +34,7 @@ mod key_usage;
mod middleware;
pub mod openai;
mod provider;
mod request_context;
mod session_cookie;
mod time;
@@ -151,19 +151,6 @@ async fn handle(
if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) {
request.extensions_mut().insert(plan.clone());
}
if inherited.is_none()
&& !plan
.as_ref()
.is_some_and(|plan| plan.has_http() || plan.has_service())
{
let settings = request
.extensions()
.get::<Settings>()
.and_then(|settings| settings.runtime.clone());
return SETTINGS
.scope(settings, dispatch(router, request, started))
.await;
}
let context = inherited.unwrap_or_else(|| {
let request_id = request
.headers()
@@ -176,7 +163,8 @@ async fn handle(
call_id: request_id.clone(),
request_id,
parent_call_id: None,
extensions: Default::default(),
extensions: gateway_core::engine::extensions::ExtensionCallScope::default()
.with_request(crate::request_context::capture(&request)),
cancellation: CancellationToken::new(),
plan: plan.clone(),
}
@@ -72,6 +72,7 @@ impl upgrade::WebSocketUpgrade for Upgrade {
let reader_task = tokio::spawn(read_messages(reader, incoming, cancellation));
Ok(Arc::new(Session {
reader: tokio::sync::Mutex::new(received), reader_task: reader_task.abort_handle(), writer,
extensions: context.extensions,
plan: context.plan, connection_id: context.request_id, headers,
cancellation: context.cancellation,
}) as Arc<dyn core::Session>)
@@ -108,6 +109,7 @@ pub(crate) async fn transform(
}
struct Session {
extensions: gateway_core::engine::extensions::ExtensionCallScope,
reader: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Message>>,
reader_task: tokio::task::AbortHandle,
writer: Arc<Writer>,
@@ -125,6 +127,7 @@ impl Session {
) -> Result<Option<core::Message>, MiddlewareError> {
transform(
core::Context {
extensions: self.extensions.clone(),
plan: self.plan.clone(),
connection_id: self.connection_id.clone(),
direction,
@@ -118,6 +118,9 @@ fn client_authentication_request(
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = ClientAuthenticationRequest::new(raw.to_owned())
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
let request = request.with_request_context(
crate::middleware::current().and_then(|context| context.extensions.shared_request()),
);
Ok(match crate::middleware::current_settings() {
Some(settings) => request.with_settings(settings),
None => request,
@@ -1,6 +1,6 @@
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界
use std::net::IpAddr;
use std::{future::Future, net::IpAddr};
use axum::{
body::{Body, Bytes},
@@ -30,7 +30,7 @@ use super::{
};
/// Provider 自有端点也先冻结身份,再通过统一请求链进入原有执行与结算路径
pub(super) async fn provider_endpoint_response<F>(
pub(super) async fn provider_endpoint_response<F, Fut>(
service: OpenAiService,
client: AuthenticatedClient,
input: RequestInput,
@@ -39,7 +39,8 @@ pub(super) async fn provider_endpoint_response<F>(
decode: F,
) -> Response
where
F: FnOnce(Bytes, &HeaderMap) -> Result<Operation, GatewayError> + Send + 'static,
F: FnOnce(Bytes, HeaderMap) -> Fut + Send + 'static,
Fut: Future<Output = Result<Operation, GatewayError>> + Send,
{
let execution = service.execution();
let prepared = match execution.prepare_execution(client).await {
@@ -58,7 +59,7 @@ where
if protocol != "openai" {
return Err(MiddlewareError::Rejected);
}
let operation = request.apply_capabilities(decode(body, &headers)?)?;
let operation = request.apply_capabilities(decode(body, headers).await?)?;
let started = match service
.start_prepared_provider_endpoint(
prepared, operation,
@@ -100,12 +100,17 @@ async fn handle_image_request(
.await
}
fn image_operation(
async fn image_operation(
body: Bytes,
headers: &HeaderMap,
mut headers: HeaderMap,
kind: ImageRequestKind,
) -> Result<Operation, GatewayError> {
let mut context = OpenAiRequestHeaders::from_headers(headers).session_context();
let body = if kind == ImageRequestKind::Edit {
super::request::normalize_edit_body(body, &mut headers).await?
} else {
body
};
let mut context = OpenAiRequestHeaders::from_headers(&headers).session_context();
if let Some(turn_id) = headers
.get("x-codex-image-turn-id")
.and_then(|value| value.to_str().ok())
@@ -1,5 +1,6 @@
//! OpenAI/Codex Images JSON 透明 adapter
//! OpenAI/Codex Images JSON 透传与图片上传适配
mod http;
mod request;
pub(crate) use http::{image_edits, image_generations};
@@ -0,0 +1,131 @@
//! 将图片编辑 multipart 上传映射到原生 Images JSON,保留图片字节与字段语义
use axum::{
body::{Body, Bytes},
extract::{DefaultBodyLimit, FromRequest, Multipart, Request},
http::{
HeaderMap, HeaderValue,
header::{CONTENT_LENGTH, CONTENT_TYPE},
},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use serde::ser::{SerializeMap, Serializer as _};
use serde_json::{Number, Value, json};
pub(super) async fn normalize_edit_body(
body: Bytes,
headers: &mut HeaderMap,
) -> Result<Bytes, GatewayError> {
let is_multipart = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|media| media.trim().eq_ignore_ascii_case("multipart/form-data"));
if !is_multipart {
return Ok(body);
}
if headers.get_all(CONTENT_TYPE).iter().count() != 1 {
return Err(invalid("Image edit requires a single Content-Type header"));
}
let mut request = Request::new(Body::from(body));
*request.headers_mut() = headers.clone();
// 原路由不设正文私有上限,重新提取时不能引入 Axum 默认的 2 MiB 限制
DefaultBodyLimit::disable().apply(&mut request);
let mut multipart = Multipart::from_request(request, &())
.await
.map_err(|_| invalid("Image edit multipart boundary is invalid"))?;
let mut body = Vec::new();
let mut serializer = serde_json::Serializer::new(&mut body);
// 直接序列化字段,避免经过 Map 后覆盖同名参数
let mut fields = serializer.serialize_map(None).map_err(encode_error)?;
let mut images = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| invalid("Image edit multipart body is invalid"))?
{
let name = field
.name()
.ok_or_else(|| invalid("Image edit multipart fields must have names"))?
.to_owned();
let is_file = field.file_name().is_some();
let bytes = field
.bytes()
.await
.map_err(|_| invalid("Image edit multipart field is invalid"))?;
if matches!(name.as_str(), "image" | "image[]") {
// 按表单中的出现顺序追加,多图顺序参与编辑语义
images.push(image_reference(&bytes)?);
continue;
}
let value = if name == "mask" {
image_reference(&bytes)?
} else {
if is_file {
return Err(invalid(
"Only image, image[] and mask may contain image files",
));
}
scalar_field(&name, &bytes)?
};
fields
.serialize_entry(&name, &value)
.map_err(encode_error)?;
}
if images.is_empty() {
return Err(invalid(
"Image edit multipart requires an image or image[] upload",
));
}
fields
.serialize_entry("images", &images)
.map_err(encode_error)?;
fields.end().map_err(encode_error)?;
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
headers.remove(CONTENT_LENGTH);
Ok(Bytes::from(body))
}
fn scalar_field(name: &str, bytes: &[u8]) -> Result<Value, GatewayError> {
match name {
"n" | "output_compression" | "partial_images" => serde_json::from_slice::<Number>(bytes)
.map(Value::Number)
.map_err(|_| invalid("Image edit numeric fields must contain JSON numbers")),
"stream" => serde_json::from_slice::<bool>(bytes)
.map(Value::Bool)
.map_err(|_| invalid("Image edit stream field must contain a JSON boolean")),
// 其余表单字段保留名称与文本值,不因字段尚未识别而丢弃或拒绝
_ => {
let text = std::str::from_utf8(bytes)
.map_err(|_| invalid("Image edit text fields must be valid UTF-8"))?;
Ok(Value::String(text.to_owned()))
}
}
}
fn image_reference(bytes: &[u8]) -> Result<Value, GatewayError> {
// 与官方 Original 路径一样保留 PNG/JPEG/WebP 原始字节;不依赖文件名或上传 MIME
let mime = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
"image/png"
} else if bytes.starts_with(b"\xff\xd8\xff") {
"image/jpeg"
} else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
"image/webp"
} else {
return Err(invalid("Image edit uploads must be PNG, JPEG or WebP"));
};
Ok(json!({ "image_url": format!("data:{mime};base64,{}", STANDARD.encode(bytes)) }))
}
fn invalid(message: &'static str) -> GatewayError {
GatewayError::new(GatewayErrorKind::InvalidRequest, message)
}
fn encode_error(_: serde_json::Error) -> GatewayError {
GatewayError::new(
GatewayErrorKind::Internal,
"Could not encode image edit JSON",
)
}
@@ -1,8 +1,10 @@
//! OpenAI Responses HTTP 与 SSE adapter
use crate::request_context::request_client_context;
use std::collections::VecDeque;
use std::future::Future;
use std::net::{IpAddr, SocketAddr};
use std::net::SocketAddr;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
@@ -11,7 +13,7 @@ use axum::{
extract::{Extension, State, connect_info::ConnectInfo},
http::{
HeaderMap, HeaderName, HeaderValue, StatusCode,
header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE},
},
response::Response,
};
@@ -374,55 +376,6 @@ impl MiddlewareBody for AuthorizedResponseBody {
}
}
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实
pub(crate) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
let client_ip = ["cf-connecting-ip", "x-real-ip"]
.into_iter()
.find_map(|name| header_ip(headers, name))
.or_else(|| forwarded_client_ip(headers))
.or_else(|| peer_address.map(|address| address.ip()));
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(client_ip, user_agent)
}
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.and_then(|value| value.parse().ok())
}
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
let addresses = headers
.get("x-forwarded-for")?
.to_str()
.ok()?
.split(',')
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
.collect::<Vec<_>>();
addresses
.iter()
.copied()
.find(|address| !is_private_or_loopback(*address))
.or_else(|| addresses.first().copied())
}
const fn is_private_or_loopback(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
}
}
async fn execution_middleware_response(
started: StartedExecution,
connection_guard: Option<Box<dyn ConnectionGuard>>,
@@ -8,10 +8,10 @@ mod response;
mod validation;
pub mod websocket;
pub(crate) use crate::request_context::request_client_context;
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
pub(crate) use http::{
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses,
request_client_context, responses,
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
};
pub use http::{collect_execution_response, stream_execution_response};
pub(crate) use request::decode_request_with_body;
@@ -332,6 +332,9 @@ where
let (command_tx, command_rx) = mpsc::channel(OUTBOUND_COMMAND_BUFFER);
let (incoming_tx, incoming_rx) = mpsc::channel(INBOUND_EVENT_BUFFER);
let (exit_tx, exit_rx) = oneshot::channel();
let extensions = crate::middleware::current()
.map(|context| context.extensions)
.unwrap_or_default();
let pump_task = tokio::spawn(run_pump(
socket,
command_rx,
@@ -345,6 +348,7 @@ where
config,
middleware,
headers,
extensions,
));
ResponsesWebSocketConnection {
connection_id,
@@ -100,6 +100,7 @@ pub(super) async fn transform(
};
crate::middleware::websocket::transform(
core::Context {
extensions: context.extensions.clone(),
plan: Some(plan.clone()),
connection_id: context.connection_id.to_string(),
direction,
@@ -37,11 +37,13 @@ pub(super) async fn run_pump<S, E>(
config: ConnectionConfig,
middleware: Option<FrozenMiddlewarePlan>,
headers: Arc<[MiddlewareHeader]>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
) where
S: Stream<Item = Result<Message, E>> + Sink<Message, Error = E> + Unpin + Send + 'static,
E: fmt::Display + Send + 'static,
{
let context = Arc::new(PumpContext {
extensions,
connection_id,
cancellation: cancellation.child_token(),
opened_at,
@@ -64,6 +64,7 @@ impl ConnectionStats {
}
pub(super) struct PumpContext {
pub(super) extensions: gateway_core::engine::extensions::ExtensionCallScope,
pub(super) middleware: Option<FrozenMiddlewarePlan>,
pub(super) headers: Arc<[MiddlewareHeader]>,
pub(super) connection_id: Arc<str>,
@@ -43,8 +43,7 @@ use crate::{
};
use super::{
http::request_client_context, request::OpenAiRequestHeaders,
validation::ResponseValidationFacts,
request::OpenAiRequestHeaders, request_client_context, validation::ResponseValidationFacts,
};
use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext};
use forward::{
@@ -54,7 +54,7 @@ pub(crate) async fn standalone_search(
},
client_ip,
user_agent,
search_operation,
async move |body, headers| search_operation(body, &headers),
)
.await
}
@@ -0,0 +1,85 @@
//! HTTP 入口快照与统一来源地址解析
use axum::{
extract::ConnectInfo,
http::{HeaderMap, Request, header::USER_AGENT},
};
use gateway_core::engine::{extensions::RequestContext, middleware::MiddlewareHeader};
use std::{
net::{IpAddr, SocketAddr},
sync::Arc,
};
pub(crate) fn capture<B>(request: &Request<B>) -> Arc<RequestContext> {
let peer_addr = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0);
let (client_ip, _) = request_client_context(request.headers(), peer_addr);
Arc::new(RequestContext {
method: request.method().to_string(),
uri: request.uri().to_string(),
http_version: format!("{:?}", request.version()),
headers: request
.headers()
.iter()
.map(|(name, value)| {
MiddlewareHeader::new(
name.as_str(),
bytes::Bytes::copy_from_slice(value.as_bytes()),
)
})
.collect(),
peer_addr,
client_ip,
})
}
/// 保留宿主的来源地址解析顺序,插件仍可从入口快照自行解释转发头
pub(crate) fn request_client_context(
headers: &HeaderMap,
peer_address: Option<SocketAddr>,
) -> (Option<IpAddr>, Option<String>) {
let client_ip = ["cf-connecting-ip", "x-real-ip"]
.into_iter()
.find_map(|name| header_ip(headers, name))
.or_else(|| forwarded_client_ip(headers))
.or_else(|| peer_address.map(|address| address.ip()));
let user_agent = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
(client_ip, user_agent)
}
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.and_then(|value| value.parse().ok())
}
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
let addresses = headers
.get("x-forwarded-for")?
.to_str()
.ok()?
.split(',')
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
.collect::<Vec<_>>();
addresses
.iter()
.copied()
.find(|address| !is_private_or_loopback(*address))
.or_else(|| addresses.first().copied())
}
const fn is_private_or_loopback(address: IpAddr) -> bool {
match address {
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
}
}
@@ -372,3 +372,73 @@ async fn quota_forecast_requires_admin_and_valid_account_query() {
assert!(value["message"].is_string());
}
}
#[tokio::test]
async fn concurrency_details_require_admin_validate_paging_and_report_lease_time() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
*fixture.account.lock().unwrap() = Some(account_page_item(
gateway_core::account::AccountStatusFacts {
enabled: true,
credential_state: gateway_core::account::CredentialState::Ready,
access_token_expires_at: None,
quota: gateway_core::account::QuotaState::unknown(),
cooldown: None,
last_error_reason: None,
last_error_message: None,
},
chrono::Utc::now(),
"api_key",
));
let router = admin::accounts::router::<AdminTestState>().with_state(fixture.state());
for (authenticated, query, expected) in [
(false, "accountId=acct_cooldown", StatusCode::UNAUTHORIZED),
(
true,
"accountId=acct_cooldown&page=0",
StatusCode::BAD_REQUEST,
),
(
true,
"accountId=acct_cooldown&pageSize=101",
StatusCode::BAD_REQUEST,
),
(
true,
"accountId=acct_cooldown&page=4294967295&pageSize=100",
StatusCode::BAD_REQUEST,
),
(true, "accountId=acct_missing", StatusCode::NOT_FOUND),
(
true,
"accountId=acct_cooldown&page=2&pageSize=20",
StatusCode::OK,
),
] {
let mut request = Request::builder()
.uri(format!("/api/admin/accounts/concurrency?{query}"))
.header("x-request-id", "req_concurrency");
if authenticated {
request = request.header(header::COOKIE, "cpr_session=valid-session");
}
let response = router
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected, "{query}");
if expected == StatusCode::OK {
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let body = to_bytes(response.into_body(), 8192).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(value["data"]["total"], 21);
assert_eq!(value["data"]["page"], 2);
assert_eq!(value["data"]["entries"][0]["occupiedMs"], 5000);
assert_eq!(value["data"]["entries"][0]["clientIp"], "2001:db8::376");
assert_eq!(
value["data"]["entries"][0]["startedAtDisplay"],
"2026-10-17 07:59:45"
);
}
}
}
@@ -646,6 +646,8 @@ impl SettingsStore for MemorySettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
};
*settings = updated.clone();
@@ -1130,6 +1132,15 @@ impl ClientKeyVerifier for UnusedClientKeyVerifier {
#[async_trait]
impl AccountStore for UnusedStore {
async fn account_exists(&self, id: &str) -> AdminStoreResult<bool> {
Ok(self
.account
.lock()
.expect("account")
.as_ref()
.is_some_and(|account| account.account.id == id))
}
async fn list_plugin_accounts(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
@@ -1348,6 +1359,32 @@ impl AccountStore for UnusedStore {
#[async_trait]
impl AccountRuntimeStore for UnusedStore {
async fn account_concurrency(
&self,
account_id: &str,
offset: u64,
limit: u32,
) -> AdminStoreResult<gateway_admin::model::accounts::AccountConcurrencyPage> {
use gateway_admin::model::accounts::{AccountConcurrencyEntry, AccountConcurrencyPage};
assert_eq!(account_id, "acct_cooldown");
assert_eq!((offset, limit), (20, 20));
let now = "2026-10-17T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
Ok(AccountConcurrencyPage {
total: 21,
observed_at: now,
entries: vec![AccountConcurrencyEntry {
lease_id: "lease_current".into(),
request_id: Some("req_current".into()),
client_key_id: Some("key_fixture".into()),
client_key_name: Some("测试 Key".into()),
model: Some("gpt-5.4".into()),
client_ip: Some("2001:db8::376".into()),
started_at: Some(now - chrono::Duration::seconds(15)),
acquired_at: Some(now - chrono::Duration::seconds(5)),
}],
})
}
async fn active_rate_limits(&self) -> AdminStoreResult<AccountRuntimeSnapshot> {
Ok(AccountRuntimeSnapshot {
cooldown: BTreeMap::new(),
@@ -1789,6 +1826,8 @@ fn test_runtime_settings() -> RuntimeSettings {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
}
}
@@ -1845,3 +1884,29 @@ fn unsupported_provider() -> ProviderAdminError {
fn unavailable_system() -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, "unused test operation")
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore for UnusedStore {
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
+303 -82
View File
@@ -30,14 +30,14 @@ async fn proxy_location_round_trips_preserves_omitted_and_clears_null() {
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
Some(json!([{
"name":"Tokyo", "proxyUrl":"http://proxy.example:8080", "location":location
})),
}])),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["location"], location);
assert_eq!(status, StatusCode::OK);
assert_eq!(created["data"][0]["data"]["record"]["location"], location);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0]["location"], location);
for (revision, change, expected) in [
@@ -70,24 +70,29 @@ async fn proxy_location_rejects_invalid_and_incomplete_input() {
let mut invalid =
json!({"country":"JP", "region":"Tokyo", "city":"Tokyo", "timezone":"Asia/Tokyo"});
invalid[field] = value;
let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Invalid", "proxyUrl":"http://proxy.example:8080", "location":invalid})), true).await;
assert!(status.is_client_error(), "invalid {field} accepted");
let (status, result) = request(&fixture, "/api/admin/proxies/create", Some(json!([{"name":"Invalid", "proxyUrl":"http://proxy.example:8080", "location":invalid}])), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
result["data"][0]["httpStatus"], 400,
"invalid {field} accepted"
);
}
let (status, _) = request(&fixture, "/api/admin/proxies/create", Some(json!({"name":"Incomplete", "proxyUrl":"http://proxy.example:8080", "location":{"country":"JP"}})), true).await;
assert!(status.is_client_error());
let (status, result) = request(&fixture, "/api/admin/proxies/create", Some(json!([{"name":"Incomplete", "proxyUrl":"http://proxy.example:8080", "location":{"country":"JP"}}])), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"][0]["httpStatus"], 400);
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Legacy", "proxyUrl":"http://proxy.example:8080"})),
Some(json!([{"name":"Legacy", "proxyUrl":"http://proxy.example:8080"}])),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert!(created["data"]["record"]["location"].is_null());
assert_eq!(status, StatusCode::OK);
assert!(created["data"][0]["data"]["record"]["location"].is_null());
}
#[derive(Default)]
pub(super) struct MemoryProxies(Mutex<Option<ProxyRecord>>);
pub(super) struct MemoryProxies(Mutex<Vec<ProxyRecord>>);
#[tokio::test]
async fn proxy_enabled_updates_preserve_connection_and_omitted_state() {
@@ -96,14 +101,14 @@ async fn proxy_enabled_updates_preserve_connection_and_omitted_state() {
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
Some(json!([{
"name":"Office", "proxyUrl":"http://proxy.example:8080"
})),
}])),
true,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["enabled"], true);
assert_eq!(status, StatusCode::OK);
assert_eq!(created["data"][0]["data"]["record"]["enabled"], true);
for (revision, enabled, expected) in [
(1, Some(false), false),
(2, None, false),
@@ -119,7 +124,7 @@ async fn proxy_enabled_updates_preserve_connection_and_omitted_state() {
assert_eq!(changed["data"]["record"]["enabled"], expected);
assert_eq!(
changed["data"]["record"]["endpoint"],
created["data"]["record"]["endpoint"]
created["data"][0]["data"]["record"]["endpoint"]
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["items"][0]["enabled"], expected);
@@ -155,7 +160,7 @@ impl ProxyStore for MemoryProxies {
Err(missing())
}
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
let items: Vec<_> = self.0.lock().unwrap().iter().cloned().collect();
let items = self.0.lock().unwrap().clone();
Ok(ProxyPage {
total: u64::try_from(items.len()).unwrap(),
items,
@@ -163,8 +168,14 @@ impl ProxyStore for MemoryProxies {
page_size: query.page_size.get(),
})
}
async fn get(&self, _: &str) -> AdminStoreResult<ProxyRecord> {
self.0.lock().unwrap().clone().ok_or_else(missing)
async fn get(&self, id: &str) -> AdminStoreResult<ProxyRecord> {
self.0
.lock()
.unwrap()
.iter()
.find(|record| record.id == id)
.cloned()
.ok_or_else(missing)
}
async fn list_accounts(
&self,
@@ -174,8 +185,8 @@ impl ProxyStore for MemoryProxies {
.0
.lock()
.unwrap()
.as_ref()
.is_some_and(|record| record.id == query.proxy_id)
.iter()
.any(|record| record.id == query.proxy_id)
{
return Err(missing());
}
@@ -201,12 +212,27 @@ impl ProxyStore for MemoryProxies {
command: NewProxy,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
if stored
.iter()
.any(|record| record.proxy.expose_url() == command.proxy.expose_url())
{
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy",
"duplicate",
));
}
let mut record = ProxyRecord {
enabled: true,
auto_location: command.auto_location,
detected_location: None,
location: command.location,
id: "proxy_test".to_owned(),
id: if stored.is_empty() {
"proxy_test".to_owned()
} else {
format!("proxy_test_{}", stored.len() + 1)
},
name: command.name,
proxy: command.proxy,
revision: Revision::new(1).unwrap(),
@@ -221,7 +247,7 @@ impl ProxyStore for MemoryProxies {
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
}
*self.0.lock().unwrap() = Some(record.clone());
stored.push(record.clone());
Ok(ProxyMutation {
config_revision: record.revision,
record,
@@ -233,7 +259,10 @@ impl ProxyStore for MemoryProxies {
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
let record = stored
.iter_mut()
.find(|record| record.id == command.id)
.ok_or_else(missing)?;
record.name = command.name;
if let Some(enabled) = command.enabled {
record.enabled = enabled;
@@ -248,16 +277,20 @@ impl ProxyStore for MemoryProxies {
record.location = location;
}
if let Some(proxy) = command.proxy {
if record.proxy != proxy {
if record.proxy.expose_url() != proxy.expose_url() {
record.detected_location = None;
}
record.proxy = proxy;
record.proxy =
proxy.with_max_websocket_connections(record.proxy.max_websocket_connections());
}
if let Some(result) = command.test {
record.detected_location = record.detected_location_after_test(&result);
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
}
if let Some(limit) = command.max_websocket_connections {
record.proxy = record.proxy.clone().with_max_websocket_connections(limit);
}
record.revision = Revision::new(record.revision.get() + 1).unwrap();
Ok(ProxyMutation {
config_revision: record.revision,
@@ -266,22 +299,37 @@ impl ProxyStore for MemoryProxies {
}
async fn delete(
&self,
_: &str,
id: &str,
_: Revision,
_: &MutationContext,
) -> AdminStoreResult<Revision> {
self.0.lock().unwrap().take().ok_or_else(missing)?;
let mut stored = self.0.lock().unwrap();
let index = stored
.iter()
.position(|record| record.id == id)
.ok_or_else(missing)?;
stored.remove(index);
Ok(Revision::new(3).unwrap())
}
async fn record_test(
&self,
_: &str,
_: Revision,
id: &str,
revision: Revision,
result: ProxyTestResult,
_: &MutationContext,
) -> AdminStoreResult<ProxyMutation> {
let mut stored = self.0.lock().unwrap();
let record = stored.as_mut().ok_or_else(missing)?;
let record = stored
.iter_mut()
.find(|record| record.id == id)
.ok_or_else(missing)?;
if record.revision != revision {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"proxy",
"stale revision",
));
}
record.detected_location = record.detected_location_after_test(&result);
record.last_test = Some(result);
record.last_test_at = Some(Utc::now());
@@ -299,11 +347,15 @@ pub(super) struct SuccessfulProbe;
#[async_trait]
impl ProxyProbe for SuccessfulProbe {
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult {
if let Some(proxy) = proxy {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
} else {
assert!(detect_location);
}
ProxyTestResult {
location: if detect_location {
ProxyLocationDetection::Detected { location: serde_json::from_value(json!({"country":"JP","region":"Tokyo","city":"Tokyo","timezone":"Asia/Tokyo"})).unwrap() }
@@ -359,22 +411,33 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({
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!(status, StatusCode::OK);
assert_eq!(created["data"][0]["data"]["record"]["name"], "Office");
assert_eq!(
created["data"]["record"]["endpoint"],
created["data"][0]["data"]["record"]["hasAuthentication"],
true
);
assert_eq!(
created["data"][0]["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());
assert!(
created["data"][0]["data"]["record"]
.get("proxyUrl")
.is_none()
);
assert_eq!(created["data"][0]["data"]["record"]["accountCount"], 0);
assert!(
created["data"][0]["data"]["record"]
.get("accounts")
.is_none()
);
let (status, linked) = request(
&fixture,
@@ -400,12 +463,15 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
Some(json!([{"id": "proxy_test", "revision": 1}])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(tested["data"]["lastTest"]["exitIp"], "203.0.113.2");
assert_eq!(
tested["data"][0]["data"]["lastTest"]["exitIp"],
"203.0.113.2"
);
let (_, listed) = request(
&fixture,
"/api/admin/proxies?page=1&pageSize=20",
@@ -413,7 +479,7 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
true,
)
.await;
assert_eq!(listed["data"]["items"][0], tested["data"]);
assert_eq!(listed["data"]["items"][0], tested["data"][0]["data"]);
let (status, _) = request(
&fixture,
@@ -423,18 +489,20 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = request(
let (status, conflict) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 1})),
Some(json!([{"id": "proxy_test", "revision": 1}])),
true,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(status, StatusCode::OK);
assert_eq!(conflict["data"][0]["status"], "failed");
assert_eq!(conflict["data"][0]["httpStatus"], 409);
let (status, _) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id": "proxy_test", "revision": 2})),
Some(json!([{"id": "proxy_test", "revision": 2}])),
true,
)
.await;
@@ -484,14 +552,17 @@ async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records
let (_, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
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"]);
assert_eq!(
listed["data"]["items"][0],
created["data"][0]["data"]["record"]
);
}
#[tokio::test]
@@ -599,25 +670,30 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() {
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(
let (status, result) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!({"name":"Invalid","proxyUrl":"ftp://test-user:private-password@proxy.example"})),
Some(json!([
{"name":" ","proxyUrl":"http://proxy.example:8080"}
])),
true,
)
.await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"][0]["httpStatus"], 400);
for proxy_url in ["", "ftp://test-user:private-password@proxy.example"] {
let (status, result) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!([
{"name":"Invalid", "proxyUrl":proxy_url}
])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"][0]["httpStatus"], 400);
}
}
#[tokio::test]
@@ -679,11 +755,11 @@ async fn auto_location_detects_on_enable_preserves_manual_and_refreshes_only_on_
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let manual = json!({"country":"US", "region":"California", "city":"Los Angeles", "timezone":"America/Los_Angeles"});
let (status, created) = request(&fixture, "/api/admin/proxies/create", Some(json!({
let (status, created) = request(&fixture, "/api/admin/proxies/create", Some(json!([{
"name":"Automatic", "proxyUrl":"http://test-user:private-password@proxy.example:8080", "autoLocation":true, "location":manual
})), true).await;
assert_eq!(status, StatusCode::CREATED);
let record = &created["data"]["record"];
}])), true).await;
assert_eq!(status, StatusCode::OK);
let record = &created["data"][0]["data"]["record"];
assert_eq!(record["autoLocation"], true);
assert_eq!(
record["detectedLocation"]["location"]["timezone"],
@@ -710,13 +786,16 @@ async fn auto_location_detects_on_enable_preserves_manual_and_refreshes_only_on_
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id":"proxy_test", "revision":2})),
Some(json!([{"id":"proxy_test", "revision":2}])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(tested["data"]["revision"], 3);
assert_eq!(tested["data"]["lastTest"]["location"]["status"], "detected");
assert_eq!(tested["data"][0]["data"]["revision"], 3);
assert_eq!(
tested["data"][0]["data"]["lastTest"]["location"]["status"],
"detected"
);
let (_, disabled) = request(
&fixture,
"/api/admin/proxies/update",
@@ -736,21 +815,21 @@ async fn manual_proxy_can_detect_location_once_without_enabling_auto_location()
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let manual = json!({"country":"US", "region":"California", "city":"Los Angeles", "timezone":"America/Los_Angeles"});
let (status, created) = request(&fixture, "/api/admin/proxies/create", Some(json!({
let (status, created) = request(&fixture, "/api/admin/proxies/create", Some(json!([{
"name":"Manual", "proxyUrl":"http://test-user:private-password@proxy.example:8080", "location":manual
})), true).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(created["data"]["record"]["autoLocation"], false);
}])), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(created["data"][0]["data"]["record"]["autoLocation"], false);
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!({"id":"proxy_test", "revision":1, "detectLocation":true})),
Some(json!([{"id":"proxy_test", "revision":1, "detectLocation":true}])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let record = &tested["data"];
let record = &tested["data"][0]["data"];
assert_eq!(record["revision"], 1);
assert_eq!(record["autoLocation"], false);
assert_eq!(record["location"], manual);
@@ -764,3 +843,145 @@ async fn manual_proxy_can_detect_location_once_without_enabling_auto_location()
"Asia/Tokyo"
);
}
#[tokio::test]
async fn proxy_arrays_preserve_input_order_and_individual_failures() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, created) = request(
&fixture,
"/api/admin/proxies/create",
Some(json!([
{"name":"First", "proxyUrl":"http://test-user:private-password@proxy.example:8080"},
{"name":"Duplicate", "proxyUrl":"http://test-user:private-password@proxy.example:8080"},
{"name":" ", "proxyUrl":"http://invalid-name.example:8080"},
{"name":"Invalid URL", "proxyUrl":"ftp://test-user:private-password@proxy.example"},
{"name":"Last", "proxyUrl":"http://last.example:8080"}
])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let items = created["data"].as_array().unwrap();
assert_eq!(items.len(), 5);
assert_eq!(items[0]["data"]["record"]["name"], "First");
assert_eq!(items[1]["status"], "failed");
assert_eq!(items[1]["httpStatus"], 409);
assert_eq!(items[1]["code"], 40901);
assert_eq!(items[2]["httpStatus"], 400);
assert_eq!(items[3]["httpStatus"], 400);
assert_eq!(items[4]["data"]["record"]["name"], "Last");
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 2);
let (status, tested) = request(
&fixture,
"/api/admin/proxies/test",
Some(json!([
{"id":"missing", "revision":1},
{"id":"malformed", "revision":"not-a-number"},
{"id":"proxy_test", "revision":1, "detectLocation":true},
{"id":"proxy_test_2", "revision":2}
])),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
let items = tested["data"].as_array().unwrap();
assert_eq!(items.len(), 4);
assert_eq!(items[0]["httpStatus"], 404);
assert_eq!(items[1]["httpStatus"], 400);
assert_eq!(items[2]["data"]["id"], "proxy_test");
assert_eq!(
items[2]["data"]["lastTest"]["location"]["status"],
"detected"
);
assert_eq!(items[3]["httpStatus"], 409);
}
#[tokio::test]
async fn proxy_arrays_reject_invalid_shape_size_and_duplicate_test_ids_before_execution() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (path, item) in [
(
"/api/admin/proxies/create",
json!({"name":"Proxy", "proxyUrl":"http://proxy.example:8080"}),
),
(
"/api/admin/proxies/test",
json!({"id":"proxy_test", "revision":1}),
),
] {
assert_eq!(
request(&fixture, path, Some(item.clone()), true).await.0,
StatusCode::UNPROCESSABLE_ENTITY
);
for body in [json!([]), json!(vec![item.clone(); 101])] {
assert_eq!(
request(&fixture, path, Some(body), true).await.0,
StatusCode::BAD_REQUEST
);
}
}
assert_eq!(
request(
&fixture,
"/api/admin/proxies/test",
Some(json!([
{"id":"proxy_test", "revision":1}, {"id":"proxy_test", "revision":1}
])),
true
)
.await
.0,
StatusCode::BAD_REQUEST
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 0);
}
#[tokio::test]
async fn proxy_websocket_capacity_round_trips_and_preserves_omitted_updates() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let (status, created) = request(&fixture, "/api/admin/proxies/create", Some(json!([
{"name":"Limited", "proxyUrl":"http://proxy.example:8080", "maxWebsocketConnections":24},
{"name":"Unlimited", "proxyUrl":"http://other.example:8080"}
])), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
created["data"][0]["data"]["record"]["maxWebsocketConnections"],
24
);
assert_eq!(
created["data"][1]["data"]["record"]["maxWebsocketConnections"],
0
);
for (revision, change, expected) in [(1, None, 24), (2, Some(12), 12), (3, Some(0), 0)] {
let mut body = json!({"id":"proxy_test", "revision":revision, "name":"Limited"});
if let Some(limit) = change {
body["maxWebsocketConnections"] = json!(limit);
}
let (status, updated) =
request(&fixture, "/api/admin/proxies/update", Some(body), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
updated["data"]["record"]["maxWebsocketConnections"],
expected
);
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(
listed["data"]["items"][0]["maxWebsocketConnections"],
expected
);
assert_eq!(listed["data"]["items"][1]["maxWebsocketConnections"], 0);
}
for invalid in [json!(-1), json!(1.5), json!(4294967296u64)] {
let (status, result) = request(&fixture, "/api/admin/proxies/create", Some(json!([
{"name":"Invalid", "proxyUrl":"http://invalid.example:8080", "maxWebsocketConnections":invalid}
])), true).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["data"][0]["httpStatus"], 400);
}
}
@@ -74,10 +74,59 @@ fn update_body() -> Value {
"accountAutoFreezeAdaptiveConcurrency": true,
"accountWarmupEnabled": false,
"accountWarmupScheduleTime": "08:00",
"accountWarmupModel": null
"accountWarmupModel": null,
"resetCreditLeadMinutes": 10,
"resetCreditMaxWeeklyRemainingPercent": null
})
}
#[tokio::test]
async fn location_detection_requires_auth_and_leaves_saved_settings_unchanged() {
let fixture = AdminTestFixture::new().await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/location/detect",
None,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
fixture.auth.insert_session("valid-session");
let before = response_json(
app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap(),
)
.await;
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/location/detect",
None,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"],
json!({
"status": "detected",
"location": {"country": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": "Asia/Tokyo"}
})
);
let after = response_json(
app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap(),
)
.await;
assert_eq!(before, after);
}
#[tokio::test]
async fn privacy_save_requires_provider_validation_and_preview_requires_admin_auth() {
let fixture = AdminTestFixture::new().await;
@@ -361,6 +410,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
};
@@ -412,6 +463,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"accountWarmupEnabled": false,
"accountWarmupScheduleTime": "08:00",
"accountWarmupModel": null,
"resetCreditLeadMinutes": 10,
"resetCreditMaxWeeklyRemainingPercent": null,
"updatedAt": "2026-08-02T10:30:00Z",
"updatedAtDisplay": "2026-08-02 18:30:00"
})
@@ -488,6 +541,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
reset_credit_max_weekly_remaining_percent: None,
},
};
@@ -1570,3 +1625,63 @@ async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_valu
assert_eq!(response.status(), expected_status, "{field}");
}
}
#[test]
fn reset_credit_lead_minutes_requires_an_integer_within_one_day() {
for (value, valid) in [
(0, false),
(1, true),
(10, true),
(1440, true),
(1441, false),
] {
let mut body = update_body();
body["resetCreditLeadMinutes"] = json!(value);
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
let result = request.validate();
if valid {
assert!(result.is_ok());
} else {
assert_eq!(result.unwrap_err().field(), "resetCreditLeadMinutes");
}
}
}
#[test]
fn reset_credit_weekly_threshold_accepts_unset_and_zero_to_one_hundred() {
let mut omitted = update_body();
omitted
.as_object_mut()
.unwrap()
.remove("resetCreditMaxWeeklyRemainingPercent");
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(omitted).unwrap();
request.validate().unwrap();
assert!(
request
.values
.reset_credit_max_weekly_remaining_percent
.is_none()
);
for value in [json!(null), json!(0), json!(70), json!(100)] {
let mut body = update_body();
body["resetCreditMaxWeeklyRemainingPercent"] = value.clone();
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
request.validate().unwrap();
assert_eq!(
serde_json::to_value(request.values.reset_credit_max_weekly_remaining_percent).unwrap(),
value
);
}
let mut body = update_body();
body["resetCreditMaxWeeklyRemainingPercent"] = json!(101);
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
assert_eq!(
request.validate().unwrap_err().field(),
"resetCreditMaxWeeklyRemainingPercent"
);
for value in [json!(-1), json!(70.5), json!("70")] {
let mut body = update_body();
body["resetCreditMaxWeeklyRemainingPercent"] = value;
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
}
@@ -210,6 +210,24 @@ impl MiddlewarePlan for Plan {
Ok(response)
}
Mode::Rewrite => {
let facts = context
.extensions
.request()
.expect("entry request snapshot");
assert_eq!(facts.method, "POST");
assert_eq!(facts.uri, "/unrecognized/client/path?raw=%2f%FF");
assert_eq!(facts.http_version, "HTTP/1.1");
assert_eq!(facts.peer_addr.unwrap().to_string(), "192.0.2.1:42000");
assert_eq!(facts.client_ip.unwrap().to_string(), "203.0.113.7");
assert_eq!(
facts
.headers
.iter()
.filter(|header| header.name() == "x-repeat")
.count(),
2
);
assert!(!format!("{facts:?}").contains("fixture-secret"));
assert_eq!(request.uri(), "/unrecognized/client/path?raw=%2f%FF");
assert_eq!(request.headers()["authorization"], "Bearer fixture-secret");
assert_eq!(request.headers().get_all("x-repeat").iter().count(), 2);
@@ -303,6 +321,10 @@ async fn rewrites_unknown_path_before_routing_without_reading_request_body() {
Request::builder()
.method("POST")
.uri("/unrecognized/client/path?raw=%2f%FF")
.extension(axum::extract::ConnectInfo(
"192.0.2.1:42000".parse::<std::net::SocketAddr>().unwrap(),
))
.header("x-forwarded-for", "203.0.113.7, 10.0.0.1")
.header("authorization", "Bearer fixture-secret")
.header("x-repeat", "one")
.header("x-repeat", "two")
@@ -647,6 +647,32 @@ async fn upstream_message_too_big_returns_actionable_http_error() {
);
}
#[tokio::test]
async fn session_policy_rejection_returns_bad_request_with_the_policy_code() {
let error = EngineError::Provider(
ProviderError::new(
ProviderErrorKind::SessionPolicyRestricted,
UpstreamSendState::NotSent,
)
.with_retry_prohibited()
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"No account is available for this session after a policy rejection.",
Some("cyber_policy".to_owned()),
Some("invalid_request_error".to_owned()),
)),
);
let response = engine_error_response(&error);
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["error"]["code"], "cyber_policy");
assert_eq!(body["error"]["type"], "invalid_request_error");
assert_eq!(
body["error"]["message"],
"No account is available for this session after a policy rejection."
);
}
#[tokio::test]
async fn continuation_recovery_should_preserve_the_official_retry_signal() {
let error = GatewayError::from_provider(
@@ -1,5 +1,7 @@
//! 图像生成与编辑 HTTP 接口的请求传递和响应交付测试
mod request;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
@@ -260,7 +262,7 @@ impl ExecutionSession for ImageSession {
}
#[tokio::test]
async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directions() {
async fn image_json_routes_should_preserve_both_directions_without_decoding() {
let mut execution = ImageExecution::new();
let middleware = Arc::new(crate::openai::middleware::RequestMiddleware {
expected_operation: Some(OperationKind::GenerateImage),
@@ -282,12 +284,6 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
"application/json",
br#"{"model":"gpt-image-2","images":[{"image_url":"data:image/png;base64,AAEC"}],"prompt":"add fog","prompt":"duplicate stays raw"}"#.as_slice(),
),
(
"/v1/images/edits",
ImageRequestKind::Edit,
"multipart/form-data; boundary=image-boundary",
b"--image-boundary\r\nContent-Disposition: form-data; name=\"image\"; filename=\"image.png\"\r\nContent-Type: image/png\r\n\r\n\x89PNG\x00\xff\r\n--image-boundary--\r\n".as_slice(),
),
];
for (path, _, content_type, body) in &cases {
@@ -332,11 +328,7 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
assert_eq!(captured.len(), cases.len());
assert_eq!(
*middleware.endpoints.lock().unwrap(),
vec![
"/v1/images/generations",
"/v1/images/edits",
"/v1/images/edits"
]
vec!["/v1/images/generations", "/v1/images/edits"]
);
for (captured, (endpoint, kind, _, body)) in captured.iter().zip(cases.iter()) {
assert_eq!(captured.provider, "openai");
@@ -0,0 +1,347 @@
//! 图片上传与原生 JSON 的等价性、拒绝边界及请求中间件顺序测试
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::{Value, json};
use std::sync::Arc;
use tower::ServiceExt;
use super::{IMAGE_RESPONSE, ImageExecution, api_router};
const CONTENT_TYPE: &str = "multipart/form-data; boundary=image-boundary";
const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\x00\xfforiginal png bytes\r\n";
const JPEG: &[u8] = b"\xff\xd8\xff\x00original jpeg bytes\xff\xd9";
const WEBP: &[u8] = b"RIFF\x10\x00\x00\x00WEBPoriginal webp bytes";
fn multipart_body(parts: &[(&str, Option<&str>, &[u8])]) -> Vec<u8> {
let mut body = Vec::new();
for (name, filename, bytes) in parts {
body.extend_from_slice(
format!("--image-boundary\r\nContent-Disposition: form-data; name=\"{name}\"")
.as_bytes(),
);
if let Some(filename) = filename {
body.extend_from_slice(
format!("; filename=\"{filename}\"\r\nContent-Type: application/octet-stream")
.as_bytes(),
);
}
body.extend_from_slice(b"\r\n\r\n");
body.extend_from_slice(bytes);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--image-boundary--\r\n");
body
}
#[tokio::test]
async fn multipart_edit_should_match_native_json_fields_and_image_bytes() {
let execution = ImageExecution::new();
let router = api_router(execution.clone()).await;
let body = multipart_body(&[
("image", Some("first.bin"), PNG),
("prompt", None, " 保留空格\r\n\"和引号\" ".as_bytes()),
("image[]", Some("second.bin"), JPEG),
("mask", Some("mask.bin"), PNG),
("image[]", Some("third.bin"), WEBP),
("model", None, b"gpt-image-2"),
("background", None, b"transparent"),
("quality", None, b"high"),
("size", None, b"1024x1024"),
("input_fidelity", None, b"high"),
("moderation", None, b"auto"),
("output_format", None, b"png"),
("response_format", None, b"b64_json"),
("user", None, b"000123"),
("safety_identifier", None, b"false"),
("n", None, b"2"),
("output_compression", None, b"80"),
("partial_images", None, b"0"),
("stream", None, b"false"),
]);
let native_json = json!({
"images": [
{"image_url": format!("data:image/png;base64,{}", STANDARD.encode(PNG))},
{"image_url": format!("data:image/jpeg;base64,{}", STANDARD.encode(JPEG))},
{"image_url": format!("data:image/webp;base64,{}", STANDARD.encode(WEBP))},
],
"mask": {"image_url": format!("data:image/png;base64,{}", STANDARD.encode(PNG))},
"prompt": " 保留空格\r\n\"和引号\" ",
"model": "gpt-image-2",
"background": "transparent",
"quality": "high",
"size": "1024x1024",
"input_fidelity": "high",
"moderation": "auto",
"output_format": "png",
"response_format": "b64_json",
"user": "000123",
"safety_identifier": "false",
"n": 2,
"output_compression": 80,
"partial_images": 0,
"stream": false,
});
for (content_type, body) in [
(CONTENT_TYPE, body),
(
"application/json",
serde_json::to_vec(&native_json).unwrap(),
),
] {
let response = router
.clone()
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", content_type)
.header("content-length", body.len())
.header("x-codex-image-turn-id", "turn-multipart-edit")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
assert_eq!(
to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.as_ref(),
IMAGE_RESPONSE
);
}
let captured = execution.captured();
assert_eq!(captured.len(), 2);
assert_eq!(captured[0].context, captured[1].context);
for request in &captured {
let json: Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(json, native_json);
assert_eq!(request.context["image_turn_id"], "turn-multipart-edit");
for (reference, original) in json["images"]
.as_array()
.unwrap()
.iter()
.zip([PNG, JPEG, WEBP])
{
let (_, encoded) = reference["image_url"]
.as_str()
.unwrap()
.split_once(',')
.unwrap();
assert_eq!(STANDARD.decode(encoded).unwrap(), original);
}
}
}
#[tokio::test]
async fn multipart_edit_should_preserve_omitted_parameters_and_exact_numbers() {
let execution = ImageExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", CONTENT_TYPE)
.body(Body::from(multipart_body(&[
("image", Some("image.png"), PNG),
("prompt", None, b"false"),
// 参数值的可接受范围仍由上游决定,适配不能经浮点数中转损失精度
("n", None, b"9007199254740993"),
])))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let captured = execution.captured();
let body: Value = serde_json::from_slice(&captured[0].body).unwrap();
assert_eq!(
body,
json!({
"images": [{"image_url": format!("data:image/png;base64,{}", STANDARD.encode(PNG))}],
"prompt": "false",
"n": 9_007_199_254_740_993_u64,
})
);
}
#[tokio::test]
async fn multipart_edit_should_forward_unknown_fields_and_repeated_keys_in_order() {
let execution = ImageExecution::new();
let router = api_router(execution.clone()).await;
let body = multipart_body(&[
("future_field", None, b"123"),
("prompt", None, b"first"),
("image", Some("image.png"), PNG),
("future_field", None, b"false"),
("prompt", None, b"second"),
("n", None, b"1"),
("n", None, b"2"),
("mask", Some("first.png"), PNG),
("mask", Some("second.webp"), WEBP),
]);
let native_json = format!(
concat!(
r#"{{"future_field":"123","prompt":"first","future_field":"false","prompt":"second","n":1,"n":2,"#,
r#""mask":{{"image_url":"data:image/png;base64,{}"}},"mask":{{"image_url":"data:image/webp;base64,{}"}},"#,
r#""images":[{{"image_url":"data:image/png;base64,{}"}}]}}"#,
),
STANDARD.encode(PNG),
STANDARD.encode(WEBP),
STANDARD.encode(PNG),
);
for (content_type, body) in [
(CONTENT_TYPE, body),
("application/json", native_json.as_bytes().to_vec()),
] {
let response = router
.clone()
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", content_type)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
}
let captured = execution.captured();
assert_eq!(captured.len(), 2);
// Value 会折叠重复键,这里直接比较原生 JSON 字节,确认每次出现都保留
assert_eq!(captured[0].body, captured[1].body);
assert_eq!(captured[0].body.as_ref(), native_json.as_bytes());
}
#[tokio::test]
async fn multipart_edit_should_reject_fields_that_cannot_be_mapped_without_loss() {
let cases = [
(
"invalid_number",
vec![("n", None, b"not-a-number".as_slice())],
),
("invalid_boolean", vec![("stream", None, b"yes".as_slice())]),
("invalid_utf8", vec![("prompt", None, b"\xff".as_slice())]),
(
"file_in_scalar",
vec![("prompt", Some("prompt.txt"), b"text".as_slice())],
),
(
"unsupported_image",
vec![("image", Some("image.gif"), b"GIF89a".as_slice())],
),
(
"unsupported_mask",
vec![("mask", Some("mask.png"), b"not an image".as_slice())],
),
];
for (name, mut parts) in cases {
parts.push(("image", Some("image.png"), PNG));
let execution = ImageExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", CONTENT_TYPE)
.body(Body::from(multipart_body(&parts)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{name}");
let error: Value =
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
.unwrap();
assert_eq!(error["error"]["code"], "invalid_request", "{name}");
assert!(execution.captured().is_empty(), "{name}");
}
}
#[tokio::test]
async fn multipart_edit_should_reject_invalid_envelopes_before_provider_execution() {
for (content_type, body) in [
(
"multipart/form-data",
multipart_body(&[("image", Some("image.png"), PNG)]),
),
(CONTENT_TYPE, b"--image-boundary\r\ntruncated".to_vec()),
(CONTENT_TYPE, multipart_body(&[("prompt", None, b"edit")])),
] {
let execution = ImageExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", content_type)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(execution.captured().is_empty());
}
}
#[tokio::test]
async fn multipart_edit_should_not_inherit_the_default_extractor_size_limit() {
let mut image = PNG.to_vec();
image.resize(2 * 1024 * 1024 + 1, 0xff);
let execution = ImageExecution::new();
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", CONTENT_TYPE)
.body(Body::from(multipart_body(&[(
"image",
Some("image.png"),
&image,
)])))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let captured = execution.captured();
let body: Value = serde_json::from_slice(&captured[0].body).unwrap();
let (_, encoded) = body["images"][0]["image_url"]
.as_str()
.unwrap()
.split_once(',')
.unwrap();
assert_eq!(STANDARD.decode(encoded).unwrap(), image);
}
#[tokio::test]
async fn request_middleware_should_run_before_multipart_decoding() {
let mut execution = ImageExecution::new();
let middleware = Arc::new(crate::openai::middleware::RequestMiddleware {
expected_operation: Some(gateway_core::operation::OperationKind::GenerateImage),
short_circuit_json: Some(bytes::Bytes::from_static(IMAGE_RESPONSE)),
..Default::default()
});
Arc::get_mut(&mut execution).unwrap().middleware = Some(middleware.clone());
let response = api_router(execution.clone())
.await
.oneshot(
Request::post("/v1/images/edits")
.header(AUTHORIZATION, "Bearer sk_images_test")
.header("content-type", CONTENT_TYPE)
.body(Body::from("invalid multipart handled by middleware"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(*middleware.endpoints.lock().unwrap(), ["/v1/images/edits"]);
assert!(execution.captured().is_empty());
}
@@ -1,10 +1,13 @@
//! 账号出站代理端点与认证值,诊断及普通管理投影不暴露凭据
//! 账号出站代理连接与容量配置,诊断及普通管理投影不暴露凭据
use std::fmt;
use url::Url;
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct OutboundProxy(Url);
pub struct OutboundProxy {
url: Url,
max_websocket_connections: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid outbound proxy; expected http, https, socks5 or socks5h URL with a host and port")]
@@ -25,17 +28,32 @@ impl OutboundProxy {
{
return Err(InvalidOutboundProxy);
}
Ok(Self(url))
Ok(Self {
url,
max_websocket_connections: 0,
})
}
/// 此出口的上游 WebSocket 容量,零值表示不限制
#[must_use]
pub fn with_max_websocket_connections(mut self, limit: u32) -> Self {
self.max_websocket_connections = limit;
self
}
#[must_use]
pub const fn max_websocket_connections(&self) -> u32 {
self.max_websocket_connections
}
#[must_use]
pub fn expose_url(&self) -> &str {
self.0.as_str()
self.url.as_str()
}
#[must_use]
pub fn endpoint(&self) -> String {
let mut url = self.0.clone();
let mut url = self.url.clone();
let _ = url.set_username("");
let _ = url.set_password(None);
url.to_string()
@@ -18,12 +18,31 @@ pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024;
/// API 从数据面 Authorization 头构造的有界认证信封
#[derive(Clone, PartialEq, Eq)]
pub struct ClientAuthenticationRequest {
request_context: Option<Arc<super::extensions::RequestContext>>,
settings: Option<crate::routing::request_settings::RequestSettings>,
authorization: Arc<str>,
native_bearer: Option<PlaintextClientApiKey>,
}
impl ClientAuthenticationRequest {
#[must_use]
pub fn with_request_context(
mut self,
request: Option<Arc<super::extensions::RequestContext>>,
) -> Self {
self.request_context = request;
self
}
#[must_use]
pub fn request_context(&self) -> Option<&super::extensions::RequestContext> {
self.request_context.as_deref()
}
#[must_use]
pub fn shared_request_context(&self) -> Option<Arc<super::extensions::RequestContext>> {
self.request_context.clone()
}
#[must_use]
pub fn with_settings(
mut self,
@@ -61,6 +80,7 @@ impl ClientAuthenticationRequest {
.and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok());
Ok(Self {
settings: None,
request_context: None,
authorization: authorization.into(),
native_bearer,
})
@@ -328,6 +328,17 @@ where
deadline_timer: deadline.wait().fuse(),
lease: Some(self.engine.store.maintain_request(&request.id, deadline)),
requested_model: request.requested_model.clone(),
lease_metadata: Arc::new(crate::provider_ports::ProviderLeaseMetadata {
request_id: request.id.as_str().to_owned(),
client_key_id: request.client_api_key_ref.clone(),
client_key_name: request.client_api_key_name_snapshot.clone(),
model: request
.requested_model
.as_ref()
.map(|model| model.as_str().to_owned()),
client_ip: request.client_ip,
started_at: request.started_at,
}),
pending_request: Some(request),
request_persisted: false,
response_control,
@@ -469,6 +480,7 @@ pub struct ResponseExecutionSession<S: ?Sized> {
lease: Option<Box<dyn LeaseGuard>>,
/// 会话级 deadline 计时器;deadline 固定,帧循环内复用而非逐事件新建
deadline_timer: Fuse<BoxFuture<'static, ()>>,
lease_metadata: Arc<crate::provider_ports::ProviderLeaseMetadata>,
pending_request: Option<NewModelRequest>,
requested_model: Option<crate::routing::PublicModelId>,
request_persisted: bool,
@@ -1103,6 +1115,7 @@ where
}
let context = AttemptContext::new(
RequestAttemptContext::new(self.request_id.clone(), self.client_api_key_ref.clone())
.with_lease_metadata(Arc::clone(&self.lease_metadata))
.with_response_control(self.response_control.clone())
.with_request_profile(self.request_profiles.get(candidate.provider()).cloned())
.with_fast_mode(self.plan.fast_mode())
@@ -1232,6 +1245,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::SessionPolicyRestricted
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ConcurrencyQueueFull
| ProviderErrorKind::ConcurrencyQueueTimeout
@@ -1255,6 +1269,7 @@ where
error.kind(),
ProviderErrorKind::AccountCapacityUnavailable
| ProviderErrorKind::NoEligibleAccount
| ProviderErrorKind::SessionPolicyRestricted
| ProviderErrorKind::QuotaExhausted
| ProviderErrorKind::ProviderInfrastructureUnavailable
| ProviderErrorKind::ConcurrencyQueueFull
@@ -1,4 +1,4 @@
//! 执行扩展计划的弱引用索引、冻结解析与插件递归作用域
//! 执行扩展计划的冻结解析、入口请求事实与插件调用作用域
use std::{collections::BTreeSet, sync::Arc};
@@ -10,9 +10,26 @@ use std::{collections::BTreeSet, sync::Arc};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtensionCallScope {
instance_ids: Arc<BTreeSet<String>>,
request: Option<Arc<RequestContext>>,
}
impl ExtensionCallScope {
#[must_use]
pub fn with_request(mut self, request: Arc<RequestContext>) -> Self {
self.request = Some(request);
self
}
#[must_use]
pub fn request(&self) -> Option<&RequestContext> {
self.request.as_deref()
}
#[must_use]
pub fn shared_request(&self) -> Option<Arc<RequestContext>> {
self.request.clone()
}
pub const MAXIMUM_DEPTH: usize = 4;
#[must_use]
@@ -40,6 +57,7 @@ impl ExtensionCallScope {
instance_ids.insert(instance_id);
Some(Self {
instance_ids: Arc::new(instance_ids),
request: self.request.clone(),
})
}
}
@@ -122,3 +140,25 @@ impl ExecutionExtensionIndex {
))
}
}
/// 不可变入口快照;正文由所属中间件资源持有,不随每次扩展调用复制
#[derive(Clone, PartialEq, Eq)]
pub struct RequestContext {
pub method: String,
pub uri: String,
pub http_version: String,
pub headers: Vec<super::middleware::MiddlewareHeader>,
pub peer_addr: Option<std::net::SocketAddr>,
pub client_ip: Option<std::net::IpAddr>,
}
impl std::fmt::Debug for RequestContext {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// URI 查询与请求头可能包含凭据,不进入诊断日志
formatter
.debug_struct("RequestContext")
.field("method", &self.method)
.field("http_version", &self.http_version)
.finish_non_exhaustive()
}
}
@@ -37,6 +37,7 @@ pub trait Session: Sender {
#[derive(Clone)]
pub struct Context {
pub extensions: crate::engine::extensions::ExtensionCallScope,
pub plan: Option<crate::engine::middleware::FrozenMiddlewarePlan>,
pub connection_id: String,
pub direction: Direction,
@@ -286,6 +286,7 @@ impl ContinuationAttempt {
/// Provider 每次执行可见的 request-local context
#[derive(Debug, Clone)]
pub struct RequestAttemptContext {
lease_metadata: Option<Arc<crate::provider_ports::ProviderLeaseMetadata>>,
response_control: Option<response_control::ResponseControl>,
pricing: Arc<crate::metering::PricingOverrides>,
request_profile: Option<crate::account::OpaqueProviderData>,
@@ -310,6 +311,15 @@ pub struct RequestAttemptContext {
}
impl RequestAttemptContext {
#[must_use]
pub fn with_lease_metadata(
mut self,
metadata: Arc<crate::provider_ports::ProviderLeaseMetadata>,
) -> Self {
self.lease_metadata = Some(metadata);
self
}
#[must_use]
pub fn with_requested_model(mut self, model: Option<PublicModelId>) -> Self {
self.requested_model = model;
@@ -377,6 +387,7 @@ impl RequestAttemptContext {
pub fn new(request_id: ModelRequestId, client_api_key_ref: ClientApiKeyId) -> Self {
Self {
response_control: None,
lease_metadata: None,
request_id,
client_api_key_ref,
request_profile: None,
@@ -531,6 +542,11 @@ pub struct AttemptContext {
}
impl AttemptContext {
#[must_use]
pub fn lease_metadata(&self) -> Option<Arc<crate::provider_ports::ProviderLeaseMetadata>> {
self.request.lease_metadata.clone()
}
#[must_use]
pub fn execution_effects(&self) -> Option<Arc<nested::ExecutionEffects>> {
self.request.execution_effects.as_ref().map(Arc::clone)
@@ -281,6 +281,14 @@ pub struct RetryInput {
pub account_group_ids: Arc<[AccountGroupId]>,
pub extension_scope: ExtensionCallScope,
pub facts: RetryFacts,
execution_effects: Arc<ExecutionEffects>,
}
impl RetryInput {
#[must_use]
pub fn execution_effects(&self) -> Arc<ExecutionEffects> {
Arc::clone(&self.execution_effects)
}
}
/// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断
@@ -353,6 +361,7 @@ impl RequestPolicyContext {
account_group_ids: self.account_group_ids.clone(),
extension_scope: self.extension_scope.clone(),
facts,
execution_effects: Arc::clone(&self.execution_effects),
})
.await
.unwrap_or_else(|_| {
@@ -46,6 +46,8 @@ pub enum ProviderErrorKind {
ConcurrencyQueueTimeout,
/// Provider 已确认当前请求无法选出可用账号
NoEligibleAccount,
/// 会话已有的策略限制排除了可用账号,本地选号终止且不允许重放
SessionPolicyRestricted,
/// Provider 的账号存储、租约协调或本地凭据数据不可用
ProviderInfrastructureUnavailable,
/// 请求超时
@@ -84,6 +86,7 @@ impl ProviderErrorKind {
Self::ConcurrencyQueueFull => "concurrency_queue_full",
Self::ConcurrencyQueueTimeout => "concurrency_queue_timeout",
Self::NoEligibleAccount => "no_eligible_account",
Self::SessionPolicyRestricted => "session_policy_restricted",
Self::ProviderInfrastructureUnavailable => "provider_infrastructure_unavailable",
Self::Timeout => "timeout",
Self::Transport => "transport",
@@ -1187,6 +1190,10 @@ impl GatewayError {
ProviderErrorKind::InvalidRequest => {
Self::new(GatewayErrorKind::InvalidRequest, "invalid upstream request")
}
ProviderErrorKind::SessionPolicyRestricted => Self::new(
GatewayErrorKind::InvalidRequest,
"session policy excludes all eligible accounts",
),
ProviderErrorKind::ContinuationRecoveryRequired => Self::new(
GatewayErrorKind::InvalidRequest,
"conversation continuation must be rebuilt",
+9 -4
View File
@@ -359,10 +359,7 @@ impl CoreControlPlaneStartup {
match self.publisher.refresh().await {
Ok(_) => {}
// 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求
Err(
routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable
| routing::snapshot::RuntimeSnapshotCompileError::InvalidExtensionModels,
) => {}
Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {}
Err(_) => return Err(CoreError::SnapshotUnavailable),
}
Ok(CoreControlPlaneBundle {
@@ -397,6 +394,7 @@ pub struct CoreCommandPlaneStartup {
pub struct CoreCommandPlaneBundle {
control: CoreControlPlaneBundle,
account_probe: Arc<dyn AccountProbe>,
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
affinity_lookup: Arc<dyn AffinityLookupPort>,
}
@@ -426,10 +424,12 @@ impl CoreCommandPlaneStartup {
)
.with_snapshot_refresh(Arc::clone(&control.publisher)),
);
let account_probe: Arc<dyn AccountProbe> = service.clone();
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
Ok(CoreCommandPlaneBundle {
control,
account_probe,
nested_model_execution,
affinity_lookup,
})
@@ -437,6 +437,11 @@ impl CoreCommandPlaneStartup {
}
impl CoreCommandPlaneBundle {
#[must_use]
pub fn account_probe(&self) -> Arc<dyn AccountProbe> {
Arc::clone(&self.account_probe)
}
#[must_use]
pub fn snapshots(&self) -> RuntimeSnapshotHandle {
self.control.snapshots()
@@ -47,9 +47,21 @@ impl ProviderSchedulingState {
}
}
/// 并发占用关联的入口事实;不含密钥原文或请求正文
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderLeaseMetadata {
pub request_id: String,
pub client_key_id: ClientApiKeyId,
pub client_key_name: Option<String>,
pub model: Option<String>,
pub client_ip: Option<std::net::IpAddr>,
pub started_at: std::time::SystemTime,
}
/// 请求级账号 lease 的全部中立事实
#[derive(Debug, Clone)]
pub struct ProviderSchedulingLeaseRequest {
metadata: Option<std::sync::Arc<ProviderLeaseMetadata>>,
provider_kind: ProviderKind,
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
@@ -69,6 +81,7 @@ impl ProviderSchedulingLeaseRequest {
deadline: impl Into<crate::lifecycle::Deadline>,
) -> Self {
Self {
metadata: None,
provider_kind,
account_id,
credential_revision,
@@ -79,6 +92,20 @@ impl ProviderSchedulingLeaseRequest {
}
}
#[must_use]
pub fn with_metadata(
mut self,
metadata: Option<std::sync::Arc<ProviderLeaseMetadata>>,
) -> Self {
self.metadata = metadata;
self
}
#[must_use]
pub fn metadata(&self) -> Option<&ProviderLeaseMetadata> {
self.metadata.as_deref()
}
#[must_use]
pub fn with_cancellation(mut self, cancellation: crate::lifecycle::CancellationToken) -> Self {
self.cancellation = cancellation;
@@ -4,9 +4,9 @@ mod error;
pub use error::{ProviderStoreError, ProviderStoreErrorKind};
mod lease;
pub use lease::{
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort,
ProviderLeaseRequest, ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest,
ProviderSchedulingLeaseRequest, ProviderSchedulingState,
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeaseMetadata,
ProviderLeasePort, ProviderLeaseRequest, ProviderRefreshCapacityRequest,
ProviderRefreshLeaseRequest, ProviderSchedulingLeaseRequest, ProviderSchedulingState,
};
mod affinity;
pub use affinity::{
@@ -225,8 +225,6 @@ pub enum RuntimeSnapshotCompileError {
RevisionChanged,
#[error("runtime snapshot contains invalid frozen data")]
InvalidData,
#[error("extension model aliases conflict with the provider catalog or model mappings")]
InvalidExtensionModels,
#[error("provider model catalog changed while the snapshot was compiling")]
CatalogChanged,
}
@@ -378,7 +376,6 @@ impl RuntimeSnapshotCompiler {
observed_generations
})
.with_extensions(extensions);
snapshot.validate_extension_models()?;
return Ok(snapshot);
}
}
@@ -655,45 +652,6 @@ impl RuntimeSnapshot {
.find(|alias| alias.id.as_str() == model)
}
fn validate_extension_models(&self) -> Result<(), RuntimeSnapshotCompileError> {
let mut ids = BTreeSet::new();
for alias in self.model_aliases() {
let valid = ids.insert(&alias.id)
&& self.providers.contains(&alias.provider)
&& !self
.settings
.values
.model_mappings
.contains_key(alias.id.as_str())
&& !self
.settings
.values
.model_mappings
.contains_key(alias.target.as_str())
&& !self
.settings
.values
.model_mappings
.values()
.any(|target| target == alias.id.as_str())
&& self.model_alias(alias.target.as_str()).is_none()
&& !self.provider_models.values().any(|models| {
models
.keys()
.any(|model| model.as_str() == alias.id.as_str())
})
&& self
.provider_models
.get(&alias.provider)
.is_some_and(|models| models.contains_key(&alias.target));
if !valid {
tracing::warn!(owner = %alias.owner, model = %alias.id, "插件模型别名与宿主目录或映射冲突,拒绝发布候选快照");
return Err(RuntimeSnapshotCompileError::InvalidExtensionModels);
}
}
Ok(())
}
#[must_use]
pub fn responses_max_decompressed_body_bytes(&self) -> usize {
self.settings.responses_max_decompressed_body_bytes.get()
+4 -1
View File
@@ -26,10 +26,11 @@ pub enum WorkerKind {
Backup,
AccountImport,
AccountFreezeRecovery,
ResetCreditAutomation,
}
impl WorkerKind {
pub const ALL: [Self; 11] = [
pub const ALL: [Self; 12] = [
Self::OAuthRefresh,
Self::QuotaCatalogHealth,
Self::RuntimeSnapshotReconciliation,
@@ -41,6 +42,7 @@ impl WorkerKind {
Self::Backup,
Self::AccountImport,
Self::AccountFreezeRecovery,
Self::ResetCreditAutomation,
];
#[must_use]
@@ -57,6 +59,7 @@ impl WorkerKind {
Self::Backup => "backup",
Self::AccountImport => "account_import",
Self::AccountFreezeRecovery => "account_freeze_recovery",
Self::ResetCreditAutomation => "reset_credit_automation",
}
}
}
@@ -3660,74 +3660,76 @@ fn exhausted_atomic_response_failed_should_deliver_only_the_last_failure_once()
#[test]
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(failure)],
},
Script::Error(ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)),
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
for kind in [
ProviderErrorKind::NoEligibleAccount,
ProviderErrorKind::SessionPolicyRestricted,
] {
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(failure)],
},
Script::Error(ProviderError::new(kind, UpstreamSendState::NotSent)),
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let terminal = block_on(session.next_event())
.expect("last account failure remains deliverable")
.expect("terminal failure batch");
let events = terminal.into_provider_events();
let failure = events
.iter()
.filter_map(ProviderEvent::wire_event)
.find(|wire| wire.event_type() == Some("response.failed"))
.expect("last upstream response.failed");
assert_eq!(
failure
.data()
.pointer("/response/id")
.and_then(Value::as_str),
Some("response-only-failed")
);
block_on(session.commit_downstream(Some(200))).expect("commit terminal failure");
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
let terminal = block_on(session.next_event())
.expect("last account failure remains deliverable")
.expect("terminal failure batch");
let events = terminal.into_provider_events();
let failure = events
.iter()
.filter_map(ProviderEvent::wire_event)
.find(|wire| wire.event_type() == Some("response.failed"))
.expect("last upstream response.failed");
assert_eq!(
failure
.data()
.pointer("/response/id")
.and_then(Value::as_str),
Some("response-only-failed")
);
block_on(session.commit_downstream(Some(200))).expect("commit terminal failure");
assert!(matches!(
block_on(session.next_event()),
Err(EngineError::Provider(_))
));
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);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
assert_eq!(
state.finalizations[0].client_response_id.as_deref(),
Some("response-only-failed")
);
assert_eq!(
state.finalizations[0].upstream_response_id.as_deref(),
Some("response-only-failed")
);
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);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Incomplete);
assert_eq!(
state.finalizations[0].client_response_id.as_deref(),
Some("response-only-failed")
);
assert_eq!(
state.finalizations[0].upstream_response_id.as_deref(),
Some("response-only-failed")
);
}
}
#[test]
@@ -4647,8 +4649,10 @@ fn transient_backoff_can_be_cancelled_before_the_next_attempt() {
}
#[test]
fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
for capacity_error in [
fn final_selection_exhaustion_returns_the_last_retryable_upstream_failure() {
for selection_kind in [
ProviderErrorKind::SessionPolicyRestricted,
ProviderErrorKind::NoEligibleAccount,
ProviderErrorKind::AccountCapacityUnavailable,
ProviderErrorKind::QuotaExhausted,
ProviderErrorKind::ConcurrencyQueueFull,
@@ -4670,15 +4674,18 @@ fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
Some(b"application/json".to_vec()),
raw_body.clone(),
));
let selection_error = ProviderError::new(selection_kind, UpstreamSendState::NotSent);
let selection_error = if selection_kind == ProviderErrorKind::SessionPolicyRestricted {
selection_error.with_retry_prohibited()
} else {
selection_error
};
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(upstream_error)],
},
Script::Error(ProviderError::new(
capacity_error,
UpstreamSendState::NotSent,
)),
Script::Error(selection_error),
]);
let mut session = block_on(coordinator.start(
@@ -4721,58 +4728,60 @@ fn final_capacity_exhaustion_returns_the_last_retryable_upstream_failure() {
}
#[test]
fn provider_infrastructure_failure_does_not_restore_the_last_upstream_failure() {
let operation = generate_operation();
let route_plan = plan(&operation);
let upstream_error =
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_retry()
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"upstream marker",
Some("upstream_marker".to_owned()),
Some("upstream_error".to_owned()),
))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
502,
Some(b"application/json".to_vec()),
Bytes::from_static(b"{\"error\":\"upstream marker\"}"),
));
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(upstream_error)],
},
Script::Error(ProviderError::new(
ProviderErrorKind::ProviderInfrastructureUnavailable,
UpstreamSendState::NotSent,
)),
]);
fn new_terminal_failure_does_not_restore_the_last_upstream_failure() {
for kind in [
ProviderErrorKind::ProviderInfrastructureUnavailable,
ProviderErrorKind::InvalidRequest,
ProviderErrorKind::RequestPolicyDenied,
ProviderErrorKind::Unavailable,
ProviderErrorKind::Timeout,
] {
let operation = generate_operation();
let route_plan = plan(&operation);
let upstream_error =
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::NotSent)
.with_pre_delivery_retry()
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
"upstream marker",
Some("upstream_marker".to_owned()),
Some("upstream_error".to_owned()),
))
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
502,
Some(b"application/json".to_vec()),
Bytes::from_static(b"{\"error\":\"upstream marker\"}"),
));
let (coordinator, store, provider) = coordinator(vec![
Script::Stream {
account_id: "acct_first",
items: vec![Err(upstream_error)],
},
Script::Error(ProviderError::new(kind, UpstreamSendState::NotSent)),
]);
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("provider infrastructure failure must remain terminal");
let EngineError::Provider(error) = error else {
panic!("expected provider error");
};
let mut session = block_on(coordinator.start(
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
operation,
route_plan,
None,
None,
CancellationToken::new(),
))
.expect("start execution");
let error = block_on(session.collect_uncommitted())
.expect_err("new terminal failure must not be replaced by an earlier upstream error");
let EngineError::Provider(error) = error else {
panic!("expected provider error");
};
assert_eq!(
error.kind(),
ProviderErrorKind::ProviderInfrastructureUnavailable
);
assert!(error.client_visible_upstream_error().is_none());
assert!(error.client_visible_upstream_response().is_none());
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(error.kind(), kind);
assert!(error.client_visible_upstream_error().is_none());
assert!(error.client_visible_upstream_response().is_none());
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 2);
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
}
}
#[test]
@@ -4995,6 +5004,11 @@ fn cancellation_before_pending_delivery_commit_reaches_terminal_state() {
#[test]
fn no_eligible_account_before_stream_records_failure_without_fabricating_an_attempt() {
for (kind, status, gateway_kind) in [
(
ProviderErrorKind::SessionPolicyRestricted,
400,
GatewayErrorKind::InvalidRequest,
),
(
ProviderErrorKind::NoEligibleAccount,
503,
@@ -6363,13 +6377,15 @@ fn provider_candidate_advance_stops_at_the_account_rotation_budget() {
#[test]
fn prepare_failure_can_forbid_provider_fallback_without_affecting_ordinary_empty_selection() {
for prohibited in [false, true] {
for (kind, prohibited, fallback) in [
(ProviderErrorKind::NoEligibleAccount, false, true),
(ProviderErrorKind::NoEligibleAccount, true, false),
(ProviderErrorKind::SessionPolicyRestricted, false, false),
(ProviderErrorKind::SessionPolicyRestricted, true, false),
] {
let operation = generate_operation();
let route_plan = dual_provider_plan(&operation);
let mut error = ProviderError::new(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
);
let mut error = ProviderError::new(kind, UpstreamSendState::NotSent);
if prohibited {
error = error.with_retry_prohibited();
}
@@ -6397,8 +6413,8 @@ fn prepare_failure_can_forbid_provider_fallback_without_affecting_ordinary_empty
CancellationToken::new(),
))
.unwrap();
assert_eq!(block_on(session.collect_uncommitted()).is_err(), prohibited);
assert_eq!(xai.contexts.lock().unwrap().len(), usize::from(!prohibited));
assert_eq!(block_on(session.collect_uncommitted()).is_err(), !fallback);
assert_eq!(xai.contexts.lock().unwrap().len(), usize::from(fallback));
}
}
@@ -780,6 +780,8 @@ struct FixedRoutingPolicy {
struct RetryTestPolicy {
decision: gateway_core::engine::policy::RetryDecision,
facts: Arc<Mutex<Vec<gateway_core::engine::policy::RetryFacts>>>,
observe_effect: bool,
fail: bool,
}
impl RequestPolicyPlan for RetryTestPolicy {
@@ -800,9 +802,19 @@ impl RequestPolicyPlan for RetryTestPolicy {
input: gateway_core::engine::policy::RetryInput,
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
{
if self.observe_effect {
input.execution_effects().observe();
}
self.facts.lock().unwrap().push(input.facts);
let decision = self.decision;
Box::pin(async move { Ok(decision) })
let fail = self.fail;
Box::pin(async move {
if fail {
Err(RequestPolicyFault)
} else {
Ok(decision)
}
})
}
}
@@ -853,11 +865,21 @@ impl Provider for RetryTestProvider {
#[test]
fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
use gateway_core::engine::policy::RetryDecision;
for (decision, send_state, replay_safe, expected_allowed, expected_calls) in [
for (
decision,
send_state,
replay_safe,
observe_effect,
fail,
expected_allowed,
expected_calls,
) in [
(
RetryDecision::Stop,
UpstreamSendState::NotSent,
true,
false,
false,
true,
1,
),
@@ -865,6 +887,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
RetryDecision::Retry,
UpstreamSendState::NotSent,
true,
false,
false,
true,
2,
),
@@ -873,6 +897,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
UpstreamSendState::Ambiguous,
true,
false,
false,
false,
1,
),
(
@@ -880,6 +906,26 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
UpstreamSendState::Sent,
false,
false,
false,
false,
1,
),
(
RetryDecision::Retry,
UpstreamSendState::NotSent,
true,
true,
false,
true,
1,
),
(
RetryDecision::Delegate,
UpstreamSendState::NotSent,
true,
true,
true,
true,
1,
),
] {
@@ -903,6 +949,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() {
Arc::new(RetryTestPolicy {
decision,
facts: facts.clone(),
observe_effect,
fail,
}),
)
.unwrap();
@@ -202,12 +202,16 @@ fn contributed_alias_uses_the_same_target_for_listing_profiles_and_routing() {
}
#[test]
fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_publication() {
fn contributed_alias_business_data_does_not_block_publication_or_native_routing() {
let mut unknown_provider = alias("plugin-model", "upstream-model");
unknown_provider.provider = ProviderKind::new("unregistered").unwrap();
for aliases in [
vec![alias("upstream-model", "upstream-model")],
vec![alias("configured", "upstream-model")],
vec![alias("plugin-model", "configured")],
vec![alias("plugin-model", "missing")],
vec![alias("plugin-model", "plugin-model")],
vec![unknown_provider],
vec![
alias("plugin-model", "other"),
alias("other", "plugin-model"),
@@ -217,13 +221,71 @@ fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_public
alias("plugin-model", "upstream-model"),
],
] {
let count = aliases.len();
let handle =
gateway_core::runtime::RuntimeSnapshotHandle::new(compile_aliases(vec![]).unwrap());
handle.publish(
compile_aliases(aliases).expect("plugin business data must not prevent publication"),
);
let snapshot = handle.acquire().unwrap();
assert_eq!(snapshot.extensions().unwrap().model_aliases().len(), count);
let native = PublicModelId::new("upstream-model").unwrap();
assert!(snapshot.contains_public_model_for_scope(&native, &snapshot.all_account_scope()));
let plan = snapshot
.plan(
&native,
&super::operation(),
snapshot.all_account_scope(),
&Default::default(),
)
.unwrap();
assert_eq!(plan.candidates()[0].provider().as_str(), "alpha");
assert_eq!(
compile_aliases(aliases).unwrap_err(),
RuntimeSnapshotCompileError::InvalidExtensionModels
plan.candidates()[0].upstream_model().unwrap().as_str(),
"upstream-model"
);
}
}
#[test]
fn contributed_alias_is_resolved_once_and_preserves_the_first_matching_entry() {
let snapshot = compile_aliases(vec![
alias("duplicate", "first-target"),
alias("duplicate", "upstream-model"),
alias("self", "self"),
alias("left", "right"),
alias("right", "left"),
alias("configured", "plugin-target"),
alias("to-configured", "configured"),
])
.unwrap();
assert_eq!(snapshot.mapped_model("duplicate"), "first-target");
assert_eq!(snapshot.mapped_model("self"), "self");
assert_eq!(snapshot.mapped_model("left"), "right");
assert_eq!(snapshot.mapped_model("right"), "left");
assert_eq!(snapshot.mapped_model("configured"), "plugin-target");
assert_eq!(snapshot.mapped_model("to-configured"), "configured");
}
#[test]
fn contributed_alias_unknown_provider_is_a_request_routing_error() {
let mut unknown_provider = alias("plugin-model", "upstream-model");
unknown_provider.provider = ProviderKind::new("unregistered").unwrap();
let snapshot = compile_aliases(vec![unknown_provider]).unwrap();
let model = PublicModelId::new("plugin-model").unwrap();
assert!(!snapshot.contains_public_model_for_scope(&model, &snapshot.all_account_scope()));
assert!(
snapshot
.plan(
&model,
&super::operation(),
snapshot.all_account_scope(),
&Default::default(),
)
.is_err()
);
}
impl ExtensionPreparationPort for CountingExtensionPreparation {
fn prepare(
&self,
+21 -19
View File
@@ -1,4 +1,4 @@
//! 使用与 Provider 请求一致的显式代理协议,执行有超时和响应大小限制的出口测试
//! 使用与 Provider 请求一致的显式代理或直连路径,执行有超时和响应大小限制的出口测试
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
@@ -77,22 +77,24 @@ impl HttpProxyProbe {
async fn exit_ip_at(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
endpoint: &str,
) -> Result<IpAddr, &'static str> {
let proxy = reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?;
let builder = reqwest::Client::builder()
let mut builder = reqwest::Client::builder()
.no_proxy()
.proxy(proxy)
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(reqwest::redirect::Policy::none());
if let Some(proxy) = proxy {
builder = builder
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?);
}
let client = (self.build_client)(builder)?;
let mut response = client.get(endpoint).send().await.map_err(|error| {
if error.is_timeout() {
"代理连接超时"
"出口连接超时"
} else {
"代理连接失败,请检查地址、认证和网络"
"出口连接失败,请检查网络或代理配置"
}
})?;
if !response.status().is_success() {
@@ -123,7 +125,7 @@ impl HttpProxyProbe {
}
impl HttpProxyProbe {
async fn test_connection(&self, proxy: &OutboundProxy) -> ProxyTestResult {
async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult {
let started = Instant::now();
let timeout_limit = Duration::from_secs(15);
@@ -131,7 +133,7 @@ impl HttpProxyProbe {
ProbeStrategy::Single(endpoint) => {
let result =
tokio::time::timeout(timeout_limit, self.exit_ip_at(proxy, endpoint)).await;
let result = result.unwrap_or(Err("代理连接超时"));
let result = result.unwrap_or(Err("出口连接超时"));
let latency_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
match result {
@@ -219,7 +221,7 @@ impl HttpProxyProbe {
let message = res_v4
.err()
.or(res_v6.err())
.unwrap_or("代理连接失败")
.unwrap_or("出口连接失败")
.to_owned();
ProxyTestResult {
location: ProxyLocationDetection::NotRequested,
@@ -239,7 +241,7 @@ impl HttpProxyProbe {
exit_ip: None,
exit_ipv4: None,
exit_ipv6: None,
message: "代理连接超时".to_owned(),
message: "出口连接超时".to_owned(),
},
}
}
@@ -257,17 +259,17 @@ impl HttpProxyProbe {
async fn location_for_ip(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
ip: IpAddr,
) -> Result<RequestLocation, &'static str> {
// 出口 IP 已由代理探测确认;按这个固定 IP 从服务端查询位置,避免代理出口屏蔽位置服务
// 出口 IP 已由探测确认;按这个固定 IP 从服务端查询位置,避免代理出口屏蔽位置服务
// 部署环境无法直连位置服务时仍尝试原代理路径
match self.lookup_location(ip, None).await {
Ok(location) => Ok(location),
Err(direct_error) => self
.lookup_location(ip, Some(proxy))
.await
.or(Err(direct_error)),
Err(direct_error) if proxy.is_some() => {
self.lookup_location(ip, proxy).await.or(Err(direct_error))
}
Err(error) => Err(error),
}
}
@@ -348,7 +350,7 @@ impl HttpProxyProbe {
async fn detect_location(
&self,
proxy: &OutboundProxy,
proxy: Option<&OutboundProxy>,
result: &ProxyTestResult,
) -> ProxyLocationDetection {
let lookup = async |ip: Option<IpAddr>| match ip {
@@ -378,7 +380,7 @@ impl HttpProxyProbe {
#[async_trait]
impl ProxyProbe for HttpProxyProbe {
async fn test(&self, proxy: &OutboundProxy, detect_location: bool) -> ProxyTestResult {
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult {
let mut result = self.test_connection(proxy).await;
if detect_location {
result.location = if result.success {
+107 -11
View File
@@ -1,4 +1,4 @@
//! 验证代理探测的双栈地址、认证、证书与失败边界
//! 验证代理及直连探测的双栈地址、认证、证书与失败边界
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_core::account::OutboundProxy;
@@ -28,7 +28,7 @@ async fn proxy_probe_supports_ipv4_and_ipv6_proxies_and_exit_addresses() {
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address()))
.unwrap();
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&proxy, false)
.test(Some(&proxy), false)
.await;
assert!(
result.success,
@@ -54,7 +54,10 @@ async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_resp
.mount(&proxy_server)
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), false)
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
false,
)
.await;
assert!(!result.success);
assert!(result.exit_ip.is_none());
@@ -72,7 +75,9 @@ async fn unavailable_proxy_never_falls_back_to_direct_connection() {
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, false).await;
let result = HttpProxyProbe::new(target.uri())
.test(Some(&proxy), false)
.await;
assert!(!result.success);
}
@@ -86,7 +91,7 @@ async fn invalid_certificate_configuration_should_not_fall_back_or_expose_detail
.await;
let result = HttpProxyProbe::new(target.uri())
.with_client_builder(|_| Err("private-certificate-path"))
.test(&OutboundProxy::parse(&target.uri()).unwrap(), false)
.test(Some(&OutboundProxy::parse(&target.uri()).unwrap()), false)
.await;
assert!(!result.success);
assert!(!result.message.contains("private-certificate-path"));
@@ -112,7 +117,7 @@ async fn dual_stack_proxy_probe_reports_both_addresses_when_available() {
format!("{}/v4", proxy_server.uri()),
format!("{}/v6", proxy_server.uri()),
)
.test(&proxy, false)
.test(Some(&proxy), false)
.await;
assert!(result.success);
@@ -141,7 +146,7 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() {
format!("{}/v4", proxy_server.uri()),
format!("{}/v6", proxy_server.uri()),
)
.test(&proxy, false)
.test(Some(&proxy), false)
.await;
assert!(result.success);
@@ -154,6 +159,85 @@ fn location_response(ip: &str, timezone: &str) -> serde_json::Value {
json!({"success": true, "ip": ip, "country_code": "JP", "region": "Tokyo", "city": "Tokyo", "timezone": {"id": timezone}})
}
#[tokio::test]
async fn direct_location_uses_verified_addresses_and_rejects_conflicting_timezones() {
use gateway_admin::model::proxies::ProxyLocationDetection;
for v6_timezone in [Some("Asia/Tokyo"), Some("America/New_York"), None] {
let server = MockServer::start().await;
for (endpoint, ip) in [("/v4", "203.0.113.8"), ("/v6", "2001:db8::8")] {
let response = if endpoint == "/v6" && v6_timezone.is_none() {
ResponseTemplate::new(503)
} else {
ResponseTemplate::new(200).set_body_json(json!({"ip": ip}))
};
Mock::given(path(endpoint))
.respond_with(response)
.expect(1)
.mount(&server)
.await;
}
for (ip, timezone) in [
("203.0.113.8", Some("Asia/Tokyo")),
("2001:db8::8", v6_timezone),
] {
if let Some(timezone) = timezone {
Mock::given(path(format!("/geo/{ip}")))
.respond_with(
ResponseTemplate::new(200).set_body_json(location_response(ip, timezone)),
)
.expect(1)
.mount(&server)
.await;
}
}
let result = HttpProxyProbe::new_dual(
format!("{}/v4", server.uri()),
format!("{}/v6", server.uri()),
)
.with_location_endpoint(format!("{}/geo", server.uri()))
.test(None, true)
.await;
assert!(result.success);
if v6_timezone == Some("America/New_York") {
assert_eq!(result.location, ProxyLocationDetection::Conflict);
} else {
let ProxyLocationDetection::Detected { location } = result.location else {
panic!("direct location missing")
};
assert_eq!(location.timezone.name(), "Asia/Tokyo");
assert_eq!(location.city, "Tokyo");
}
}
}
#[tokio::test]
async fn direct_location_failure_does_not_retry_the_same_path_or_return_a_location() {
use gateway_admin::model::proxies::ProxyLocationDetection;
let server = MockServer::start().await;
Mock::given(path("/ip"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ip": "203.0.113.8"})))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/geo/203.0.113.8"))
.respond_with(ResponseTemplate::new(429))
.expect(1)
.mount(&server)
.await;
let result = HttpProxyProbe::new(format!("{}/ip", server.uri()))
.with_location_endpoint(format!("{}/geo", server.uri()))
.test(None, true)
.await;
assert_eq!(
result.location,
ProxyLocationDetection::Failed {
message: "位置查询服务限流,请稍后手动重试".to_owned()
}
);
}
#[tokio::test]
async fn location_lookup_uses_verified_exit_ip_without_requiring_proxy_access_to_geo_service() {
use gateway_admin::model::proxies::ProxyLocationDetection;
@@ -176,7 +260,10 @@ async fn location_lookup_uses_verified_exit_ip_without_requiring_proxy_access_to
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint(format!("{}/geo", location_server.uri()))
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true)
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
assert!(result.success);
@@ -212,7 +299,10 @@ async fn location_lookup_uses_probed_ips_through_the_proxy_and_checks_both_timez
"http://unresolvable.invalid/v6",
)
.with_location_endpoint("http://unresolvable.invalid/geo/")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true)
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
assert!(result.success);
if conflict {
@@ -255,7 +345,10 @@ async fn geolocation_failure_never_turns_connectivity_into_failure_or_uses_unver
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint("http://unresolvable.invalid/geo")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), true)
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
assert!(result.success);
assert!(matches!(
@@ -280,7 +373,10 @@ async fn disabled_geolocation_never_calls_the_location_service() {
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint("http://unresolvable.invalid/geo")
.test(&OutboundProxy::parse(&proxy_server.uri()).unwrap(), false)
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
false,
)
.await;
assert!(result.success);
}
+2 -1
View File
@@ -19,7 +19,7 @@ use gateway_core::task::{
use gateway_host::workers::{WorkerStartError, WorkerSupervisor};
use tokio::sync::Notify;
const ACTIVE_KINDS: [WorkerKind; 10] = [
const ACTIVE_KINDS: [WorkerKind; 11] = [
WorkerKind::OAuthRefresh,
WorkerKind::QuotaCatalogHealth,
WorkerKind::RuntimeSnapshotReconciliation,
@@ -30,6 +30,7 @@ const ACTIVE_KINDS: [WorkerKind; 10] = [
WorkerKind::Backup,
WorkerKind::AccountImport,
WorkerKind::AccountFreezeRecovery,
WorkerKind::ResetCreditAutomation,
];
#[derive(Clone)]
@@ -1,18 +1,18 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [2],
"protocol_versions": [4],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [1] },
{ "capability": "scheduler", "versions": [2] },
{ "capability": "model_router", "versions": [1] },
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
{ "capability": "model_router", "versions": [2] },
{ "capability": "model_catalog", "versions": [1] },
{ "capability": "retry_policy", "versions": [1] },
{ "capability": "middleware", "versions": [4] },
{ "capability": "upstream_adapter", "versions": [2] },
{ "capability": "observer", "versions": [1] },
{ "capability": "retry_policy", "versions": [2] },
{ "capability": "middleware", "versions": [5] },
{ "capability": "upstream_adapter", "versions": [3] },
{ "capability": "observer", "versions": [2] },
{ "capability": "command_line", "versions": [1] },
{ "capability": "management", "versions": [1] },
{ "capability": "management", "versions": [2] },
{ "capability": "maintenance", "versions": [1] }
]
}
@@ -1,6 +1,6 @@
//! 编译插件模型目录贡献并校验能力绑定
use std::{collections::BTreeSet, time::Duration};
use std::time::Duration;
use gateway_admin::model::{
AdminError,
@@ -29,7 +29,7 @@ pub(crate) async fn prepare(
)
.await
.map_err(|_| AdminError::invalid("插件模型目录注册失败"))?;
if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 {
if reply.payload.len() > 64 * 1024 {
return Err(AdminError::invalid("插件模型目录信封无效或过大"));
}
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
@@ -37,21 +37,12 @@ pub(crate) async fn prepare(
if registration.models.len() > 256 {
return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名"));
}
let mut ids = BTreeSet::new();
registration
.models
.into_iter()
.map(|model| {
if !matches!(model.provider.as_str(), "openai" | "xai") || model.id == model.model {
return Err(AdminError::invalid(
"模型别名必须指向内置 Provider 的不同上游模型",
));
}
let id = PublicModelId::new(model.id)
.map_err(|_| AdminError::invalid("模型别名 ID 无效"))?;
if !ids.insert(id.clone()) {
return Err(AdminError::invalid(format!("插件模型别名重复:{id}")));
}
Ok(ContributedModelAlias {
owner: instance.id.clone(),
id,
@@ -96,9 +96,7 @@ impl PluginCommand {
)
.await
.map_err(rpc_failure)?;
if reply.result != serde_json::json!({})
|| reply.payload.len() > limits.maximum_buffered_body_bytes
{
if reply.payload.len() > limits.maximum_buffered_body_bytes {
self.session.invalid_response(Stage::CommandLine);
return Err("结果信封无效");
}
@@ -1,6 +1,6 @@
//! 插件命令行参数定义校验、输入解析与帮助文本生成
//! 按插件声明解析命令行输入并生成帮助文本
use std::collections::{BTreeMap, BTreeSet};
use std::collections::BTreeMap;
use gateway_admin::model::AdminError;
use gateway_plugin_sdk::call::management::{
@@ -9,35 +9,6 @@ use gateway_plugin_sdk::call::management::{
const MAXIMUM_ARGUMENT_BYTES: usize = 64 * 1024;
pub(super) fn validate(command: &CommandDescriptor) -> Result<(), AdminError> {
let mut names = BTreeSet::new();
if !identifier(&command.name)
|| matches!(
command.name.as_str(),
"help" | "version" | "serve" | "plugin"
)
|| !description(&command.description)
|| command.parameters.len() > 64
{
return Err(AdminError::invalid("插件命令描述不合法"));
}
for parameter in &command.parameters {
if !identifier(&parameter.name)
|| matches!(parameter.name.as_str(), "help" | "version")
|| !description(&parameter.description)
|| !names.insert(&parameter.name)
|| parameter.default.as_ref().is_some_and(|value| {
!matches_type(value, parameter.value_type)
|| parameter.required
|| matches!(value, CommandValue::String(value) if value.len() > MAXIMUM_ARGUMENT_BYTES)
})
{
return Err(AdminError::invalid("插件命令参数、默认值或名称冲突"));
}
}
Ok(())
}
pub(super) fn parse(
command: &CommandDescriptor,
arguments: &[String],
@@ -150,30 +121,6 @@ fn parse_value(value: &str, kind: CommandParameterType) -> Option<CommandValue>
})
}
fn matches_type(value: &CommandValue, kind: CommandParameterType) -> bool {
match (value, kind) {
(CommandValue::Bool(_), CommandParameterType::Bool)
| (CommandValue::String(_), CommandParameterType::String)
| (CommandValue::Int(_), CommandParameterType::Int)
| (CommandValue::Int64(_), CommandParameterType::Int64)
| (CommandValue::Duration(_), CommandParameterType::Duration) => true,
(CommandValue::Float64(value), CommandParameterType::Float64) => value.is_finite(),
_ => false,
}
}
fn identifier(value: &str) -> bool {
(1..=64).contains(&value.len())
&& value.as_bytes()[0].is_ascii_lowercase()
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
})
}
fn description(value: &str) -> bool {
!value.trim().is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control)
}
fn invalid_argument() -> AdminError {
// 不回显用户参数;即使未声明 sensitive,参数也可能包含凭据
AdminError::invalid("插件命令参数未知、重复、缺值或类型不正确;请查看 --help")
@@ -1,6 +1,6 @@
//! 校验插件命令行注册声明并准备可执行命令
use super::{PluginCommand, parameters};
use super::PluginCommand;
use crate::{RpcSession, callback::PluginCallbacks};
use gateway_admin::model::{AdminError, plugins::instances::PluginInstance};
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::management::CommandRegistration};
@@ -29,22 +29,20 @@ pub(crate) async fn prepare(
)
.await
.map_err(|_| AdminError::invalid("插件命令注册失败"))?;
if reply.result != serde_json::json!({}) || reply.payload.len() > MAXIMUM_REGISTRATION_BYTES {
if reply.payload.len() > MAXIMUM_REGISTRATION_BYTES {
return Err(AdminError::invalid("插件命令注册信封无效或过大"));
}
let registration: CommandRegistration = serde_json::from_slice(&reply.payload)
.map_err(|_| AdminError::invalid("插件命令注册数据无效"))?;
if registration.commands.is_empty() || registration.commands.len() > 32 {
return Err(AdminError::invalid("每个插件须注册 1 至 32 个命令"));
if registration.commands.len() > 32 {
return Err(AdminError::invalid("每个插件最多注册 32 个命令"));
}
let mut names = std::collections::BTreeSet::new();
registration
.commands
.into_iter()
.map(|descriptor| {
parameters::validate(&descriptor)?;
if !names.insert(descriptor.name.clone()) {
return Err(AdminError::invalid("插件命令名重复"));
if descriptor.parameters.len() > 64 {
return Err(AdminError::invalid("插件命令参数超过容量限制"));
}
Ok(Arc::new(PluginCommand {
instance_id: instance.id.clone(),
@@ -53,9 +53,6 @@ pub(crate) async fn prepare_entry(
)
.await
.map_err(|_| AdminError::invalid("插件入口认证器注册失败"))?;
if !reply.payload.is_empty() {
return Err(AdminError::invalid("插件入口认证器注册结果无效"));
}
let identifier: FrontendAuthenticationIdentifier = serde_json::from_value(reply.result)
.map_err(|_| AdminError::invalid("插件入口认证器注册结果无效"))?;
if !valid_identifier(&identifier.identifier) {
@@ -200,9 +197,10 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
.invocation
.as_ref()
.ok_or(FrontendAuthenticationError)?;
let context = session.context(Stage::Authentication, self.timeout);
let mut context = session.context(Stage::Authentication, self.timeout);
context.request = crate::adapter::request_context(request.request_context());
let _scope = callbacks
.prepare_frontend_authentication(&context)
.prepare_frontend_authentication(&context, request.shared_request_context())
.map_err(|_| FrontendAuthenticationError)?;
let input = FrontendAuthenticationRequest {
authorization: request.authorization().to_owned(),
@@ -216,10 +214,6 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
)
.await
.map_err(|_| FrontendAuthenticationError)?;
if reply.result != serde_json::json!({}) {
session.invalid_response(Stage::Authentication);
return Err(FrontendAuthenticationError);
}
let result: FrontendAuthenticationResult = serde_json::from_slice(&reply.payload)
.map_err(|_| {
session.invalid_response(Stage::Authentication);

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