mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 08:06:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45954899e5 | ||
|
|
78f533d9d3 | ||
|
|
376305fed4 | ||
|
|
a82bcdb8f8 | ||
|
|
3d072fedcf | ||
|
|
a0e708071a | ||
|
|
e9ad1811dd | ||
|
|
615e8d66f4 | ||
|
|
d79c76a105 | ||
|
|
1bf47f71b6 | ||
|
|
2f3f2975ef | ||
|
|
759935b75d | ||
|
|
fcf4af0ddd | ||
|
|
8fb48dd6c3 | ||
|
|
5a81d61979 | ||
|
|
1fadc75ba2 | ||
|
|
83f5d71853 | ||
|
|
f4eceb076b | ||
|
|
0b02d0d98a | ||
|
|
a014da6916 | ||
|
|
9d4c58f967 | ||
|
|
c67b081ec1 | ||
|
|
1ce9710afa |
@@ -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#项目约定)的对应条目
|
||||
|
||||
@@ -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 缺陷,也不把第三方补偿方案当成预期行为;症状反馈仍不以证明根因为前提
|
||||
|
||||
## 正文与证据
|
||||
|
||||
|
||||
@@ -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. 按贡献文档的审查维度追踪完整调用链,找到共享业务事实、同类实现及已有验证。
|
||||
对既有特殊分支查清服务的场景,再判断本次改动是否引入或加重问题。
|
||||
主动检查本次变更新引入或加重的冗余与代码异味,说明具体位置、维护成本或行为影响及最小修复方向;
|
||||
|
||||
@@ -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 为准
|
||||
|
||||
## 按当前步骤读取
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
Generated
+16
-2
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
+117
-34
@@ -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 != ¤t.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())
|
||||
}
|
||||
|
||||
+129
-6
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(¶meter.name)
|
||||
|| matches!(parameter.name.as_str(), "help" | "version")
|
||||
|| !description(¶meter.description)
|
||||
|| !names.insert(¶meter.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
Reference in New Issue
Block a user