mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +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 | ||
|
|
447869860a | ||
|
|
815c93a715 | ||
|
|
5843091969 | ||
|
|
1280c437b4 | ||
|
|
d965bc9050 | ||
|
|
bd9b017ed3 | ||
|
|
338cde1505 | ||
|
|
b25f96559a | ||
|
|
a8a96d8919 | ||
|
|
7b8339ede3 | ||
|
|
f439a30612 | ||
|
|
8572873c5f | ||
|
|
57c8ef358f | ||
|
|
70ab8ebc8d | ||
|
|
14ea5234ce | ||
|
|
f8480f3bdc | ||
|
|
52b8103bc9 | ||
|
|
dacdd3f0e2 | ||
|
|
ac36f5bc13 | ||
|
|
e0a022518c | ||
|
|
37f7a55427 | ||
|
|
f6085206eb | ||
|
|
9813c86055 | ||
|
|
260f775039 | ||
|
|
1424cea3a9 | ||
|
|
787ad28424 | ||
|
|
db23560b0e | ||
|
|
d700df8a4f | ||
|
|
c9adfd9a2b | ||
|
|
654b6baf89 |
@@ -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`,完成后回读核对
|
||||
|
||||
@@ -42,8 +42,11 @@ else
|
||||
mkdir -p "$(dirname "$directory")"
|
||||
temporary="$(mktemp -d "${directory}.XXXXXX")"
|
||||
trap 'rm -rf "$temporary"' EXIT
|
||||
mkdir "$temporary/context"
|
||||
"${command[@]}" --output "type=local,dest=$temporary/snapshot" "$temporary/context"
|
||||
mkdir "$temporary"/{context,snapshot}
|
||||
# tar 流避开 local exporter 的逐文件同步阻塞,直接解包以免额外落盘整份归档。
|
||||
# https://github.com/moby/buildkit/issues/2950
|
||||
"${command[@]}" --output type=tar,dest=- "$temporary/context" \
|
||||
| tar -xf - -C "$temporary/snapshot"
|
||||
if usable "$temporary/snapshot"; then
|
||||
rm -rf "$directory"
|
||||
mv "$temporary/snapshot" "$directory"
|
||||
|
||||
@@ -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
+49
-4
@@ -145,6 +145,18 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
@@ -931,6 +943,23 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
version = "0.15.27"
|
||||
@@ -1717,6 +1746,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"regex",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1848,6 +1878,7 @@ dependencies = [
|
||||
name = "gateway-plugin-sdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2749,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"
|
||||
@@ -3750,9 +3793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -4743,6 +4786,7 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
@@ -5600,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,
|
||||
@@ -107,7 +108,7 @@ async fn execute(
|
||||
let settings = gateway_admin::initialize_settings(
|
||||
store.admin_ports().settings(),
|
||||
core.snapshot_control(),
|
||||
providers.admin,
|
||||
providers.admin.clone(),
|
||||
std::sync::Arc::new(gateway_host::pricing::ModelsDevPricing),
|
||||
);
|
||||
let mut services = gateway_admin::public_service::Registry::new(service_middleware(
|
||||
@@ -115,6 +116,22 @@ 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(),
|
||||
providers.admin,
|
||||
);
|
||||
or_shutdown!(plugin_runtime, services.register_usage_data(&usage_data));
|
||||
let services = std::sync::Arc::new(services);
|
||||
or_shutdown!(plugin_runtime, plugin_runtime.bind_services(&services));
|
||||
let commands = or_shutdown!(plugin_runtime, plugin_runtime.prepare_command_line().await);
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use syn::{Item, visit::Visit};
|
||||
@@ -26,22 +27,24 @@ pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
|
||||
#[test]
|
||||
fn workspace_member_list_matches_the_frozen_dag_scope() {
|
||||
let manifest =
|
||||
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
assert!(
|
||||
manifest.contains(&format!("\"{member}\"")),
|
||||
"workspace must include {member}"
|
||||
);
|
||||
}
|
||||
let member_count = manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
|
||||
.count();
|
||||
let metadata = cargo_metadata_json();
|
||||
let root = backend_root();
|
||||
let actual: BTreeSet<_> = metadata["packages"]
|
||||
.as_array()
|
||||
.expect("Cargo packages")
|
||||
.iter()
|
||||
.map(|package| {
|
||||
Path::new(package["manifest_path"].as_str().expect("package manifest"))
|
||||
.parent()
|
||||
.expect("package directory")
|
||||
.strip_prefix(&root)
|
||||
.expect("workspace member path")
|
||||
.to_path_buf()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
member_count,
|
||||
WORKSPACE_MEMBERS.len(),
|
||||
actual,
|
||||
WORKSPACE_MEMBERS.iter().map(PathBuf::from).collect(),
|
||||
"new workspace members must extend the dependency DAG rules"
|
||||
);
|
||||
}
|
||||
@@ -378,7 +381,7 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
#[test]
|
||||
fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
let metadata = cargo_metadata_json();
|
||||
let actual = workspace_runtime_dependency_edges(&metadata);
|
||||
let actual = workspace_runtime_dependency_edges(metadata);
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
@@ -474,24 +477,6 @@ fn adapter_public_module_surfaces_match_allowlist() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let src = backend_root().join(member).join("src");
|
||||
let sources = super::rust_files(&src);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = src.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_modules_follow_conventional_file_layout() {
|
||||
let targets = test_source_roots();
|
||||
@@ -748,40 +733,46 @@ pub(super) fn backend_root() -> PathBuf {
|
||||
.expect("backend workspace root")
|
||||
}
|
||||
|
||||
fn cargo_metadata_json() -> serde_json::Value {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
fn cargo_metadata_json() -> &'static serde_json::Value {
|
||||
static METADATA: OnceLock<serde_json::Value> = OnceLock::new();
|
||||
METADATA.get_or_init(|| {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps", "--locked"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
})
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束
|
||||
/// 复用 Cargo 解析后的实际包名,覆盖重命名依赖与平台条件,排除测试和构建依赖
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
let mut in_dependencies = false;
|
||||
let mut names = Vec::new();
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_dependencies = line == "[dependencies]";
|
||||
continue;
|
||||
}
|
||||
if !in_dependencies || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, _)) = line.split_once('=') {
|
||||
names.push(name.trim().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
let manifest = backend_root().join(member).join("Cargo.toml");
|
||||
let package = cargo_metadata_json()["packages"]
|
||||
.as_array()
|
||||
.expect("Cargo packages")
|
||||
.iter()
|
||||
.find(|package| {
|
||||
Path::new(package["manifest_path"].as_str().expect("package manifest")) == manifest
|
||||
})
|
||||
.expect("workspace member");
|
||||
package["dependencies"]
|
||||
.as_array()
|
||||
.expect("package dependencies")
|
||||
.iter()
|
||||
.filter(|dependency| dependency["kind"].is_null())
|
||||
.map(|dependency| {
|
||||
dependency["name"]
|
||||
.as_str()
|
||||
.expect("dependency name")
|
||||
.to_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,9 +59,16 @@ fn cargo_library_root_is_conventional_lib() {
|
||||
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
for member in architecture::WORKSPACE_MEMBERS {
|
||||
let root = architecture::backend_root().join(member).join("src");
|
||||
for relative in rust_files(&root) {
|
||||
let sources = rust_files(&root);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -39,6 +40,7 @@ pub use use_case::{
|
||||
proxies::ProxiesService,
|
||||
settings::SettingsService,
|
||||
system::SystemService,
|
||||
usage_data::UsageDataService,
|
||||
};
|
||||
|
||||
use model::AdminError;
|
||||
@@ -217,6 +219,7 @@ pub struct AdminServices {
|
||||
client_distribution: Arc<dyn ClientDistributionService>,
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
usage_data: Arc<dyn UsageDataService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
credentials: Arc<CredentialsService>,
|
||||
// Runtime 只持有 Weak,这个强引用保障账号端口与 Admin 服务同寿命
|
||||
@@ -285,6 +288,11 @@ impl AdminServices {
|
||||
self.client_distribution.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn usage_data(&self) -> &dyn UsageDataService {
|
||||
self.usage_data.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> &dyn ObservabilityService {
|
||||
self.observability.as_ref()
|
||||
@@ -413,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(),
|
||||
@@ -467,8 +477,11 @@ async fn initialize_inner(
|
||||
registry.clone(),
|
||||
pricing_source,
|
||||
);
|
||||
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,
|
||||
public_services: Arc::new(public_services),
|
||||
@@ -512,8 +525,10 @@ async fn initialize_inner(
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
timezone,
|
||||
system.clone(),
|
||||
)),
|
||||
settings,
|
||||
usage_data,
|
||||
system,
|
||||
credentials,
|
||||
_plugin_accounts: plugin_accounts,
|
||||
@@ -542,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,
|
||||
@@ -630,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>,
|
||||
@@ -644,3 +708,15 @@ pub fn initialize_settings(
|
||||
pricing_source,
|
||||
))
|
||||
}
|
||||
|
||||
/// HTTP、插件及 CLI 共用的用量领域用例,宿主提供已验证的调用主体
|
||||
#[must_use]
|
||||
pub fn initialize_usage_data(
|
||||
store: Arc<dyn ports::usage_data::UsageDataStore>,
|
||||
settings: Arc<dyn ports::store::SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Arc<dyn UsageDataService> {
|
||||
Arc::new(use_case::usage_data::DefaultUsageDataService::new(
|
||||
store, settings, providers,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ pub struct AccountGroupListQuery {
|
||||
/// 完整账号分组摘要
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRecord {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
@@ -139,6 +140,7 @@ pub struct AccountGroupOptionsPage {
|
||||
/// 创建账号分组
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -148,6 +150,7 @@ pub struct CreateAccountGroup {
|
||||
/// 已生成稳定标识、可直接提交存储的创建命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAccountGroup {
|
||||
pub allow_image_generation: bool,
|
||||
pub fast_mode: FastMode,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
@@ -158,6 +161,7 @@ pub struct NewAccountGroup {
|
||||
/// 更新账号分组的描述字段
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccountGroup {
|
||||
pub allow_image_generation: Option<bool>,
|
||||
pub fast_mode: Option<FastMode>,
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
|
||||
@@ -124,6 +124,7 @@ pub struct AccountRecord {
|
||||
pub weight: AccountWeight,
|
||||
pub model_access: gateway_core::account::AccountModelAccess,
|
||||
pub outbound_proxy: Option<gateway_core::account::OutboundProxy>,
|
||||
pub outbound_proxy_enabled: bool,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
@@ -133,6 +134,16 @@ pub struct AccountRecord {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AccountRecord {
|
||||
/// 配置保留绑定,执行请求时禁用代理按直连处理
|
||||
#[must_use]
|
||||
pub fn effective_outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
|
||||
self.outbound_proxy
|
||||
.as_ref()
|
||||
.filter(|_| self.outbound_proxy_enabled)
|
||||
}
|
||||
}
|
||||
|
||||
pub use super::observability::{
|
||||
AccountModelUsage, AccountPoolMetrics as AccountSummary, AccountRequestBucket,
|
||||
CurrencyCost as AccountCost,
|
||||
@@ -283,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>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 故障诊断导出的目标、已采集证据与缺口
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{
|
||||
observability::{OpsError, UsageDetail},
|
||||
system::SystemVersion,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DiagnosticTarget {
|
||||
Request(String),
|
||||
Operation(String),
|
||||
}
|
||||
|
||||
impl DiagnosticTarget {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Request(id) | Self::Operation(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiagnosticGap {
|
||||
pub section: String,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub struct DiagnosticRequest {
|
||||
pub request_id: String,
|
||||
pub detail: Option<UsageDetail>,
|
||||
pub errors: Vec<OpsError>,
|
||||
}
|
||||
|
||||
/// 原始受控详情只能经导出投影脱敏后交付,不参与普通日志或 Debug
|
||||
pub struct DiagnosticBundle {
|
||||
pub target: DiagnosticTarget,
|
||||
pub collected_at: DateTime<Utc>,
|
||||
pub environment: Option<SystemVersion>,
|
||||
pub error: Option<OpsError>,
|
||||
pub requests: Vec<DiagnosticRequest>,
|
||||
pub gaps: Vec<DiagnosticGap>,
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod diagnostics;
|
||||
pub mod import_tasks;
|
||||
pub mod key_usage;
|
||||
pub mod observability;
|
||||
@@ -21,9 +22,11 @@ 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;
|
||||
pub mod usage_data;
|
||||
|
||||
/// 管理用例对外返回的稳定错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
@@ -172,9 +172,19 @@ pub struct UsageFilter {
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
/// 请求列表的结果筛选,不改变汇总和计量口径
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum UsageRecordStatus {
|
||||
#[default]
|
||||
Success,
|
||||
Error,
|
||||
All,
|
||||
}
|
||||
|
||||
/// 用量记录分页查询
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageQuery {
|
||||
pub status: UsageRecordStatus,
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub current_page: u32,
|
||||
@@ -653,6 +663,7 @@ pub struct DashboardObservation {
|
||||
/// 使用记录表格的窄读模型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageListRecord {
|
||||
pub outcome: RequestOutcome,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
@@ -845,7 +856,7 @@ pub struct UsageAttempt {
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 一条请求及其全部尝试
|
||||
/// 一条请求及其保留的尝试与诊断轨迹
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageDetail {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -69,6 +69,7 @@ pub struct ProxyTestResult {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyRecord {
|
||||
pub enabled: bool,
|
||||
pub auto_location: bool,
|
||||
pub detected_location: Option<DetectedProxyLocation>,
|
||||
pub location: Option<gateway_core::account::RequestLocation>,
|
||||
@@ -103,6 +104,9 @@ 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>,
|
||||
/// 外层为空保留配置,内层为空恢复全局继承
|
||||
@@ -119,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>,
|
||||
}
|
||||
@@ -24,7 +24,7 @@ impl RetentionPolicy {
|
||||
audit_days: u32,
|
||||
) -> Result<(), &'static str> {
|
||||
for (valid, field) in [
|
||||
(usage_days >= 31, "usage_retention_days"),
|
||||
((1..=31).contains(&usage_days), "usage_retention_days"),
|
||||
(ops_days > 0, "ops_event_retention_days"),
|
||||
(audit_days > 0, "audit_retention_days"),
|
||||
] {
|
||||
@@ -38,7 +38,7 @@ impl RetentionPolicy {
|
||||
pub fn try_new(usage_days: u32, ops_days: u32, audit_days: u32) -> Result<Self, AdminError> {
|
||||
if Self::validate_values(usage_days, ops_days, audit_days).is_err() {
|
||||
return Err(AdminError::invalid(
|
||||
"请求保留期至少 31 天,事件与审计保留期必须为正数",
|
||||
"请求保留期必须为 1~31 天,事件与审计保留期必须为正数",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 由宿主传入的可信写入主体,不属于插件请求载荷
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageActor {
|
||||
Admin {
|
||||
subject: String,
|
||||
},
|
||||
Plugin {
|
||||
publisher: String,
|
||||
name: String,
|
||||
instance_id: String,
|
||||
},
|
||||
System,
|
||||
}
|
||||
|
||||
impl super::UsageRecordChange {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Put { id, .. } | Self::Update { id, .. } | Self::Delete { id } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 按归属时间和维度维护统计贡献,业务来源与调整历史由调用方保存
|
||||
|
||||
use super::{UsageDelta, UsageRecordFilter};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageMetric {
|
||||
RequestCount,
|
||||
SuccessCount,
|
||||
FailureCount,
|
||||
CancelledCount,
|
||||
IncompleteCount,
|
||||
CompletedRequestCount,
|
||||
InputTokens,
|
||||
OutputTokens,
|
||||
CachedInputTokens,
|
||||
CacheWriteInputTokens,
|
||||
ReasoningOutputTokens,
|
||||
ImageInputTokens,
|
||||
ImageOutputTokens,
|
||||
TotalTokens,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageDimensions {
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub client_key_ref: Option<String>,
|
||||
pub operation: Option<String>,
|
||||
pub transport: Option<String>,
|
||||
}
|
||||
|
||||
/// 整个键决定覆盖目标;调用方可将来源标识编码进 key,重试相同键不会累计
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregateKey {
|
||||
pub bucket_start_ms: i64,
|
||||
pub dimensions: UsageDimensions,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
/// 值按 bucket_start_ms 归属,不自动摊分;负值用于相对原生统计的修正
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregate {
|
||||
pub identity: UsageAggregateKey,
|
||||
pub counts: BTreeMap<UsageMetric, UsageDelta>,
|
||||
pub costs: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageAggregateChange {
|
||||
Put { aggregate: UsageAggregate },
|
||||
Delete { identity: UsageAggregateKey },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteUsageAggregates {
|
||||
pub changes: Vec<UsageAggregateChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregateWriteResult {
|
||||
pub changed: Vec<UsageAggregateKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListUsageAggregates {
|
||||
pub filter: UsageRecordFilter,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageAggregatePage {
|
||||
pub items: Vec<UsageAggregate>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 用量基础操作的错误分类,不保存失败或重试状态
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageErrorKind {
|
||||
InvalidInput,
|
||||
NotFound,
|
||||
Conflict,
|
||||
CapacityExceeded,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageDataError {
|
||||
pub kind: UsageErrorKind,
|
||||
pub message: String,
|
||||
}
|
||||
impl UsageDataError {
|
||||
#[must_use]
|
||||
pub fn new(kind: UsageErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(UsageErrorKind::InvalidInput, message)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(UsageErrorKind::Unavailable, message)
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for UsageDataError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for UsageDataError {}
|
||||
pub type UsageDataResult<T> = Result<T, UsageDataError>;
|
||||
@@ -0,0 +1,22 @@
|
||||
//! 用量明细、聚合维护与纯计价的公开合同
|
||||
|
||||
mod value;
|
||||
pub use value::*;
|
||||
mod record;
|
||||
pub use record::*;
|
||||
mod query;
|
||||
pub use query::*;
|
||||
mod pricing;
|
||||
pub use pricing::*;
|
||||
mod error;
|
||||
pub use error::*;
|
||||
|
||||
pub const USAGE_DATA_MAX_TARGETS: usize = 500;
|
||||
pub const USAGE_DATA_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
pub const USAGE_DATA_MAX_PAGE_SIZE: u16 = 200;
|
||||
|
||||
mod actor;
|
||||
pub use actor::*;
|
||||
|
||||
mod aggregate;
|
||||
pub use aggregate::*;
|
||||
@@ -0,0 +1,62 @@
|
||||
//! 纯 Token 计价合同,费用是否采用与额外费用合并由调用方决定
|
||||
|
||||
use super::{UsageCost, UsageTokens};
|
||||
use gateway_core::metering::ModelPriceOverride;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsagePriceSelection {
|
||||
Current,
|
||||
Override { price: Box<ModelPriceOverride> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingInput {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub tokens: UsageTokens,
|
||||
pub service_tier: Option<String>,
|
||||
pub price: UsagePriceSelection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EvaluateUsagePricing {
|
||||
pub items: Vec<UsagePricingInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingBreakdown {
|
||||
pub input_amount: String,
|
||||
pub output_amount: String,
|
||||
pub cache_read_amount: String,
|
||||
pub cache_write_amount: String,
|
||||
pub standard_amount: String,
|
||||
pub long_context_billing_applied: bool,
|
||||
pub service_tier: Option<String>,
|
||||
pub custom_multiplier_bps: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsagePricingResult {
|
||||
Estimated {
|
||||
id: String,
|
||||
cost: UsageCost,
|
||||
breakdown: UsagePricingBreakdown,
|
||||
},
|
||||
NotEstimable {
|
||||
id: String,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsagePricingEvaluation {
|
||||
pub items: Vec<UsagePricingResult>,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! 请求明细的有界筛选与稳定顺序分页
|
||||
|
||||
use super::UsageRecord;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GetUsageRecord {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordFilter {
|
||||
pub start_ms: i64,
|
||||
pub end_ms: i64,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub client_key_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListUsageRecords {
|
||||
pub filter: UsageRecordFilter,
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordPage {
|
||||
pub items: Vec<UsageRecord>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! 既有请求明细的用量读写合同,不承载插件任务或来源状态
|
||||
|
||||
use super::UsageCount;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Token 使用宿主口径:缓存包含在输入中,推理包含在输出中,未知值为 null
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageTokens {
|
||||
pub input_tokens: Option<UsageCount>,
|
||||
pub output_tokens: Option<UsageCount>,
|
||||
pub cached_input_tokens: Option<UsageCount>,
|
||||
pub cache_write_input_tokens: Option<UsageCount>,
|
||||
pub reasoning_output_tokens: Option<UsageCount>,
|
||||
pub image_input_tokens: Option<UsageCount>,
|
||||
pub image_output_tokens: Option<UsageCount>,
|
||||
pub total_tokens: Option<UsageCount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageCostSource {
|
||||
ProviderReported,
|
||||
Calculated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageCost {
|
||||
#[default]
|
||||
Unavailable,
|
||||
Known {
|
||||
source: UsageCostSource,
|
||||
amount: String,
|
||||
currency: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UsageOutcome {
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Cancelled,
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecordData {
|
||||
pub client_key_ref: String,
|
||||
pub provider: Option<String>,
|
||||
pub account_ref: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub protocol: String,
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
pub client_transport: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub started_at_ms: i64,
|
||||
pub completed_at_ms: Option<i64>,
|
||||
pub outcome: UsageOutcome,
|
||||
pub tokens: UsageTokens,
|
||||
pub cost: UsageCost,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageRecord {
|
||||
pub id: String,
|
||||
pub data: UsageRecordData,
|
||||
pub billing_snapshot: Option<serde_json::Value>,
|
||||
pub provider_observation: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum UsageRecordChange {
|
||||
Put {
|
||||
id: String,
|
||||
data: Box<UsageRecordData>,
|
||||
},
|
||||
/// tokens 替换全部 Token 字段;cost 的 unavailable 明确清空费用
|
||||
Update {
|
||||
id: String,
|
||||
tokens: Option<UsageTokens>,
|
||||
cost: Option<UsageCost>,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WriteUsageRecords {
|
||||
pub changes: Vec<UsageRecordChange>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UsageWriteResult {
|
||||
pub changed_ids: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 用量计数使用十进制字符串,避免插件前端丢失整数精度
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UsageCount(pub u64);
|
||||
impl UsageCount {
|
||||
pub const ZERO: Self = Self(0);
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
impl From<u64> for UsageCount {
|
||||
fn from(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
impl fmt::Display for UsageCount {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
impl Serialize for UsageCount {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0.to_string())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for UsageCount {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(D::Error::custom("count must be an unsigned decimal string"));
|
||||
}
|
||||
value.parse().map(Self).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// 有符号统计贡献受 PostgreSQL bigint 范围限制,JSON 使用十进制字符串
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct UsageDelta(pub i64);
|
||||
impl UsageDelta {
|
||||
#[must_use]
|
||||
pub const fn get(self) -> i64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
impl Serialize for UsageDelta {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.0.to_string())
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for UsageDelta {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
let digits = value.strip_prefix('-').unwrap_or(&value);
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(D::Error::custom("delta must be a signed decimal string"));
|
||||
}
|
||||
value.parse().map(Self).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
pub mod usage_data;
|
||||
|
||||
@@ -26,6 +26,14 @@ use crate::model::{
|
||||
quota_forecast_sampling::QuotaForecastObservation,
|
||||
};
|
||||
|
||||
/// 按原生 Token 口径计算的报价输入,额外费用由调用方处理
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderUsagePricingInput {
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub usage: gateway_core::metering::Usage,
|
||||
}
|
||||
|
||||
/// Provider 管理失败的稳定分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdminErrorKind {
|
||||
@@ -161,6 +169,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
fn evaluate_usage_pricing(
|
||||
&self,
|
||||
_input: &ProviderUsagePricingInput,
|
||||
_pricing: Option<&gateway_core::metering::ModelPriceOverride>,
|
||||
) -> Result<Option<gateway_core::metering::CalculatedCostBreakdown>, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 只读取原生 Provider 能力及账号类型,不执行网络或数据库查询
|
||||
|
||||
@@ -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(
|
||||
@@ -485,6 +496,13 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
) -> AdminStoreResult<DiagnosticsObservation>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
|
||||
/// 按精确目标读取受控错误,不受列表时间窗口与当前页面影响
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
target: &crate::model::diagnostics::DiagnosticTarget,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<Vec<crate::model::observability::OpsError>>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 写入
|
||||
@@ -565,9 +583,15 @@ pub struct AdminStorePorts {
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
usage_data: Arc<dyn super::usage_data::UsageDataStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn usage_data(&self) -> Arc<dyn super::usage_data::UsageDataStore> {
|
||||
self.usage_data.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_resources(&self) -> Arc<dyn super::plugin_resources::PluginResourceStore> {
|
||||
self.plugin_resources.clone()
|
||||
@@ -598,6 +622,7 @@ impl AdminStorePorts {
|
||||
plugins: Arc<dyn super::plugins::PluginStore>,
|
||||
plugin_state: Arc<dyn super::plugins::PluginStateStore>,
|
||||
plugin_resources: Arc<dyn super::plugin_resources::PluginResourceStore>,
|
||||
usage_data: Arc<dyn super::usage_data::UsageDataStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
@@ -609,6 +634,7 @@ impl AdminStorePorts {
|
||||
plugins,
|
||||
plugin_state,
|
||||
plugin_resources,
|
||||
usage_data,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! 原生请求用量的有界读取和事务写入端口
|
||||
|
||||
use crate::model::{auth::AdminAuditEvent, usage_data::*};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct StoredUsageWrite {
|
||||
pub changes: Vec<(UsageRecordChange, AdminAuditEvent)>,
|
||||
}
|
||||
|
||||
pub struct StoredAggregateWrite {
|
||||
pub changes: Vec<(UsageAggregateChange, AdminAuditEvent)>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageDataStore: Send + Sync {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage>;
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
command: StoredAggregateWrite,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult>;
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord>;
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage>;
|
||||
async fn write_records(&self, command: StoredUsageWrite) -> UsageDataResult<UsageWriteResult>;
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! 公开服务写入主体由受信宿主边界设置,插件输入不能覆盖
|
||||
|
||||
use crate::model::usage_data::UsageActor;
|
||||
use crate::model::{AdminError, MutationActor, MutationContext};
|
||||
use std::future::Future;
|
||||
|
||||
tokio::task_local! {
|
||||
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).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,7 +1,11 @@
|
||||
//! 主动服务调用的统一组合入口;普通业务方法由所属 HTTP 或执行边界包裹
|
||||
|
||||
mod accounts;
|
||||
mod actor;
|
||||
mod registry;
|
||||
mod settings;
|
||||
mod usage_data;
|
||||
pub use actor::scope_actor;
|
||||
pub use registry::Registry;
|
||||
|
||||
use std::{future::Future, sync::Arc};
|
||||
@@ -98,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,
|
||||
@@ -54,6 +61,57 @@ impl Registry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_usage_data(
|
||||
&mut self,
|
||||
service: &Arc<dyn crate::UsageDataService>,
|
||||
) -> Result<(), AdminError> {
|
||||
super::usage_data::register(self, service)
|
||||
}
|
||||
|
||||
pub(crate) fn register_usage<I, O>(
|
||||
&mut self,
|
||||
name: &'static str,
|
||||
handler: impl Fn(I) -> BoxFuture<'static, crate::model::usage_data::UsageDataResult<O>>
|
||||
+ Send
|
||||
+ Sync
|
||||
+ 'static,
|
||||
) -> Result<(), AdminError>
|
||||
where
|
||||
I: DeserializeOwned + Send + 'static,
|
||||
O: Serialize + Send + 'static,
|
||||
{
|
||||
let std::collections::btree_map::Entry::Vacant(entry) = self.operations.entry(name) else {
|
||||
return Err(AdminError::internal(format!("公开服务重复登记:{name}")));
|
||||
};
|
||||
let handler = Arc::new(handler);
|
||||
entry.insert(Arc::new(move |input| {
|
||||
let handler = handler.clone();
|
||||
Box::pin(async move {
|
||||
// 在反序列化之前限制编码后的完整请求,避免单条记录绕过批量行数约束
|
||||
let bytes =
|
||||
serde_json::to_vec(&input).map_err(|_| Error::invalid("服务输入编码无效"))?;
|
||||
if bytes.len() > crate::model::usage_data::USAGE_DATA_MAX_REQUEST_BYTES {
|
||||
return Err(super::usage_data::encode_error(
|
||||
crate::model::usage_data::UsageDataError::new(
|
||||
crate::model::usage_data::UsageErrorKind::CapacityExceeded,
|
||||
"usage input exceeds 2 MiB",
|
||||
),
|
||||
));
|
||||
}
|
||||
let input = serde_json::from_value(input).map_err(|_| {
|
||||
super::usage_data::encode_error(
|
||||
crate::model::usage_data::UsageDataError::invalid("服务输入类型无效"),
|
||||
)
|
||||
})?;
|
||||
let output = handler(input)
|
||||
.await
|
||||
.map_err(super::usage_data::encode_error)?;
|
||||
serde_json::to_value(output).map_err(|_| Error::unavailable("服务结果编码失败"))
|
||||
})
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn call(&self, operation: &str, input: Value) -> Result<Value, Error> {
|
||||
let (operation, handler) =
|
||||
self.operations
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 原生用量能力的公开服务登记
|
||||
|
||||
use crate::{
|
||||
UsageDataService,
|
||||
model::{AdminError, usage_data::*},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn register(
|
||||
registry: &mut super::Registry,
|
||||
service: &Arc<dyn UsageDataService>,
|
||||
) -> Result<(), AdminError> {
|
||||
macro_rules! read {
|
||||
($name:literal, $method:ident, $input:ty) => {{
|
||||
let service = service.clone();
|
||||
registry.register_usage($name, move |request: $input| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move { service.$method(request).await })
|
||||
})?;
|
||||
}};
|
||||
}
|
||||
read!("usage.records.get", get_record, GetUsageRecord);
|
||||
read!("usage.records.list", list_records, ListUsageRecords);
|
||||
read!(
|
||||
"usage.aggregates.list",
|
||||
list_aggregates,
|
||||
ListUsageAggregates
|
||||
);
|
||||
read!("pricing.evaluate", evaluate_pricing, EvaluateUsagePricing);
|
||||
let aggregates = service.clone();
|
||||
registry.register_usage(
|
||||
"usage.aggregates.write",
|
||||
move |request: WriteUsageAggregates| {
|
||||
let service = aggregates.clone();
|
||||
Box::pin(async move {
|
||||
let actor = super::actor::current_actor()?;
|
||||
service.write_aggregates(&actor, request).await
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let service = service.clone();
|
||||
registry.register_usage("usage.records.write", move |request: WriteUsageRecords| {
|
||||
let service = service.clone();
|
||||
Box::pin(async move {
|
||||
let actor = super::actor::current_actor()?;
|
||||
service.write_records(&actor, request).await
|
||||
})
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn encode_error(
|
||||
error: UsageDataError,
|
||||
) -> gateway_core::engine::middleware::service::Error {
|
||||
gateway_core::engine::middleware::service::Error {
|
||||
kind: serde_json::to_value(error.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_else(|| "unavailable".into()),
|
||||
message: error.message.clone(),
|
||||
details: serde_json::to_value(error).ok(),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,7 @@ impl AccountGroupService for DefaultAccountGroupService {
|
||||
self.store
|
||||
.create_account_group(
|
||||
NewAccountGroup {
|
||||
allow_image_generation: command.allow_image_generation,
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
|
||||
+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(())
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,8 @@ pub(super) async fn pending_authorization(
|
||||
proxy = required_credential(accounts, provider_kind, account_id, resource)
|
||||
.await?
|
||||
.credential
|
||||
.outbound_proxy;
|
||||
.effective_outbound_proxy()
|
||||
.cloned();
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
@@ -121,6 +122,9 @@ pub(super) async fn pending_authorization(
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
proxy_id = Some(record.id);
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
|
||||
}
|
||||
Some(record.proxy)
|
||||
}
|
||||
Some(AccountProxySelection::Direct) | None => None,
|
||||
|
||||
@@ -225,6 +225,7 @@ impl KeyUsageService for DefaultKeyUsageService {
|
||||
KeyUsageRecordKind::Success => KeyUsageRecords::Success(
|
||||
self.observations
|
||||
.list_usage_records(UsageQuery {
|
||||
status: Default::default(),
|
||||
range: query.usage.range,
|
||||
filter: usage_filter(&id, query.usage.model),
|
||||
current_page: query.current_page,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod plugins;
|
||||
pub mod proxies;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod usage_data;
|
||||
|
||||
mod credential_mutation;
|
||||
mod error;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! 按故障目标收集诊断证据,补充读取失败不丢弃已取得的记录
|
||||
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::time::{Instant, timeout_at};
|
||||
|
||||
use super::{DefaultObservabilityService, map_store_error};
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind,
|
||||
diagnostics::{DiagnosticBundle, DiagnosticGap, DiagnosticRequest, DiagnosticTarget},
|
||||
observability::OpsError,
|
||||
};
|
||||
|
||||
const MAX_REQUESTS: usize = 8;
|
||||
const MAX_ERRORS: u16 = 128;
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
pub(super) async fn collect_diagnostics(
|
||||
&self,
|
||||
target: DiagnosticTarget,
|
||||
) -> Result<DiagnosticBundle, AdminError> {
|
||||
if target.id().trim().is_empty() || target.id().len() > 256 {
|
||||
return Err(AdminError::invalid("诊断目标 ID 不合法"));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let (collected, environment) = tokio::join!(
|
||||
self.collect_records(target, deadline),
|
||||
timeout_at(deadline, self.system.version()),
|
||||
);
|
||||
let mut bundle = collected?;
|
||||
match environment {
|
||||
Ok(Ok(version)) => bundle.environment = Some(version),
|
||||
_ => gap(&mut bundle, "environment", "collection_failed"),
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn collect_records(
|
||||
&self,
|
||||
target: DiagnosticTarget,
|
||||
deadline: Instant,
|
||||
) -> Result<DiagnosticBundle, AdminError> {
|
||||
let mut bundle = DiagnosticBundle {
|
||||
target,
|
||||
collected_at: chrono::Utc::now(),
|
||||
environment: None,
|
||||
error: None,
|
||||
requests: Vec::new(),
|
||||
gaps: Vec::new(),
|
||||
};
|
||||
let mut remaining_errors = MAX_ERRORS;
|
||||
let first_id = match &bundle.target {
|
||||
DiagnosticTarget::Request(id) => Some(id.clone()),
|
||||
DiagnosticTarget::Operation(_) => {
|
||||
let mut errors = self.export_errors(&bundle.target, 1, deadline).await?;
|
||||
let error = errors
|
||||
.pop()
|
||||
.ok_or_else(|| AdminError::not_found("错误记录不存在或已过期"))?;
|
||||
let id = error.request_id.clone();
|
||||
bundle.error = Some(error);
|
||||
remaining_errors -= 1;
|
||||
id
|
||||
}
|
||||
};
|
||||
let mut pending = VecDeque::from_iter(first_id);
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(id) = pending.pop_front() {
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
if bundle.requests.len() >= MAX_REQUESTS {
|
||||
gap(&mut bundle, "requests", "related_request_limit");
|
||||
break;
|
||||
}
|
||||
let section = format!("requests.{id}");
|
||||
let target = DiagnosticTarget::Request(id.clone());
|
||||
let selected = bundle
|
||||
.error
|
||||
.as_ref()
|
||||
.filter(|error| error.request_id.as_deref() == Some(id.as_str()));
|
||||
// 事件导出已经持有选中记录,额外读取一个槽位用于去重后判断是否超限
|
||||
let read_limit = remaining_errors + 1 + u16::from(selected.is_some());
|
||||
let (detail, errors) = tokio::join!(
|
||||
timeout_at(deadline, self.store.usage_record_detail(&id)),
|
||||
self.export_errors(&target, read_limit, deadline),
|
||||
);
|
||||
let (detail, detail_error) = match detail {
|
||||
Ok(Ok(detail)) => (Some(detail), None),
|
||||
Ok(Err(error)) => (None, Some(map_store_error(error, "diagnostic request"))),
|
||||
Err(_) => (
|
||||
None,
|
||||
Some(AdminError::new(AdminErrorKind::Unavailable, "诊断采集超时")),
|
||||
),
|
||||
};
|
||||
let mut errors = match errors {
|
||||
Ok(mut errors) => {
|
||||
if let Some(selected) = selected {
|
||||
errors.retain(|error| {
|
||||
error.source != selected.source || error.event_id != selected.event_id
|
||||
});
|
||||
}
|
||||
errors
|
||||
}
|
||||
Err(error) => {
|
||||
if detail.is_none() && bundle.requests.is_empty() && bundle.error.is_none() {
|
||||
return Err(error);
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.errors"),
|
||||
"collection_failed",
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
if detail.is_none()
|
||||
&& errors.is_empty()
|
||||
&& bundle.requests.is_empty()
|
||||
&& bundle.error.is_none()
|
||||
{
|
||||
return Err(
|
||||
detail_error.unwrap_or_else(|| AdminError::not_found("请求记录不存在或已过期"))
|
||||
);
|
||||
}
|
||||
if errors.len() > usize::from(remaining_errors) {
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.errors"),
|
||||
"error_record_limit",
|
||||
);
|
||||
errors.truncate(usize::from(remaining_errors));
|
||||
}
|
||||
remaining_errors =
|
||||
remaining_errors.saturating_sub(u16::try_from(errors.len()).unwrap_or(MAX_ERRORS));
|
||||
if let Some(record) = &detail {
|
||||
for related in &record.related_requests {
|
||||
if let Some(id) = related.get("requestId").and_then(serde_json::Value::as_str) {
|
||||
pending.push_back(id.to_owned());
|
||||
}
|
||||
}
|
||||
if record.request.outcome == crate::model::observability::RequestOutcome::Running {
|
||||
gap(&mut bundle, §ion, "request_not_finalized");
|
||||
}
|
||||
match &record.trace {
|
||||
None => gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"not_recorded_or_retained",
|
||||
),
|
||||
Some(trace) => {
|
||||
if trace["droppedEvents"].as_u64().unwrap_or(0) > 0 {
|
||||
gap(&mut bundle, &format!("{section}.trace"), "events_evicted");
|
||||
}
|
||||
let events = trace["events"].as_array();
|
||||
if !events.is_some_and(|events| {
|
||||
events.iter().any(|event| {
|
||||
event["stage"] == "request.routed"
|
||||
&& event["data"]["accountPolicy"].is_object()
|
||||
})
|
||||
}) {
|
||||
gap(&mut bundle, §ion, "incident_policy_not_recorded");
|
||||
}
|
||||
for event in events.into_iter().flatten() {
|
||||
if event["stage"] == "capture.gap" || event["data"]["truncated"] == true
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"capture_gap_or_truncation",
|
||||
);
|
||||
}
|
||||
if event["stage"] == "retry.decided" && event["data"]["phase"].is_null()
|
||||
{
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.trace"),
|
||||
"legacy_retry_facts_not_recorded",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
gap(
|
||||
&mut bundle,
|
||||
&format!("{section}.attempts"),
|
||||
"best_effort_persistence",
|
||||
);
|
||||
} else {
|
||||
gap(&mut bundle, §ion, "request_detail_unavailable");
|
||||
}
|
||||
bundle.requests.push(DiagnosticRequest {
|
||||
request_id: id,
|
||||
detail,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
async fn export_errors(
|
||||
&self,
|
||||
target: &DiagnosticTarget,
|
||||
limit: u16,
|
||||
deadline: Instant,
|
||||
) -> Result<Vec<OpsError>, AdminError> {
|
||||
timeout_at(deadline, self.store.diagnostic_errors(target, limit))
|
||||
.await
|
||||
.map_err(|_| AdminError::new(AdminErrorKind::Unavailable, "诊断采集超时"))?
|
||||
.map_err(|error| map_store_error(error, "diagnostic errors"))
|
||||
}
|
||||
}
|
||||
|
||||
fn gap(bundle: &mut DiagnosticBundle, section: &str, reason: &'static str) {
|
||||
if !bundle
|
||||
.gaps
|
||||
.iter()
|
||||
.any(|gap| gap.section == section && gap.reason == reason)
|
||||
{
|
||||
bundle.gaps.push(DiagnosticGap {
|
||||
section: section.to_owned(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
|
||||
mod export;
|
||||
mod store_cache;
|
||||
pub(crate) use store_cache::CachedObservabilityStore;
|
||||
|
||||
@@ -70,6 +71,10 @@ pub trait ObservabilityService: Send + Sync {
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError>;
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError>;
|
||||
async fn export_diagnostics(
|
||||
&self,
|
||||
target: crate::model::diagnostics::DiagnosticTarget,
|
||||
) -> Result<crate::model::diagnostics::DiagnosticBundle, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
@@ -78,6 +83,7 @@ pub(crate) struct DefaultObservabilityService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
@@ -88,6 +94,7 @@ impl DefaultObservabilityService {
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
timezone: gateway_core::time::DeploymentTimeZone,
|
||||
system: Arc<dyn super::system::SystemService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
timezone,
|
||||
@@ -95,6 +102,7 @@ impl DefaultObservabilityService {
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
system,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +200,13 @@ impl DefaultObservabilityService {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityService for DefaultObservabilityService {
|
||||
async fn export_diagnostics(
|
||||
&self,
|
||||
target: crate::model::diagnostics::DiagnosticTarget,
|
||||
) -> Result<crate::model::diagnostics::DiagnosticBundle, AdminError> {
|
||||
self.collect_diagnostics(target).await
|
||||
}
|
||||
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 有界统计缓存:身份和完整筛选参与键,删除版本阻止旧数据复活
|
||||
//! 有界统计缓存:身份和完整筛选参与键,统计发布代次使旧快照失效
|
||||
|
||||
use crate::model::observability::*;
|
||||
use crate::ports::store::{
|
||||
@@ -121,7 +121,7 @@ impl<K: Eq + Hash, T: AnalyticsValue> Cache<K, T> {
|
||||
};
|
||||
// 同键刷新只执行一次;失败不会缓存成成功,也不延长旧结果寿命
|
||||
let mut cached = slot.lock().await;
|
||||
// 等锁期间代次可能推进;取得同键锁后再核对,失败时也不能回用已删除事实
|
||||
// 等锁期间代次可能推进;取得同键锁后再核对,失败时也不能回用已被新统计替代的快照
|
||||
let generation = store.analytics_cache_generation().await?;
|
||||
if let Some(entry) = cached.as_mut()
|
||||
&& entry.value.metadata().generation == generation
|
||||
@@ -289,4 +289,12 @@ impl ObservabilityStore for CachedObservabilityStore {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
self.inner.list_ops_errors(query).await
|
||||
}
|
||||
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
target: &crate::model::diagnostics::DiagnosticTarget,
|
||||
limit: u16,
|
||||
) -> AdminStoreResult<Vec<crate::model::observability::OpsError>> {
|
||||
self.inner.diagnostic_errors(target, limit).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ impl PluginResourceAccess for DefaultPluginResourceAccess {
|
||||
owner,
|
||||
resource_key,
|
||||
NewAccountGroup {
|
||||
allow_image_generation: command.allow_image_generation,
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
|
||||
@@ -305,6 +305,9 @@ impl PluginsService {
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("下载代理已禁用,请启用或改为直连"));
|
||||
}
|
||||
Ok(Some(PluginDistributionEgress::new(
|
||||
record.id,
|
||||
record.revision,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 公开原生用量读写与纯计价,插件拥有跨调用流程和恢复策略
|
||||
|
||||
mod pricing;
|
||||
mod validation;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
usage_data::*,
|
||||
},
|
||||
ports::{provider::ProviderAdminRegistry, store::SettingsStore, usage_data::*},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UsageDataService: Send + Sync {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage>;
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult>;
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord>;
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage>;
|
||||
async fn write_records(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageRecords,
|
||||
) -> UsageDataResult<UsageWriteResult>;
|
||||
async fn evaluate_pricing(
|
||||
&self,
|
||||
request: EvaluateUsagePricing,
|
||||
) -> UsageDataResult<UsagePricingEvaluation>;
|
||||
}
|
||||
|
||||
pub struct DefaultUsageDataService {
|
||||
store: Arc<dyn UsageDataStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
}
|
||||
impl DefaultUsageDataService {
|
||||
pub fn new(
|
||||
store: Arc<dyn UsageDataStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
settings,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageDataService for DefaultUsageDataService {
|
||||
async fn list_aggregates(
|
||||
&self,
|
||||
request: ListUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregatePage> {
|
||||
validation::query(&request.filter, request.cursor.as_deref(), request.limit)?;
|
||||
self.store.list_aggregates(request).await
|
||||
}
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageAggregates,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult> {
|
||||
validation::bounded(&request, request.changes.len())?;
|
||||
let mut changes = Vec::with_capacity(request.changes.len());
|
||||
for change in request.changes {
|
||||
validation::aggregate(&change)?;
|
||||
let identity = match &change {
|
||||
UsageAggregateChange::Put { aggregate } => &aggregate.identity,
|
||||
UsageAggregateChange::Delete { identity } => identity,
|
||||
};
|
||||
let action = match &change {
|
||||
UsageAggregateChange::Put { .. } => "usage.aggregate.put",
|
||||
UsageAggregateChange::Delete { .. } => "usage.aggregate.delete",
|
||||
};
|
||||
let audit = audit_event(
|
||||
actor,
|
||||
action,
|
||||
"usage_aggregate",
|
||||
&identity.key,
|
||||
vec!["aggregate".into()],
|
||||
);
|
||||
changes.push((change, audit));
|
||||
}
|
||||
self.store
|
||||
.write_aggregates(StoredAggregateWrite { changes })
|
||||
.await
|
||||
}
|
||||
async fn get_record(&self, request: GetUsageRecord) -> UsageDataResult<UsageRecord> {
|
||||
validation::text(&request.id, 128)?;
|
||||
self.store.get_record(request).await
|
||||
}
|
||||
async fn list_records(&self, request: ListUsageRecords) -> UsageDataResult<UsageRecordPage> {
|
||||
validation::query(&request.filter, request.cursor.as_deref(), request.limit)?;
|
||||
self.store.list_records(request).await
|
||||
}
|
||||
async fn write_records(
|
||||
&self,
|
||||
actor: &UsageActor,
|
||||
request: WriteUsageRecords,
|
||||
) -> UsageDataResult<UsageWriteResult> {
|
||||
validation::bounded(&request, request.changes.len())?;
|
||||
let mut changes = Vec::with_capacity(request.changes.len());
|
||||
for change in request.changes {
|
||||
validation::change(&change)?;
|
||||
let action = match &change {
|
||||
UsageRecordChange::Put { .. } => "usage.put",
|
||||
UsageRecordChange::Update { .. } => "usage.update",
|
||||
UsageRecordChange::Delete { .. } => "usage.delete",
|
||||
};
|
||||
let fields = match &change {
|
||||
UsageRecordChange::Update { tokens, cost, .. } => tokens
|
||||
.as_ref()
|
||||
.map(|_| "tokens".to_owned())
|
||||
.into_iter()
|
||||
.chain(cost.as_ref().map(|_| "cost".to_owned()))
|
||||
.collect(),
|
||||
_ => vec!["record".into()],
|
||||
};
|
||||
let audit = audit_event(actor, action, "model_request", change.id(), fields);
|
||||
changes.push((change, audit));
|
||||
}
|
||||
self.store.write_records(StoredUsageWrite { changes }).await
|
||||
}
|
||||
async fn evaluate_pricing(
|
||||
&self,
|
||||
request: EvaluateUsagePricing,
|
||||
) -> UsageDataResult<UsagePricingEvaluation> {
|
||||
validation::bounded(&request, request.items.len())?;
|
||||
let stored = self
|
||||
.settings
|
||||
.load_pricing()
|
||||
.await
|
||||
.map_err(|_| UsageDataError::unavailable("读取价目失败"))?;
|
||||
let prices = gateway_core::metering::merge_pricing(stored.synced, &stored.overrides);
|
||||
let mut items = Vec::with_capacity(request.items.len());
|
||||
for input in request.items {
|
||||
validation::text(&input.id, 128)?;
|
||||
items.push(self.evaluate_one(input, &prices)?);
|
||||
}
|
||||
Ok(UsagePricingEvaluation { items })
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_event(
|
||||
actor: &UsageActor,
|
||||
action: &str,
|
||||
entity_kind: &str,
|
||||
id: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> AdminAuditEvent {
|
||||
let actor_ref = match actor {
|
||||
UsageActor::Plugin {
|
||||
instance_id,
|
||||
publisher,
|
||||
name,
|
||||
..
|
||||
} => format!("plugin:{publisher}/{name}:{instance_id}"),
|
||||
UsageActor::Admin { subject } => format!("admin:{subject}"),
|
||||
UsageActor::System => "system".into(),
|
||||
};
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", uuid::Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::System,
|
||||
actor_admin_user_id: None,
|
||||
actor_ref,
|
||||
request_id: None,
|
||||
action: action.into(),
|
||||
entity_kind: entity_kind.into(),
|
||||
entity_ref: id.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! 复用 Provider 的单请求 Token 计价规则,不写入历史或账本
|
||||
|
||||
use super::{DefaultUsageDataService, validation};
|
||||
use crate::{
|
||||
model::usage_data::*,
|
||||
ports::provider::{ProviderAdminErrorKind, ProviderUsagePricingInput},
|
||||
};
|
||||
use gateway_core::{
|
||||
metering::{PricingOverrides, Usage},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
|
||||
impl DefaultUsageDataService {
|
||||
pub(super) fn evaluate_one(
|
||||
&self,
|
||||
input: UsagePricingInput,
|
||||
prices: &PricingOverrides,
|
||||
) -> UsageDataResult<UsagePricingResult> {
|
||||
validation::text(&input.provider, 128)?;
|
||||
validation::text(&input.model, 128)?;
|
||||
validation::tokens(&input.tokens)?;
|
||||
let unavailable = |reason: &str| UsagePricingResult::NotEstimable {
|
||||
id: input.id.clone(),
|
||||
reason: reason.into(),
|
||||
};
|
||||
let kind = ProviderKind::new(input.provider.clone())
|
||||
.map_err(|_| UsageDataError::invalid("Provider 无效"))?;
|
||||
let Ok(provider) = self.providers.require(&kind) else {
|
||||
return Ok(unavailable("Provider 没有可用的计价规则"));
|
||||
};
|
||||
let tokens = &input.tokens;
|
||||
let price = match &input.price {
|
||||
UsagePriceSelection::Current => prices
|
||||
.get(&input.provider)
|
||||
.and_then(|models| models.get(&input.model)),
|
||||
UsagePriceSelection::Override { price } => {
|
||||
price.validate().map_err(UsageDataError::invalid)?;
|
||||
Some(price.as_ref())
|
||||
}
|
||||
};
|
||||
let request = ProviderUsagePricingInput {
|
||||
upstream_model_id: input.model.clone(),
|
||||
service_tier: input.service_tier.clone(),
|
||||
usage: Usage {
|
||||
input_tokens: tokens.input_tokens.map(UsageCount::get),
|
||||
output_tokens: tokens.output_tokens.map(UsageCount::get),
|
||||
cached_input_tokens: tokens.cached_input_tokens.map(UsageCount::get),
|
||||
cache_write_input_tokens: tokens.cache_write_input_tokens.map(UsageCount::get),
|
||||
reasoning_output_tokens: tokens.reasoning_output_tokens.map(UsageCount::get),
|
||||
image_input_tokens: tokens.image_input_tokens.map(UsageCount::get),
|
||||
image_output_tokens: tokens.image_output_tokens.map(UsageCount::get),
|
||||
total_tokens: tokens.total_tokens.map(UsageCount::get),
|
||||
},
|
||||
};
|
||||
let calculated = match provider.evaluate_usage_pricing(&request, price) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => return Ok(unavailable("用量不完整、包含图像或价格不可用")),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
ProviderAdminErrorKind::Unsupported | ProviderAdminErrorKind::Invalid
|
||||
) =>
|
||||
{
|
||||
return Ok(unavailable("Provider 不支持该计价输入"));
|
||||
}
|
||||
Err(_) => return Err(UsageDataError::unavailable("Provider 计价失败")),
|
||||
};
|
||||
Ok(UsagePricingResult::Estimated {
|
||||
id: input.id,
|
||||
cost: UsageCost::Known {
|
||||
source: UsageCostSource::Calculated,
|
||||
amount: calculated.total_amount().amount().canonical(),
|
||||
currency: calculated.total_amount().currency().as_str().into(),
|
||||
},
|
||||
breakdown: UsagePricingBreakdown {
|
||||
input_amount: calculated.input_amount().amount().canonical(),
|
||||
output_amount: calculated.output_amount().amount().canonical(),
|
||||
cache_read_amount: calculated.cache_read_amount().amount().canonical(),
|
||||
cache_write_amount: calculated.cache_write_amount().amount().canonical(),
|
||||
standard_amount: calculated.standard_amount().amount().canonical(),
|
||||
long_context_billing_applied: calculated.long_context_billing_applied(),
|
||||
service_tier: calculated.service_tier().map(str::to_owned),
|
||||
custom_multiplier_bps: calculated.custom_multiplier_bps(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! 用量输入沿用原生记录的数值、时间和资源边界
|
||||
|
||||
use crate::model::usage_data::*;
|
||||
use gateway_core::metering::Decimal;
|
||||
use serde::Serialize;
|
||||
|
||||
pub(super) fn text(value: &str, max: usize) -> UsageDataResult<()> {
|
||||
if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
|
||||
return Err(UsageDataError::invalid("文本为空、过长或包含控制字符"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn bounded(value: &impl Serialize, count: usize) -> UsageDataResult<()> {
|
||||
if count == 0
|
||||
|| count > USAGE_DATA_MAX_TARGETS
|
||||
|| serde_json::to_vec(value)
|
||||
.map_err(|_| UsageDataError::invalid("请求编码失败"))?
|
||||
.len()
|
||||
> USAGE_DATA_MAX_REQUEST_BYTES
|
||||
{
|
||||
return Err(UsageDataError::new(
|
||||
UsageErrorKind::CapacityExceeded,
|
||||
"每次操作接受 1~500 项且不超过 2 MiB",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn tokens(value: &UsageTokens) -> UsageDataResult<()> {
|
||||
if [
|
||||
value.input_tokens,
|
||||
value.output_tokens,
|
||||
value.cached_input_tokens,
|
||||
value.cache_write_input_tokens,
|
||||
value.reasoning_output_tokens,
|
||||
value.image_input_tokens,
|
||||
value.image_output_tokens,
|
||||
value.total_tokens,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.get() > i64::MAX as u64)
|
||||
{
|
||||
return Err(UsageDataError::invalid("Token 超出原生存储范围"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cost(value: &UsageCost) -> UsageDataResult<()> {
|
||||
if let UsageCost::Known {
|
||||
amount, currency, ..
|
||||
} = value
|
||||
{
|
||||
let _ = amount
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| UsageDataError::invalid("费用不是有效十进制数"))?;
|
||||
if currency.len() != 3 || !currency.bytes().all(|b| b.is_ascii_uppercase()) {
|
||||
return Err(UsageDataError::invalid("费用或币种不符合原生存储范围"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn change(value: &UsageRecordChange) -> UsageDataResult<()> {
|
||||
text(value.id(), 128)?;
|
||||
match value {
|
||||
UsageRecordChange::Put { data, .. } => {
|
||||
text(&data.client_key_ref, 128)?;
|
||||
for v in [
|
||||
&data.provider,
|
||||
&data.account_ref,
|
||||
&data.requested_model,
|
||||
&data.model,
|
||||
&data.service_tier,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(v, 128)?;
|
||||
}
|
||||
for v in [
|
||||
&data.protocol,
|
||||
&data.operation,
|
||||
&data.endpoint,
|
||||
&data.client_transport,
|
||||
] {
|
||||
text(v, 256)?;
|
||||
}
|
||||
if data.outcome == UsageOutcome::Running
|
||||
|| data
|
||||
.completed_at_ms
|
||||
.is_none_or(|end| end < data.started_at_ms)
|
||||
{
|
||||
return Err(UsageDataError::invalid("只允许写入已结束的请求明细"));
|
||||
}
|
||||
for at in [Some(data.started_at_ms), data.completed_at_ms]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if chrono::DateTime::from_timestamp_millis(at).is_none() {
|
||||
return Err(UsageDataError::invalid("请求时间无效"));
|
||||
}
|
||||
}
|
||||
tokens(&data.tokens)?;
|
||||
cost(&data.cost)?;
|
||||
}
|
||||
UsageRecordChange::Update {
|
||||
tokens: new_tokens,
|
||||
cost: new_cost,
|
||||
..
|
||||
} => {
|
||||
if new_tokens.is_none() && new_cost.is_none() {
|
||||
return Err(UsageDataError::invalid("修改必须指定 Token 或费用"));
|
||||
}
|
||||
if let Some(value) = new_tokens {
|
||||
tokens(value)?;
|
||||
}
|
||||
if let Some(value) = new_cost {
|
||||
cost(value)?;
|
||||
}
|
||||
}
|
||||
UsageRecordChange::Delete { .. } => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn query(
|
||||
filter: &UsageRecordFilter,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<u16>,
|
||||
) -> UsageDataResult<()> {
|
||||
if limit.is_some_and(|v| v == 0 || v > USAGE_DATA_MAX_PAGE_SIZE)
|
||||
|| filter.start_ms >= filter.end_ms
|
||||
|| chrono::DateTime::from_timestamp_millis(filter.start_ms).is_none()
|
||||
|| chrono::DateTime::from_timestamp_millis(filter.end_ms).is_none()
|
||||
{
|
||||
return Err(UsageDataError::invalid("查询时间范围或分页大小无效"));
|
||||
}
|
||||
for v in [
|
||||
&filter.provider,
|
||||
&filter.model,
|
||||
&filter.account_ref,
|
||||
&filter.client_key_ref,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(v, 128)?;
|
||||
}
|
||||
if cursor.is_some_and(|v| v.len() > 4096) {
|
||||
return Err(UsageDataError::invalid("游标过长"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn aggregate(value: &UsageAggregateChange) -> UsageDataResult<()> {
|
||||
let identity = match value {
|
||||
UsageAggregateChange::Put { aggregate } => &aggregate.identity,
|
||||
UsageAggregateChange::Delete { identity } => identity,
|
||||
};
|
||||
text(&identity.key, 128)?;
|
||||
if chrono::DateTime::from_timestamp_millis(identity.bucket_start_ms).is_none() {
|
||||
return Err(UsageDataError::invalid("聚合归属时间无效"));
|
||||
}
|
||||
let d = &identity.dimensions;
|
||||
for value in [
|
||||
&d.provider,
|
||||
&d.model,
|
||||
&d.account_ref,
|
||||
&d.client_key_ref,
|
||||
&d.operation,
|
||||
&d.transport,
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
text(value, 128)?;
|
||||
}
|
||||
if let UsageAggregateChange::Put { aggregate } = value {
|
||||
if aggregate.costs.len() > 32 {
|
||||
return Err(UsageDataError::invalid("费用币种数量超过限制"));
|
||||
}
|
||||
for (currency, amount) in &aggregate.costs {
|
||||
if currency.len() != 3
|
||||
|| !currency.bytes().all(|b| b.is_ascii_uppercase())
|
||||
|| amount.len() > 128
|
||||
{
|
||||
return Err(UsageDataError::invalid("聚合费用格式无效"));
|
||||
}
|
||||
amount
|
||||
.strip_prefix('-')
|
||||
.unwrap_or(amount)
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| UsageDataError::invalid("聚合费用不是有效十进制数"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,13 @@ use gateway_admin::model::retention::{RetentionPolicy, RetentionTarget};
|
||||
|
||||
#[test]
|
||||
fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows() {
|
||||
for (usage, ops, audit) in [(30, 1, 1), (31, 0, 1), (31, 1, 0)] {
|
||||
for (usage, ops, audit) in [
|
||||
(0, 1, 1),
|
||||
(32, 1, 1),
|
||||
(u32::MAX, 1, 1),
|
||||
(1, 0, 1),
|
||||
(1, 1, 0),
|
||||
] {
|
||||
assert!(RetentionPolicy::try_new(usage, ops, audit).is_err());
|
||||
}
|
||||
let policy = RetentionPolicy::try_new(31, 7, 90).unwrap();
|
||||
@@ -12,3 +18,13 @@ fn retention_policy_rejects_invalid_windows_and_preserves_independent_windows()
|
||||
assert_eq!(policy.days(RetentionTarget::OpsEvents), 7);
|
||||
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_retention_accepts_short_windows_without_changing_other_targets() {
|
||||
for days in [1, 3, 7, 15, 30, 31] {
|
||||
let policy = RetentionPolicy::try_new(days, 30, 90).unwrap();
|
||||
assert_eq!(policy.days(RetentionTarget::ModelRequests), days);
|
||||
assert_eq!(policy.days(RetentionTarget::OpsEvents), 30);
|
||||
assert_eq!(policy.days(RetentionTarget::AdminAuditEvents), 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -317,6 +330,7 @@ impl AccountRuntimeStore for FakeRuntimeStore {
|
||||
fn group_record() -> AccountGroupRecord {
|
||||
let now = Utc::now();
|
||||
AccountGroupRecord {
|
||||
allow_image_generation: true,
|
||||
fast_mode: FastMode::Default,
|
||||
id: group_id(),
|
||||
name: "Primary".to_owned(),
|
||||
@@ -390,6 +404,7 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
|
||||
.create(
|
||||
&context,
|
||||
CreateAccountGroup {
|
||||
allow_image_generation: true,
|
||||
fast_mode: FastMode::Default,
|
||||
name: " invalid".to_owned(),
|
||||
description: None,
|
||||
@@ -403,6 +418,7 @@ async fn invalid_group_fields_are_rejected_before_store_mutations() {
|
||||
.update(
|
||||
&context,
|
||||
UpdateAccountGroup {
|
||||
allow_image_generation: None,
|
||||
fast_mode: None,
|
||||
id: record.id,
|
||||
name: "valid".to_owned(),
|
||||
|
||||
+130
-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))
|
||||
@@ -3137,6 +3194,7 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
outbound_proxy_enabled: true,
|
||||
notes: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
@@ -3404,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()
|
||||
@@ -3573,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())
|
||||
}
|
||||
@@ -3635,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;
|
||||
@@ -3666,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())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod plugins;
|
||||
mod proxies;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod usage_data;
|
||||
|
||||
use std::{
|
||||
str::FromStr,
|
||||
@@ -95,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>,
|
||||
@@ -120,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()),
|
||||
@@ -273,6 +276,7 @@ impl AdminHarness {
|
||||
self.plugin_store,
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
Arc::new(plugins::TestPluginPorts),
|
||||
),
|
||||
gateway_admin::AdminRuntimePorts {
|
||||
timezone: Default::default(),
|
||||
@@ -289,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,
|
||||
@@ -474,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,
|
||||
@@ -643,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())
|
||||
}
|
||||
@@ -747,6 +768,14 @@ impl ClientKeyStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnavailableStore {
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
_target: &gateway_admin::model::diagnostics::DiagnosticTarget,
|
||||
_limit: u16,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::OpsError>> {
|
||||
Err(unavailable("diagnostic errors"))
|
||||
}
|
||||
|
||||
fn query_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(10)
|
||||
}
|
||||
@@ -1150,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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,6 +945,14 @@ impl FixtureObservabilityStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for FixtureObservabilityStore {
|
||||
async fn diagnostic_errors(
|
||||
&self,
|
||||
_target: &gateway_admin::model::diagnostics::DiagnosticTarget,
|
||||
_limit: u16,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::OpsError>> {
|
||||
Err(super::unavailable("diagnostic errors"))
|
||||
}
|
||||
|
||||
fn query_timeout(&self) -> std::time::Duration {
|
||||
std::time::Duration::from_secs(10)
|
||||
}
|
||||
@@ -1132,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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1194,6 +1204,7 @@ fn observation_range(end: DateTime<Utc>) -> TimeRange {
|
||||
|
||||
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
status: Default::default(),
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
current_page: 1,
|
||||
@@ -1208,6 +1219,7 @@ fn total_record(
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: id.to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
|
||||
@@ -534,6 +534,7 @@ fn preview_fixture() -> Fixture {
|
||||
fn source_proxy() -> ProxyRecord {
|
||||
let now = chrono::Utc::now();
|
||||
ProxyRecord {
|
||||
enabled: true,
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
@@ -608,6 +609,38 @@ async fn saved_source_proxy_is_resolved_for_queries_and_frozen_into_verified_sou
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_source_proxy_rejects_queries_and_downloads_before_network_access() {
|
||||
let mut fixture = preview_fixture();
|
||||
fixture.binding.get_mut().unwrap().outbound_proxy_id = Some("source-proxy".into());
|
||||
let mut proxy = source_proxy();
|
||||
proxy.enabled = false;
|
||||
*fixture.proxy.get_mut().unwrap() = Some(proxy);
|
||||
let fixture = Arc::new(fixture);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.check_update("test.example", &[])
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
let mut request = preview_request();
|
||||
request.outbound_proxy_id = Some("source-proxy".into());
|
||||
assert_eq!(
|
||||
fixture
|
||||
.service()
|
||||
.verify_remote(request)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(fixture.egresses.lock().unwrap().is_empty());
|
||||
assert_eq!(*fixture.downloads.lock().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_revision_change_during_download_rejects_the_verified_candidate() {
|
||||
let mut fixture = preview_fixture();
|
||||
|
||||
@@ -434,6 +434,7 @@ impl PluginStateStore for LifecycleFixture {
|
||||
from_schema_version: 1,
|
||||
to_schema_version: 2,
|
||||
}],
|
||||
guard: Arc::new(()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! 插件管理用例的测试入口与共用端口替身
|
||||
|
||||
mod usage_data;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_admin::model::plugins::distribution::{
|
||||
DownloadedPlugin, GithubReleaseQuery, PluginRelease, RemotePluginLocation, SourceCredential,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 通用用量端口的插件管理测试夹具,非本测试调用路径禁止访问
|
||||
use super::TestPluginPorts;
|
||||
use gateway_admin::{model::usage_data::*, ports::usage_data::*};
|
||||
#[async_trait::async_trait]
|
||||
impl UsageDataStore for TestPluginPorts {
|
||||
async fn get_record(&self, _: GetUsageRecord) -> UsageDataResult<UsageRecord> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn list_records(&self, _: ListUsageRecords) -> UsageDataResult<UsageRecordPage> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn write_records(&self, _: StoredUsageWrite) -> UsageDataResult<UsageWriteResult> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn list_aggregates(&self, _: ListUsageAggregates) -> UsageDataResult<UsageAggregatePage> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
async fn write_aggregates(
|
||||
&self,
|
||||
_: StoredAggregateWrite,
|
||||
) -> UsageDataResult<UsageAggregateWriteResult> {
|
||||
unreachable!("unexpected usage call")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -126,6 +136,7 @@ async fn authorization_uses_selected_proxy_regardless_of_probe_status() {
|
||||
.accounts(FakeAccountStore::new(kind, events.clone()))
|
||||
.proxies(Arc::new(TestProxies {
|
||||
record: Some(ProxyRecord {
|
||||
enabled: true,
|
||||
auto_location: false,
|
||||
detected_location: None,
|
||||
location: None,
|
||||
@@ -309,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);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ fn contract_type(mut item: Item, name: &str) -> Item {
|
||||
| "RegeneratedAdminApiKey"
|
||||
| "CodexPrivacyPolicy"
|
||||
| "PrivacyRule"
|
||||
| "PrivacyStep"
|
||||
| "PrivacyPreviewRequest"
|
||||
| "PrivacyPreviewResult"
|
||||
);
|
||||
@@ -123,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"));
|
||||
@@ -137,6 +221,7 @@ fn sdk_settings_contract_matches_host_declarations() {
|
||||
"PrivacyScope",
|
||||
"PrivacyAction",
|
||||
"PrivacyRule",
|
||||
"PrivacyStep",
|
||||
"PrivacyRuleOutcome",
|
||||
"PrivacyPreviewRequest",
|
||||
"PrivacyPreviewResult",
|
||||
@@ -238,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()),
|
||||
@@ -338,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,119 @@
|
||||
//! 用量服务的 wire 精度、可信主体和独立 SDK 合同校验
|
||||
use gateway_admin::model::usage_data::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn counters_preserve_integer_precision_and_reject_json_numbers() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UsageCount>(json!(u64::MAX.to_string()))
|
||||
.unwrap()
|
||||
.get(),
|
||||
u64::MAX
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<UsageDelta>(json!(i64::MIN.to_string()))
|
||||
.unwrap()
|
||||
.get(),
|
||||
i64::MIN
|
||||
);
|
||||
for value in [json!(1), json!("1.5"), json!("+1")] {
|
||||
assert!(serde_json::from_value::<UsageCount>(value.clone()).is_err());
|
||||
assert!(serde_json::from_value::<UsageDelta>(value).is_err());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn usage_mutations_require_trusted_actor_and_old_workflow_operations_are_absent() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let denied = registry
|
||||
.call("usage.records.write", json!({"changes":[]}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.message.contains("trusted caller"));
|
||||
assert!(
|
||||
registry
|
||||
.call(
|
||||
"usage.records.write",
|
||||
json!({"changes":[],"actor":{"kind":"system"}})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let ops = registry
|
||||
.operations()
|
||||
.filter(|op| op.starts_with("usage.") || *op == "pricing.evaluate")
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ops,
|
||||
std::collections::BTreeSet::from([
|
||||
"usage.records.get",
|
||||
"usage.records.list",
|
||||
"usage.records.write",
|
||||
"usage.aggregates.list",
|
||||
"usage.aggregates.write",
|
||||
"pricing.evaluate"
|
||||
])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sdk_wire_types_match_host_contracts() {
|
||||
use quote::ToTokens as _;
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let items = |path: std::path::PathBuf| {
|
||||
syn::parse_file(&std::fs::read_to_string(path).unwrap())
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
syn::Item::Struct(_)
|
||||
| syn::Item::Enum(_)
|
||||
| syn::Item::Impl(_)
|
||||
| syn::Item::Type(_)
|
||||
)
|
||||
})
|
||||
.map(|item| item.into_token_stream().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
for name in [
|
||||
"value.rs",
|
||||
"record.rs",
|
||||
"query.rs",
|
||||
"aggregate.rs",
|
||||
"pricing.rs",
|
||||
"error.rs",
|
||||
] {
|
||||
assert_eq!(
|
||||
items(root.join("src/model/usage_data").join(name)),
|
||||
items(
|
||||
root.join("../gateway-plugin/sdk/src/call/services/usage")
|
||||
.join(name)
|
||||
),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn batch_and_wire_limits_fail_before_reaching_storage() {
|
||||
let admin = super::AdminHarness::new().build().await;
|
||||
let registry = admin.public_services();
|
||||
let changes = (0..501)
|
||||
.map(|i| json!({"kind":"delete","id":format!("item_{i}")}))
|
||||
.collect::<Vec<_>>();
|
||||
let too_many = gateway_admin::public_service::scope_actor(
|
||||
UsageActor::System,
|
||||
registry.call("usage.records.write", json!({"changes":changes})),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(too_many.kind, "capacity_exceeded");
|
||||
let oversized = registry
|
||||
.call(
|
||||
"usage.records.get",
|
||||
json!({"id":"x".repeat(USAGE_DATA_MAX_REQUEST_BYTES)}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(oversized.kind, "capacity_exceeded");
|
||||
}
|
||||
@@ -25,6 +25,7 @@ gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
gateway-protocol = { path = "../gateway-protocol" }
|
||||
headers = "0.4.1"
|
||||
regex = "1.13.1"
|
||||
secrecy = "0.10"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
@@ -32,7 +33,7 @@ sha2 = "0.11.0"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt", "sync", "time"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tower-http = { version = "0.7.0", features = ["compression-gzip", "cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
@@ -91,9 +91,15 @@ impl ListAccountGroupsQuery {
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_allow_image_generation() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CreateAccountGroupRequest {
|
||||
#[serde(default = "default_allow_image_generation")]
|
||||
allow_image_generation: bool,
|
||||
#[serde(default)]
|
||||
fast_mode: FastMode,
|
||||
name: String,
|
||||
@@ -104,6 +110,7 @@ struct CreateAccountGroupRequest {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpdateAccountGroupRequest {
|
||||
allow_image_generation: Option<bool>,
|
||||
fast_mode: Option<FastMode>,
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -120,6 +127,7 @@ struct AccountGroupIdRequest {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupView {
|
||||
allow_image_generation: bool,
|
||||
fast_mode: FastMode,
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -167,6 +175,7 @@ impl From<(AccountGroupRecord, crate::time::TimePresenter)> for AccountGroupView
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
fast_mode: record.fast_mode,
|
||||
allow_image_generation: record.allow_image_generation,
|
||||
color: record.color.as_str().to_owned(),
|
||||
enabled: record.enabled,
|
||||
member_count: record.member_count,
|
||||
@@ -362,6 +371,7 @@ where
|
||||
.create(
|
||||
&auth.context().mutation_context(),
|
||||
CreateAccountGroup {
|
||||
allow_image_generation: request.allow_image_generation,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
fast_mode: request.fast_mode,
|
||||
@@ -390,6 +400,7 @@ where
|
||||
.update(
|
||||
&auth.context().mutation_context(),
|
||||
UpdateAccountGroup {
|
||||
allow_image_generation: request.allow_image_generation,
|
||||
id: group_id(request.id)?,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! 管理端普通 JSON 响应的压缩范围与大小门槛
|
||||
|
||||
use http::{Response, header};
|
||||
use http_body::Body;
|
||||
use tower_http::compression::predicate::Predicate;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct AdminJson;
|
||||
|
||||
impl Predicate for AdminJson {
|
||||
fn should_compress<B: Body>(&self, response: &Response<B>) -> bool {
|
||||
// 只处理长度已知的 JSON,不为判断门槛收集流式正文
|
||||
response
|
||||
.body()
|
||||
.size_hint()
|
||||
.exact()
|
||||
.is_some_and(|size| size >= 1024)
|
||||
&& response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(';').next())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"))
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ use axum::{
|
||||
response::Response,
|
||||
routing::any,
|
||||
};
|
||||
use tower_http::compression::{CompressionLayer, CompressionLevel};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backups;
|
||||
pub mod client_keys;
|
||||
mod compression;
|
||||
pub(crate) mod diagnostics;
|
||||
mod extract;
|
||||
pub mod observability;
|
||||
@@ -54,6 +56,12 @@ where
|
||||
.method_not_allowed_fallback(method_not_allowed)
|
||||
.route("/api/admin", any(admin_not_found))
|
||||
.route("/api/admin/{*path}", any(admin_not_found))
|
||||
.layer(
|
||||
CompressionLayer::new()
|
||||
.quality(CompressionLevel::Fastest)
|
||||
.compress_when(compression::AdminJson),
|
||||
)
|
||||
.merge(plugins::public_router().method_not_allowed_fallback(method_not_allowed))
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 诊断文件的 HTTP 下载与受控数据投影,普通详情响应不携带时间线
|
||||
|
||||
use axum::{extract::State, http::header, response::IntoResponse};
|
||||
use gateway_admin::model::{
|
||||
diagnostics::{DiagnosticBundle, DiagnosticTarget},
|
||||
observability::OpsError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminError, AdminQuery, map_service_error, ops_error_view, usage_detail_view,
|
||||
};
|
||||
use crate::{auth::SessionState, time::TimePresenter};
|
||||
|
||||
mod privacy;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct ExportQuery {
|
||||
request_id: Option<String>,
|
||||
error_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn download<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ExportQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
{
|
||||
let target = match (query.request_id, query.error_id) {
|
||||
(Some(id), None) => DiagnosticTarget::Request(id),
|
||||
(None, Some(id)) => DiagnosticTarget::Operation(id),
|
||||
_ => {
|
||||
return Err(map_service_error(
|
||||
gateway_admin::model::AdminError::invalid("请指定一个请求 ID 或错误记录 ID"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let bundle = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.export_diagnostics(target)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let time = TimePresenter::new(state.admin_services().timezone());
|
||||
let document = privacy::redact(document(bundle, time));
|
||||
let body = serde_json::to_vec_pretty(&document).map_err(|_| {
|
||||
map_service_error(gateway_admin::model::AdminError::internal(
|
||||
"诊断文件生成失败",
|
||||
))
|
||||
})?;
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=diagnostics.json",
|
||||
),
|
||||
(header::CACHE_CONTROL, "private, no-store"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
],
|
||||
body,
|
||||
))
|
||||
}
|
||||
|
||||
fn document(bundle: DiagnosticBundle, time: TimePresenter) -> Value {
|
||||
let target = match &bundle.target {
|
||||
DiagnosticTarget::Request(id) => json!({"requestId": id}),
|
||||
DiagnosticTarget::Operation(id) => json!({"errorId": id}),
|
||||
};
|
||||
let requests = bundle.requests.into_iter().map(|record| {
|
||||
let evidence = record.detail.map(|mut detail| {
|
||||
let trace = detail.trace.take();
|
||||
let related = std::mem::take(&mut detail.related_requests);
|
||||
let configuration = json!({
|
||||
"revision": detail.request.config_revision,
|
||||
"routingScope": detail.request.routing_scope,
|
||||
"routingGroupIds": detail.request.routing_group_refs,
|
||||
"deadlineAt": detail.request.deadline_at,
|
||||
});
|
||||
// 页面 DTO 没有展示的尝试原因与等待事实仍属于导出证据
|
||||
let attempt_details = detail.attempts.iter().map(|attempt| json!({
|
||||
"id": attempt.id, "attemptIndex": attempt.attempt_index,
|
||||
"component": attempt.component, "operation": attempt.operation,
|
||||
"message": attempt.message, "retryAfterMs": attempt.retry_after_ms,
|
||||
})).collect::<Vec<_>>();
|
||||
json!({
|
||||
"detail": usage_detail_view(detail, time), "trace": trace,
|
||||
"relatedRequests": related, "incidentConfiguration": configuration,
|
||||
"attemptDetails": attempt_details,
|
||||
})
|
||||
});
|
||||
json!({
|
||||
"requestId": record.request_id,
|
||||
"evidence": evidence,
|
||||
"errors": record.errors.into_iter().map(|error| export_error(error, time)).collect::<Vec<_>>(),
|
||||
})
|
||||
}).collect::<Vec<_>>();
|
||||
json!({
|
||||
"schemaVersion": 5,
|
||||
"source": "gateway.diagnostics",
|
||||
"exportPolicy": "support_credentials_redacted",
|
||||
"target": target,
|
||||
"collectedAt": bundle.collected_at,
|
||||
"environment": {
|
||||
"scope": "export_time", "observedAt": bundle.collected_at,
|
||||
"gateway": bundle.environment.map(|version| json!({
|
||||
"version": version.version, "gitSha": version.git_sha,
|
||||
"buildTime": version.build_time, "deploymentMode": version.deployment_mode,
|
||||
})),
|
||||
},
|
||||
"error": bundle.error.map(|error| export_error(error, time)),
|
||||
"requests": requests,
|
||||
"availability": {"gaps": bundle.gaps.into_iter().map(|gap| json!({"section": gap.section, "reason": gap.reason})).collect::<Vec<_>>()},
|
||||
"omitted": ["credentials_and_authentication", "account_contact_details", "uncollected_wire_payloads", "unrelated_requests_and_system_secrets"],
|
||||
"notes": [
|
||||
"诊断包包含错误原因链、上游错误与业务上下文,用于故障分析",
|
||||
"认证字段、账号联系信息与可识别的内嵌凭据已脱敏,任意业务内容不属于自动识别范围",
|
||||
"environment 为导出时环境,incidentConfiguration 与 request.routed 为事发配置事实,当前版本不能代替事发版本",
|
||||
"缺失、截断、淘汰与读取失败见 availability.gaps,没有记录不表示没有发生",
|
||||
"attempts 为异步 best-effort 投影,firstTokenMs 为空不证明没有输出",
|
||||
"未采集的请求正文、过期记录、上游内部状态与退出前未落盘信息无法由导出补回"
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// 复用错误字段投影,原始详情直接进入脱敏遍历,避免先为页面缩进再解析一遍
|
||||
fn export_error(mut error: OpsError, time: TimePresenter) -> Value {
|
||||
let details = error.error_details.take();
|
||||
let mut value = json!(ops_error_view(error, time));
|
||||
value["errorDetails"] = json!(details);
|
||||
value
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! 诊断文件按认证与观测合同移除敏感字段,保留错误事实并明确导出截断
|
||||
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
const REDACTED: &str = "<redacted>";
|
||||
const MAX_TEXT: usize = 256 * 1024;
|
||||
static INLINE: LazyLock<Vec<(Regex, &str)>> = LazyLock::new(|| {
|
||||
[
|
||||
(r"(?i)\b((?:set-cookie|cookie2?|(?:proxy-)?authorization|x-api-key|x-oai-is(?:-update)?|x-oai-attestation)\s*:\s*)[^\r\n]+", "${1}<redacted>"),
|
||||
(r#"(?i)\b(Bearer|Basic)\s+[^\s"'<>;,]+"#, "${1} <redacted>"),
|
||||
// 官方 TokenData 的 access_token / id_token 为 JWT,不按猜测的令牌前缀过滤普通文本
|
||||
(r"\beyJ[\w-]+\.[\w-]+\.[\w-]+\b", "<redacted>"),
|
||||
(r"(?i)([a-z][a-z0-9+.-]*://)[^\s/@]+@", "${1}<redacted>@"),
|
||||
(r#"(?i)\b((?:(?:personal[_-]?)?access[_-]?token|refresh[_-]?token|id[_-]?token|(?:(?:openai|x)[_-]?)?api[_-]?key|(?:current[_-]?|new[_-]?)?password|authorization|proxy[_-]?authorization|x[_-]?oai[_-]?(?:is(?:[_-]?update)?|attestation))["']?\s*[=:]\s*)("[^"\r\n]*"|'[^'\r\n]*'|[^\s&,;]+)"#, "${1}<redacted>"),
|
||||
].into_iter().map(|(pattern, replacement)| (Regex::new(pattern).expect("static credential pattern"), replacement)).collect()
|
||||
});
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Report {
|
||||
credential_fields: usize,
|
||||
personal_fields: usize,
|
||||
inline_credentials: usize,
|
||||
truncated_values: usize,
|
||||
}
|
||||
|
||||
struct Redactor {
|
||||
report: Report,
|
||||
bytes: usize,
|
||||
nodes: usize,
|
||||
}
|
||||
|
||||
pub(super) fn redact(mut value: Value) -> Value {
|
||||
let mut redactor = Redactor {
|
||||
report: Report::default(),
|
||||
bytes: 8 * 1024 * 1024,
|
||||
nodes: 100_000,
|
||||
};
|
||||
// 先处理有界文件信息,正文耗尽预算也不能删除 schema、目标或既有缺口
|
||||
let mut error = value["error"].take();
|
||||
let mut requests = value["requests"].take();
|
||||
redactor.visit(&mut value, 0);
|
||||
redactor.visit(&mut error, 0);
|
||||
redactor.visit(&mut requests, 0);
|
||||
value["error"] = error;
|
||||
value["requests"] = requests;
|
||||
if redactor.report.truncated_values > 0 {
|
||||
// 缺口清单可能也触及预算,顶层统计始终保留以免截断变成静默丢失
|
||||
value["exportTruncated"] = json!(true);
|
||||
if !value["availability"]["gaps"].is_array() {
|
||||
value["availability"] = json!({"gaps": []});
|
||||
}
|
||||
value["availability"]["gaps"]
|
||||
.as_array_mut()
|
||||
.expect("gap array")
|
||||
.push(json!({
|
||||
"section": "export", "reason": "export_size_limit",
|
||||
}));
|
||||
}
|
||||
value["redaction"] = json!(redactor.report);
|
||||
value
|
||||
}
|
||||
|
||||
fn normalized(key: &str) -> String {
|
||||
key.chars()
|
||||
.filter(|character| !matches!(character, '_' | '-'))
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// OAuth 字段来自 Provider credential schema 与官方 Codex TokenData;API Key 来自
|
||||
// CodexCprApiKeyExportAccount / AuthDotJson;密码来自管理登录合同,头部来自传输身份合同
|
||||
// 不用 token、secret、credential 等泛称推断敏感性,避免删除业务错误上下文
|
||||
fn secret(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"accesstoken"
|
||||
| "personalaccesstoken"
|
||||
| "refreshtoken"
|
||||
| "idtoken"
|
||||
| "apikey"
|
||||
| "openaiapikey"
|
||||
| "password"
|
||||
| "currentpassword"
|
||||
| "newpassword"
|
||||
| "authorization"
|
||||
| "proxyauthorization"
|
||||
| "xapikey"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "setcookie"
|
||||
| "xoaiis"
|
||||
| "xoaiisupdate"
|
||||
| "xoaiattestation"
|
||||
)
|
||||
}
|
||||
|
||||
impl Redactor {
|
||||
fn omitted(&mut self) -> Value {
|
||||
self.report.truncated_values += 1;
|
||||
json!({"omitted": "export_size_limit"})
|
||||
}
|
||||
|
||||
fn text(&mut self, text: &str) -> String {
|
||||
let mut result = text.to_owned();
|
||||
for (pattern, replacement) in INLINE.iter() {
|
||||
self.report.inline_credentials += pattern.find_iter(&result).count();
|
||||
result = pattern.replace_all(&result, *replacement).into_owned();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn visit(&mut self, value: &mut Value, depth: usize) {
|
||||
if self.nodes == 0 || self.bytes == 0 || depth > 32 {
|
||||
*value = self.omitted();
|
||||
return;
|
||||
}
|
||||
self.nodes -= 1;
|
||||
// 预算同时计入缩进和容器分隔符,避免深层 JSON 的下载体积显著超过正文预算
|
||||
self.bytes = self.bytes.saturating_sub(24 + depth * 4);
|
||||
match value {
|
||||
Value::String(text) => {
|
||||
if text.trim_start().starts_with('{') || text.trim_start().starts_with('[') {
|
||||
if text.len() > MAX_TEXT {
|
||||
*value = self.omitted();
|
||||
return;
|
||||
}
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(text) {
|
||||
*value = parsed;
|
||||
self.visit(value, depth + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let cleaned = self.text(text);
|
||||
let limit = MAX_TEXT.min(self.bytes / 2);
|
||||
if cleaned.len() > limit {
|
||||
let boundary = cleaned.floor_char_boundary(limit);
|
||||
*value = json!({"text": &cleaned[..boundary], "omittedBytes": cleaned.len() - boundary});
|
||||
self.report.truncated_values += 1;
|
||||
} else {
|
||||
*text = cleaned;
|
||||
}
|
||||
self.bytes = self.bytes.saturating_sub(value.to_string().len());
|
||||
}
|
||||
Value::Array(items) => {
|
||||
// 头部也可能以二元组承载,不能只识别对象字段名
|
||||
if items.len() == 2
|
||||
&& items[0]
|
||||
.as_str()
|
||||
.is_some_and(|name| secret(&normalized(name)))
|
||||
{
|
||||
items[1] = json!(REDACTED);
|
||||
self.report.credential_fields += 1;
|
||||
}
|
||||
let original_len = items.len();
|
||||
let mut kept = 0;
|
||||
for item in items.iter_mut().take(4096) {
|
||||
if self.nodes == 0 || self.bytes == 0 {
|
||||
break;
|
||||
}
|
||||
self.visit(item, depth + 1);
|
||||
kept += 1;
|
||||
}
|
||||
if kept < original_len {
|
||||
items.truncate(kept);
|
||||
items.push(json!({"omittedItems": original_len - kept}));
|
||||
self.report.truncated_values += 1;
|
||||
}
|
||||
}
|
||||
Value::Object(object) => {
|
||||
if object
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|name| secret(&normalized(name)))
|
||||
&& object.contains_key("value")
|
||||
{
|
||||
object.insert("value".to_owned(), json!(REDACTED));
|
||||
self.report.credential_fields += 1;
|
||||
}
|
||||
let fields = std::mem::take(object);
|
||||
let total = fields.len();
|
||||
for (key, mut item) in fields {
|
||||
if object.len() >= 4096 || self.nodes == 0 || self.bytes == 0 {
|
||||
break;
|
||||
}
|
||||
let normalized = normalized(&key);
|
||||
if secret(&normalized) {
|
||||
item = json!(REDACTED);
|
||||
self.report.credential_fields += 1;
|
||||
} else if matches!(
|
||||
normalized.as_str(),
|
||||
"email"
|
||||
| "accountemail"
|
||||
| "clientip"
|
||||
| "accountname"
|
||||
| "accountnotes"
|
||||
| "credentialname"
|
||||
| "clientapikeyname"
|
||||
| "accountlabel"
|
||||
) {
|
||||
item = json!(REDACTED);
|
||||
self.report.personal_fields += 1;
|
||||
} else {
|
||||
self.visit(&mut item, depth + 1);
|
||||
}
|
||||
self.bytes = self.bytes.saturating_sub(key.len());
|
||||
object.insert(self.text(&key), item);
|
||||
}
|
||||
if object.len() < total {
|
||||
object.insert("_omittedFields".to_owned(), json!(total - object.len()));
|
||||
self.report.truncated_values += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use super::{
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
mod export;
|
||||
mod presenter;
|
||||
mod query;
|
||||
mod routes;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 观测领域事实到管理页面的展示投影
|
||||
|
||||
use super::*;
|
||||
use gateway_core::metering::Usage;
|
||||
|
||||
pub(crate) fn display_duration(value: Option<u64>) -> String {
|
||||
let Some(value) = value.and_then(|value| i64::try_from(value).ok()) else {
|
||||
@@ -100,7 +101,7 @@ pub(crate) fn attempt_metrics_view(metrics: &domain::AttemptMetrics) -> AttemptM
|
||||
}
|
||||
|
||||
pub(crate) fn token_details(record: &domain::UsageRecord) -> TokenDetailsView {
|
||||
TokenDetailsView {
|
||||
token_details_view(&Usage {
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_input_tokens: record.cached_input_tokens,
|
||||
@@ -109,35 +110,11 @@ pub(crate) fn token_details(record: &domain::UsageRecord) -> TokenDetailsView {
|
||||
image_input_tokens: record.image_input_tokens,
|
||||
image_output_tokens: record.image_output_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
input_tokens_display: record
|
||||
.input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
output_tokens_display: record
|
||||
.output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
cached_input_tokens_display: record
|
||||
.cached_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_compact_number),
|
||||
cache_write_input_tokens_display: record
|
||||
.cache_write_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_compact_number),
|
||||
reasoning_output_tokens_display: record
|
||||
.reasoning_output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_input_tokens_display: record
|
||||
.image_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_output_tokens_display: record
|
||||
.image_output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
total_tokens_display: record
|
||||
.total_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn usage_list_token_details(record: &domain::UsageListRecord) -> TokenDetailsView {
|
||||
TokenDetailsView {
|
||||
token_details_view(&Usage {
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_input_tokens: record.cached_input_tokens,
|
||||
@@ -146,28 +123,41 @@ pub(crate) fn usage_list_token_details(record: &domain::UsageListRecord) -> Toke
|
||||
image_input_tokens: record.image_input_tokens,
|
||||
image_output_tokens: record.image_output_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
input_tokens_display: record
|
||||
})
|
||||
}
|
||||
|
||||
fn token_details_view(usage: &Usage) -> TokenDetailsView {
|
||||
TokenDetailsView {
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cached_input_tokens: usage.cached_input_tokens,
|
||||
cache_write_input_tokens: usage.cache_write_input_tokens,
|
||||
reasoning_output_tokens: usage.reasoning_output_tokens,
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
total_tokens: usage.total_tokens,
|
||||
input_tokens_display: usage
|
||||
.input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
output_tokens_display: record
|
||||
output_tokens_display: usage
|
||||
.output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
cached_input_tokens_display: record
|
||||
cached_input_tokens_display: usage
|
||||
.cached_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_compact_number),
|
||||
cache_write_input_tokens_display: record
|
||||
cache_write_input_tokens_display: usage
|
||||
.cache_write_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_compact_number),
|
||||
reasoning_output_tokens_display: record
|
||||
reasoning_output_tokens_display: usage
|
||||
.reasoning_output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_input_tokens_display: record
|
||||
image_input_tokens_display: usage
|
||||
.image_input_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
image_output_tokens_display: record
|
||||
image_output_tokens_display: usage
|
||||
.image_output_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
total_tokens_display: record
|
||||
total_tokens_display: usage
|
||||
.total_tokens
|
||||
.map_or_else(|| "-".to_owned(), format_number),
|
||||
}
|
||||
@@ -272,6 +262,7 @@ pub(crate) fn usage_list_record_view(
|
||||
.clone()
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
UsageListRecordView {
|
||||
logical_outcome: record.outcome.as_str().to_owned(),
|
||||
client_api_key_name: record.client_api_key_name,
|
||||
id: record.id,
|
||||
provider: record.provider_kind,
|
||||
@@ -545,8 +536,6 @@ pub(crate) fn usage_detail_view(
|
||||
time: crate::time::TimePresenter,
|
||||
) -> UsageRecordDetailView {
|
||||
UsageRecordDetailView {
|
||||
trace: detail.trace,
|
||||
related_requests: detail.related_requests,
|
||||
request: usage_record_view(detail.request, time),
|
||||
attempts: detail
|
||||
.attempts
|
||||
@@ -626,7 +615,7 @@ pub(crate) fn ops_error_view(
|
||||
subagent_kind: error.subagent_kind,
|
||||
compact: error.compact,
|
||||
message: error.message,
|
||||
error_details: error.error_details,
|
||||
error_details: error.error_details.map(error_details_view),
|
||||
metadata: OpsErrorMetadataView {
|
||||
source: error.source,
|
||||
component: error.component,
|
||||
@@ -650,6 +639,20 @@ pub(crate) fn ops_error_view(
|
||||
}
|
||||
}
|
||||
|
||||
// 受控错误正文保持原文语义,嵌套 JSON 只在展示投影中展开缩进
|
||||
fn error_details_view(raw: String) -> String {
|
||||
let Ok(mut value) = serde_json::from_str::<Value>(&raw) else {
|
||||
return raw;
|
||||
};
|
||||
if let Some(upstream) = value.get_mut("upstream")
|
||||
&& let Some(text) = upstream.as_str()
|
||||
&& let Ok(parsed) = serde_json::from_str::<Value>(text)
|
||||
{
|
||||
*upstream = parsed;
|
||||
}
|
||||
serde_json::to_string_pretty(&value).unwrap_or(raw)
|
||||
}
|
||||
|
||||
pub(crate) fn ops_page_view(
|
||||
page: domain::OpsErrorPage,
|
||||
time: crate::time::TimePresenter,
|
||||
|
||||
@@ -29,6 +29,7 @@ impl DashboardQuery {
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageQuery {
|
||||
pub status: Option<String>,
|
||||
pub current_page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub kind: Option<String>,
|
||||
@@ -330,6 +331,12 @@ pub(crate) fn usage_command(
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
Ok(domain::UsageQuery {
|
||||
status: match query.status.as_deref() {
|
||||
None | Some("success") => domain::UsageRecordStatus::Success,
|
||||
Some("error") => domain::UsageRecordStatus::Error,
|
||||
Some("all") => domain::UsageRecordStatus::All,
|
||||
Some(_) => return Err(WireValidationError::new("status")),
|
||||
},
|
||||
range: usage_range(
|
||||
query.start_time.as_deref(),
|
||||
query.end_time.as_deref(),
|
||||
|
||||
@@ -29,6 +29,10 @@ where
|
||||
get(usage_insights_diagnostics::<S>),
|
||||
)
|
||||
.route("/api/admin/operations/errors", get(ops_errors::<S>))
|
||||
.route(
|
||||
"/api/admin/diagnostics/export",
|
||||
get(super::export::download::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn dashboard_summary<S>(
|
||||
|
||||
@@ -86,6 +86,7 @@ pub struct ImageBillingView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageListRecordView {
|
||||
pub logical_outcome: String,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub provider: Option<String>,
|
||||
@@ -275,8 +276,6 @@ pub struct UsageAttemptView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordDetailView {
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub related_requests: Vec<serde_json::Value>,
|
||||
#[serde(flatten)]
|
||||
pub request: UsageRecordView,
|
||||
pub attempts: Vec<UsageAttemptView>,
|
||||
|
||||
@@ -53,8 +53,19 @@ pub(super) fn router<S: SessionState + Clone + Send + Sync + 'static>() -> Route
|
||||
.route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/callback-tickets", post(start_callback::<S>))
|
||||
.route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/api/{*path}", any(handle::<S>))
|
||||
.route("/api/admin/plugins/extensions/{instance_id}/{artifact_sha256}/{revision}/resources/{*path}", get(resource::<S>))
|
||||
.route("/plugins/resources/{instance_id}/{artifact_sha256}/{revision}/{*path}", get(public_resource::<S>))
|
||||
.route("/plugins/callbacks/{instance_id}/{artifact_sha256}/{revision}/{*path}", get(callback::<S>))
|
||||
}
|
||||
|
||||
pub(in crate::admin) fn public_router<S: SessionState + Clone + Send + Sync + 'static>() -> Router<S>
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/plugins/resources/{instance_id}/{artifact_sha256}/{revision}/{*path}",
|
||||
get(public_resource::<S>),
|
||||
)
|
||||
.route(
|
||||
"/plugins/callbacks/{instance_id}/{artifact_sha256}/{revision}/{*path}",
|
||||
get(callback::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::admin) fn model_router() -> Router<crate::ApiState> {
|
||||
@@ -240,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,
|
||||
@@ -312,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,
|
||||
|
||||
@@ -5,7 +5,7 @@ mod distribution;
|
||||
mod instances;
|
||||
mod management;
|
||||
|
||||
pub(super) use management::model_router;
|
||||
pub(super) use management::{model_router, public_router};
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
||||
@@ -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,8 @@ 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")]
|
||||
location: Option<Option<gateway_core::account::RequestLocation>>,
|
||||
@@ -145,6 +149,8 @@ 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>,
|
||||
location: Option<gateway_core::account::RequestLocation>,
|
||||
@@ -168,6 +174,8 @@ 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,
|
||||
id: record.id,
|
||||
@@ -339,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>(
|
||||
@@ -419,6 +477,8 @@ where
|
||||
.proxies()
|
||||
.update(
|
||||
UpdateProxy {
|
||||
max_websocket_connections: request.max_websocket_connections,
|
||||
enabled: request.enabled,
|
||||
auto_location: request.auto_location,
|
||||
test: None,
|
||||
location: request.location,
|
||||
@@ -476,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(
|
||||
@@ -488,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;
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ use axum::{
|
||||
http::{HeaderName, HeaderValue, Request, StatusCode},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use gateway_core::engine::middleware::FrozenMiddlewarePlan;
|
||||
use gateway_core::engine::middleware::http as contract;
|
||||
use gateway_core::engine::middleware::http::Settings;
|
||||
use gateway_core::engine::middleware::{FrozenMiddlewarePlan, MiddlewareError};
|
||||
use gateway_core::error::GatewayErrorKind;
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use gateway_core::middleware::compose;
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
@@ -150,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()
|
||||
@@ -175,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(),
|
||||
}
|
||||
@@ -270,6 +259,11 @@ async fn handle(
|
||||
}
|
||||
response.map(|body| Body::new(ResponseBody::new(body, guard)))
|
||||
}
|
||||
Err(MiddlewareError::Gateway(error))
|
||||
if error.kind() == GatewayErrorKind::MiddlewareCapacityUnavailable =>
|
||||
{
|
||||
crate::openai::error::gateway_error_response(&error)
|
||||
}
|
||||
Err(error) => {
|
||||
let status = if error.is_rejected() {
|
||||
StatusCode::FORBIDDEN
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user