Compare commits

...
Author SHA1 Message Date
fawney19 4e96b9d870 Merge pull request #418 from Avilianb/feat/codex-responses-websocket
feat: support Codex Responses WebSocket transport
2026-05-15 12:49:45 +08:00
Avilianb d3c0b1aa7f feat: support Codex Responses WebSocket transport 2026-05-10 01:59:48 +08:00
phenetr0andphenetr0 66bfd3e592 fix: avoid double conversion for forced cli sync streams (#367)
Co-authored-by: phenetr0 <[email protected]>
2026-05-03 02:11:16 +08:00
DaoandYour Name 392c557831 优化 OAuth Token 导入解析与号池管理直刷空列表问题 (#330)
* docs: add aether-proxy C++ rewrite design

* chore: ignore local worktrees directory

* 修复 OAuth Token 导入识别与账号信息解析

* 修复号池管理直刷账号列表为空

---------

Co-authored-by: Your Name <[email protected]>
2026-04-30 18:07:00 +08:00
49 c34565b02b fix(provider-test): 修复测试供应商配置时 DetachedInstanceError (#270)
两处修复:

1. endpoint_checker.py: 移除 asyncio.to_thread 包装同步 DB 查询,
   避免跨线程导致 SQLAlchemy Session 失效

2. provider_query.py: 并发测试预加载 ProviderEndpoint 和 ProviderAPIKey 时
   添加 joinedload(provider),确保 expunge 后不会触发懒加载
2026-04-17 10:37:19 +08:00
fawney19 f57fe6e13e fix(migration): 用 UPDATE...FROM 子查询修复 total_tokens 自引用更新问题,增加批次上限防止死循环 2026-03-24 02:07:24 +08:00
fawney19 7c678b715f fix(migration): 修复 usage token 语义迁移脚本
- 将内联 SQL 提取为模块级常量 _UPGRADE_BACKFILL_SQL / _DOWNGRADE_BACKFILL_SQL
- 抽取 run_backfill_in_batches() 统一批量更新逻辑,通过 autocommit_block 释放 ALTER TABLE 锁
- 修正 upgrade 中 total_tokens 计算逻辑:用 COALESCE(input_tokens,0)+COALESCE(output_tokens,0) 补全 input_output_total_tokens
- WHERE 条件改为按实际字段差异筛选待回填行,替换原先仅过滤 input_context_tokens=0 AND total_tokens=0 的不完整条件
- 批量大小由 5000 调整为 500,减少单批锁持有时间
2026-03-24 01:56:36 +08:00
fawney19andNyaDoo bd4e5f3a5d feat(analytics): 重构统计分析模块,统一 API 与前端视图
close #260

- 新增 `src/services/analytics/query_service.py`,集中实现排行榜、性能、时间序列等查询逻辑
- 新增 `src/api/analytics/routes.py`,替代原 `stats/` 和 `dashboard/` 的分散路由
- 删除旧 `src/api/admin/stats/`、`src/api/dashboard/` 模块
- 重构 `src/api/user_me/routes.py` 与 `src/api/admin/usage/routes.py`,精简用量查询接口
- 新增 Alembic 迁移,修正 token 语义字段
- 前端新增 Analytics.vue、LeaderboardTab、PerformanceTab、ReportsTab 及 Reports 用户视图
- 新增 composables(useAnalyticsFilters、useReportsData、useLeaderboardData、usePerformanceData)
- 新增工具函数:analyticsGranularity、analyticsTimeseries、chartTheme、csvExport、usageBreakdown
- 删除旧 CostAnalysis、PerformanceAnalysis、UserStats 页面及相关组件
- 前端 API 层重组:新增 analytics.ts、request-details.ts,删除 dashboard.ts 和 usage.ts

Co-authored-by: NyaDoo <[email protected]>
2026-03-24 01:51:53 +08:00
York Zang 165d9eab8f fix(proxy): 修正代理连通性测试地址,避免 1.1.1.1 证书校验失败 (#257)
代理连通性测试此前使用 https://1.1.1.1/cdn-cgi/trace 作为探测地址,在标准 TLS 校验下会因为证书与 IP 不匹配而失败。
改为使用基于域名的 Cloudflare trace 地址,避免触发 CERTIFICATE_VERIFY_FAILED,并恢复代理测试结果的准确性。
2026-03-24 00:04:01 +08:00
fawney19andAAEE86 dfb95f09e1 feat(health): 增强健康监控面板,支持按 Key 查看详情与摘要统计
- 后端 health monitor 新增 summary 接口,按 api_format 聚合健康摘要
- 新增 GroupedFormatKey 类型与 key 分组查询接口
- HealthMonitorCard 重构为卡片+详情对话框,展示成功率、响应时间、Key 状态
- 抽取 HealthMonitorDetailDialog 独立组件
- 新增 useRouteQuery composable 用于 URL query 参数双向绑定
- PoolManagement/ProviderManagement 集成健康监控入口
- 新增 health monitor summary 单元测试

Closes #256

Co-authored-by: AAEE86 <[email protected]>
2026-03-24 00:00:08 +08:00
fawney19andhemo94931 4d5c591654 fix: 覆写规则条件可使用映射前请求体
新增 rules_original_body 参数贯穿请求构建链路,确保 body_rules/header_rules
条件评估使用模型映射前的原始请求体;附带将 handlers __init__ 改为延迟导入。

Closes #255

Co-authored-by: hemo94931 <[email protected]>
2026-03-23 17:31:29 +08:00
fawney19 46737d32f8 feat: 引入 status_snapshot 统一 provider key 状态管理
- 新增 StatusSnapshot 模型,聚合 OAuth / 账号 / 配额三维状态
- 新增 StatusSnapshotStore 负责快照的持久化与查询
- 重构 response_builder / endpoint_models,基于 snapshot 输出状态字段
- 前端抽取 providerKeyStatus / oauthRefreshFeedback 工具函数,
  统一 PoolManagement、ProviderDetailDrawer、BatchDialog 的状态展示
- errorParser 增加已知 OAuth 错误的友好提示
- refresher 适配 snapshot 写入,account_state 扩展状态分类
- 新增 alembic 迁移及存量数据回填脚本
- 补充前后端单元测试
2026-03-20 19:16:52 +08:00
fawney19 25d38ae632 feat(oauth): 账号封禁前置 OAuth 验证、抽取 provider_context、完善账号状态分类
- 新增 verify_oauth_before_account_block:在标记账号封禁前先尝试刷新 token,
  区分 OAuth 过期与真正的账号级封禁,避免误标
- 抽取 provider_context.py 统一解析 provider_type,解决 ORM detached 访问问题
- account_state 新增 workspace_deactivated 分类和 auto-removable 状态集合,
  补充中文验证关键词匹配
- OAuth refresh 成功后仅清除可恢复的 token 错误,不再自动清除账号级 block
- deploy.sh 依赖指纹改用纯 shell 实现,移除对 Python tomllib 的依赖
- 前端 Pool 管理页面新增筛选和批量操作优化
- 补充对应测试用例
2026-03-20 16:50:59 +08:00
fawney19 aa83b4a7a7 fix: 加固续租失败处理、verify_auth 异常捕获及调度器注册追踪
- task_coordinator: 续租连续失败 5 次后主动触发 lock_lost 回调,失败间加指数退避
- proxy_nodes: lock_lost 回调由 lambda 改为具名 async 函数,确保异步停止逻辑正确执行
- provider_ops: 将 prepare_verify_config 纳入外层 try,捕获 ValueError 并返回失败响应
- maintenance_scheduler: 用 _registered_job_ids 动态追踪已注册任务,stop 时按列表清理
- stats_aggregator: 内联 _do_aggregate 为 for/range(2) 循环,消除内嵌函数
2026-03-20 01:22:07 +08:00
fawney19 913ce2dbcb Merge pull request #252 from AAEE86/nn
fix(startup): 收口 leader 失锁后的后台任务
2026-03-20 01:13:01 +08:00
fawney19 cae5e520ac fix(frontend): 修复 restoreOriginalPlaceholder 递归调用、优化日志参数格式,补全 tsconfig lib 配置 2026-03-20 01:06:15 +08:00
fawney19 772f2ea601 fix(vertex): SA 认证注入代理配置,细化 token 获取异常处理
- _auth_service_account 接收 endpoint 参数,通过 _get_proxy_config 解析代理
- vertex_auth 区分 TimeoutException/RequestError/通用异常,提供可读错误信息
- 新增测试覆盖代理传递和超时场景
2026-03-20 00:49:44 +08:00
fawney19 28fa03451c feat(provider): 模型测试支持自定义请求头,优化对话框布局与并发策略
- 前后端新增 request_headers 字段,测试时可自定义额外请求头
- ModelTestDialog 拆分为请求头/请求体并排双栏布局,增加格式化与重置按钮
- 区分 Pool 托管(并发5)和单 Key Provider(并发1)的测试并发数
- JsonImportInput 新增 multiple prop 支持单文件模式
- KeyFormDialog Service Account 输入改用 JsonImportInput,支持拖拽导入
2026-03-20 00:24:56 +08:00
fawney19 6984984c22 feat(provider): 重构模型测试对话框,加固 Vertex AI 传输层
模型测试:
- 将消息输入替换为完整 JSON 请求体编辑器,支持格式化和校验
- 新增端点选择面板,测试前可选择目标端点
- 新增调试检查器,可查看每次尝试的请求/响应头和体
- 结果视图改用 HorizontalRequestTimeline 组件展示请求追踪
- endpoint_checker 返回完整调试数据,通过 candidate extra_data 持久化

Vertex AI:
- 改进上下文检测逻辑,不再仅依赖 provider_type,支持从 base_url 推断
- Service Account 密钥现支持自动拉取模型(使用 auth_config 而非 api_key)
- 移除 Gemini Developer API 回退,API Key 仅走 Express 模式
- 端点表单为 Vertex AI 显示格式特定的默认路径模板
- 密钥格式校验仅在 auth_type/api_formats 变更时执行

其他:
- 禁用 ClaudeCode 提供商类型创建入口
- Dialog 组件新增 closeOnBackdrop 属性
2026-03-19 23:52:17 +08:00
AAEE86 1209c835c7 fix(startup): 收口 leader 失锁后的后台任务
- 为后台调度器注册失锁回调并只在 stop 成功后清空生命周期引用
- 停止调度器时移除定时 job,补充启动与任务协调器回归测试
- 降低多 worker 下重复调度风险,保持停机收口与统计聚合回归一致
2026-03-19 22:26:51 +08:00
fawney19 e4ebd5cca1 refactor(oauth): LinuxDo 备用端点回退、Basic Auth 认证,修复 session 外访问 ORM 对象
- LinuxDo provider: token/userinfo 请求增加 backup 端点自动回退
- LinuxDo provider: token 请求改用 HTTP Basic Auth 认证
- 授权 URL 构建: scope 为空时不再发送该参数
- OAuthService: 引入 OAuthAuthenticatedUser 快照,避免 DB session 关闭后访问 ORM 对象
- OAuthService: _handle_login_sync 设置 expire_on_commit=False 防止属性过期
- 新增 LinuxDo provider 单元测试(Basic Auth、端点回退)
- 新增 _handle_login_sync 返回快照的集成测试
2026-03-19 20:32:33 +08:00
fawney19 f573110725 fix(frontend): 用 CSS text-security 替代 password 输入框,简化配额进度条 UI
- Input 组件 masked 模式改用 WebkitTextSecurity: disc 替代 type=password,
  避免浏览器密码管理器自动填充干扰
- LoginDialog/UserFormDialog/Settings/ProxyNodes 密码字段统一迁移到 masked 属性
- PoolManagement 配额进度条布局从 grid 改为 flex,移除未使用的
  getQuotaProgressDisplayClass/getQuotaProgressTooltip 函数

Close #249
Co-authored-by: AAEE86 <[email protected]>
2026-03-19 20:04:56 +08:00
fawney19andAAEE86 a8620e133a fix(kiro): 加固 Kiro adapter 错误处理与请求构建逻辑
- 提取 request.py 统一 URL/headers/payload 构建,消除 handler_adapter_base 与 envelope 的重复逻辑
- 新增 error_enhancer 模块,分类 HTTP 状态码与连接错误,增强上游错误诊断信息
- envelope 实现 extract_error_text / on_http_status / on_connection_error,透传错误上下文到 eventstream rewriter
- KiroRequestContext 扩展错误状态字段,支持网络诊断信息传递
- provider_oauth_utils 改用 importlib 动态加载,避免 core 层对 services 的静态依赖
- idc auth_method 下跳过 profileArn,修复 usage 查询参数

Closes #247

Co-authored-by: AAEE86 <[email protected]>
2026-03-19 13:54:40 +08:00
RWDai ddd6adbcf7 feat(provider): support custom prompts for model tests (#242) 2026-03-19 13:11:54 +08:00
fawney19 b570aaac48 fix: 补全 OpenAI 工具参数 schema 中缺失的 properties 字段
OpenAI API 要求 type=object 的 schema 节点必须声明 properties,
否则会拒绝请求。在 request_from_internal 出口处对工具参数 schema
进行深拷贝并递归补全缺失的空 properties,不影响内部表示。
2026-03-19 02:36:22 +08:00
fawney19 086efe6efe refactor(usage-queue): ACK 后立即删除消息,清理 consumer 元数据
- 消费成功/移入 DLQ 后立即 XDEL,避免主流 Redis 保留已入库历史
- 缩小 usage_queue_stream_maxlen 默认值:200000 -> 2000(仅作短暂缓冲)
- 启动时清理 pending=0 且长期闲置的旧 consumer(防 consumer group 元数据累积)
- 停机时主动 XGROUP DELCONSUMER 移除自身
- 移除 cache_fingerprint 模块及其对 telemetry/recording_helpers 的引用
- 同步更新相关测试,验证 xdel 调用及 consumer 生命周期行为
2026-03-19 02:09:09 +08:00
fawney19 56f3c95763 fix: 统一 input_context_expr 计算口径,移除按 api_format 分支的 CASE 逻辑
input_context_expr() 原先按 OpenAI/Gemini 和 Claude 分支计算输入上下文,
现统一为 input_tokens + cache_read_input_tokens,与 usage 表展示口径一致。
同步更新 admin 和 user_me 路由中的缓存命中率注释,并新增单元测试。
2026-03-19 01:35:00 +08:00
fawney19 8a6a961900 feat: 增强 OAuth 标识展示与 Codex 调试日志
- 优化 OAuth badge 支持 account_id/account_user_id,tooltip 展示完整身份信息
- 重构池管理额度进度条布局,倒计时独立展示并增加样式区分
- Codex 插件增加 token 解析快照日志,便于排查 OAuth 透传问题

Closes #245
Co-Authored-By: kayphoon <[email protected]>
2026-03-19 00:50:18 +08:00
kayphoon 6e55968487 feat: Codex account_name 透传并优化池列表 OAuth 标识与额度重置倒计时展示
(cherry picked from commit 1b9176fc4a)
2026-03-18 23:58:42 +08:00
fawney19 8d8cddcef6 fix(replay): rerun model mapping on replay target 2026-03-18 23:54:19 +08:00
RWDai b90d5095f1 Fix replay fallback when model mapping is missing 2026-03-18 23:54:19 +08:00
RWDai a4505b1281 Fix usage replay model remapping 2026-03-18 23:54:19 +08:00
fawney19 1d72a8f9c1 feat: 流式空闲超时、健康监控查询优化、限流桶内存上限与维护清理修复
Close #233

Co-authored-by: AAEE86 <[email protected]>

- cli_monitor_mixin: 引入 STREAM_IDLE_TIMEOUT_SECONDS(可通过环境变量配置),
  流传输开始后若超出空闲窗口无新 chunk 则提前取消并返回 504,避免长时间挂起
- stream_context: 新增 managed_recorded_bodies 上下文管理器,确保 chunks 在
  telemetry 完成后及时释放;stream_telemetry 使用该接口统一管理 response body 构建
- health endpoint: 将状态聚合改为 GROUP BY 直接统计,事件列表按 api_format
  单独查询,避免单次 limit 拉取大量记录导致的遗漏与性能问题;同时过滤不活跃
  provider/endpoint,与公开健康接口保持一致
- endpoint health service: 修正时间线数据按 endpoint_id 而非 key_id 聚合
- token_bucket: 引入 max_buckets/bucket_expiry 上限与定时清理,防止内存无限增长;
  修复 refill_rate=0 时 get_reset_time 除零异常;新增 _is_unlimited_rate_limit 判断
- maintenance_scheduler: 调整清理顺序(先删整行再按窗口清理),新增 newer_than
  边界参数,避免同一行在同一轮中被重复改写
- sync_execute: 新增 create_pending_usage 开关,允许已预创建记录的调用方跳过重复创建
- quota_reader / provider_ops balance: 小幅修复与健壮性提升
- Dockerfile: 添加 MALLOC_ARENA_MAX=2 环境变量以降低 gunicorn worker RSS
- 补充相关测试覆盖
2026-03-18 23:38:26 +08:00
fawney19 3d5b6141a5 feat(codex): 引入 upstream_headers hook 机制,为 Codex 注入 session/conversation/account headers
- 新增 upstream_headers.py:可注册 provider+endpoint 维度的 extra headers 构建 hook
- Codex openai:cli 注入 session_id + conversation_id(由 prompt_cache_key sha256 派生)
- Codex openai:compact 注入 chatgpt-account-id(来自 auth_config)+ session_id,不注入 conversation_id
- 修复 prompt_cache:compact 格式现统一为 codex 策略,不再跳过注入
- chat_handler_base / cli_request_mixin 均在 extra_headers 阶段调用 build_upstream_extra_headers
2026-03-18 20:43:14 +08:00
fawney19 203cd5a9d5 fix(redis): 按事件循环隔离 Redis 连接,防止子线程 asyncio.run 导致连接泄漏
RedisClientManager 新增 _redis_by_loop 字典,按 event loop id 维护独立连接,
避免 usage consumer 在 asyncio.to_thread + asyncio.run 场景下复用主循环连接。

同步重构 consumer_streams 的写库路径:_apply_record_event 统一走
record_usage_batch,_apply_streaming_event 改为 to_thread 执行同步 DB 操作,
移除冗余的 session 传递和手动 rollback 逻辑。
2026-03-18 17:56:48 +08:00
fawney19 696ec65175 refactor(normalizer): 移除 request key reorder 机制,保持自然插入顺序
移除 OpenAI/OpenAI CLI normalizer 中的 _reorder_request_prefix_keys 及
基类 _reorder_request_keys 死代码,request_from_internal 直接返回构建
顺序的 dict,测试同步更新为验证自然插入顺序。
2026-03-18 14:01:56 +08:00
fawney19 cbb66a5667 refactor(task): 引入 MutableRequestBodyState 替代 request_body_ref 字典容器
将请求体可变状态从 {"body": dict} 字典容器重构为独立的
MutableRequestBodyState 类,统一管理 original_body / current_body /
build_attempt_body / rectify 等语义,消除各层通过 ref["body"] 间接
读写的隐式约定。

- 新增 src/services/task/request_state.py 定义 Protocol 与实现
- handler/executor/mixin 层改用 request_state 参数传递
- error_handler/state_transition 通过 request_state 判断整流状态
- 新增 request_state 单元测试与 chat/cli 请求体隔离测试
2026-03-18 13:43:39 +08:00
fawney19 53ef35ec80 refactor(cli): 提取 _build_upstream_request 统一流式/非流式的上游请求构建逻辑
将 cli_stream_mixin 和 cli_sync_mixin 中重复的上游请求构建代码
(provider behavior / stream policy / envelope / auth / RequestBuilder / URL 构建)
提取到 cli_request_mixin._build_upstream_request,返回 CliUpstreamRequestResult dataclass。
2026-03-18 13:13:47 +08:00
fawney19 1af3067303 refactor(codex): 移除 envelope/request_patching 层,用 context var 统一 compact 状态判断
- 删除 CodexOAuthEnvelope 和 request_patching 模块,Codex 不再需要 envelope 层
- 移除 _aether_compact 请求体内部标记,改用 is_codex_compact_request() 集中查询
- 简化 OpenAI CLI adapter,移除 Codex 专用的 get_cli_extra_headers/build_test_request_body 逻辑
- 移除 Codex behavior variant 注册(same_format/cross_format)
- normalizer patch_same_format_request 对 codex 变为 no-op
- 更新相关测试适配新的架构
2026-03-18 12:35:57 +08:00
fawney19 d026398bab refactor(body-rules): 移除 protected_body_keys 机制,允许 body_rules 自由修改所有请求体字段
删除 get_cache_sensitive_protected_body_keys 函数及相关常量、_is_protected_path
辅助函数,从 apply_body_rules、RequestBuilder、ProviderRequestResult 等处移除
protected_body_keys 参数,更新所有调用点和测试用例。
2026-03-18 10:52:07 +08:00
fawney19 684689a82b fix(usage): session touch 独立提交避免行锁阻塞 & 管理员页面顺序加载降低并发压力
后端: 将 session touch 的 commit 从请求事务中分离,防止管理员 usage
页面的长查询持有 user_sessions 行锁阻塞后续请求。touch_session 改为
返回 bool 以支持按需提交。

前端: 管理员 Usage 页面将并行 API 调用改为顺序加载,优先显示记录表格,
统计面板在后台异步刷新,避免瞬时并发打满后端 worker。loadRecords 支持
传入 dateRange 参数确保时间范围一致性。
2026-03-18 00:13:28 +08:00
github-actions[bot] eeb5f41bad chore(proxy): update download links for proxy-v0.2.5 2026-03-17 14:23:27 +00:00
fawney19 7180eaea88 chore: bump aether-proxy version to 0.2.5 2026-03-17 22:16:47 +08:00
fawney19 7cb204f18a chore: bump aether-hub version to 0.2.0 2026-03-17 22:15:07 +08:00
fawney19 0342f609d0 feat(tunnel): 请求体流式传输 & OpenAI CLI 请求 key 排序优化
Hub 端:
- open_local_stream 不再接收 body 参数,改为通过 push_local_request_body 分块推送
- 请求体按 32KB 分帧发送,避免大请求一次性压缩和传输
- local_relay 改为流式解析 envelope 和转发请求体

Proxy 端:
- stream_handler 改为流式传输请求体到上游,不再预先收集完整 body
- upstream_client 请求体类型从 Full<Bytes> 改为 UnsyncBoxBody 以支持流式传输
- dispatcher 将 StreamEnd/StreamError 事件转发给 stream handler

Python 端:
- hub_transport relay envelope 改为异步生成器流式发送
- 提取 reorder_openai_cli_request_prefix_keys 为公共函数
- Codex passthrough 路径也应用稳定的前缀 key 排序
2026-03-17 22:07:09 +08:00
fawney19 59840fa419 feat(docker): 支持通过 GITHUB_MIRROR 参数加速 hub 二进制下载
Dockerfile.app.local 新增 GITHUB_MIRROR 构建参数,deploy.sh
新增 --mirror 选项,国内服务器可指定镜像代理地址加速下载。
2026-03-17 20:43:14 +08:00
fawney19 d390d46ee8 feat(docker): Dockerfile.app.local 支持本地 hub 二进制文件
将 aether-hub tar.gz 放到项目根目录即可跳过 GitHub 下载,
解决国内服务器构建时 GitHub 访问慢的问题。
2026-03-17 20:37:09 +08:00
fawney19 37eada9682 chore: bump aether-hub version to 0.1.9 2026-03-17 20:26:29 +08:00
fawney19 73a5325a38 refactor(hub): 用本地 HTTP relay 替代 Worker WebSocket 长连接
Hub 数据面改为 /local/relay/{node_id} HTTP 端点,Worker 通过本机
HTTP 请求转发 tunnel 帧,不再维护 /worker WebSocket 长连接。

Hub 侧:
- 新增 control_plane.rs: Hub 通过 HTTP 回调 Aether app 处理心跳 ACK 和节点状态变更
- 新增 local_relay.rs: 接收本地 HTTP 请求,在 Hub 内部打开 LocalStream 并透传到 proxy
- 移除 worker_conn.rs 及 Worker WebSocket 处理逻辑
- 简化 protocol.rs: 移除 NODE_STATUS 帧类型,抽取通用 encode_frame/decode_payload

Python 侧:
- 删除 tunnel_manager.py 及其 WebSocket 连接管理器 (HubConnectionManager)
- 简化 hub_transport.py 为 HTTP relay 调用
- 新增 src/api/internal/hub.py 接收 Hub 控制面回调 (heartbeat/node-status)
- hub_config.py 移除 WebSocket 相关配置,改为 HTTP relay URL
- service.py 新增 update_tunnel_status 方法
- 删除 src/api/admin/proxy_tunnel.py (旧管理接口)
- proxy_node 缓存 TTL 从 15s 降至 3s 加速状态感知
2026-03-17 20:22:12 +08:00
fawney19 460eb5434d fix(proxy-resolver): 将 resolve_ops_proxy_config 改为异步调用避免阻塞事件循环
在 anyrouter/nekocode/sub2api/yescode 架构及 service.py 中,
将同步的 resolve_ops_proxy_config 替换为 resolve_ops_proxy_config_async,
通过 asyncio.to_thread 包装避免同步 DB 查询阻塞事件循环。
2026-03-17 18:06:15 +08:00
fawney19 8a21cb9a55 chore: bump aether-hub version to 0.1.8 2026-03-17 17:32:01 +08:00
fawney19 d0df52ce35 fix(hub): worker 断连时向 proxy 端发送 STREAM_ERROR 防止流阻塞
将 unregister_worker 中的 stream 清理逻辑提取为 cancel_streams_for_worker
方法,与 cancel_streams_for_proxy 形成对称设计。worker 断连时主动通知
proxy 端终止相关 stream,防止 proxy 的 stream handler 永久阻塞等待请求体。
添加单元测试覆盖该场景。
2026-03-17 17:29:12 +08:00
fawney19 c1ed42fd3a feat(body-rules): 支持按 provider_type 覆盖 cache-sensitive 保护字段集合
Codex 通过 OpenAI CLI/Compact 格式转发时,endpoint body_rules 需要能
修改 instructions/input/tools 等 prompt 字段。新增 provider_type 维度的
保护集合映射,Codex 场景仅保护 prompt_cache_key,其余 prompt 字段交由
body_rules 自由调整。
2026-03-17 17:09:39 +08:00
fawney19andLewisPen c4bb6b8161 feat(auth): 重构认证系统,引入 session 会话管理
- 新增 user_sessions 数据库表及 Alembic 迁移
- 实现 SessionService 会话生命周期管理(创建/刷新/撤销/清理)
- 认证流程改用 refresh token cookie + access token 双令牌模式
- 前端实现自动静默刷新、跨标签页同步及设备指纹
- 用户设置页新增会话管理和密码修改功能
- 管理员用户管理新增强制登出和会话查看
- 密码策略增强,支持强度校验和泄露检测
- OAuth 登录流程适配新会话机制
- 新增完整的单元测试和 API 测试覆盖

Closes #232

Co-authored-by: LewisPen <[email protected]>
2026-03-17 16:34:09 +08:00
fawney19 d480aa11f3 fix(request-body): 使用 deepcopy 防止请求体在处理流程中被意外修改
handler 基类和格式转换 registry 中,原始请求体通过浅拷贝或直接引用传递,
导致下游处理(模型映射、格式转换、重试整流)可能修改原始数据,
影响后续重试或并发请求的正确性。统一改用 copy.deepcopy 隔离副本。
2026-03-17 13:23:02 +08:00
fawney19 d63d5eff85 fix(nginx): 统一所有 location 块的 CF 头剥离,补充 proxy_hide_header 防止响应泄露 2026-03-17 11:33:43 +08:00
fawney19 5dae2a4792 fix(normalizer): 移除 Claude system 中的 billing header 2026-03-17 11:10:04 +08:00
fawney19 dcbd7dc219 feat(normalizer): 稳定请求体字段顺序以提升 prompt cache 命中率
在 FormatNormalizer 基类新增 _reorder_request_keys 方法,OpenAI/OpenAI CLI
normalizer 各自定义前缀字段顺序(model, tools, messages / model, instructions,
tools, input),确保静态字段前置、动态内容后置。同时调整 OpenAI CLI 中
instructions 字段的构建位置使其在 input 之前。
2026-03-17 10:26:57 +08:00
fawney19 2dcf8b8414 refactor(prompt-cache): 移除 client_family 命名空间拆分,统一缓存 key 提升复用率
不再按 User-Agent 客户端类型拆分 prompt cache namespace,
所有客户端共享同一缓存 key,版本升级至 v3。
2026-03-17 09:49:35 +08:00
fawney19 438f16094f refactor(stream): 将不完整流 token 估算逻辑收敛到 StreamContext
- 新增 has_partial_response / ensure_estimated_output_tokens / should_estimate_incomplete_tokens 方法
- CancelledError 路径在归因前即补充 output_tokens,确保日志包含估算值
- CLI Handler 和 Chat Handler 的兜底估算统一使用 should_estimate_incomplete_tokens
- 移除 cli_monitor_mixin 和 stream_telemetry 中重复的条件判断
- 新增对应单元测试
2026-03-17 03:37:06 +08:00
fawney19 40e0b82fa0 fix(chat-handler): 修复 _prepare_provider_request 缺少 original_headers 参数导致的 NameError 2026-03-17 03:21:19 +08:00
fawney19 b9b0a75fe4 feat(cache-fingerprint): 新增字段级指纹,支持逐字段 sha256 和字节数追踪
在请求缓存指纹中为每个 cache_relevant 字段单独计算 sha256 和字节数,
便于精确定位哪些字段发生了变化。fingerprint version 升级为 2。
2026-03-17 02:57:36 +08:00
fawney19 b6cc0bc3a7 refactor(usage): 统一 cache token 提取逻辑,新增请求缓存指纹记录
- 新增 extract_cache_read_tokens() 兼容 OpenAI/Claude/Gemini 多种字段命名
- parsers/stream_processor/cli_event_mixin 统一使用提取函数替换内联逻辑
- 同时兼容 prompt_tokens/completion_tokens (OpenAI) 和 input_tokens/output_tokens (Claude)
- 新增 cache_fingerprint 模块,在 telemetry 记录时自动计算并附带请求缓存指纹
- 新增对应单元测试
2026-03-17 02:34:32 +08:00
fawney19 d2f1431269 refactor(prompt-cache): 将 prompt_cache_key 生成从 Codex 专用模块提取为通用服务,支持 OpenAI 官方 API 和 Codex 端点
- 新增 prompt_cache.py 统一管理 prompt cache key 的生成逻辑
- 基于 User-Agent 识别客户端家族(openai_python/openai_node/codex_desktop 等),不同客户端生成不同 cache key
- 在 chat_handler_base/cli_stream_mixin/cli_sync_mixin 统一调用 maybe_patch_request_with_prompt_cache_key
- Codex request_patching 不再负责 prompt cache key 注入,仅保留内部标记清理
- 新增 is_official_openai_api_url 工具函数区分 OpenAI 官方 API 与兼容端点
2026-03-17 01:55:24 +08:00
fawney19 4ecaefbade refactor(conversion): body_rules 保护 cache-sensitive 字段,normalizer 保真优化与诊断日志
- RequestBuilder 新增 protected_body_keys 机制,按 provider API 格式阻止 body_rules
  改写 prompt cache 相关的顶层请求字段(messages/tools/system 等)
- Claude/Gemini normalizer 优先复用原始 raw tool_choice,避免 round-trip 丢失信息
- Claude normalizer 修复 content blocks 输出顺序(flush_text_parts),
  _coerce_claude_message_sequence 返回结构化诊断
- OpenAI normalizer 保留 raw tool call arguments 字符串与原始 tool 定义 extra 字段
- schema_utils allOf 合并保持 required 字段插入顺序
- 各转换环节增加结构化 debug 日志用于调试
2026-03-17 01:25:29 +08:00
fawney19 c97c9332eb feat(codex): 基于用户 API key 生成稳定的 prompt_cache_key,实现跨 provider key 的 prompt 缓存复用
- prepare_context 传递 user_api_key_id 到 CodexRequestContext
- wrap_request 中调用 patch_openai_cli_request_for_codex 注入 prompt_cache_key
- 客户端已提供 prompt_cache_key 时不覆盖
- 补充对应单元测试
2026-03-16 17:25:52 +08:00
fawney19 c070e5a9f6 fix(conversion): OpenAI CLI 流式转换 tool 调用与文本输出 block index 不再复用
引入统一的 block index 分配器(_allocate_block_index / _ensure_text_block_index),
避免工具调用后紧跟文本输出时 Claude block index 冲突。
2026-03-16 16:19:27 +08:00
fawney19 8cd3a69803 fix(oauth): ACCOUNT_BLOCK 标记不再禁用 key,token 刷新成功自动解除所有 invalid 标记
- error_handler 标记 ACCOUNT_BLOCK 时保持 is_active=True,oauth_invalid 标记已
  足够阻止调度,配额刷新仍可覆盖该 key
- 移除 is_account_level_block 守卫:token 刷新成功即证明账号可用,清除所有
  oauth_invalid 标记(含 ACCOUNT_BLOCK)
- health_policy 401 瞬时失败不再设置 60s cooldown,仅清除 token 缓存后立即重试
- key_quota_service 全量刷新时纳入 ACCOUNT_BLOCK 的 key,账号恢复后可自动解除
- 各 refresher 刷新成功时显式恢复 is_active=True
2026-03-16 15:50:41 +08:00
fawney19 791c9c98dc refactor(conversion): OpenAI Chat/Responses API 跨格式字段双向转换统一化
- 将工具/tool_choice/web_search/custom tool 的双向转换函数提取到 constants.py,
  openai.py 和 openai_cli.py 共享,消除两端逻辑不一致
- 新增 Chat <-> Responses 的 passthrough 字段白名单,支持 metadata/user/
  service_tier/prompt_cache_key 等字段跨格式透传
- 支持 text config (response_format + verbosity) 在 Chat/Responses 间互转
- 修复 Gemini json_schema 解包:OpenAI 的 {name, schema, strict} 包装层
  不再被整体传入 Gemini responseSchema
- 修复 reasoning_effort 优先级:显式 effort 优先于 budget_tokens 反推,
  避免 Claude output_config.effort 被覆盖
- Gemini 格式输出增加 web_search_options -> googleSearch 工具映射
- Claude schema validator 增加 web_search 类型工具的宽松校验
- 新增覆盖测试:custom tool/tool_choice、allowed_tools、web_search 双向转换、
  text config 映射、passthrough 字段保留、跨格式 schema 校验
2026-03-16 14:21:14 +08:00
fawney19 025e979935 fix(codex): 配额刷新 401/403 不再自动禁用 key,区分软性请求失败与账户封禁
- 新增 OAUTH_REQUEST_FAILED_PREFIX 标记非 token 失效的 403 请求失败
- 引入 _merge_invalid_reason 合并逻辑,避免低优先级原因覆盖高优先级状态
- account_state 中 REFRESH_FAILED/REQUEST_FAILED 前缀不再触发封禁判定
- 401/403 返回 auto_disabled=False,不再直接设置 is_active=False
2026-03-16 01:12:35 +08:00
fawney19 131471a13f refactor(maintenance): body 压缩改为逐条独立事务,降低内存占用与锁粒度
将 _cleanup_body_fields 从批量加载完整记录改为先查询 ID 列表,
再逐条独立会话处理压缩,避免大批量事务导致的内存和锁问题。
批次大小上限从 100 降至 25,新增排序保证处理顺序确定性。
2026-03-15 23:46:01 +08:00
fawney19 7ff63077c3 feat(providers): provider 摘要列表排序增加启用状态优先
后端查询和前端展示均按 is_active 降序、priority 升序、created_at 升序排列,
确保已启用的 provider 始终排在前面。
2026-03-15 23:13:11 +08:00
fawney19 faba0cbd07 fix(openai-cli): item_id 与 call_id 不一致时 tool delta 映射错误
Responses API 中 function_call 的 item.id 和 call_id 可能不同,
增加别名注册和解析机制,确保后续 delta/done 事件统一使用 call_id。
2026-03-15 22:50:50 +08:00
fawney19 75f17935f9 fix(openai-cli): 补全 function_call 的 arguments 快照同步,修复无 delta 时参数丢失
在 output_item.done 和 function_call_arguments.done 事件中,通过 args snapshot
对比已发送的增量,补发缺失的 tool arguments delta,确保客户端收到完整参数。
2026-03-15 22:20:54 +08:00
fawney19 60842fbbb5 fix(openai): tool_call delta 重复携带 function.name,增强严格客户端兼容性
在 ToolCallStart 时记录 block_index 到 tool_name 的映射,
ToolCallDelta 时同步输出 function.name,避免严格客户端丢弃无 name 的 delta。
提取 _ss_dict 辅助方法统一 stream state 中 dict 字段的初始化逻辑。
2026-03-15 22:04:21 +08:00
fawney19 d58c27d22d fix(openai): tool_call delta 重复携带 id/type,修复严格客户端兼容性
部分 OpenAI 兼容客户端要求每个 tool_call delta chunk 都包含 id 和 type
字段,否则会将后续 delta 视为无效。通过 block_to_tool_id 映射在
ContentBlockStart 时记录 tool_id,确保后续 ToolCallDelta 能正确回填。
2026-03-15 21:51:44 +08:00
fawney19 65550159bb refactor(frontend): 优化条件编辑器 UI,限制嵌套深度为两层
- 顶层组合条件增加 ListFilter 图标按钮,可转回单条件
- 子组隐藏"+ 子组"按钮,防止无意义的深层嵌套
- 嵌套叶子节点隐藏转组合按钮,保持最多两层结构
2026-03-15 21:02:05 +08:00
fawney19 3c7ad81d62 feat(rules): 条件系统增强,支持 all/any 组合条件和 original/current 数据源切换
- evaluate_condition 支持递归 all/any 组合节点和 source 字段
- header_rules 支持 condition 条件触发,HeaderBuilder.apply_rules 透传 body/original_body
- 提取 EndpointConditionEditor 组件统一请求头/请求体规则的条件编辑 UI
- header_rules 新增服务端结构校验(action/key/from/to/condition)
- 新增组合条件、source 切换、fail-closed 等测试用例
2026-03-15 20:27:53 +08:00
fawney19 6b23c9b3ce fix(frontend): 使用记录表格"命中率"列名改为"缓存命中率" 2026-03-15 18:32:42 +08:00
fawney19 900e54d740 feat(conversion): 支持 Claude output_config.effort 跨格式转换,新增 xhigh 档位
- 新增 Claude output_config.effort 与标准化 reasoning_effort 的双向映射
- 新增 xhigh 档位(budget_tokens=8192),对应 Claude effort=max
- reasoning_effort 独立于 thinking 存入 extra,支持无 thinking 场景的跨格式传递
- OpenAI/Responses API 输出时 xhigh 自动降级为 high
2026-03-15 17:57:12 +08:00
fawney19 8cc70934da fix(openai,oauth): 修复 resp_ ID 前缀转换和 token 失效误判为账号级 block
- OpenAI normalizer: resp_ 前缀 ID 规范化为 chatcmpl- 前缀,流式 tool_call index 增加 block_index 回落
- OAuth token: 提取 token 失效关键词为共享常量,token invalidated 不再被判定为账号级 block
- Codex refresher: 403 + token invalidated 标记为 OAUTH_EXPIRED,允许 refresh_token 恢复
2026-03-15 16:56:58 +08:00
fawney19andLewisPen f92b0943b5 feat(rate-limit): 实现分层 RPM 限速,支持系统默认/用户/独立Key三级配置
- 新增用户级 rate_limit 字段,支持系统默认/用户自定义/不限制三种模式
- 独立 Key 的 rate_limit 语义调整:null=跟随系统默认,0=不限制,>0=自定义
- 实现 UserRpmLimiter 基于 Redis sliding window 的 RPM 限速引擎
- Pipeline 请求流程集成用户级 RPM 检查
- 管理后台和用户面板新增 RPM 限速配置与实时状态查看
- 系统设置新增全局默认 RPM 配置项
- 迁移脚本回填现有 API Key 的 rate_limit 默认值
- 新增用户/Key RPM 状态监控 API 和前端展示

Closes #231

Co-authored-by: LewisPen <[email protected]>
2026-03-15 14:22:59 +08:00
fawney19 920a383136 refactor(claude-code): 移除 TLS 指纹模拟配置,简化上下文构建逻辑
移除 enable_tls_fingerprint 配置项、TLS_PROFILE_CLAUDE_CODE 常量及
resolve_claude_code_tls_profile 函数,TLS profile 改为仅由 fingerprint
的 impersonate 字段决定,简化 build_and_set_claude_code_request_context
返回值为单一 context 对象。
2026-03-15 00:23:19 +08:00
fawney19 693e37d2df fix(frontend): 统计表格缓存列仅显示缓存读取token,与命中率计算口径一致
缓存列移除cache_creation_tokens,只保留cache_read_tokens,
避免缓存值大于输入值的反直觉展示。
2026-03-14 16:39:48 +08:00
fawney19 9c6036a103 fix(frontend): 统计表格token列防止换行,缓存token合并为总值显示 2026-03-14 16:26:07 +08:00
fawney19 751a4d9111 feat(usage): 统计表格新增输入/输出与缓存创建token细分展示
- 后端接口(admin/user_me/query)新增 output_tokens、cache_creation_tokens、total_input_context 字段返回
- 前端统计表格(模型/提供商/API格式)合并 Tokens 列为"输入/输出"+"缓存读取/创建"两行紧凑布局
- 合并"缓存Token"和"缓存命中率"为单一"命中率"列,减少表格宽度
- 修正 UsageRecordsTable 缩进及模板格式
2026-03-14 16:11:56 +08:00
fawney19 aafd332198 revert(frontend): 使用记录表格恢复格式与类型为独立列 2026-03-14 15:01:38 +08:00
fawney19 337cd0c505 fix(frontend): 修复模型缓存价格设为0时不生效的问题
缓存价格更新函数使用 numValue > 0 判断是否手动设置,
导致输入 0 时被错误地当作清空处理。改为检查原始输入
是否为空字符串/null/undefined 来区分清空与有效输入。
2026-03-14 14:32:58 +08:00
fawney19 b15ce9977a feat(frontend): 用量页面 UI 优化 - 合并格式/类型列、分析面板可折叠
- UsageRecordsTable: 合并 API 格式与类型为单列,缩减列宽使表格更紧凑
- MainLayout: 添加 header 级 Teleport 插入点供子页面注入操作按钮
- Usage: 用量分析面板改为可折叠,折叠状态持久化到 localStorage
2026-03-14 13:28:56 +08:00
fawney19andAAEE86 e0286aebe3 refactor: 共享请求管道、按需懒加载、流式内存护栏与连接池治理
- 抽取 ApiRequestPipeline 单例,44 个路由文件共享同一实例
- Handler/Adapter 模块级 __getattr__ 延迟导入,减少启动时间
- 新增 ensure_stream_buffer_limit() 流式内存护栏(16MB 单行 / 32MB 总量)
- HTTP 空闲连接清理与 curl_cffi LRU 会话池
- ensure_providers_bootstrapped 按需引导指定 provider_types
- Usage 事件序列化迁移至 msgpack,Redis codec 隔离
- 启动预热任务(/readyz 就绪门控)与优雅关闭
- 通知邮件模块独立开关与 SMTP 配置校验
- CryptoService DCL 线程安全修复
- 通知模块开关 DB 查询 30s 内存缓存
- /readyz 对 unknown 状态返回 503
- 预热关闭 5s 超时保护
- 预热适配器逐个 try-except 容错
- FormatConversionRegistry 哨兵模式防并发重复物化
- 流式缓冲检查无条件执行

Closes #230

Co-authored-by: AAEE86 <[email protected]>
2026-03-14 11:59:07 +08:00
fawney19 45985f1c04 Merge pull request #228 from NyaDoo/fix/config-timezone-and-key-dialog
fix: APP_TIMEZONE 循环导入 & 独立密钥额度编辑空白
2026-03-14 01:42:28 +08:00
fawney19andEntropy-Xu 776dd2f8ea feat(cleanup): 解耦 request_candidates 与 provider_api_keys 生命周期
- 移除 request_candidates.key_id 对 provider_api_keys 的外键约束(含迁移脚本)
- 删除 Key 时不再级联删除候选记录,改为独立按保留天数定时清理
- 新增 request_candidates_retention_days / request_candidates_cleanup_batch_size 配置项
- batch_delete_task 增加 lock_timeout 及超时自动降批重试机制
- cleanup_key_references 提取阶段化清理流程,移除 RequestCandidate 联动删除
- 前端 CleanupPolicySection 新增候选记录保留天数和清理批次配置

Closes #227

Co-authored-by: Entropy-Xu <[email protected]>
2026-03-14 01:33:08 +08:00
fawney19andAAEE86 bdfe4adc98 feat(usage): 修复缓存命中率计算并新增用户端 API 格式统计
- 新增 input_context_expr() 按 api_format 区分 input_tokens 语义
  (OpenAI/Gemini input_tokens 已含 cache_read,Claude 需额外加上)
- 缓存命中率统一改为基于归一化后的 total_input_context 计算
- 用户 /me/usage 接口新增 summary_by_api_format 后端聚合字段
- 前端 API 格式统计改用后端聚合数据,移除前端逐条记录手动统计
- 提取 formatHitRate 到 utils/format.ts 消除三处重复定义
- 移除 PoolManager 中未使用的 select_key 方法

Co-Authored-By: AAEE86 <[email protected]>
2026-03-14 00:33:19 +08:00
LewisPen 00a0371997 fix(frontend): 独立密钥编辑模式额度区域空白问题
v-else-if 条件遗漏导致编辑模式下非 unlimited 的密钥
额度区域既不显示 Input 也不显示提示文本,对齐 UserFormDialog
的 v-else 逻辑。
2026-03-13 10:58:34 +08:00
LewisPen ded6b5b081 fix(config): 统一 APP_TIMEZONE 至 Config 类,修复 wallet 循环导入
将散落在 scheduler / stats_aggregator / daily_usage_ledger / routes
中的 os.getenv("APP_TIMEZONE") 收归 Config.app_timezone,消除
wallet → system → maintenance_scheduler → user.preference → wallet
的循环导入链。
2026-03-13 10:54:21 +08:00
fawney19 e9678ea899 fix(admin): 优先级脏检查、base_url 校验、usage detail 延迟加载及导入数据验证
- 前端优先级管理: 保存时对比原始快照,仅提交实际变更的 provider/key 优先级,
  并限制并发请求数(SAVE_CONCURRENCY=6),避免无效 API 调用
- handler_adapter_base: _normalize_test_base_url 改为 _validate_test_base_url,
  移除对 dict 类型 base_url 的兼容,严格要求字符串输入
- provider_query: 新增 _require_test_endpoint_base_url,在测试链路提前校验
  endpoint.base_url 类型和非空
- system.py: 导入 endpoint 时通过 ProviderEndpointCreate 模型校验数据,
  拒绝非法 base_url 类型(如 dict)
- usage detail: 使用 defer() 延迟加载 body 列,通过 SQL CASE 表达式在
  数据库端计算 has_*_body 标记,减少不必要的大字段传输
- provider routes: 新建 provider 时 priority=0 边界处理,clamp 并 shift
2026-03-12 17:11:14 +08:00
fawney19 8d69f72e2a fix(stability): 健康监控 DB 操作异步化,防止 worker 超时崩溃
- executor: record_success 通过 asyncio.to_thread offload 到线程池
- error_handler: 3 处 record_failure 同样 offload 到线程池
- sync_execute: 设置 expire_on_commit=False 防止 commit 后 ORM 懒加载
- handler_adapter_base: 归一化 check_endpoint 的 base_url 输入
2026-03-12 16:17:22 +08:00
fawney19 127b4e11de ci(hub): 移除 build-hub workflow 中的 Docker 构建和推送步骤 2026-03-12 15:34:09 +08:00
fawney19 22093bed4d chore: bump aether-hub version to 0.1.7 2026-03-12 15:21:47 +08:00
fawney19 ebd53ad679 fix(hub): 修复 worker 连接清理逻辑,按完成顺序有序取消对端任务 2026-03-12 15:17:00 +08:00
fawney19 280c604327 移除deplpy.sh中每次自动拉取最新代码, 以便于回退版本 2026-03-12 14:57:15 +08:00
fawney19 ad31cdbf85 perf(stability): batch committer 异步化及降级冷却参数调优
- 将 batch_committer 的 DB commit 操作通过 asyncio.to_thread 移至线程池,避免阻塞事件循环
- 将 f-string 日志替换为 loguru 惰性格式化
- 降低事件循环延迟降级的冷却时间和乘数,加快降级恢复
2026-03-12 14:22:55 +08:00
fawney19 0112ab752b refactor(proxy): 将 proxy resolver 同步阻塞操作异步化,避免阻塞事件循环
- 为 resolve_proxy_info、resolve_delegate_config、build_proxy_url、
  get_system_proxy_config、build_post_kwargs、build_stream_kwargs 新增
  _async 异步版本,通过 asyncio.to_thread 在工作线程中执行同步 DB 查询
- 为 _proxy_node_cache 和 _system_proxy_cache 添加 threading.Lock 保护
  多线程并发读写安全
- 大 payload 的 gzip 压缩超过 64KB 阈值时走线程池,小 payload 仍在事件
  循环中同步执行以避免不必要的线程调度开销
- hub_transport 的 frame 压缩同样增加异步版本
- 删除已无调用者的同步方法 create_client_with_proxy,将其逻辑内联至
  get_upstream_client 并改为异步
- 更新所有 handler/executor/failover 调用点使用新的异步 API
- 补充 async 版本的单元测试
2026-03-12 13:59:21 +08:00
fawney19 66fec80e79 fix(frontend): 修复用户表单密码框无法输入及编辑模式额度框不显示
- 密码框: masked 属性改为始终开启,避免聚焦时 DOM 重建导致焦点丢失
- 额度框: 编辑模式下 unlimited=false 时显示"按钱包余额限制"而非空白
2026-03-12 13:31:43 +08:00
fawney19 fddfaecf5e chore: bump aether-hub version to 0.1.6 2026-03-12 12:21:52 +08:00
fawney19 71ae1a2307 feat(hub,stability): bounded outbound queue、worker liveness 检测、事件循环 watchdog 及 DB 操作异步化
- aether-hub: unbounded channel 改为 bounded channel (BoundedOutbound),队列满时标记拥塞并主动关闭连接,防止内存无限增长
- aether-hub: worker idle timeout 从命令行参数改为基于心跳的 liveness 检测,默认 60 秒
- aether-hub: 新增 ConnConfig 统一管理连接配置,新增 outbound_queue_capacity 参数
- hub_transport: 新增事件循环 watchdog,检测 lag 超过阈值时临时降级暂停新流
- gunicorn_conf: 启用 faulthandler,worker abort 时自动 dump 全部线程栈用于诊断
- health/endpoint_checker/recording: 同步 DB 操作移至 asyncio.to_thread,避免阻塞事件循环
- Dockerfile: 移除 --worker-idle-timeout 0 命令行参数,改由环境变量和默认值控制
2026-03-12 12:19:04 +08:00
fawney19 4d338ebd3d fix(priority): 允许号池聚合项在全局 Key 优先级管理中拖拽和编辑排序
之前号池(Pool)类型的 Key 在优先级管理对话框中被禁止拖拽和编辑优先级,
用户只能去号池高级设置中单独修改 global_priority,体验不一致。

现在号池聚合项可以像普通 Key 一样参与拖拽排序和点击编辑优先级,
变更会同步到 provider.pool_advanced.global_priority 并在保存时持久化。
2026-03-12 11:44:19 +08:00
fawney19 dc440f1507 fix(ci): 修正 deploy-pages action 版本号 (deploy-pages@v4, upload-pages-artifact@v3) 2026-03-12 10:41:26 +08:00
fawney19 5c732f844a chore(ci): 升级 GitHub Actions 版本,解决 Node.js 20 弃用警告
- actions/checkout v4 → v5
- actions/upload-artifact v4 → v5
- actions/download-artifact v4 → v5
- actions/setup-node v4 → v5
- actions/configure-pages v4 → v5
- actions/upload-pages-artifact v3 → v4
- actions/deploy-pages v4 → v5
- docker/build-push-action v5 → v6
- Node.js 版本 20 → 22
2026-03-12 10:34:29 +08:00
fawney19 c4044ba0b1 chore: bump aether-hub version to 0.1.5 2026-03-12 10:20:42 +08:00
fawney19 a8159b7bda fix(tunnel): 禁用 idle timeout 默认值,防止空闲 tunnel 连接被断开
Hub 端 proxy_idle_timeout 和 worker_idle_timeout 默认改为 0(禁用),
直连模式 proxy_tunnel.py 同步调整。连接存活检测依赖 PING/PONG 心跳。
2026-03-12 10:07:41 +08:00
fawney19 0ab20be667 fix(proxy): 抑制 tunnel/hub 高频重复日志,增加快速断开退避机制
- proxy_tunnel: idle timeout 日志首次 warning,后续降级为 debug/info 汇总
- hub_transport: 连续断开日志降级,追踪快速断开并调整重连初始 delay
- tunnel_manager: connect/disconnect 日志按 reconnect 计数分级输出
2026-03-12 09:56:34 +08:00
fawney19 3f048d373f refactor: 将路由层同步 DB 操作移至线程池执行,统一认证工具函数
- 管理端和用户端路由中的同步数据库操作提取为独立函数,通过 run_in_threadpool
  在线程池中执行,避免阻塞事件循环(涉及 api_keys、payments、users、wallets、
  provider_oauth、system、user_me、wallet 等模块)
- 抽取 authenticate_user_from_bearer_token 统一 token 验证逻辑,支持
  ManagementToken 和 JWT 两种认证方式,消除多处重复代码
- key_command_service 的 CRUD 操作改为线程池执行
- maintenance_scheduler 定时任务中的数据库操作改用 asyncio.to_thread
- Dockerfile 中 aether-hub 增加 --worker-idle-timeout 0 防止空闲断连
- 新增 test_api_auth_conventions 和 test_auth_utils 单元测试
2026-03-12 09:33:24 +08:00
fawney19 8b49a3d264 fix(frontend): 优化用户表单密码输入框,使用 masked 和 disable-autofill 属性简化实现 2026-03-12 01:46:41 +08:00
fawney19 6e51a3f45d feat: Provider 异步删除、可配置密码策略、Hub 超时优化及多项改进
- 新增 Provider 异步删除任务系统,后台分阶段删除子资源并清理残留引用
- 新增可配置密码策略等级(weak/medium/strong),支持系统设置面板调整
- aether-hub 升级至 0.1.4,idle timeout 支持禁用(设为 0),worker 默认超时调整为 120s
- OAuth 手动续期增加 Redis 分布式锁,防止并发刷新冲突
- ProxyNode 心跳检测改为 asyncio.to_thread,避免阻塞事件循环
- 删除 ModelMultiSelect 和 useInvalidModels,MultiSelect 组件通用化
- 明确 allowed_providers/allowed_api_formats 的 NULL 与空数组语义
- 前端 StandaloneKeyFormDialog、UserFormDialog 等多处 UI 优化
- 新增 Alembic 迁移脚本清理 Provider 删除后的残留引用
- 补充相关测试用例
2026-03-12 01:11:35 +08:00
fawney19 0d770d1c4d feat(oauth): 新增 Codex account_user_id 和 organizations 字段采集、展示与判重
- 从 Codex id_token claims 和 token_response 中提取 account_user_id 和 organizations
- OAuth 判重逻辑改为优先按 account_user_id 匹配,支持同用户不同 Team 不误判
- 号池和 Provider 详情页展示组织标签、account ID 和 account_user_id
- 前端重复的 OAuth identity 工具函数提取到 utils/oauthIdentity.ts
- 后端重复的 normalize_oauth_organizations 提取到 core/provider_oauth_utils.py
2026-03-11 21:37:08 +08:00
fawney19 b45f021bba feat(pool): 批量操作对话框改为服务端分页筛选,新增凭据导出功能
- 批量操作对话框从全量加载改为服务端分页+筛选,支持搜索和快捷选择器的服务端过滤
- 新增 resolve-selection API,支持"全选筛选结果"时解析完整匹配列表
- 新增批量导出凭据功能(仅 OAuth 账号),并发下载后导出为 JSON 文件
- 将快捷选择器和全文搜索的匹配逻辑从前端迁移到后端,统一复用
- 提取 pool key 序列化与过滤的公共函数,消除 AdminListPoolKeysAdapter 中的重复代码
- entrypoint.sh 增加 PostgreSQL 就绪等待,避免数据库未启动时迁移失败
2026-03-11 19:32:19 +08:00
fawney19 1a1bce3e8c Merge pull request #224 from AAEE86/fix/pool-last-used-at-display
fix(usage,pool): 修复号池最后使用时间不更新
2026-03-11 16:33:36 +08:00
fawney19 6aeb5d40ab Merge pull request #222 from AAEE86/fix/provider-mapping
fix(provider-mapping): 修复详情页映射延迟刷新并补齐 mapping-preview 缓存治理
2026-03-11 16:17:48 +08:00
AAEE86 31ef2d134e fix(usage,pool): 修复号池最后使用时间不更新
统一在 ProviderAPIKey 累计更新时刷新 last_used_at/updated_at,即使 token/cost 增量为 0。

补充回归测试:覆盖 zero delta 场景和 provider_api_key_id 为空时跳过更新。
2026-03-11 16:12:36 +08:00
fawney19 85b50e67e1 Merge pull request #221 from AAEE86/fix/frontend
fix(frontend): 修复健康监控中 OpenAI Compact 百分比换行
2026-03-11 16:02:38 +08:00
fawney19 9353f89af0 Merge pull request #220 from AAEE86/master
fix(pool): recent_refresh 按 provider_type 解析 reset_seconds 并锁定 Codex 周重置语义
2026-03-11 16:02:14 +08:00
fawney19andAAEE86 380d69e096 feat(pool): 记录并展示 Provider Key 累计 Token 与费用
Closes #219

Co-authored-by: AAEE86 <[email protected]>
2026-03-11 15:56:58 +08:00
fawney19 02e2f4f500 fix: 修复钱包迁移脚本 _to_decimal 遇到 NaN/Infinity 值时的 InvalidOperation 异常 2026-03-11 15:22:27 +08:00
fawney19 0dbfefa834 Merge branch 'feat/wallet-billing-state-machine-and-daily-usage' 2026-03-11 15:15:01 +08:00
fawney19andLewisPen 04ab4bd9f2 feat: 强化用量计费状态机,新增钱包每日消费汇总分类账
- 将 usage.billing_status 默认值从 settled 改为 pending,完善
  pending -> settled/void 的状态转换逻辑,确保终态不可逆
- 新增 WalletDailyUsageLedger 模型和聚合服务,按账单日汇总
  每个钱包的消费金额、请求数和 token 用量
- 前端钱包中心页面集成每日消费流水展示,支持与充值记录混合
  排序和分页
- 新增两个数据库迁移:修复历史数据状态一致性、创建每日汇总表
- 补充计费状态机单元测试

Closes #218

Co-authored-by: LewisPen <[email protected]>
2026-03-11 15:11:33 +08:00
AAEE86 4955166b85 fix(provider-mapping): 修复详情页映射延迟刷新并补齐 mapping-preview 缓存治理
- 前端:Provider 详情页在 key/模型关联/模型保存/映射保存后并行刷新 endpoints 与 mapping-preview
- 前端:为 mapping-preview 请求增加 requestId 保护,避免旧响应回写覆盖新状态
- 后端:Key.allowed_models 变更时按 provider 精准失效 mapping-preview 缓存
- 后端:GlobalModel 变更(含创建)时全量失效 mapping-preview 缓存
- 监控:在 Redis 缓存分类中新增 provider_mapping_preview(admin:providers:mapping-preview:*)

验证:
- frontend `npm run type-check`
- backend `python -m compileall`(相关文件)
- pytest 定向用例通过
2026-03-11 09:58:25 +08:00
fawney19 6235c772ac fix: 修正 PR #217 合并后的两个细节问题
- user_me usage 接口文档注释补充 summary_by_provider 字段说明
- 导出配置中 internal_priority 为 NULL 时映射为 inf,保持与原 SQL NULLS LAST 一致
2026-03-10 23:33:02 +08:00
fawney19 e2ec3f7942 Merge pull request #217 from AAEE86/1233
perf: 优化请求鉴权链路并批量化统计/调度查询
2026-03-10 23:31:28 +08:00
fawney19andEntropy.Xu a816235efb feat: 新增 Gemini CLI provider adapter
Closes #216

Co-authored-by: Entropy.Xu <[email protected]>
2026-03-10 23:15:05 +08:00
fawney19andEntropy.Xu 1e39ab3c2e feat: 新增 Gemini CLI provider adapter
- 新增 gemini_cli adapter 包(client/constants/envelope/plugin/quota)
- 实现 v1internal 协议封装、OAuth enrichment、loadCodeAssist/onboardUser 流程
- 实现配额耗尽检测与冷却元数据管理(RESOURCE_EXHAUSTED 解析)
- 新增 GeminiCliQuotaReader 支持按模型粒度的配额展示
- endpoint check 支持流式回退和 v1internal 响应解包
- health_policy 429 处理增加 Google 配额冷却解析
- error_handler 在限流时同步 Gemini CLI 配额状态
- 前端添加 Gemini CLI provider 类型选项和 OAuth 图标
- 新增 preset models(gemini-2.5-pro/flash, gemini-3-pro/flash, gemini-3.1-pro)
- 新增 gemini_cli quota 单元测试

Closes #216

Co-authored-by: Entropy.Xu <[email protected]>
2026-03-10 23:14:05 +08:00
fawney19 85aa66c76d refactor: 优化 pending 清理和压缩任务的内存使用
- 将 pending 请求清理改为分批处理,使用轻量列查询代替全量 ORM 加载
- 限制 pending 清理和历史压缩的批次大小上限,防止单次查询占用过多内存
- 移除 processed_ids 集合,改用 synchronize_session=False 避免内存累积
- 更新 README 中部署方式描述
2026-03-10 18:11:08 +08:00
AAEE86 9a4817faf8 fix(frontend): 修复健康监控中 OpenAI Compact 百分比换行
- 调整 HealthMonitorCard 左侧信息区宽度(sm:w-44 -> sm:w-52)
- 为 API 格式与成功率 Badge 添加 whitespace-nowrap,避免文本断行
2026-03-10 16:50:26 +08:00
fawney19 7b0c80a0c4 refactor: 增加内存保护措施,精简启动任务
- 流式响应不再存储完整文本,仅记录长度用于 token 估算
- complete_response 文本累积增加 64KB 上限保护
- 通知缓冲区(email/webhook)增加溢出保护,超限丢弃旧通知
- 熔断器淘汰策略改进,全部 open/half-open 时按最旧失败时间淘汰
- 移除启动时清理任务和统计聚合回填,减少启动负担
- token 估算简化为基于长度的方法,避免持有完整文本
2026-03-10 16:08:11 +08:00
fawney19 6ec8df97e8 refactor: 限制流式文本收集内存增长,降低默认连接池和缓存上限
- StreamContext.append_text 增加 16KB 上限,超出后仅计数不存储,
  避免长流式响应导致内存持续增长;token 估算改用 collected_text_length
- 降低 DB 连接池上限 (30->15) 和 HTTP 连接池上限 (200->100)
- tiktoken 编码器缓存从 32 缩减到 4(实际编码种类只有几种)
- dev.sh 添加开发环境低配连接池默认值,uvicorn 热重载仅监视 src 目录
2026-03-10 15:33:46 +08:00
AAEE86 f82964217e fix(pool): recent_refresh 按 provider_type 解析 reset_seconds 并锁定 Codex 周重置语义
- 为 `extract_reset_seconds` 增加 `provider_type` 解析链路(显式参数 -> key.provider_type -> key.provider.provider_type -> metadata 推断)。
- Codex 场景固定读取 `primary_reset_seconds`(周限额),避免误用 `secondary_reset_seconds`(5 小时窗口);Kiro/Antigravity 继续走统一 quota reader。
- 在 `RecentRefreshDimension` 和 `PoolManager` 的策略上下文中透传 `provider_type`,并在缺失时从 key provider 回退推断。
- 新增/补强测试覆盖:
  - multi_score `recent_refresh` 在 Codex 下按 weekly reset 排序;
  - quota reader helper 在 Codex provider 下返回 primary reset;
  - Codex 付费计划(plus/enterprise)在 headers 与 WHAM 响应中的窗口映射与主次窗口对齐(10080/300 分钟)。

降低 recent_refresh 评分偏差风险,并为 Codex 付费窗口解析提供回归保护。
2026-03-10 14:55:48 +08:00
fawney19 cfa5535f6e refactor: 引入 safe_create_task 防止后台任务被 GC 回收,降低默认连接池和 worker 数量
- 新增 safe_create_task 统一替代裸 asyncio.create_task,通过全局集合持有 task 引用
- 默认 worker 数量从 4 降为 1,HTTP 连接池总预算从 800 降为 200
- 为 health_cache 和 affinity_manager 内存缓存增加上限淘汰机制
- MemoryCachePlugin 支持延迟启动清理任务
- gunicorn when_ready 增加 gc.collect() 并记录 post_worker_init RSS
2026-03-10 14:42:33 +08:00
fawney19 2d846b2c58 refactor: 移除启动缓存预热功能
删除 cache_warmup.py 及相关配置项和测试,简化启动流程
2026-03-10 14:11:41 +08:00
fawney19 f40e8037dd feat: 添加启动任务开关,修复统计聚合内存泄漏
- 新增 CACHE_WARMUP_ENABLED 和 MAINTENANCE_STARTUP_TASKS_ENABLED 环境变量,
  允许禁用缓存预热和维护调度器启动任务
- 在统计聚合批量处理循环中添加 db.expunge_all(),释放 Session identity map,
  防止 ORM 对象累积导致内存暴涨
- 添加启动任务开关的单元测试
2026-03-10 13:42:38 +08:00
fawney19 2b21a75982 refactor: 优化模型获取调度器并发模型和缓存格式
- 用固定 worker 数的队列消费模式替换 Semaphore+gather,避免大号池一次性创建大量协程
- 分批扫描 Key ID(keyset pagination),避免一次性加载全量 ID
- 上游模型缓存从 per-api_format 去重改为 per-model-id 聚合 api_formats 列表,减少 Redis 占用
- 启动阶段支持环境变量控制(MODEL_FETCH_STARTUP_ENABLED / MODEL_FETCH_STARTUP_DELAY_SECONDS)
- 新增单元测试覆盖聚合逻辑和并发上限验证
2026-03-10 12:47:50 +08:00
fawney19 9ee27308db refactor: defer ProviderAPIKey 大 JSON 字段,减少调度器和模型获取的内存占用
- candidate_builder: defer adjustment_history/utilization_samples/upstream_metadata,
  号池路径在释放 DB 连接前预计算 _pool_account_state 避免后续 N+1 查询
- pool/manager: 优先读取预计算的 _pool_account_state,fallback 到实时解析
- fetch_scheduler: 三处 ProviderAPIKey 查询改用 defer/load_only 排除无关字段
2026-03-10 12:33:15 +08:00
fawney19 1518223de6 refactor: 优化调度器内存占用,增加 session 全局清理
- main: 启动阶段 Provider 查询改用聚合查询,避免全量加载 ORM 对象
- envelope: Claude Code session 增加全局定时清理,防止 scope_key 无界增长
- health_cache: 增量更新时清理已移除的 stale key 条目
- aware_scheduler: 日志中用 key.name 替代 api_key 后四位,避免泄露凭证片段
- candidate_builder: defer api_key/auth_config 等冷字段,减少调度热路径内存占用
2026-03-10 11:40:29 +08:00
fawney19 3063938a82 refactor: 优化调度器并发与内存占用,修复循环依赖
- fetch_scheduler: 模型获取从串行改为 Semaphore 限并发并行
- pool_quota_probe_scheduler: 取消启动时立即探测避免阻塞,
  改为逐 provider 独立查询 key 避免一次性加载全部到内存,
  移除未使用的 _ProviderProbeTask 数据类
- proxy_node/__init__: 移除 health_scheduler 导入避免循环依赖
2026-03-10 11:22:11 +08:00
fawney19 86449cae52 refactor: 迁移文件内联 helpers,删除共享 alembic/helpers.py
将 _SchemaCache、replace_fk_if_needed、batch_alter_type 等辅助函数
内联到各迁移文件中,使每个迁移自包含、不依赖外部模块。
同时修正 a3f1b7c9d2e4 的 down_revision 为 d7649c1f8e21。
2026-03-10 10:53:41 +08:00
fawney19 7c580e843f fix: 治理 Prometheus 指标基数爆炸和内存缓存无界增长
- 移除 token/latency Prometheus 指标的 model 标签,避免 provider x model 笛卡尔积
- HealthMonitor 滑动窗口从 DB JSON 迁移至进程内存,减少写放大
- ModelCostService 三层缓存增加 500 条上限,超限时清空
- StickyPriority 粘性缓存和健康状态字典增加容量淘汰
- AffinityManager 请求锁字典增加 500 条上限,淘汰空闲锁
- 配额刷新/探测查询使用 defer/load_only 避免加载大 JSON 列
- Alembic 迁移清理 DB 中遗留的 request_results_window 数据
- 同步更新测试适配 batch_get_cooldowns 返回值和批量删除异步化
2026-03-10 10:40:50 +08:00
fawney19 c9f0685b40 fix: 治理 Prometheus 指标基数爆炸和内存缓存无界增长
- 删除高基数标签 key_id/model,移除未使用的指标 concurrency_slots_in_use/streaming_request_duration_seconds
- HTTP 客户端池:命名客户端添加 LRU 淘汰上限,tunnel 客户端添加 LRU 淘汰和 last_used_time 追踪
- ResilienceManager:last_errors 改用 deque(maxlen),error_stats/circuit_breakers 添加上限淘汰
- HealthMonitor:_circuit_history 改用 deque(maxlen)
- ProviderHealthTracker:清理已无记录的过期 key
- PrometheusPlugin:动态指标数量添加上限,超限后拒绝创建
- 中间件使用路由模板替代实际路径,防止动态路径段导致标签爆炸
2026-03-10 09:52:21 +08:00
fawney19 afd0dcf2ff feat: OAuth 导入完成后自动触发配额刷新,统一配额常量定义
- 单个导入、批量导入、Kiro device flow 完成后后台异步刷新配额
- 将 CODEX_WHAM_USAGE_URL 和 QUOTA_REFRESH_PROVIDER_TYPES 统一到 key_quota_service.py,消除 3 处重复硬编码
2026-03-10 09:36:47 +08:00
AAEE86 68d4df71d8 perf(frontend): 优化图表更新链路与缓存监控倒计时开销
- 收敛 LineChart 的配置构建逻辑,复用 options 生成函数
- 将 LineChart 的 data/options 监听从深监听改为引用监听
- 统一使用 chart.update('none'),减少不必要的动画与重绘

- 为 ScatterChart 新增 prepareRenderData 流程,合并间隙压缩与点位转换
- 消除 createChart/updateChart 中重复的数据预处理逻辑
- 将散点图更新改为监听 data、compressGaps、gapThreshold、compressedGapSize
- 移除 compressGaps 切换时的 destroy + recreate 路径,改为原图更新
- 将散点图 options 更新改为无动画刷新,降低全量重算成本

- 为缓存监控页新增 nextExpireAt 状态,跟踪最近过期时间
- 在拉取 affinity 列表后立即按当前时间裁剪已过期数据
- 将每秒全表 filter 改为按最近过期时间触发清理
- 页面恢复可见时先补执行过期清理,再恢复倒计时
- 保留每秒 currentTime 更新,仅用于倒计时显示,降低常驻扫描开销
2026-03-10 09:09:06 +08:00
AAEE86 596227659a perf(admin-usage): 精简管理员 Usage 列表的 request_metadata 下发
- 从 request_metadata JSON 中直接查询 model_version
- 管理员 Usage 列表不再返回完整 request_metadata
- 前端表格、类型和 mock 改为使用顶层 model_version
- 新增轻量响应回归测试
2026-03-10 00:02:49 +08:00
fawney19 3b0dbadb1e fix: 单个提供商更新时不再清空全部余额缓存
loadBalances 新增 fullReload 参数,单个提供商刷新时传入 false,
避免更新一个提供商的余额数据时清除其他提供商的已加载余额。
2026-03-09 23:35:51 +08:00
fawney19 8a8bc999d2 fix: 链路追踪中提供商名称原样显示,不再自动去除"反代"后缀 2026-03-09 23:17:18 +08:00
AAEE86 57c7cca556 perf: 优化请求鉴权链路并批量化统计/调度查询
- 为 Pipeline/Context 增加按需读取请求体能力,支持 async 懒加载 JSON body
- 为 chat/cli/video/claude/openai-cli 适配器关闭默认预读,减少无效 body 读取与超时风险
- 将本地登录、JWT 用户加载、API Key 鉴权迁移到线程池隔离会话执行,避免阻塞事件循环
- 为 API Key 鉴权返回结构化余额结果,并在主请求会话中重新绑定 user/api_key 后再校验状态、过期和锁定信息
- 为 management/user token 前缀认证引入独立会话与结果回绑,避免跨会话对象写入失效

- 为 Usage 余额检查补充结构化返回,统一透出 remaining 与欠费/不可用文案映射
- 为用户与管理端活跃请求查询增加 maintain_status 开关,避免轮询指定 id 时误触发状态修复
- 重写 user_me usage 汇总逻辑,支持 group_by=None 的粗粒度聚合
- 修正 provider 维度成功率与平均响应时间统计,基于 success_count 和成功响应耗时汇总计算
- 前端 Usage 轮询由 setInterval 改为串行 setTimeout,避免并发轮询叠加

- 为 StatsAggregator 增加按本地日期批量计算百分位能力,替代逐天 fan-out 查询
- 为混合统计查询合并连续实时日期区间,并批量读取 StatsDaily,减少逐日查询次数
- 为用户日统计增加批量聚合入口,替代逐用户循环聚合
- 为系统配置导出改用 selectinload 预加载 provider 关联数据,减少 N+1 查询
- 为管理员用户列表增加钱包批量查询,避免逐用户回表

- 为调度器增加 provider 轻量引用预过滤,先按 allowed_providers 缩小范围再加载完整 provider 图
- 为 CandidateBuilder 增加 provider refs/provider_ids 查询能力,保留分页顺序
- 为模型缓存增加 provider_model_mappings 索引缓存与 model_mappings 规则缓存,减少重复全量扫描
- 为请求候选中间态改为 flush/batch commit,降低 pending/streaming 状态切换的事务往返
- 为钱包访问结果补充 balance_snapshot,并抽取余额快照复用逻辑

- 补充 pipeline、auth、admin users、user_me usage、stats aggregator、model cache、
  scheduler、wallet、request candidate 等回归与契约测试
2026-03-09 22:57:23 +08:00
fawney19 9e6578a71b fix: 对 actual_total_cost_usd 显式转换为 float,防止类型不匹配导致累加异常 2026-03-09 21:30:51 +08:00
fawney19 bf818e3b61 fix: 钱包迁移回填 clamp 超范围值,防止 NUMERIC(20,8) 溢出 2026-03-09 20:45:13 +08:00
fawney19 9e7f291aaf Merge pull request #213 from AAEE86/123
fix: 处理客户端断开连接情况,以防止出现虚假的系统错误报告
2026-03-09 20:23:56 +08:00
fawney19andAoaoMH 46bab1b97f feat: 为下拉选择组件添加搜索过滤功能,支持拼音匹配
- 新增 search.ts 搜索工具,支持中文拼音(全拼/首字母)模糊匹配
- 新增 select-search-context.ts,通过 provide/inject 为 radix-vue Select 组件注入搜索能力
- MultiSelect、ModelMultiSelect 组件集成搜索框,选项超过阈值时自动显示
- select-content/select-item 组件支持搜索过滤与空状态提示
- StandaloneKeyFormDialog、UserFormDialog 中手写下拉框替换为复用 MultiSelect 组件
- 引入 pinyin-pro 依赖,按需懒加载

Closes #210

Co-authored-by: AoaoMH <[email protected]>
2026-03-09 19:53:54 +08:00
fawney19andAAEE86 0258d01ee6 refactor: 将 adapter 层的计费/模型抓取/行为变体能力下沉到 core.api_format 注册表
- 新增 core/api_format/capabilities.py,统一注册计费模板、模型抓取、
  total_input_context 计算和 provider behavior variant
- 新增 core/usage_tokens.py,抽取 cache token 解析逻辑到 core 层
- handler adapter 移除各自的 compute_total_input_context / fetch_models /
  BILLING_TEMPLATE 覆盖,改为委托 core 注册表解析
- provider/behavior.py 改为薄封装,底层委托 core registry
- 新增 tests/test_architecture_import_rules.py 架构导入约束测试
- 新增 tests/services/api_format/test_capabilities.py 能力注册表测试

Closes #207

Co-authored-by: AAEE86 <[email protected]>
2026-03-09 18:26:53 +08:00
fawney19 4999a1a0a8 fix: batch_alter_type 转换 NUMERIC 类型前 clamp 超范围值,防止溢出 2026-03-09 16:56:41 +08:00
fawney19 8cb8666456 fix: alembic helpers 限定 schema 查询范围,修复跨 schema 误匹配
- information_schema 查询增加 current_schema() 过滤条件
- 新增 _fk_exists() 通过 pg_constraint 直接检查外键是否存在
- replace_fk_if_needed 在缓存未命中时回退到 pg_constraint 查找
- index_exists 增加 schemaname 过滤
2026-03-09 16:46:38 +08:00
fawney19 48f3f481db fix: 号池调度 label 默认维度数改为动态计算 2026-03-09 15:44:04 +08:00
fawney19 e60462e068 fix: 号池调度 label 在无配置时错误显示为 LRU + 粘性
pool_advanced 为 null 时,poolSchedulingLabel 的 fallback 逻辑因
可选链默认值导致 lruEnabled=true、stickyEnabled=true,始终显示
"LRU + 粘性"。现在提前返回 "2 维度" 以匹配后端默认行为
(cache_affinity + recent_refresh)。
2026-03-09 15:42:32 +08:00
fawney19 c4877e3b6a fix: 号池 legacy 配置默认回退到 cache_affinity,multi_score 模式补全 LRU 数据获取
_build_from_legacy_fields 在无 legacy 字段时默认返回 cache_affinity 而非 lru,
与 PoolConfig 默认值保持一致。PoolManager 在 scheduling_mode 为 multi_score 时
也获取和更新 LRU 数据,修复 cache_affinity/single_account 维度因缺少 lru_scores
导致排序失效的问题。
2026-03-09 15:22:17 +08:00
fawney19 afbb1b9a5d perf: Redis 操作优化,移除号池 key 列表的 Usage 聚合查询
- 号池 key 列表移除 Usage 表聚合查询(total_tokens/total_cost_usd),消除慢 SQL
- cooldown 计数从 SCAN 改为 SCARD (O(1)),通过 cooldown_idx SET 维护索引
- 读路径 Lua 脚本移除 ZREMRANGEBYSCORE,清理移至写路径减少开销
- affinity 清理从 KEYS 改为 SCAN 分批删除,invalidate_all_for_provider 改用 pipeline 批量 MGET+UNLINK
- 缓存监控页添加清除按钮 loading 状态,Redis SCAN 并发限制为 4
- 提取 redis_utils 模块统一 SCAN+批量删除逻辑
- 调度热路径跳过 cooldown TTL 查询,account_state 预计算移出循环
2026-03-09 14:54:45 +08:00
fawney19 9516619b92 feat: 号池调度默认选择缓存亲和,默认开启额度刷新优先 2026-03-09 14:07:20 +08:00
fawney19 e106a65c1d perf: 在 Redis 密集操作前释放 DB 连接,candidate_records 改为异步写入
- 号池排序涉及大量 Redis I/O,在调用前提前释放 DB 连接避免连接池压力
- 新增 create_candidate_records_async,通过 asyncio.to_thread 执行同步 DB 写入
- 同步执行、异步提交、TaskService 三条路径统一改用异步版本
2026-03-09 13:53:39 +08:00
fawney19 d84c9d4b71 feat: 配置导入导出支持 ProxyNode,号池 key 可用性检查延迟到排序后分页执行
配置导入导出:
- 导出/导入新增 ProxyNode(代理节点)数据
- 导入时自动建立 old_id -> new_id 映射,重映射 Provider/Endpoint/Key 中的 node_id
- 前端预览和结果展示新增代理节点统计

号池调度优化:
- CandidateBuilder 不再逐 key 调用 _check_key_availability,直接收集全部 active key
- 将可用性检查参数打包到 PoolCandidate._deferred_check_params
- PoolManager.select_pool_keys 排序后分页调用 availability_checker,找到足够可用 key 即停止
- 减少大号池场景下不必要的可用性检查开销
2026-03-09 13:26:54 +08:00
fawney19 0046123e22 feat: Provider 摘要 API 改为服务端分页,支持搜索和筛选
- 后端 /summary 接口新增 page/page_size/search/status/api_format/model_id 参数
- 新增 ProviderSummaryPageResponse 分页响应模型
- 前端 useProviderFilters 从客户端筛选改为构建服务端查询参数
- ProviderManagement 通过 watch queryParams 实现分页/筛选联动,搜索 debounce 300ms
- PriorityManagementDialog 改为对话框打开时自行加载全量 providers
- 其他使用方(StandaloneKeyFormDialog/UserFormDialog/ReplayDialog/ModelManagement)适配新接口
2026-03-09 12:45:54 +08:00
fawney19 40736a8334 refactor: 移除迁移脚本中不必要的 backfill SQL
快照字段(username/api_key_name)已在业务层写入时填充,无需迁移时回填历史数据
2026-03-09 12:17:45 +08:00
fawney19 0a256adc94 fix: 修复迁移helper 2026-03-09 11:52:16 +08:00
fawney19 0bddc7965b perf: 同步 DB 操作迁移到 asyncio.to_thread,避免阻塞事件循环
failover/stream_telemetry/recording/stream 中的同步 DB 操作(commit/execute/query)
会阻塞 asyncio 事件循环,导致 Hub PING 心跳无法发送、worker idle timeout 断连。
将这些操作包装到 asyncio.to_thread() 中执行。

同时提取 Alembic 迁移脚本中重复的幂等性辅助函数到 alembic/helpers.py,
用批量查询缓存替代逐条 information_schema 查询,backfill SQL 合并为 LEFT JOIN。

新增 failover 中客户端断连的快速终止路径,避免继续无意义的重试。
2026-03-09 11:49:03 +08:00
AAEE86 258be3b640 fix: 处理客户端断开连接情况,以防止出现虚假的系统错误报告
捕获 starlette.requests.ClientDisconnect 异常,避免客户端主动断开连接时被当作系统未知错误(500)处理。
- 在读取请求体阶段捕获 ClientDisconnect,返回 499 状态码
- 在 adapter.handle 阶段捕获 ClientDisconnect,记录审计日志并返回 499
- 日志级别从 ERROR 降为 WARNING,减少误报告警噪音
2026-03-09 10:38:14 +08:00
fawney19 4dbfeb87b8 fix: 迁移脚本循环 2026-03-09 03:56:20 +08:00
fawney19 fa69287449 perf: 依赖数据库 CASCADE/SET NULL 替代手动清理关联表,缩短删除事务
- 批量删除移除 cleanup_key_references 手动清理,改为依赖 FK CASCADE/SET NULL
- video_tasks.key_id FK 增加 ondelete="SET NULL",附带幂等迁移脚本
- _sync_delete 增加 statement_timeout 和任务级超时保护
- 批量导入在每次 await 前释放闲置 DB 连接,按批次提交写入避免长事务
- 前端轮询改为先查后等,首次查询不再多等一个间隔
2026-03-09 03:48:20 +08:00
fawney19 654ce89541 fix: 批量删除任务完成前等待所有进度更新的异步回调完成
收集 run_coroutine_threadsafe 返回的 Future,在标记任务完成前
通过 asyncio.gather 等待所有进度更新回调执行完毕,避免任务
状态提前跳到 completed 而进度数据尚未写入 Redis 的竞态问题。
2026-03-09 02:52:38 +08:00
fawney19 fd32597015 perf: 优化批量删除策略,缩小事务粒度并增强容错
- 批量删除分批大小从 500 降至 50,减少单事务锁持有时间
- 单批失败时跳过并继续,不再中断整个删除任务
- 进度上报改为按批次计数(每 5 批或末批),替代时间限频
- 关联表清理简化为直接 DELETE WHERE IN,移除逐行分批删除
2026-03-09 02:35:07 +08:00
fawney19 84cf07b7a2 refactor: 批量删除任务状态存储从内存字典迁移到 Redis
- BatchDeleteTask 改为 BatchDeleteTaskInfo,状态序列化存入 Redis
- submit_batch_delete / get_batch_delete_task 改为 async 函数
- 删除进度通过限频回调写入 Redis,替代直接修改内存属性
- 移除内存任务注册表和过期清理逻辑,依赖 Redis TTL 自动过期
- 路由层对应调整为 await 调用
2026-03-09 02:09:24 +08:00
fawney19 e4476d0bc6 perf: Pool 批量删除改为异步任务模式,避免大批量删除阻塞请求
- 新增 batch_delete_task 模块,提交删除后立即返回 task_id,后台线程分批执行
- 新增查询任务进度的 API 端点,前端轮询展示实时进度
- RequestCandidate 大表清理改为按行数分批删除,防止单条语句超时
2026-03-09 01:35:01 +08:00
fawney19 0379f01ce8 perf: 删除 Key 前显式清理关联表,避免 CASCADE 级联删除超时
在 ProviderAPIKey 删除前,先批量删除 RequestCandidate、GeminiFileMapping、
VideoTask 等关联表记录,替代依赖数据库 CASCADE 级联删除,防止大量关联
记录导致删除操作超时。Pool 批量删除、封禁清理、Endpoint 批量删除三处
统一使用 cleanup_key_references。
2026-03-09 00:07:20 +08:00
fawney19 95e72594ea perf: Pool 账号删除后改用乐观更新替代全量重载
- PoolAccountBatchDialog: 批量删除全部成功时直接从本地列表移除,其余操作保留原重载逻辑
- PoolManagement: 单个删除后本地移除条目并更新 total,当前页为空时自动跳转前一页
2026-03-08 23:46:04 +08:00
fawney19 f9ffb1cae5 chore: bump aether-hub version to 0.1.4 2026-03-08 23:06:06 +08:00
fawney19 91b6e0a382 fix: Hub 连接清理顺序修复、OAuth 类型常量提取、disassociate 逻辑优化
- Hub proxy/worker 连接关闭时先 unregister 再延迟 abort writer,确保缓冲消息排空
- 提取 OAUTH_AUTH_TYPES 常量,替代各处硬编码的 OAuth 类型列表
- auto-disassociate 跳过 OAuth Key,避免其动态 allowed_models 干扰判定
- 删除 Key 时传入 skip_disassociate=True,跳过不必要的解关联检查
- ModelMapper 缓存命中时将 ORM 实例脱离 Session,修复 DetachedInstanceError
2026-03-08 23:03:56 +08:00
fawney19 f5f7a23bb0 fix: 错误响应读取移至连接关闭前,ModelMapper 缓存改为模块级共享
1. chat_handler_base/cli_stream_mixin: 将 _extract_error_text 提前到
   response_ctx.__aexit__ 之前执行,避免连接关闭后无法读取错误响应体
2. ModelMapperMiddleware: 实例级缓存改为模块级共享缓存,消除多实例
   间缓存不一致问题;缓存失效服务改为直接调用静态方法
2026-03-08 22:18:10 +08:00
fawney19 8be9601963 fix: recording.py 中所有 float() 改为 Decimal(),修复与数据库 Numeric 字段相加的类型错误 2026-03-08 21:58:55 +08:00
fawney19 eaad1579e6 fix: dashboard 统计字段补充 float() 转换,修复 Decimal 与 float 混合运算的类型错误 2026-03-08 21:54:20 +08:00
fawney19 1d8bf56efd fix: defer() 改为链式调用,修复 SQLAlchemy 多参数报错 2026-03-08 21:40:36 +08:00
fawney19 73db997e92 fix: 统计聚合中成本字段 float 改 Decimal,修复与数据库 Numeric
字段相加的类型错误
2026-03-08 21:33:05 +08:00
fawney19 2c5654d694 fix: 单个迁移文件自行提交 2026-03-08 18:53:36 +08:00
fawney19 f490c3a5cd fix: 优化迁移脚本效率 2026-03-08 18:42:27 +08:00
fawney19 2d9158b321 fix: 把同一张表的多个列放在一条 ALTER TABLE 里 2026-03-08 17:56:03 +08:00
fawney19 48d13762d9 refactor(cost,perf): 成本字段 Float 改 Numeric 并优化多处查询性能
- 数据库所有 cost/price 字段从 Float 改为 Numeric(20,8),解决浮点精度问题
- API 响应中 Decimal 值统一用 float() 转换确保 JSON 序列化
- 路由中手动 commit 后标记 tx_committed_by_route 防止中间件重复提交
- 多处查询优化:SQL 聚合替代 Python 遍历、load_only/defer 减少字段加载、
  批量 DELETE 替代逐条 ORM 删除、N+1 查询消除、UNION ALL 合并多表日期查询
- 新增 provider_api_keys (provider_id, is_active) 复合索引
- 候选构建热路径 defer 冷字段,钱包扣费合并解析与加锁查询
2026-03-08 16:44:16 +08:00
fawney19 bd3f73c2fc feat(retention): 删除用户/Key 时保留历史记录,外键改 SET NULL 并添加名称快照
- Usage/RequestCandidate/VideoTask/Stats 等表的 user_id/api_key_id 外键从
  CASCADE 改为 SET NULL,删除用户或 Key 后历史记录不再丢失
- 各表添加 username/api_key_name 快照字段,删除后仍可追溯归属
- 新增 bulk_cleanup 模块,分批置空大表外键避免长事务锁
- 删除用户/Key 流程集成预清理步骤,先置空再删除
- 精简 candidate_builder 冗余 debug 日志
- 修复 proxy_nodes 启动日志 format 占位符错误({} -> %s)
- 前端批量操作请求增加 5 分钟超时配置
2026-03-08 14:31:15 +08:00
fawney19 25c33846be perf(pool): 添加性能计时日志,优化批量删除分批与模型解除关联查询
- 前端批量操作对话框添加 loadAllKeys/executeAction 计时日志
- 后端池账号列表接口和批量删除接口添加分阶段耗时日志
- 批量删除按数据库类型自动选择分批大小,统一前端 batch size 为 2000
- 优化 auto_disassociate 查询:先检查 unlimited key 提前返回,使用 load_only 减少字段加载
- 新增批量操作路由和自动解除关联的单元测试
2026-03-08 03:58:10 +08:00
fawney19 124c4ca403 fix(pool): 优化批量删除性能,使用 SQL 批量删除替代逐条 ORM 删除
- 后端批量删除改用 sa_delete 直接执行 SQL,避免逐条加载和删除
- 前端删除操作批次大小从 2000 减小到 50,防止大批量删除超时
- 增加前端批量操作失败时的错误日志输出
2026-03-08 03:08:27 +08:00
fawney19 ef0f8dd4d0 feat(pool,provider_ops): 扩展批量操作支持代理设置,优化签到缓存与余额刷新
- pool batch-action 新增 clear_proxy/set_proxy 操作,前端统一使用
  batch-action API 替代逐个调用,批量上限从 100 提升至 2000
- batch-action 删除操作后执行 key 删除副作用(run_delete_key_side_effects)
- BalanceAction 签到增加 6 小时缓存冷却,同一 host 避免重复签到
- 异步余额刷新增加 per-provider 防重入保护
2026-03-08 02:29:51 +08:00
fawney19 d0eca509d4 fix(http): 支持默认HTTP客户端原子重建以恢复HTTP/2流容量
feat(db): 为外键列补充缺失的数据库索引并添加迁移脚本

- HTTPClientPool._reset_default_client 原子替换共享客户端,旧客户端在宽限期后异步关闭
- reset_upstream_client 对无代理路由不再直接跳过,改为调用 _reset_default_client
- 为 Usage, WalletTransaction, PaymentCallback, RefundRequest, VideoTask, RequestCandidate 等表的外键列添加 index=True
2026-03-08 01:27:50 +08:00
fawney19 90663793a2 Merge branch 'feat/wallet-system' into master
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

Closes #204
2026-03-08 00:06:05 +08:00
LewisPen 783f654953 feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
2026-03-08 00:05:48 +08:00
fawney19 9cdcce1b5f fix(providers): 允许 max_probe_interval_minutes 设为 0 并修复零值被覆盖的问题
- 将 max_probe_interval_minutes 校验范围从 2-32 改为 0-32
- 修复 routes.py 中 max_probe_interval_minutes 和 cache_ttl_minutes
  使用 `or` 短路导致零值被默认值覆盖的问题,改用 is not None 判断
- 前端表单同步调整最小值约束和提示文案
- 新增零值校验的单元测试
2026-03-07 18:43:28 +08:00
fawney19 06b483f79d fix(task): 修复 SyncTaskExecutionService 调用 dispatch 缺少 user_id 参数及返回值解包数量不匹配 2026-03-07 17:32:17 +08:00
fawney19 a00e137ffc fix(providers): 修复 Key 表单状态同步与 streaming 候选状态码处理
前端:
- KeyAllowedModelsEditDialog 同时监听 open 和 apiKey 变化,避免切换 key 时状态未刷新
- KeyFormDialog 新增 api_formats 过滤与默认值逻辑,可用格式变化时自动同步表单
- ProviderDetailDrawer 合并 provider 和 endpoint 的 api_formats 传递给 Key 表单,
  数据刷新后同步 currentEndpoint 和 editingKey 引用

后端:
- mark_candidate_streaming 移除 status_code 参数,streaming 阶段不再提前写入状态码
- 简化 active_requests 中 streaming 请求的完成判断,不再依赖 status_code 条件
2026-03-07 17:11:19 +08:00
fawney19andAAEE86 239238fe47 refactor(task): 拆分 TaskService 并重构任务生命周期
- 将 task 公共协议/上下文/异常/schema 下沉到 core,并迁移 polling 目录
- 新增 execute/submit/video 子模块,拆分同步执行、异步提交流程、错误处理与视频任务操作
- 收敛 TaskService 为门面编排,内部委派到 SyncTaskExecutionService、AsyncTaskSubmitService、VideoTaskOperationsService
- 重构 main 生命周期管理:引入 LifecycleState,拆分启动与关闭流程
- 删除未使用的 TaskExecuteFacadeService 与 TaskSubmitFacadeService

Closes #201

Co-authored-by: AAEE86 <[email protected]>
2026-03-07 15:33:29 +08:00
github-actions[bot] 4cd6e0d10f chore(proxy): update download links for proxy-v0.2.4 2026-03-06 20:06:59 +00:00
fawney19 9b29a65c68 chore: bump aether-hub version to 0.1.3 2026-03-07 04:02:02 +08:00
fawney19 df4a49a9fb chore: bump aether-proxy version to 0.2.4 2026-03-07 04:00:27 +08:00
fawney19 bb268310e2 feat(keys): 新增 Provider Keys 批量删除 API
- 后端新增 POST /keys/batch-delete 接口,支持一次删除最多 100 个 Key
- 按 provider_id 聚合执行副作用,避免逐个删除导致的重复 Redis 操作
- 前端批量操作对话框中删除操作改用批量 API,按 100 个一批分批调用
2026-03-07 03:49:44 +08:00
fawney19 7b0908cd87 fix(task): TaskPoller Redis 分布式锁操作增加异常捕获,避免 Redis 故障时任务轮询崩溃 2026-03-07 03:14:48 +08:00
fawney19 95bc742057 fix(gunicorn): worker timeout 从 120s 放宽到 300s,收敛到配置文件统一管理
- 移除 Dockerfile 命令行中硬编码的 --timeout 120,由 gunicorn_conf.py 统一管理
- timeout 默认值从 120 调整为 300,减少异步 worker 偶发心跳延迟导致的误杀
- timeout 和 graceful_timeout 均支持环境变量覆盖(GUNICORN_TIMEOUT / GUNICORN_GRACEFUL_TIMEOUT)
2026-03-07 03:13:44 +08:00
fawney19 e40c890a8e fix(usage): 超时请求清理增强,支持恢复已成功的 streaming 请求
重构 pending/streaming 请求清理逻辑:
- 提取 _find_completed_request_ids 和 _sync_candidate_status_to_success 公用方法
- 清理超时请求时检查 RequestCandidate,已成功的恢复为 completed 而非标记 failed
- 同步更新 candidate 状态,保持 Usage 与 RequestCandidate 一致
- 启动时主动执行一次 pending 清理
2026-03-07 03:04:09 +08:00
fawney19 357c4fd61f fix(build): 构建时 GitHub API 请求支持可选 GITHUB_TOKEN 避免限流
未认证 GitHub API 限流 60 次/小时/IP,频繁本地构建容易触发。
添加可选的 GITHUB_TOKEN 支持(认证后 5000 次/小时),不传 token 时行为不变。
2026-03-07 03:03:55 +08:00
fawney19 d28fea80df fix(trace): 请求追踪详情默认展示全部候选记录 2026-03-07 02:30:48 +08:00
fawney19 fb1aeb789a feat(test,quota,failover): 模型并发测试、统一配额读取器与故障转移取消支持
- 新增 QuotaReader 抽象层,统一 Codex/Kiro/Antigravity 配额解析逻辑,
  替换 pool/routes.py 中分散的配额构建函数
- 模型测试支持并发执行多候选,前端新增 useModelTest composable 统一
  ModelsTab 和 ModelMappingTab 的测试逻辑
- ModelTestDialog 增加结果概览摘要、超长结果折叠、端点列和新状态支持,
  删除已合并的 TestResultDialog
- FailoverEngine 新增客户端断开检测,支持取消剩余候选并标记记录
- 刷新配额改为分批执行,直连测试候选按可用性排序
- 修复 error 判断从 "error" in dict 改为 dict.get("error") 避免误判
2026-03-07 02:16:03 +08:00
fawney19 1f3693d3a2 fix(migration): body_rules 回填限定 codex 提供商类型
原迁移仅按 api_format 过滤,会误回填所有 openai:cli 端点;
现 JOIN providers 表增加 provider_type = 'codex' 条件。
2026-03-06 23:44:37 +08:00
fawney19 90760da499 feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留
- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时
- 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条)
- 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段
- SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token
- 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改)
- Codex envelope 移除合成头部,保留客户端原始请求头
- endpoint_checker 支持自定义超时透传
- 新增 x-forwarded-scheme 到上游丢弃头部列表
2026-03-06 21:06:45 +08:00
fawney19 7950ba7dc5 fix(usage): 请求详情默认选中轻量 tab,避免自动加载大 body
首次打开请求详情时优先选择 request-headers / response-headers / metadata
等轻量 tab,而非直接落到 body tab 触发按需加载。
2026-03-06 18:19:57 +08:00
fawney19 a7088ee538 fix: 修复 http缺少cls 2026-03-06 18:01:28 +08:00
fawney19 050cba9563 feat(proxy,failover,transport): Hyper 上游客户端精细计时、连续失败退避与连接泄漏修复
Proxy:
- 将上游 HTTP 客户端从 reqwest 替换为 hyper,新增 InstrumentedConnector
  实现 TCP 连接/TLS 握手级别的独立计时,上报 connection_reused 等指标
- 前端展示细粒度代理计时(连接复用、等待响应头等)

Failover:
- 引入连续失败退避机制,每 10 次失败递增退避间隔
- 检测 H2 max outbound streams 错误并触发上游客户端重建
- 新增 HTTPClientPool.reset_upstream_client 支持按需重建缓存客户端

连接泄漏修复:
- Handler 异常路径确保 response_ctx 被正确关闭
- HubResponseStream 迭代结束后在 finally 块中清理 stream_id
- HubTunnelTransport.handle_request 捕获所有异常并清理流状态
2026-03-06 17:55:14 +08:00
fawney19 2269617a9f fix(nginx): 剥离 Cloudflare 请求头,防止泄露给上游 AI 提供商
在反向代理配置中将 CF-Connecting-IP、CF-IPCountry、CF-Ray、
CF-Visitor、CDN-Loop、True-Client-IP、CF-Worker、CF-EW-Via
等头置空,避免用户真实 IP 及 CF 元数据被透传到上游。
2026-03-06 15:54:34 +08:00
fawney19 bdccfa6e78 feat(usage,pool,codex): 请求详情 body 按需加载、配额选择器重构与 Codex body rules 修正
- usage 详情 API 新增 include_bodies 参数,支持跳过 body 内容返回 has_*_body 标记
- 前端请求详情抽屉首次加载不含 body,切换到 body tab 时延迟加载并展示 skeleton
- Timeline 组件延迟 120ms 挂载,避免阻塞抽屉渲染
- 提取号池配额判断逻辑到 quota-selectors 工具模块并添加单测
- Codex 移除 openai:compact 的默认 body rules 注册
- ModelTestDialog/TestResultDialog 模板格式化
2026-03-06 15:40:11 +08:00
fawney19 d97ec3fde2 feat(admin,pool,billing): 端点级模型测试、Provider 自动置顶、缓存 TTL 分级计费展示与账号状态增强
- 模型测试支持指定端点:新增 ModelTestDialog 组件,多端点时弹窗选择,单端点直接测试;
  后端 test-model-failover 接口新增 endpoint_id 参数,支持 global/direct 模式下按端点过滤候选
- 创建 Provider 时优先级自动置顶(provider_priority 默认 None,后端取 min-1),
  显式指定优先级时 shift 已有行;前端创建时不发送 priority,更新时保留
- 缓存计费 UI 增强:RequestDetailDrawer 支持 5min/1h 缓存创建 token 分级展示,
  含按 TTL 匹配单价和分行成本计算;ModelDetailDrawer/ModelsTab 标签区分 5min/1h 缓存创建
- Pool 批量操作额度筛选拆分为「无5H限额」和「无周限额」,按 | 分隔 segment 匹配
- KeyFormDialog 优化非 vertex_ai 时布局,API 密钥输入内联到 grid 右列
- Codex refresher 结构化错误标记:401/402/403 使用 [OAUTH_EXPIRED]/[ACCOUNT_BLOCK] 前缀,
  新增 deactivated_workspace 识别与分类
- 前后端 accountBlock 关键词同步:新增 token invalidated、deactivated_workspace 识别,
  OAuth 失效提示清理 block 前缀后展示
- PoolConfig 新增 batch_concurrency 配置(默认 8,上限 32)
- 预设模型新增 gpt-5.4;TestResultDialog 响应式布局与 key 脱敏优化
2026-03-06 13:13:01 +08:00
github-actions[bot] d17472f09e chore(proxy): update download links for proxy-v0.2.3 2026-03-05 18:01:47 +00:00
fawney19 f8b7cd2925 chore: bump aether-proxy version to 0.2.3 2026-03-06 01:48:50 +08:00
fawney19 e9c3ac94c6 feat(proxy,oauth,pool): H2 头过滤、OAuth 过期分级标记与批量操作进度条
- proxy: 屏蔽 host/content-length 头转发,避免 H2 PROTOCOL_ERROR
- oauth: 区分 [REFRESH_FAILED] 与 [OAUTH_EXPIRED] 标记,token 过期
  自动阻止调度但不停用账号,便于管理员恢复
- pool/account_state: 识别新增的 OAUTH_EXPIRED/REFRESH_FAILED 前缀
- 前端: 批量操作显示实时进度条,倍率编辑 Escape/blur 竞态修复,
  OAuth 刷新失败后自动刷新列表
2026-03-06 01:47:09 +08:00
fawney19 fa71cddb60 feat(proxy,billing,pool): 增强隧道/流中断诊断日志、修复缓存 TTL 差异化计价
- stream_processor: 上游流中断时记录完整异常链与已传输 token 统计
- hub_transport: 断连影响 in-flight 流、STREAM_ERROR、超时场景补充 warning 日志;
  未启用时跳过重连循环,重连失败日志降频避免刷屏
- tunnel_manager: 流超时/错误/全部取消/STREAM_ERROR 增加诊断日志与字节统计
- billing_integration: 计费时自动补全 cache_ttl_minutes(从 provider key 查询
  或从 5m/1h 细分 token 回推),修复缓存 TTL 差异化计价缺失
- PoolManagement.vue: 移除重复的账号告警 Badge
- 新增 billing integration 单元测试
2026-03-06 00:14:10 +08:00
fawney19 228cbc8f87 feat(pool,admin): 拆分调度维度、重构调度 UI 与增强配置导入导出
调度维度:
- 新增 cache_affinity/free_first/team_first/plus_first/load_balance 五个独立维度
- 将 free_team_first 标记为 hidden,保留后向兼容但不再显示
- Registry 新增 hidden 属性,_helpers 新增 plus_only 优先级评分

前端调度对话框:
- 分配模式(互斥组)独立为按钮组选择,策略调度保留拖拽排序
- 默认调度从 LRU 轮转改为缓存亲和
- 提取 buildPresetListItem/insertMissingByPreferredOrder 消除重复代码

配置导入导出:
- 导出时 api_formats 支持规范化、去重与 None 回退到 Provider 端点
- 导入时兼容 supported_endpoints 别名与历史 None 语义
- 新增 test_admin_system_key_formats 单元测试
2026-03-05 23:22:25 +08:00
fawney19 da915208a8 feat(provider,adapter): Codex 默认 body_rules 按 provider_type 维度注册,adapter 全链路传递 provider_type
- 新增 register_provider_default_body_rules 注册机制,将 Codex 特有的
  body_rules 从 EndpointDefinition 全局默认移至 codex plugin 按
  (provider_type, endpoint_sig) 维度注册
- handler adapter 的 build_endpoint_url/build_request_body/get_cli_extra_headers
  增加 provider_type 参数,Codex 判断优先使用 provider_type 而非 URL 匹配
- 前端 getDefaultBodyRules API 支持 provider_type 参数,缓存 key 区分不同
  provider 类型;Codex 路径判断同样优先使用 provider_type
- ProviderDetailDrawer 将 mapping-preview 拆为独立加载,不阻塞首屏渲染
- PoolManagement 补全 KeyFormDialog 缺失的 endpoint/available-api-formats props
- 固定类型 Provider 创建时自动填充 provider-scoped 默认 body_rules
2026-03-05 19:04:21 +08:00
fawney19 694167f78f feat(pool,trace): 账号封禁原因细分与请求追踪 attempted_only 过滤
- 将账号封禁原因从笼统的"账号异常"细分为封禁/停用/需要验证三类,
  前后端关键词组同步拆分,号池管理页面展示对应分类标签
- trace API 新增 attempted_only 参数,支持仅返回实际尝试过的候选,
  前端时间线组件默认启用过滤,排除 available/unused/skipped 记录
2026-03-05 17:32:21 +08:00
fawney19 a7697032a4 feat(pool): 账号停用检测、OAuth refresh 失效标记与号池管理性能优化
- 新增 account_deactivated 关键词检测,覆盖 error_handler / health_policy / account_state / 前端
- 401 健康策略分级冷却:账号永久停用 1h,临时认证失败 60s
- OAuth refresh token 失败时标记 oauth_invalid(不停用 key),成功时清除非账号级标记
- 号池管理 API 使用 load_only + SQL 聚合替代全量拉取,减少查询开销
- Redis 冷却统计改用 batch_count_provider_cooldowns (SCAN) 替代逐 key 检查
- 前端时间线移除 executableTimeline 过滤层,Provider 选择改为非阻塞加载
2026-03-05 16:23:58 +08:00
fawney19 e86c8edd4b feat(provider): 新增 keep_priority_on_conversion 字段,支持格式转换时保持调度优先级 2026-03-05 15:28:03 +08:00
fawney19 b1be413dc0 feat(pool): 号池额度主动探测、封禁自动清除、调度硬优先级与前端重构
- 新增 PoolQuotaProbeScheduler,按 probing_interval_minutes 主动探测静默 Key 额度
- pool_advanced 增加 probing_enabled / auto_remove_banned_keys 配置项
- error_handler 和 quota_service 支持封禁 Key 自动删除及缓存清理
- multi_score 策略从加权混合重构为硬优先级排序,引入 mutex_group 互斥组
- 指纹注入从 handler 层下移至 ClaudeCode envelope 层
- OAuth 批量导入支持 concurrency 并发参数
- 前端号池管理拆分高级设置/账号批量/代理设置为独立组件
- 号池总览接口精简,仅返回已启用调度的 Provider
2026-03-05 15:15:26 +08:00
fawney19 fdb50a065b refactor(fingerprint): 移除请求头构建路径中的 per-key 指纹定制化注入
headers.py 删除 build_browser_fingerprint_headers / build_anthropic_extra_headers
函数及相关辅助,改为直接使用固定常量;PassthroughRequestBuilder 移除
Claude 格式的指纹注入步骤;Antigravity constants 移除 per-key 指纹
覆盖逻辑,统一使用进程级固定值。
2026-03-05 10:24:52 +08:00
fawney19 1ac59d4894 feat(pool): 新增调度维度、互斥组机制、健康策略扩展与配额刷新增强
- 新增 priority_first/health_first/latency_first/cost_first 四个调度维度
- 引入 mutex_group 互斥组机制,lru 与 single_account 归入 distribution_mode
- 维度 compute_metric 签名扩展 context 参数,支持获取 cost_totals 等上下文
- 各维度增加 evidence_hint 字段描述评分依据
- 健康策略扩展 408/409/423/425/5xx 瞬态状态码冷却,403 按 body 分级冷却
- Codex 配额刷新增强 401/402/403 错误处理,402 生成 fallback 元数据
- 前端号池管理支持账号优先级内联编辑与互斥维度切换 UI
- 列表排序改为 internal_priority + created_at,移除 sticky_counts 查询
2026-03-05 09:53:23 +08:00
fawney19 32ccf61baa feat(fingerprint): 引入 per-key 请求指纹系统,替代全局 TLS 指纹开关
为每个 ProviderAPIKey 生成并持久化独立的请求指纹配置,涵盖 TLS impersonate
profile、浏览器 UA、Stainless SDK 头部、Node/Chrome/Electron 版本等维度。
指纹基于 key ID 确定性生成,支持手动编辑和批量重新生成。

- 新增 fingerprint 模块:生成、加载、校验、懒持久化
- 数据库迁移:provider_api_keys 新增 fingerprint JSON 列
- 请求链路注入:handler 基类设置上下文指纹,request_builder 和 envelope 消费
- HTTP Client 支持动态 impersonate profile 选择
- Antigravity 适配器使用指纹覆盖 UA/session/Node 版本
- 前端移除手动 TLS 指纹开关,新增批量 regenerate_fingerprint 操作
- 号池管理 UI 优化:token 缩写格式、blocked 行样式、时间显示改为日期格式
2026-03-05 02:07:34 +08:00
fawney19 1d04c41ae7 refactor(pool): 移除调度多维评分详情,简化为 reasons 摘要
移除 scheduling_score、candidate_eligible、scheduling_dimensions 等字段,
前后端统一使用 scheduling_reasons 展示调度状态,精简号池列表信息密度。
2026-03-04 22:51:02 +08:00
fawney19 b2dcf82ca8 feat(pool): 引入多维评分调度策略与账号状态检测
- 新增 multi_score 调度模式,支持 LRU/延迟/健康度/剩余额度多维加权评分
- 新增调度预设维度系统(free_team_first, quota_balanced, recent_refresh, single_account),支持有序对象列表配置格式并兼容旧字符串列表
- 新增 account_state 模块,统一账号封禁/受限检测逻辑,替代分散在 routes 中的判断代码
- 新增 health_cache 模块和 latency 采样(redis_ops.record_latency / batch_get_latency_avgs)
- RequestDispatcher 返回 ttfb_ms,PoolManager.on_request_success 记录延迟样本
- 前端:PoolConfigDialog 替换为 PoolSchedulingDialog,支持预设维度可视化配置;号池管理页增加调度模式标签与账号异常 Badge 显示
- 提取前端 accountBlock 工具函数,ProviderDetailDrawer 复用统一判断
- scheduling_dimensions 增加 account_state 和 latency 维度评估
- 补充 account_state、health_cache、multi_score 策略、preset 维度、redis latency 等测试
2026-03-04 22:06:19 +08:00
fawney19 57b86034cf feat(pool,priority): 号池聚合显示、API 格式归一化与优先级管理重构
- 优先级管理对话框按 family 分组显示 API 格式,号池 key 聚合为单条目展示
- 拖拽排序改用 key ID 替代数组索引,号池聚合项禁用拖拽/编辑/开关操作
- 后端 key 分组查询增加 API 格式键归一化,返回 provider_id
- 提取 OAuth auth_config 解密逻辑,新增 _derive_oauth_expires_at 从加密配置派生过期时间
- 号池管理移除会话列,调整 OAuth 过期信息与刷新按钮的布局顺序
2026-03-04 12:59:27 +08:00
fawney19 095e312ab3 fix(usage): 修正请求时间线 unused 候选过滤与分组排序逻辑
- 号池内 unused key 不再展示,非号池 unused 仅保留 retry_index=0
- buildProviderGroups 使用 candidate_index 替代数组下标作为分组索引
- 号池组与供应商组合并后统一按 startIndex 排序
2026-03-04 10:26:19 +08:00
fawney19 82a9fb3c39 feat(codex): 增强 OAuth 导入解析与账号信息提取,优化维护调度器线程模型
Codex OAuth:
- 导入解析支持附加账号字段(account_id/plan_type/user_id/email)
- enrich_codex 扩展从 access_token 和直接字段提取账号信息
- parse_codex_id_token 支持 JWT/JSON 字符串/dict 三种输入格式
- request patching 新增 openai:compact 格式支持
- codex_usage_parser 新增 credits_unlimited 字段解析

维护调度器:
- 同步 DB 操作迁移到线程池执行,避免阻塞事件循环
- 新增 request_candidates 定期清理任务
- 新增每周 VACUUM ANALYZE 数据库表维护任务
- 新增 enable_db_maintenance 配置项

前端:
- ElapsedTimeText 从 setInterval 改为 requestAnimationFrame
- 时间线过滤 available/unused 占位记录
- Usage 页面默认关闭全局自动刷新
- 移除号池管理中的配额更新时间显示
2026-03-04 10:00:38 +08:00
fawney19 e181329a81 fix(usage): 抽取 ElapsedTimeText 组件、缩短缓存 TTL 提升实时性,修复 _KeyCandidate slots
- 将 UsageRecordsTable 中的内联计时逻辑抽取为独立的 ElapsedTimeText 组件
- usage records 缓存 TTL 从 15s 降为 3s,全局自动刷新间隔从 5s 改为 3s 并默认开启
- 补充 PoolManager._KeyCandidate 缺失的 __slots__ 字段
2026-03-04 02:24:46 +08:00
fawney19 cdce817928 fix(announcement): 将分页计数移至排序之前执行 2026-03-03 22:35:08 +08:00
fawney19 97b0146ce9 perf: 全栈查询优化、前端缓存去重与页面可见性优化
后端:
- SQL count 查询统一改用 func.count() 子查询替代 query.count()
- Dashboard/Audit 等页面多次独立查询合并为单次聚合查询
- Provider summary 列表改为批量查询消除 N+1 问题
- DailyStats 逐天循环查询改为 CASE 分桶单次查询
- 使用 load_only() 减少不必要的列加载
- cache_decorator 支持嵌套属性路径解析(dotted vary_by)
- 多个管理/公共端点新增 @cache_result 缓存装饰

前端:
- cache.ts 新增 in-flight 请求复用、dedupedRequest、buildCacheKey
- 大量 API 调用添加前端缓存或去重
- 多个页面定时器在标签页隐藏时暂停、可见时恢复
- Auth 检查从 setInterval 改为 storage + visibilitychange 事件驱动
- 请求竞态防护(requestId 模式)

数据库:
- Usage 表新增 idx_usage_status_user_created 复合索引
2026-03-03 22:04:40 +08:00
fawney19 0a60492146 fix(proxy_nodes): 更新代理节点模块描述 2026-03-03 17:28:56 +08:00
fawney19 4ea187cfac feat(pool): 号池候选重构为 PoolCandidate 单候选模式与池内 key 故障转移
- 新增 PoolCandidate 子类,排序阶段作为单候选参与,执行阶段在 pool_keys 内部选择/切换 key
- FailoverEngine 新增 _execute_pool_candidate 方法,支持池内 key 级别故障转移与重试
- 提取 _execute_attempt / _attach_attempt_context / _classify_attempt_error 公共方法
- CandidateBuilder 对号池 Provider 构建单个 PoolCandidate(包含所有可用 key)
- CandidateSorter 支持 PoolCandidate 独立优先级分组(global_priority / pool_priority)
- CandidateResolver PRE_EXPAND 模式按 pool_keys 展开预创建记录,附加 pool_group_id
- TaskService._apply_pool_reorder 改为对 PoolCandidate 调用 select_pool_keys
- PoolManager 新增 select_pool_keys 方法,复用 reorder_candidates 逻辑
- 新增 global_priority 号池配置字段(前后端同步)
- 前端 Timeline 支持按 pool_group_id 分组显示多号池尝试
- 异步提交路径新增 _expand_pool_candidates_for_async_submit 展开逻辑
2026-03-03 17:24:22 +08:00
fawney19 dcba7c62a2 docs: 更新 README 部署说明与默认配置
- 调整 Docker Compose 部署方式的标题描述
- 恢复预构建镜像部署的正常命令流程
- 移除 deploy.sh 的 --hub-tag 选项说明
- Aether Proxy 标注为可选组件
- GUNICORN_WORKERS 默认值调整为 2
2026-03-03 16:38:22 +08:00
fawney19 dba99455a7 fix: 移除redis持久化功能 2026-03-03 12:44:48 +08:00
fawney19 7ebce161e8 feat(pool,ui,trace): 号池按页配额刷新、配额倒计时、Trace 全量候选与 UI 用语统一
- 号池管理支持按当前页 Key 刷新配额,后端 refresh-quota 接口支持 key_ids 参数筛选
- 配额进度条 tooltip 展示重置倒计时,前端解析后端重置时间并实时倒计时
- Provider 选择器在无号池提供商时禁用,切换/刷新后保持选中状态对齐
- 启停账号后立即更新调度标签并刷新列表
- Trace 监控展示全量候选记录,不再过滤 available/unused 状态
- 请求时间线号池节点与 Provider 节点去重
- 全局 UI 用语统一:已停用/已禁用 -> 停用/禁用
- 导航菜单调整模型管理与号池管理顺序
2026-03-03 11:32:41 +08:00
fawney19 022aec5720 fix: 修复迁移版本号重复问题 2026-03-03 09:28:33 +08:00
fawney19andAAEE86 11997c024e feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构
- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康)
- 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因
- Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息)
- 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key
- Handler 基类增加请求调度元数据采集,stream telemetry 增强
- 请求时间线组件增强,支持 attempted 候选展示
- Kiro OAuth 凭证导入解析改进
- 新增 usage 表 provider_key 索引迁移
- 补充调度维度、配额冷却、候选枚举等单元测试

Closes #197

Co-authored-by: AAEE86 <[email protected]>
2026-03-03 09:22:20 +08:00
fawney19 f787b1b02a fix(codex): 取消对 openai:compact 端点的 body_rules 修改
Codex 的请求体规则(drop max_output_tokens/temperature/top_p, set store=false 等)
只应作用于 openai:cli, 不应影响 openai:compact 端点。
2026-03-03 03:39:14 +08:00
fawney19 a03368a3fe fix(usage): metadata tab 也显示展开/收缩和复制按钮 2026-03-02 22:44:49 +08:00
fawney19 e26ed8481f fix(scheduling): 负载均衡模式与无亲和键场景统一走随机排序
重构 CandidateSorter.shuffle_keys_by_internal_priority 中同优先级
Key 的排序逻辑,将三分支简化为两分支:
- 随机排序:TTL=0 / 负载均衡模式 / 无 affinity_key
- 哈希确定性排序:缓存亲和模式且有 affinity_key

移除了原先"无 affinity_key 时按 ID 排序"的冗余分支,新增
对应单元测试覆盖三种场景。
2026-03-02 22:36:24 +08:00
fawney19 8e98eed5c8 refactor(failover): 用 provider failover_rules 替代硬编码 ErrorClassifier 判断
移除 submit_with_failover 中基于 ErrorClassifier 的客户端错误硬编码逻辑,
改为读取 provider.config.failover_rules 进行规则匹配:
- error_stop_patterns: 错误响应命中时终止 failover
- success_failover_patterns: 2xx 响应命中时继续尝试下一个候选
同步更新相关注释、异常描述及测试用例
2026-03-02 22:21:13 +08:00
fawney19 0bff15f964 feat(endpoint): 端点默认 body_rules 机制与 Codex 规则回填
- EndpointDefinition 新增 default_body_rules 字段,openai:cli/compact 配置 Codex 默认规则
- 创建端点时若未指定 body_rules 则自动填充对应格式的默认值
- 新增 GET /defaults/{api_format}/body-rules 接口查询默认规则
- 前端 EndpointFormDialog 增加"重置请求体"按钮,支持一键恢复默认
- Alembic 迁移回填已有 Codex 端点的默认 body_rules
- 新增 metadata 和 endpoint 创建默认值的单元测试
2026-03-02 22:08:39 +08:00
fawney19 3384c6d666 fix(alembic,pipeline,resilience): 数据库迁移与异常处理健壮性修复
- alembic env: 改用事务级 advisory lock (pg_advisory_xact_lock),事务结束自动释放
- 迁移脚本: 使用原生 SQL IF NOT EXISTS/IF EXISTS 替代运行时列检查
- pipeline: SQLAlchemy 异常后先回滚事务再写审计,防止 aborted 状态二次报错
- resilience: ProgrammingError 标记为不可恢复,缩减 DB 重试异常范围
- 前端: 端点规则支持拖拽排序
2026-03-02 18:02:21 +08:00
fawney19 5f1c74aca0 fix(cli): 统一 CLI handler pending usage 的 api_format 来源
CLI stream/sync mixin 中创建 pending usage 记录时,api_format 取值
来源不一致(部分用 FORMAT_ID,部分用 allowed_api_formats[0])。
新增 primary_api_format 属性并统一使用,确保 pending 记录的格式
与实际客户端请求格式一致。
2026-03-02 15:13:28 +08:00
fawney19 310355bcc1 fix(ci): 修复多架构Docker镜像构建,arm64覆盖amd64问题
分开push到同一tag会导致后者覆盖前者manifest。
改为先按digest分别push,再用imagetools合并为多架构manifest。
2026-03-02 13:59:25 +08:00
fawney19 ab07d83aaf fix(ci): 使用认证的gh CLI替代未认证curl调用GitHub API
download-hub步骤使用未认证的curl获取release列表,
频繁触发GitHub API速率限制导致构建失败。
改用gh CLI自带GITHUB_TOKEN认证避免此问题。
2026-03-02 13:41:05 +08:00
github-actions[bot] 11cdf52f7b chore(proxy): update download links for proxy-v0.2.2 2026-03-02 05:37:31 +00:00
fawney19 8a2c3596ce chore: bump aether-hub version to 0.1.2 2026-03-02 13:29:27 +08:00
fawney19 a61d16d120 chore: bump aether-proxy version to 0.2.2 2026-03-02 13:29:15 +08:00
fawney19 01df063cc1 feat(ci,alembic): Hub Docker镜像构建发布,数据库迁移并发安全加固
- build-hub.yml 新增 Docker job,构建多架构镜像推送至 GHCR 和 Docker Hub
- build-hub/build-proxy Release 名称简化为 tag 名
- alembic/env.py 使用 PostgreSQL advisory lock 防止多进程并发迁移竞态
- 迁移脚本改用 ADD/DROP COLUMN IF NOT EXISTS 替代 inspector 检查
2026-03-02 13:24:58 +08:00
fawney19 68bae686da feat(proxy): 支持远程推送升级与proxy元数据上报
- aether-proxy 注册和心跳时上报 proxy_metadata(含版本号)
- 心跳 ACK 支持 upgrade_to 字段,proxy 收到后自动执行升级
- 重构 upgrade 逻辑,新增 perform_upgrade 用于远程触发的自动升级
- stream_handler 延迟统计改为仅记录连接建立延迟(DNS+TCP/TLS+TTFB)
- 后端新增 proxy_metadata 数据库字段和批量升级 API
- 远程配置支持下发 upgrade_to 版本指令
- 前端展示节点版本号,支持单节点和批量升级操作
2026-03-02 12:58:41 +08:00
fawney19 f978888759 feat(heartbeat): 心跳可靠性增强,原子计数与去重优化
- Rust proxy: 引入 snapshot+ACK 确认机制,心跳未确认时保留快照重发,
  避免指标丢失;添加 heartbeat_session_id 防跨进程去重误判
- Hub transport: Redis SETNX 心跳去重,避免多 worker 重复写库;
  ACK 回显 heartbeat_id 供 Rust 端匹配
- ProxyNodeService.heartbeat: 改用 SQLAlchemy atomic update 原子累加
  指标,避免 ORM read-modify-write 的并发覆盖问题
- 启动顺序修正: tunnel 状态重置移到 Hub 连接建立之前,避免竞态
- OAuth 批量导入: 动态超时(默认30s,走代理60s),Kiro 适配器透传
- 提取 normalize_heartbeat_id 到 tunnel_protocol 共享模块,消除重复
2026-03-02 12:27:51 +08:00
fawney19 f3b9f42202 refactor(tunnel): 移除直连tunnel模式,统一使用Hub转发
- 删除 TunnelTransport 及 TunnelManager 相关引用,所有 tunnel 请求统一走 Hub
- 简化 health_scheduler,不再依赖进程内 TunnelManager 状态判断节点在线
- 简化 resolver,移除本地 tunnel miss 判断与多 worker 告警逻辑
- 简化 service 中 tunnel 连通性检测,统一以 DB 状态为准
- 启动/关闭流程移除 hub_enabled 分支,始终初始化 Hub 连接
- Rust 端 RequestMeta.timeout 增加浮点数反序列化支持,Python 端确保发送整数
2026-03-02 11:45:30 +08:00
fawney19 0564893c4f refactor: Hub二进制改为Docker构建时下载,优化部署流程与worker初始化
- Dockerfile: 移除COPY预编译二进制,改为构建时通过HUB_TAG从GitHub Release下载
- CI: 移除artifact上传/下载步骤,通过build-args传递Hub tag
- deploy.sh: 重构参数解析,支持--hub-tag指定版本,跟踪tag变化触发重建
- build.sh: 新增--image模式支持构建并推送Hub Docker镜像
- hub.rs: worker连接时同步所有节点在线状态,避免状态不一致
- proxy_nodes: 启动时主动建立Hub worker连接,消除懒连接窗口期
- docker-compose.yml: app镜像支持APP_IMAGE环境变量配置
- README: 更新部署文档,推荐本地构建方式
2026-03-02 11:24:14 +08:00
fawney19 c9dbe7936b fix: 补充tunnel端点nginx代理的路径前缀 2026-03-02 04:18:28 +08:00
fawney19 3f7a3d7600 fix: 修复tunnel端点nginx代理端口未替换的占位符问题 2026-03-02 04:14:48 +08:00
fawney19 5b9d9452c9 fix: 修改hub构建方式 2026-03-02 04:05:40 +08:00
fawney19 b7ef900181 fix(deploy): 恢复部署时自动拉取最新代码 2026-03-02 03:09:15 +08:00
fawney19 3eef673885 fix: 添加cargo清华镜像源,加速Hub本地构建 2026-03-02 03:04:56 +08:00
fawney19 039a18c243 feat(tunnel): 引入 aether-hub 帧路由器,支持多 worker 共享 tunnel 连接
新增 Rust 实现的 aether-hub 服务,作为 Docker 容器内部 WebSocket 帧路由器,
解决多 Gunicorn worker 进程间 tunnel 连接隔离问题。

主要改动:
- 新增 aether-hub Rust 项目,实现 proxy/worker 双向帧路由与 stream_id 重映射
- 新增 HubConnectionManager/HubTunnelTransport,worker 通过 Hub 转发 tunnel 帧
- 新增 create_tunnel_transport 工厂函数,按运行环境自动选择 Hub 或直连模式
- 新增 NODE_STATUS 广播机制,Hub 实时通知所有 worker 节点连接状态变化
- CI/CD 新增 build-hub job,Dockerfile 集成 Hub 二进制,deploy.sh 适配 Hub 构建
- 默认 GUNICORN_WORKERS 从 4 降为 2
2026-03-02 02:43:14 +08:00
fawney19 97d42703da feat(codex): 拆分openai:compact为独立端点,简化Codex请求为透传模式
- 新增openai:compact端点类型(EndpointKind.COMPACT),独立于openai:cli
- OpenAICompactAdapter继承OpenAICliAdapter,自动标记compact模式
- Codex请求补丁改为纯透传:仅清理内部标记,不再修改客户端payload
- stream_policy支持openai:compact独立策略,compact端点移除stream字段
- candidate_builder支持compact回退到cli端点
- auth_type: vertex_ai重命名为service_account,保持向后兼容
- Vertex Provider新增api_formats与auth_type组合校验
- KeyAllowedModels对话框改为从Provider获取模型,展示provider_model_name
- Dialog内Select组件自动禁用Portal,修复层级遮挡问题
- 新增Codex compact端点回填迁移脚本
2026-03-01 23:55:26 +08:00
fawney19 4bf3a453e7 feat(vertex-ai): 重构 Vertex AI 为插件化 adapter,支持 service_account 认证与动态路由
将 Vertex AI 从 transport.py 的硬编码逻辑重构为独立的 plugin adapter,
支持 service_account/oauth 认证类型、模型格式自动识别、区域路由和 URL 构建。
前端新增 Key 认证类型选择和 Service Account 配置表单。

Co-authored-by: NyaDoo <[email protected]>
Closes #194
2026-03-01 23:32:48 +08:00
fawney19 a137601728 feat(codex): 支持compact端点非流式请求,更新请求头与字段清理
- Codex适配器支持compact端点:非流式请求使用application/json Accept头,
  stream_policy根据compact上下文返回FORCE_NON_STREAM
- 更新Codex请求头格式:添加Version/Connection头,header key首字母大写
- 简化include列表处理:normalizer和request_patching统一强制为固定列表
- 清理Codex不支持的字段:truncation、context_management、user
- 默认instructions改为空字符串
- 前端用量页面:用户页面使用前端筛选后总数,避免不必要的后端分页请求
2026-03-01 18:20:42 +08:00
fawney19 d4840df447 feat(tunnel): 重连指数退避、多worker兼容与手动节点请求计数
- Proxy tunnel 重连策略从固定1s改为指数退避+jitter,首次重试立即执行,
  稳定连接30s后重置退避计数,上限3s保证快速恢复
- 多连接启动时增加错峰延迟,避免同时发起连接风暴
- 服务端 tunnel ping间隔和空闲超时支持环境变量配置
- 修复多worker启动时tunnel状态重置逻辑,仅leader执行重置避免覆盖其他worker连接
- Resolver增加本地tunnel缺失的限频告警和更短缓存TTL,加速多worker场景恢复
- 用量记录中统计手动代理节点的请求数和失败数
2026-03-01 17:37:11 +08:00
fawney19 2c1e51a490 refactor(compression): 请求压缩策略改为跟随客户端行为,响应支持gzip压缩
- 移除全局 ENABLE_REQUEST_COMPRESSION 配置,改为根据客户端 Content-Encoding
  决定是否对上游请求体进行 gzip 压缩
- 非流式响应根据客户端 Accept-Encoding 返回 gzip 压缩的 JSON
- ApiRequestContext 记录客户端编码偏好并透传至 handler 链路
- 新增 http_compression 模块统一处理压缩相关判断逻辑
- 上游请求头丢弃列表新增 content-encoding 防止客户端值泄露
- ensure_json_body 支持解压 gzip 编码的请求体
2026-03-01 15:52:32 +08:00
fawney19 2dcccc9820 fix(kiro): 修复工具schema兼容性、消息交替和重复内容问题
- 递归清理工具schema中Kiro不支持的additionalProperties和空required字段
- 将thinking prefix注入从history移到currentMessage,仅作用于当前轮次
- 修复连续assistant消息缺少user消息导致角色不交替的问题
- 增加流式content事件去重,跳过Kiro发送的重复内容
2026-03-01 14:25:38 +08:00
fawney19 8fa97ab8da feat(provider): 更新请求模型添加格式转换开关字段 2026-03-01 13:08:20 +08:00
fawney19 a66fa9792d fix(stream): 修复流式请求超时和取消归因问题
- 流式请求 timeout 改为 None,避免 provider.request_timeout 作为整条流总时长超时导致长响应被硬切断
- 重构 CancelledError 断连归因逻辑:提取探测方法并支持多次重试确认,降低误判率
- 新增 cancelled_unknown 状态处理断连检测不确定的场景,避免错误归因为 server_cancelled
- 在 upstream_response 中记录取消详情,便于排查
- 新增断连归因逻辑的单元测试
2026-03-01 13:02:26 +08:00
fawney19 dc2bc83c17 refactor(frontend): 改进故障转移规则对话框的状态码输入与校验
状态码输入从即时双向解析改为原始字符串绑定,保存时统一校验,
增加明确的错误提示;提取正则验证函数;优化小屏布局防止按钮挤压
2026-03-01 12:37:08 +08:00
fawney19 8b0e92e408 perf(frontend): 管理页面更新操作改为局部刷新,避免全量重载列表
Provider、API Key、Pool Key、Management Token 的更新操作
完成后直接替换本地列表中对应记录,仅创建操作保留全量刷新。
2026-03-01 11:57:09 +08:00
fawney19 b61fc4eb6b feat(test): 模型测试支持故障转移,展示每次尝试详情
- 后端新增 test-model-failover 接口,支持 global/direct 两种测试模式
- 利用 FailoverEngine 遍历候选并记录每次尝试的状态、延迟、错误等详情
- 前端 ModelsTab/ModelMappingTab 切换到新接口,移除格式选择下拉菜单
- 新增 TestResultDialog 组件,失败时展示候选尝试详情表格
- 简化组件 props 传递,移除不再需要的 endpoints/mappingPreview 依赖
2026-03-01 03:37:41 +08:00
fawney19 fea3d183bf Merge pull request #195 from AAEE86/keys
feat(pool): 新增账号配额展示并清理 Codex 旧限额字段
2026-03-01 00:59:49 +08:00
fawney19 20ed9cd123 feat(proxy): worker 退出时优雅关闭 tunnel 连接,提升 max-requests 默认值
- TunnelManager 新增 shutdown_all 方法,drain 飞行中请求后发送 GoAway
- shutdown 期间标记 draining 拒绝新请求进入
- gunicorn max-requests 默认值从 4000 提升到 50000,减少不必要的 worker 重启
2026-03-01 00:54:37 +08:00
AAEE86 d7a8a89aa7 feat(pool): 新增账号配额展示并清理 Codex 旧限额字段
- 后端池子 Key 列表新增 account_quota 字段,按 provider_type 生成配额摘要(codex/kiro/antigravity)
- 前端 PoolManagement 新增“配额”列与进度条展示,桌面端与移动端同步支持
- Provider 详情页移除 Codex code_review 限额展示,仅保留周限额与 5H 限额
- 清理 codex usage parser/realtime quota 中 code_review 相关解析与比较逻辑
- 同步更新调度与配额相关测试,改为忽略无关历史字段
2026-03-01 00:32:14 +08:00
fawney19 005cc3e388 feat(failover): 支持 Provider 级别故障转移规则,默认全部转移策略
- 新增 failover_rules 配置:支持 success_failover_patterns(成功响应匹配时转移)
  和 error_stop_patterns(错误响应匹配时终止),支持按 status_code 过滤
- 修改默认转移策略:ErrorClassifier 不再返回 RAISE,所有错误默认继续转移
- TaskService 中客户端错误不再直接抛出,改为 break 继续尝试下一个候选
- 修复 proxy tunnel 连接/断连竞态:引入 per-node 锁和事件时间戳排序
- 优化 ProxyNode 状态判定:OFFLINE 统一由心跳超时判定,兼容多 worker 场景
- has_tunnel 改为纯检查方法,避免在 finally 块中误清理新注册连接
- Redis stream NOGROUP 异常自愈处理
- OAuthAccountDialog 输入框焦点样式补全
2026-03-01 00:21:16 +08:00
fawney19 fbcb54a8a5 feat(oauth): 优化凭据导入,支持多文件选择与更多 JSON 格式
前端:简化导入界面状态管理,支持多文件拖拽/选择并自动合并内容,
移除冗余的 importFileName/manualPasteText 状态。
后端:_parse_tokens_input 新增支持 JSON 对象数组和单个 JSON 对象格式解析。
2026-02-28 22:06:21 +08:00
github-actions[bot] 85a126f48a chore(proxy): update download links for proxy-v0.2.1 2026-02-28 13:09:33 +00:00
fawney19 44cd35c10e chore(proxy): bump aether-proxy to 0.2.1 2026-02-28 21:02:21 +08:00
fawney19 a2d1cff3b0 perf(transport): 全链路传输压缩优化
- 上游请求启用 HTTP/2 (HPACK 头部压缩 + 多路复用),添加 h2 依赖
- 上游请求体超过阈值时自动 gzip 压缩,使用紧凑 JSON 序列化
- 添加 brotli 依赖,Accept-Encoding 支持 gzip/deflate/br
- 隧道帧压缩: Rust 端响应帧和 Python 端请求/响应帧均支持 gzip
- Rust 端压缩/解压逻辑统一提取到 protocol.rs
- Rust 端请求头构建改用 .headers() 替换 reqwest 默认值
- 新增 ENABLE_HTTP2、ENABLE_REQUEST_COMPRESSION 等环境变量配置
2026-02-28 21:02:21 +08:00
fawney19 3ff67fec2f Merge pull request #193 from AAEE86/keys
refactor(provider-keys): 拆分 keys 端点逻辑并补充配额刷新测试
2026-02-28 17:15:19 +08:00
AAEE86 6a8b5e6c8e fix(provider_keys): 提升 Codex 配额异步同步的可靠性与可观测性
- 为 flush 引入 FlushResult,统一返回更新数与重试批次
- 增加指数退避与失败日志限流,成功后重置 backoff
- 批量提交失败时回退到单条提交,降低整批失败风险
- 补充测试,覆盖 flush 重试与提交失败回退场景
2026-02-28 16:54:37 +08:00
AAEE86 92e9caf57e feat(usage): 支持 Codex 配额响应头实时异步同步
- 新增 parse_codex_usage_headers,统一解析响应头中的 Codex 配额信息
- 新增实时配额同步与异步调度器,按 provider_api_key_id 去重并后台落库
- 在 usage 记录与结算流程中接入配额同步投递,并在应用生命周期中启动/停止调度器
- 补充 codex_realtime_quota 与 codex_quota_sync_dispatcher 相关单元测试
2026-02-28 16:35:56 +08:00
AAEE86 10ccbf109c Merge branch 'fawney19:master' into keys 2026-02-28 16:13:30 +08:00
fawney19 f2dc51434d refactor(models): 删除拆分的模型子模块文件,统一回 database.py
- 删除 _base.py, auth.py, misc.py, model.py, provider.py, stats.py, usage.py, user.py
- Usage 表新增 cache_creation_input_tokens_5m/1h 字段支持按缓存 TTL 细分计费
- Model.global_model_id 改为 nullable=False,强制关联 GlobalModel
2026-02-28 16:12:32 +08:00
fawney19 9d751e3525 fix: 修复迁移脚本 2026-02-28 15:33:43 +08:00
fawney19 27047e5880 fix(migration): backfill 前清空 user_model_usage_counts 确保幂等性 2026-02-28 15:28:34 +08:00
fawney19 4fdebfc78e perf(usage): 优化 admin usage records 查询性能
- count 查询按需 JOIN,避免不必要的表关联
- 前端 onMounted 将 stats/heatmap/records/users 全部并行加载
- 调整缓存 TTL(聚合 30s->60s,列表 10s->15s)
- 为 request_candidates 添加复合索引优化 fallback/retry 查询
2026-02-28 15:09:21 +08:00
fawney19 11832edf49 Merge pull request #192 from AAEE86/master
refactor(usage): 移除时间线中的格式转换标签展示
2026-02-28 14:47:18 +08:00
fawney19 87d50052cc refactor(proxy): 移除 unhealthy 状态、前端展示失败率替换连接数、命令提示修正
- 移除 ProxyNodeStatus.UNHEALTHY 枚举值,仅保留 online/offline
- 迁移脚本将已有 unhealthy 数据迁移为 offline,upgrade/downgrade 均有幂等性保护
- 前端代理节点列表用失败率列替换连接数列,超过 5% 高亮显示
- 节点列表排序从 updated_at DESC 改为 name ASC
- Rust 端命令行提示从 aether-proxy 改为 ./aether-proxy
2026-02-28 14:44:44 +08:00
AAEE86 08b89b7ef8 refactor(provider-keys): 拆分 keys 端点逻辑并补充配额刷新测试
将 admin keys 接口的创建、更新、查询、删除、导出与配额刷新逻辑迁移到 provider_keys 服务层

新增 auth_type 归一化、重复校验、响应构建与 quota_refresh(codex/kiro/antigravity)模块,并补充对应单元测试
2026-02-28 14:12:08 +08:00
fawney19 4b02078b60 fix(proxy): 自动注册节点时按 ip+port 匹配而非 name 2026-02-28 14:10:44 +08:00
fawney19 1d644de500 fix: 修复迁移脚本 2026-02-28 14:03:42 +08:00
AAEE86 daecdb7676 feat: 统一供应商凭据标签并优化统计展示布局 2026-02-28 10:09:51 +08:00
AAEE86 423eb95f7a refactor(usage): 移除时间线中的格式转换标签展示 2026-02-28 09:42:33 +08:00
882 changed files with 125253 additions and 36462 deletions
+1
View File
@@ -11,6 +11,7 @@ ENV/
.uv/ .uv/
*.egg-info/ *.egg-info/
dist/ dist/
!aether-hub/dist/aether-hub
build/ build/
*.egg *.egg
+53 -3
View File
@@ -21,6 +21,10 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key # 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
ENCRYPTION_KEY=change-this-to-another-secure-random-string ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 支付回调共享密钥(公开 /api/payment/callback/* 入口必须携带 x-payment-callback-token)
# 建议使用 32+ 位随机字符串
PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-callback-secret
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码) # 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
ADMIN_EMAIL=[email protected] ADMIN_EMAIL=[email protected]
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
@@ -32,15 +36,53 @@ ADMIN_PASSWORD=admin123456
# 应用端口(默认 8084) # 应用端口(默认 8084)
# APP_PORT=8084 # APP_PORT=8084
# Gunicorn Worker 数量(默认 4) # 生产部署镜像(deploy.sh 会读取)
# 建议最小设置为 2 # APP_IMAGE=ghcr.io/fawney19/aether:latest
# GUNICORN_WORKERS=4
# Gunicorn Worker 数量(默认 2)
# Tunnel 请求统一经 Hub 转发,可安全使用多 worker。
# 非 Docker 运行时若使用 ProxyNode tunnel,请确保 aether-hub 可达(默认 ws://127.0.0.1:8085)。
# GUNICORN_WORKERS=2
# Gunicorn Max Requests(默认 4000) # Gunicorn Max Requests(默认 4000)
# Worker 处理指定数量请求后自动重启,防止内存泄漏 # Worker 处理指定数量请求后自动重启,防止内存泄漏
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%) # max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
# MAX_REQUESTS=4000 # MAX_REQUESTS=4000
# glibc malloc arena 上限(默认 2)
# 降低 malloc 内存碎片,减少 gunicorn worker RSS
# MALLOC_ARENA_MAX=2
# HTTP 连接池上限(默认总预算约 200,按 worker 平分)
# 如果容器内存偏高,可继续下调;例如 2 worker 时设为 80-100
# HTTP_MAX_CONNECTIONS=100
# HTTP 保活连接数(默认约为 max_connections 的 30%)
# HTTP_KEEPALIVE_CONNECTIONS=30
# HTTP 代理/Tunnel 客户端空闲清理(默认每 5 分钟扫描,空闲 600 秒即关闭)
# HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES=5
# HTTP_CLIENT_IDLE_CLEANUP_MAX_SECONDS=600
# curl_cffi session 池上限(默认 20,按 impersonate + proxy 组合缓存)
# CURL_CFFI_MAX_SESSIONS=20
# 流式响应块缓存上限(单位 MB,默认 2)
# 说明:
# - 这是单个流式请求可保留的“解析后响应块”内存上限,不是全局上限
# - 粗略峰值内存 ≈ 并发流数量 × RESPONSE_CHUNKS_MAX_SIZE_MB
# 例如:100 并发、2MB 上限,理论峰值约 200MB
# - 建议:
# - 内存敏感环境:1
# - 通用生产环境:2(默认)
# - 需要更多调试上下文:4
# RESPONSE_CHUNKS_MAX_SIZE_MB=2
# 流式空闲超时(单位秒,默认 30)
# 当流已经开始但连续一段时间没有任何新 chunk 时,提前中断并返回 504,
# 避免一直等到 worker 超时(如 300s)
# STREAM_IDLE_TIMEOUT_SECONDS=30
# API Key 前缀(默认 sk) # API Key 前缀(默认 sk)
# API_KEY_PREFIX=sk # API_KEY_PREFIX=sk
@@ -52,6 +94,14 @@ ADMIN_PASSWORD=admin123456
# 默认: * (允许所有源) # 默认: * (允许所有源)
# CORS_ORIGINS=* # CORS_ORIGINS=*
# 启动预热配置(默认启用,降低首请求冷启动延迟)
# 是否启用启动期预热任务(默认 true)
# STARTUP_WARMUP_ENABLED=true
# /readyz 是否等待预热完成(默认 true)
# STARTUP_WARMUP_GATE_READINESS=true
# 预热时优先 bootstrap 的 provider_type 列表(逗号分隔;留空表示自动探测)
# STARTUP_WARMUP_PROVIDER_TYPES=codex,kiro
# ==================== 计费系统(可选) ==================== # ==================== 计费系统(可选) ====================
# Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警) # Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警)
# BILLING_REQUIRE_RULE=false # BILLING_REQUIRE_RULE=false
+91
View File
@@ -0,0 +1,91 @@
name: Build aether-hub
on:
push:
tags: ['hub-v*']
workflow_dispatch:
permissions:
contents: write
jobs:
build:
name: ${{ matrix.name }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-gnu
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-gnu
use_cross: true
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: aether-hub -> target
key: ${{ matrix.target }}
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build
working-directory: aether-hub
shell: bash
run: |
if [ "${{ matrix.use_cross }}" = "true" ]; then
cross build --release --target ${{ matrix.target }}
else
cargo build --release --target ${{ matrix.target }}
fi
- name: Package
shell: bash
run: |
cd aether-hub/target/${{ matrix.target }}/release
chmod +x aether-hub
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: aether-hub-${{ matrix.name }}
path: aether-hub-*.tar.gz
if-no-files-found: error
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
merge-multiple: true
path: artifacts
- name: Generate checksums
working-directory: artifacts
run: sha256sum aether-hub-* > SHA256SUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "${{ github.ref_name }}"
generate_release_notes: true
files: |
artifacts/aether-hub-*
artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true
+8 -8
View File
@@ -44,7 +44,7 @@ jobs:
use_cross: false use_cross: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -87,7 +87,7 @@ jobs:
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe 7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v5
with: with:
name: aether-proxy-${{ matrix.name }} name: aether-proxy-${{ matrix.name }}
path: | path: |
@@ -101,7 +101,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
steps: steps:
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
merge-multiple: true merge-multiple: true
path: artifacts path: artifacts
@@ -113,7 +113,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
name: "aether-proxy ${{ github.ref_name }}" name: "${{ github.ref_name }}"
generate_release_notes: true generate_release_notes: true
files: | files: |
artifacts/aether-proxy-* artifacts/aether-proxy-*
@@ -125,10 +125,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Download Linux artifacts - name: Download Linux artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
pattern: aether-proxy-linux-* pattern: aether-proxy-linux-*
merge-multiple: true merge-multiple: true
@@ -174,7 +174,7 @@ jobs:
latest=auto latest=auto
- name: Build and push - name: Build and push
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: ./aether-proxy context: ./aether-proxy
push: true push: true
@@ -187,7 +187,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
with: with:
ref: master ref: master
+4 -4
View File
@@ -18,12 +18,12 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: '20' node-version: '22'
cache: 'npm' cache: 'npm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
@@ -41,7 +41,7 @@ jobs:
run: cp frontend/dist/index.html frontend/dist/404.html run: cp frontend/dist/index.html frontend/dist/404.html
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@v5
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
+83 -13
View File
@@ -15,6 +15,7 @@ env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
BASE_IMAGE_NAME: fawney19/aether-base BASE_IMAGE_NAME: fawney19/aether-base
APP_IMAGE_NAME: fawney19/aether APP_IMAGE_NAME: fawney19/aether
GITHUB_REPO: fawney19/Aether
# Base image hash inputs: # Base image hash inputs:
# - Dockerfile.base # - Dockerfile.base
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps) # - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
@@ -29,7 +30,7 @@ jobs:
outputs: outputs:
base_changed: ${{ steps.check.outputs.base_changed }} base_changed: ${{ steps.check.outputs.base_changed }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Log in to Container Registry - name: Log in to Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -107,7 +108,7 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -163,7 +164,7 @@ jobs:
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }} org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
- name: Build and push base image - name: Build and push base image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile.base file: ./Dockerfile.base
@@ -174,15 +175,36 @@ jobs:
cache-to: type=gha,mode=max,scope=base cache-to: type=gha,mode=max,scope=base
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
download-hub:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
hub_tag: ${{ steps.hub-tag.outputs.tag }}
steps:
- name: Get latest hub release tag
id: hub-tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG=$(gh release list --repo "${{ env.GITHUB_REPO }}" --limit 50 --json tagName,isDraft,isPrerelease \
--jq '[.[] | select(.tagName | startswith("hub-v")) | select(.isDraft == false and .isPrerelease == false)] | .[0].tagName')
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "No hub release found"
exit 1
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Hub release tag: $TAG"
build-app: build-app:
needs: [check-base-changes, build-base] needs: [check-base-changes, build-base, download-hub]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && needs.download-hub.result == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -243,15 +265,63 @@ jobs:
version_tuple = __version_tuple__ version_tuple = __version_tuple__
EOF EOF
- name: Build and push app image - name: Resolve hub release for build args
uses: docker/build-push-action@v5 run: |
echo "Hub release tag: ${{ needs.download-hub.outputs.hub_tag }}"
- name: Build and push app image (amd64)
id: build-amd64
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile.app file: ./Dockerfile.app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
no-cache-filters: builder no-cache-filters: builder
cache-from: type=gha,scope=app cache-from: type=gha,scope=app-amd64
cache-to: type=gha,mode=min,scope=app cache-to: type=gha,mode=min,scope=app-amd64
platforms: linux/amd64,linux/arm64 build-args: |
HUB_RELEASE_REPO=${{ env.GITHUB_REPO }}
HUB_TAG=${{ needs.download-hub.outputs.hub_tag }}
platforms: linux/amd64
outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }},docker.io/fawney19/aether",push-by-digest=true,name-canonical=true,push=true
- name: Build and push app image (arm64)
id: build-arm64
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
labels: ${{ steps.meta.outputs.labels }}
no-cache-filters: builder
cache-from: type=gha,scope=app-arm64
cache-to: type=gha,mode=min,scope=app-arm64
build-args: |
HUB_RELEASE_REPO=${{ env.GITHUB_REPO }}
HUB_TAG=${{ needs.download-hub.outputs.hub_tag }}
platforms: linux/arm64
outputs: type=image,"name=${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }},docker.io/fawney19/aether",push-by-digest=true,name-canonical=true,push=true
- name: Create multi-arch manifest and push
run: |
# Extract digests
AMD64_DIGEST="${{ steps.build-amd64.outputs.digest }}"
ARM64_DIGEST="${{ steps.build-arm64.outputs.digest }}"
echo "amd64 digest: $AMD64_DIGEST"
echo "arm64 digest: $ARM64_DIGEST"
# For each tag, create multi-arch manifest on each registry
TAGS=$(echo "${{ steps.meta.outputs.tags }}" | tr '\n' ' ')
for FULL_TAG in $TAGS; do
# Determine which registry this tag belongs to
if [[ "$FULL_TAG" == ghcr.io/* ]]; then
REPO="${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}"
elif [[ "$FULL_TAG" == docker.io/* ]]; then
REPO="docker.io/fawney19/aether"
else
continue
fi
echo "Creating manifest for $FULL_TAG"
docker buildx imagetools create -t "$FULL_TAG" \
"$REPO@$AMD64_DIGEST" \
"$REPO@$ARM64_DIGEST"
done
+6
View File
@@ -2,6 +2,7 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=python # Edit at https://www.toptal.com/developers/gitignore?templates=python
# AI Assistant Configuration # AI Assistant Configuration
.codex/
.claude/ .claude/
.serena/ .serena/
.gemini*/ .gemini*/
@@ -203,6 +204,7 @@ logs/
# Git backup # Git backup
.git.backup/ .git.backup/
.worktrees/
# Database backups # Database backups
backups/ backups/
@@ -226,6 +228,10 @@ test.py
.deps-hash .deps-hash
.code-hash .code-hash
.migration-hash .migration-hash
.hub-hash
# Hub prebuilt binaries
aether-hub/dist/
# Version file (auto-generated by hatch-vcs) # Version file (auto-generated by hatch-vcs)
src/_version.py src/_version.py
+124 -6
View File
@@ -2,14 +2,22 @@
# 运行镜像:从 base 提取产物到精简运行时 # 运行镜像:从 base 提取产物到精简运行时
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest . # 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
# 用于 GitHub Actions CI(官方源) # 用于 GitHub Actions CI(官方源)
FROM aether-base:latest AS builder FROM aether-base:latest AS builder
WORKDIR /app WORKDIR /app
# 复制前端源码并构建(CI 通过 no-cache-filters=builder 确保每次重建) # 复制前端源码并构建(CI 通过 no-cache-filters=builder 确保每次重建)
COPY frontend/ ./frontend/ COPY frontend/ ./frontend/
RUN cd frontend && npm run build RUN cd frontend && npm run build
# ==================== 运行时镜像 ==================== # ==================== 运行时镜像 ====================
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG HUB_RELEASE_REPO=fawney19/Aether
ARG HUB_TAG
ARG TARGETARCH
ARG GITHUB_TOKEN
# 运行时依赖(无 gcc/nodejs/npm,使用 BuildKit 缓存加速) # 运行时依赖(无 gcc/nodejs/npm,使用 BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -17,13 +25,49 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
nginx \ nginx \
supervisor \ supervisor \
libpq5 \ libpq5 \
curl curl \
libjemalloc2
RUN set -eux; \
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
[ -n "$jemalloc_path" ]; \
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
# 从 base 镜像复制 Python 包 # 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# 只复制需要的 Python 可执行文件 # 只复制需要的 Python 可执行文件
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/ COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# Hub 预编译二进制(构建时从 GitHub Release 下载)
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
RUN set -eux; \
auth_header=""; \
if [ -n "${GITHUB_TOKEN:-}" ]; then \
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
fi; \
tag="${HUB_TAG:-}"; \
if [ -z "$tag" ]; then \
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
fi; \
if [ -z "$tag" ]; then \
echo "Failed to resolve hub release tag"; \
exit 1; \
fi; \
arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then \
arch="$(dpkg --print-architecture)"; \
fi; \
case "$arch" in \
amd64|arm64) ;; \
x86_64) arch="amd64" ;; \
aarch64) arch="arm64" ;; \
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
esac; \
echo "Using Hub release tag: $tag"; \
url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
chmod +x /usr/local/bin/aether-hub; \
rm -f /tmp/aether-hub.tar.gz
# 从 builder 阶段复制前端构建产物 # 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html RUN chmod -R 755 /usr/share/nginx/html
@@ -46,6 +90,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \ ' "" $remote_addr;' \
'}' \ '}' \
'' \ '' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \ 'server {' \
' listen 80;' \ ' listen 80;' \
' server_name _;' \ ' server_name _;' \
@@ -77,7 +126,7 @@ RUN printf '%s\n' \
'' \ '' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \ ' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \ ' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \ ' proxy_pass http://127.0.0.1:8085/proxy;' \
' proxy_http_version 1.1;' \ ' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \ ' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
@@ -85,6 +134,25 @@ RUN printf '%s\n' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_upgrade;' \ ' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection "upgrade";' \ ' proxy_set_header Connection "upgrade";' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' proxy_read_timeout 86400s;' \ ' proxy_read_timeout 86400s;' \
' proxy_send_timeout 86400s;' \ ' proxy_send_timeout 86400s;' \
' }' \ ' }' \
@@ -97,11 +165,31 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \ ' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \ ' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \ ' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \ ' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \ ' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \ ' proxy_set_header X-Api-Key $http_x_api_key;' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' proxy_buffering off;' \ ' proxy_buffering off;' \
' proxy_cache off;' \ ' proxy_cache off;' \
' proxy_request_buffering off;' \ ' proxy_request_buffering off;' \
@@ -121,6 +209,25 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \ ' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' }' \ ' }' \
'' \ '' \
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \ ' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
@@ -143,7 +250,7 @@ RUN printf '%s\n' \
'stderr_logfile=/var/log/nginx/error.log' \ 'stderr_logfile=/var/log/nginx/error.log' \
'' \ '' \
'[program:app]' \ '[program:app]' \
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-4000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --timeout 120 --max-requests ${MAX_REQUESTS:-4000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \ 'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 127.0.0.1:8084 --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
'directory=/app' \ 'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
@@ -151,7 +258,16 @@ RUN printf '%s\n' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \ 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf 'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \
'' \
'[program:tunnel-hub]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录 # 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 入口脚本(启动前执行迁移) # 入口脚本(启动前执行迁移)
@@ -163,8 +279,10 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \ PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \ PORT=8084 \
GUNICORN_WORKERS=4 \ GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000 MAX_REQUESTS=4000
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+135 -6
View File
@@ -2,6 +2,7 @@
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本) # 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest . # 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# 用于本地/国内服务器部署 # 用于本地/国内服务器部署
FROM aether-base:latest AS builder FROM aether-base:latest AS builder
WORKDIR /app WORKDIR /app
@@ -15,6 +16,16 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG HUB_RELEASE_REPO=fawney19/Aether
ARG HUB_TAG
ARG TARGETARCH
ARG GITHUB_TOKEN
# GitHub 下载镜像前缀,国内构建时传入可用的镜像加速地址
# 用法: --build-arg GITHUB_MIRROR=https://ghfast.top
# 或: --build-arg GITHUB_MIRROR=https://gh-proxy.com
# 或: --build-arg GITHUB_MIRROR=https://mirror.ghproxy.com
ARG GITHUB_MIRROR
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速) # 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \
@@ -23,7 +34,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
nginx \ nginx \
supervisor \ supervisor \
libpq5 \ libpq5 \
curl curl \
libjemalloc2
RUN set -eux; \
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
[ -n "$jemalloc_path" ]; \
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
# 从 base 镜像复制 Python 包 # 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
@@ -33,6 +49,45 @@ COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/ COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/ COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# Hub 预编译二进制
# 国内构建: --build-arg GITHUB_MIRROR=https://ghfast.top 即可走镜像下载
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
RUN set -eux; \
arch="${TARGETARCH:-}"; \
if [ -z "$arch" ]; then \
arch="$(dpkg --print-architecture)"; \
fi; \
case "$arch" in \
amd64|arm64) ;; \
x86_64) arch="amd64" ;; \
aarch64) arch="arm64" ;; \
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
esac; \
auth_header=""; \
if [ -n "${GITHUB_TOKEN:-}" ]; then \
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
fi; \
tag="${HUB_TAG:-}"; \
if [ -z "$tag" ]; then \
tag="$(curl -sL ${auth_header:+-H "$auth_header"} "https://api.github.com/repos/${HUB_RELEASE_REPO}/releases" | python3 -c "import json,sys;print(next((r['tag_name'] for r in json.load(sys.stdin) if r.get('tag_name','').startswith('hub-v') and not r.get('draft') and not r.get('prerelease')),''))")"; \
fi; \
if [ -z "$tag" ]; then \
echo "Failed to resolve hub release tag"; \
exit 1; \
fi; \
echo "Using Hub release tag: $tag"; \
origin_url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
if [ -n "${GITHUB_MIRROR:-}" ]; then \
url="${GITHUB_MIRROR}/https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
echo "Using mirror: ${GITHUB_MIRROR}"; \
else \
url="$origin_url"; \
fi; \
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
chmod +x /usr/local/bin/aether-hub; \
rm -f /tmp/aether-hub.tar.gz
# 从 builder 阶段复制前端构建产物 # 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html RUN chmod -R 755 /usr/share/nginx/html
@@ -57,6 +112,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \ ' "" $remote_addr;' \
'}' \ '}' \
'' \ '' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \ 'server {' \
' listen 80;' \ ' listen 80;' \
' server_name _;' \ ' server_name _;' \
@@ -88,7 +148,7 @@ RUN printf '%s\n' \
'' \ '' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \ ' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \ ' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \ ' proxy_pass http://127.0.0.1:8085/proxy;' \
' proxy_http_version 1.1;' \ ' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \ ' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
@@ -96,6 +156,25 @@ RUN printf '%s\n' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_upgrade;' \ ' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection "upgrade";' \ ' proxy_set_header Connection "upgrade";' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' proxy_read_timeout 86400s;' \ ' proxy_read_timeout 86400s;' \
' proxy_send_timeout 86400s;' \ ' proxy_send_timeout 86400s;' \
' }' \ ' }' \
@@ -108,11 +187,31 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \ ' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \ ' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \ ' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \ ' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \ ' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \ ' proxy_set_header X-Api-Key $http_x_api_key;' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' proxy_buffering off;' \ ' proxy_buffering off;' \
' proxy_cache off;' \ ' proxy_cache off;' \
' proxy_request_buffering off;' \ ' proxy_request_buffering off;' \
@@ -132,6 +231,25 @@ RUN printf '%s\n' \
' proxy_set_header X-Real-IP $real_ip;' \ ' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \ ' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \ ' proxy_set_header X-Forwarded-Proto $scheme;' \
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
' proxy_hide_header CF-Connecting-IP;' \
' proxy_hide_header CF-IPCountry;' \
' proxy_hide_header CF-Ray;' \
' proxy_hide_header CF-Visitor;' \
' proxy_hide_header CDN-Loop;' \
' proxy_hide_header True-Client-IP;' \
' proxy_hide_header CF-Worker;' \
' proxy_hide_header CF-EW-Via;' \
' proxy_hide_header CF-Warp-Tag-ID;' \
' proxy_set_header CF-Connecting-IP "";' \
' proxy_set_header CF-IPCountry "";' \
' proxy_set_header CF-Ray "";' \
' proxy_set_header CF-Visitor "";' \
' proxy_set_header CDN-Loop "";' \
' proxy_set_header True-Client-IP "";' \
' proxy_set_header CF-Worker "";' \
' proxy_set_header CF-EW-Via "";' \
' proxy_set_header CF-Warp-Tag-ID "";' \
' }' \ ' }' \
'' \ '' \
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \ ' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
@@ -155,7 +273,7 @@ RUN printf '%s\n' \
'stderr_logfile=/var/log/nginx/error.log' \ 'stderr_logfile=/var/log/nginx/error.log' \
'' \ '' \
'[program:app]' \ '[program:app]' \
'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-4000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --max-requests ${MAX_REQUESTS:-4000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \ 'command=/bin/bash -c "MAX_REQUESTS_JITTER=$((${MAX_REQUESTS:-50000}/20)); exec gunicorn src.main:app -c gunicorn_conf.py --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --max-requests ${MAX_REQUESTS:-50000} --max-requests-jitter $MAX_REQUESTS_JITTER --access-logfile - --error-logfile - --log-level info"' \
'directory=/app' \ 'directory=/app' \
'autostart=true' \ 'autostart=true' \
'autorestart=true' \ 'autorestart=true' \
@@ -163,7 +281,16 @@ RUN printf '%s\n' \
'stdout_logfile_maxbytes=0' \ 'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \ 'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \ 'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf 'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \
'' \
'[program:tunnel-hub]' \
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录 # 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data RUN mkdir -p /var/log/supervisor /app/logs /app/data
@@ -178,8 +305,10 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \ PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \ PORT=8084 \
GUNICORN_WORKERS=4 \ GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000 MAX_REQUESTS=4000
EXPOSE 80 EXPOSE 80
+4 -4
View File
@@ -48,7 +48,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动执行数据库迁移) # 3. 部署 / 更新(自动执行数据库迁移)
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
# 4. 升级前备份 # 4. 升级前备份 (可选)
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
``` ```
@@ -64,6 +64,7 @@ cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动构建、启动、迁移) # 3. 部署 / 更新(自动构建、启动、迁移)
git pull
./deploy.sh ./deploy.sh
``` ```
@@ -81,7 +82,7 @@ uv sync
cd frontend && npm install && npm run dev cd frontend && npm install && npm run dev
``` ```
## Aether Proxy ## Aether Proxy (可选)
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。 Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
@@ -110,7 +111,7 @@ Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙
| `APP_PORT` | 8084 | 应用端口 | | `APP_PORT` | 8084 | 应用端口 |
| `API_KEY_PREFIX` | sk | API Key 前缀 | | `API_KEY_PREFIX` | sk | API Key 前缀 |
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) | | `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 | | `GUNICORN_WORKERS` | 2 | Gunicorn 工作进程数 |
| `DB_PORT` | 5432 | PostgreSQL 端口 | | `DB_PORT` | 5432 | PostgreSQL 端口 |
| `REDIS_PORT` | 6379 | Redis 端口 | | `REDIS_PORT` | 6379 | Redis 端口 |
@@ -180,4 +181,3 @@ docker compose up -d app
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date) [![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)
+3
View File
@@ -0,0 +1,3 @@
target/
.git/
.DS_Store
+2012
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "aether-hub"
version = "0.2.0"
edition = "2021"
description = "Tunnel Hub for Aether - frame router between workers and proxies"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive", "env"] }
dashmap = "6"
parking_lot = "0.12"
flate2 = "1"
futures-util = "0.3"
bytes = "1"
async-stream = "0.3"
http-body-util = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[profile.release]
lto = true
strip = true
codegen-units = 1
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
FROM rust:1.85-slim AS builder
WORKDIR /build/aether-hub
# 可选:配置国内 Cargo 镜像源(本地构建时传 --build-arg CARGO_MIRROR=1)
ARG CARGO_MIRROR
RUN if [ -n "$CARGO_MIRROR" ]; then \
printf '[source.crates-io]\nreplace-with = "tuna"\n\n[source.tuna]\nregistry = "sparse+https://mirrors.tuna.tsinghua.edu.cn/crates.io-index/"\n' \
> /usr/local/cargo/config.toml; \
fi
# 先构建依赖层,最大化后续代码变更时的缓存命中
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && printf 'fn main() {}\n' > src/main.rs
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
cargo build --release --locked
RUN rm -rf src
COPY src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/build/aether-hub/target,sharing=locked \
cargo build --release --locked && \
cp target/release/aether-hub /tmp/aether-hub
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
COPY --from=builder /tmp/aether-hub /usr/local/bin/aether-hub
EXPOSE 8085
ENTRYPOINT ["/usr/local/bin/aether-hub"]
CMD ["--bind", "0.0.0.0:8085"]
+38
View File
@@ -0,0 +1,38 @@
# aether-hub
`aether-hub` 是 Tunnel Hub 服务,负责在 proxy 与 worker 之间路由帧。
已集成在Docker镜像中, 无需单独部署。
## 部署端指定 Hub 版本并构建
```bash
cd /path/to/Aether
./deploy.sh --hub-tag hub-v0.1.0
```
不指定 `--hub-tag` 时,`./deploy.sh` 会自动解析最新 `hub-v*` release,并在构建 app 镜像时从 GitHub Release 下载对应架构的 Hub 二进制。
## build.sh 模式说明
- 默认是 `binary` 模式(`cross` 构建二进制)。
- `--upload <hub-vX.Y.Z>` 会把构建产物上传到 GitHub Release。
- 加 `--image` 后进入镜像模式(`docker buildx`,可选)。
常用参数:
- `--tag <tag>`: 镜像 tag
- `--image-name <name>`: 镜像名(默认 `ghcr.io/fawney19/aether-hub`)
- `--platforms <list>`: 例如 `linux/amd64,linux/arm64`
- `--push`: 推送镜像
- `--load`: 加载到本地 Docker(单平台)
- `--latest`: 额外打 `latest` tag
## 运行时参数
- `TUNNEL_HUB_WORKER_IDLE_TIMEOUT`:worker 心跳空闲超时,默认 `60` 秒
- `TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY`:单连接出站队列容量,默认 `128`;队列打满时会把连接视为拥塞并主动关闭,避免 Hub 内存无限增长
## 与部署脚本关系
- `./deploy.sh`: 本地构建部署(会本地构建 app/base,并在构建 app 时从 GitHub Release 下载 Hub,可用 `--hub-tag` 固定版本)。
+272
View File
@@ -0,0 +1,272 @@
#!/bin/bash
# aether-hub 构建脚本
#
# 支持两种模式:
# 1) binary 模式(默认): 构建多架构二进制并可上传 GitHub Release
# 2) image 模式: 构建并推送/加载 Docker 镜像(推荐生产发布用)
#
# 示例:
# # binary 模式(兼容旧行为)
# ./build.sh
# ./build.sh amd64
# ./build.sh --upload hub-v0.1.0
#
# # image 模式(多架构推送)
# ./build.sh --image --tag v0.2.5 --push --latest
# ./build.sh --image --tag sha-abc123 --image-name ghcr.io/fawney19/aether-hub --push
# ./build.sh --image --tag local-test --platforms linux/amd64 --load
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DIST_DIR="$SCRIPT_DIR/dist"
# -------------------------------
# Defaults
# -------------------------------
MODE="binary" # binary | image
# binary mode options
UPLOAD=false
UPLOAD_TAG=""
BINARY_TARGETS=""
# image mode options
IMAGE_NAME="${IMAGE_NAME:-ghcr.io/fawney19/aether-hub}"
IMAGE_TAG=""
IMAGE_PLATFORMS="linux/amd64,linux/arm64"
IMAGE_PUSH=false
IMAGE_LOAD=false
IMAGE_LATEST=false
usage() {
cat <<'EOF'
用法:
./build.sh [binary-args]
./build.sh --image [image-args]
binary 模式(默认):
amd64|arm64 仅构建指定架构(可重复)
--upload <hub-vX.Y.Z> 上传到 GitHub Release(需要 gh CLI)
image 模式:
--image 启用镜像模式
--tag <tag> 镜像 tag(默认自动从 git describe 推导)
--image-name <name> 镜像名(默认 ghcr.io/fawney19/aether-hub)
--platforms <list> 平台列表,逗号分隔(默认 linux/amd64,linux/arm64)
--push 推送镜像到仓库
--load 加载到本地 Docker(仅单平台)
--latest 额外打 latest tag
通用:
-h, --help 显示帮助
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--image)
MODE="image"
shift
;;
--tag)
IMAGE_TAG="${2:-}"
shift 2
;;
--image-name)
IMAGE_NAME="${2:-}"
shift 2
;;
--platforms)
IMAGE_PLATFORMS="${2:-}"
shift 2
;;
--push)
IMAGE_PUSH=true
shift
;;
--load)
IMAGE_LOAD=true
shift
;;
--latest)
IMAGE_LATEST=true
shift
;;
--upload)
UPLOAD=true
UPLOAD_TAG="${2:-}"
shift 2
;;
amd64|arm64)
BINARY_TARGETS="$BINARY_TARGETS $1"
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数: $1"
usage
exit 1
;;
esac
done
build_binary() {
if [ -z "$BINARY_TARGETS" ]; then
BINARY_TARGETS="amd64 arm64"
fi
if ! command -v cross >/dev/null 2>&1; then
echo "❌ 需要安装 cross: cargo install cross --git https://github.com/cross-rs/cross"
exit 1
fi
mkdir -p "$DIST_DIR"
echo "🔨 开始构建 aether-hub 二进制..."
echo " 目标平台: $BINARY_TARGETS"
echo ""
ARTIFACTS=""
for arch in $BINARY_TARGETS; do
case "$arch" in
amd64) target="x86_64-unknown-linux-gnu" ;;
arm64) target="aarch64-unknown-linux-gnu" ;;
*) echo "❌ 未知架构: $arch"; exit 1 ;;
esac
echo ">>> 构建 $arch ($target)..."
cd "$SCRIPT_DIR"
cross build --release --target "$target" --locked
BIN="target/$target/release/aether-hub"
if [ ! -f "$BIN" ]; then
echo "❌ 未找到二进制文件: $BIN"
exit 1
fi
ARCHIVE="$DIST_DIR/aether-hub-linux-$arch.tar.gz"
tar czf "$ARCHIVE" -C "target/$target/release" aether-hub
ARTIFACTS="$ARTIFACTS $ARCHIVE"
SIZE=$(du -h "$ARCHIVE" | cut -f1)
echo "✅ $arch 构建完成: $ARCHIVE ($SIZE)"
echo ""
done
cd "$DIST_DIR"
shasum -a 256 aether-hub-*.tar.gz > SHA256SUMS.txt
echo "📋 SHA256 校验和:"
cat SHA256SUMS.txt
echo ""
if [ "$UPLOAD" = true ]; then
if [ -z "$UPLOAD_TAG" ]; then
echo "❌ --upload 需要指定 tag,例如: ./build.sh --upload hub-v0.1.0"
exit 1
fi
if ! command -v gh >/dev/null 2>&1; then
echo "❌ 需要安装 GitHub CLI: brew install gh"
exit 1
fi
echo "📦 上传到 GitHub Release: $UPLOAD_TAG"
cd "$PROJECT_DIR"
if ! git rev-parse "$UPLOAD_TAG" >/dev/null 2>&1; then
git tag "$UPLOAD_TAG"
git push origin "$UPLOAD_TAG"
fi
gh release create "$UPLOAD_TAG" \
--title "aether-hub ${UPLOAD_TAG#hub-}" \
--generate-notes \
$ARTIFACTS \
"$DIST_DIR/SHA256SUMS.txt"
echo "✅ 上传完成!"
fi
echo "🎉 binary 模式完成!"
}
build_image() {
if ! command -v docker >/dev/null 2>&1; then
echo "❌ 未找到 docker,请先安装 Docker"
exit 1
fi
if ! docker buildx version >/dev/null 2>&1; then
echo "❌ 未找到 docker buildx,请先启用 buildx"
exit 1
fi
if [ "$IMAGE_PUSH" = true ] && [ "$IMAGE_LOAD" = true ]; then
echo "❌ --push 与 --load 不能同时使用"
exit 1
fi
if [ "$IMAGE_PUSH" = false ] && [ "$IMAGE_LOAD" = false ]; then
# image 模式默认走 push,符合发布场景
IMAGE_PUSH=true
fi
if [ -z "$IMAGE_TAG" ]; then
IMAGE_TAG=$(git -C "$PROJECT_DIR" describe --tags --always 2>/dev/null | sed 's/^v//')
if [ -z "$IMAGE_TAG" ]; then
IMAGE_TAG=$(date +%Y%m%d%H%M%S)
fi
fi
if [ "$IMAGE_LOAD" = true ] && [[ "$IMAGE_PLATFORMS" == *,* ]]; then
echo "❌ --load 仅支持单平台,请用 --platforms linux/amd64(或 arm64)"
exit 1
fi
local ref="${IMAGE_NAME}:${IMAGE_TAG}"
local cmd=(docker buildx build
--platform "$IMAGE_PLATFORMS"
-f "$SCRIPT_DIR/Dockerfile"
-t "$ref"
)
if [ "$IMAGE_LATEST" = true ]; then
cmd+=(-t "${IMAGE_NAME}:latest")
fi
if [ "$IMAGE_PUSH" = true ]; then
cmd+=(--push)
else
cmd+=(--load)
fi
cmd+=("$SCRIPT_DIR")
echo "🔨 开始构建 aether-hub 镜像..."
echo " image: $ref"
echo " platforms: $IMAGE_PLATFORMS"
echo " mode: $([ "$IMAGE_PUSH" = true ] && echo push || echo load)"
echo ""
"${cmd[@]}"
if [ "$IMAGE_PUSH" = true ]; then
echo "✅ 镜像已推送: $ref"
if [ "$IMAGE_LATEST" = true ]; then
echo "✅ 镜像已推送: ${IMAGE_NAME}:latest"
fi
else
echo "✅ 镜像已加载到本地: $ref"
fi
echo "🎉 image 模式完成!"
}
if [ "$MODE" = "image" ]; then
build_image
else
build_binary
fi
+85
View File
@@ -0,0 +1,85 @@
use reqwest::Client;
#[derive(Clone)]
pub struct ControlPlaneClient {
client: Option<Client>,
base_url: String,
}
impl ControlPlaneClient {
pub fn new(base_url: String) -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok();
Self { client, base_url }
}
pub fn disabled() -> Self {
Self {
client: None,
base_url: String::new(),
}
}
pub async fn heartbeat_ack(&self, payload: &[u8]) -> Result<Vec<u8>, String> {
let Some(client) = &self.client else {
return Ok(b"{}".to_vec());
};
let url = format!(
"{}/api/internal/hub/heartbeat",
self.base_url.trim_end_matches('/')
);
let response = client
.post(&url)
.header("content-type", "application/json")
.body(payload.to_vec())
.send()
.await
.map_err(|e| format!("heartbeat callback request failed: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"heartbeat callback failed with status {}",
response.status()
));
}
response
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(|e| format!("heartbeat callback body read failed: {e}"))
}
pub async fn push_node_status(
&self,
node_id: &str,
connected: bool,
conn_count: usize,
) -> Result<(), String> {
let Some(client) = &self.client else {
return Ok(());
};
let url = format!(
"{}/api/internal/hub/node-status",
self.base_url.trim_end_matches('/')
);
let response = client
.post(&url)
.json(&serde_json::json!({
"node_id": node_id,
"connected": connected,
"conn_count": conn_count,
}))
.send()
.await
.map_err(|e| format!("node-status callback request failed: {e}"))?;
if response.status().is_success() {
Ok(())
} else {
Err(format!(
"node-status callback failed with status {}",
response.status()
))
}
}
}
+878
View File
@@ -0,0 +1,878 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::Message;
use bytes::Bytes;
use dashmap::DashMap;
use parking_lot::{Mutex, RwLock};
use tokio::sync::mpsc;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{watch, Notify};
use tracing::{debug, info, warn};
use crate::control_plane::ControlPlaneClient;
use crate::protocol;
const MAX_REQUEST_BODY_FRAME_SIZE: usize = 32 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SendStatus {
Queued,
Closed,
Congested,
}
#[derive(Debug, Clone, Copy)]
pub struct ConnConfig {
pub ping_interval: Duration,
pub idle_timeout: Duration,
pub outbound_queue_capacity: usize,
}
pub struct BoundedOutbound {
tx: mpsc::Sender<Message>,
close_tx: watch::Sender<bool>,
closing: AtomicBool,
}
impl BoundedOutbound {
pub fn new(tx: mpsc::Sender<Message>, close_tx: watch::Sender<bool>) -> Self {
Self {
tx,
close_tx,
closing: AtomicBool::new(false),
}
}
pub fn send(&self, msg: Message) -> SendStatus {
if self.is_closing() {
return SendStatus::Closed;
}
match self.tx.try_send(msg) {
Ok(()) => SendStatus::Queued,
Err(TrySendError::Closed(_)) => {
self.mark_closing();
SendStatus::Closed
}
Err(TrySendError::Full(_)) => {
self.mark_closing();
SendStatus::Congested
}
}
}
pub fn is_closing(&self) -> bool {
self.closing.load(Ordering::Acquire)
}
pub fn mark_closing(&self) -> bool {
if self.closing.swap(true, Ordering::AcqRel) {
return false;
}
let _ = self.close_tx.send(true);
true
}
}
pub struct ProxyConn {
pub id: u64,
pub node_id: String,
pub node_name: String,
pub outbound: BoundedOutbound,
next_stream_id: AtomicU32,
pub stream_count: AtomicUsize,
pub max_streams: usize,
}
impl ProxyConn {
pub fn new(
id: u64,
node_id: String,
node_name: String,
tx: mpsc::Sender<Message>,
close_tx: watch::Sender<bool>,
max_streams: usize,
) -> Self {
Self {
id,
node_id,
node_name,
outbound: BoundedOutbound::new(tx, close_tx),
next_stream_id: AtomicU32::new(2),
stream_count: AtomicUsize::new(0),
max_streams,
}
}
pub fn alloc_stream_id(&self) -> Option<u32> {
let mut current = self.stream_count.load(Ordering::Relaxed);
loop {
if current >= self.max_streams || !self.is_available() {
return None;
}
match self.stream_count.compare_exchange_weak(
current,
current + 1,
Ordering::AcqRel,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(observed) => current = observed,
}
}
let sid = loop {
let current_sid = self.next_stream_id.load(Ordering::Relaxed);
let next_sid = if current_sid >= 0xFFFF_FFFE {
2
} else {
current_sid + 2
};
if self
.next_stream_id
.compare_exchange_weak(current_sid, next_sid, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
{
break current_sid;
}
};
Some(sid)
}
pub fn release_stream(&self) {
let mut current = self.stream_count.load(Ordering::Relaxed);
while current > 0 {
match self.stream_count.compare_exchange_weak(
current,
current - 1,
Ordering::AcqRel,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
pub fn is_available(&self) -> bool {
!self.outbound.is_closing()
}
pub fn request_close(&self) {
self.outbound.mark_closing();
}
pub fn send(&self, msg: Message) -> SendStatus {
let was_closing = self.outbound.is_closing();
let status = self.outbound.send(msg);
if status == SendStatus::Congested && !was_closing {
warn!(
conn_id = self.id,
node_id = %self.node_id,
node_name = %self.node_name,
queued_streams = self.stream_count.load(Ordering::Relaxed),
"proxy outbound queue full, closing congested connection"
);
}
status
}
}
#[derive(Debug, Clone)]
pub struct LocalResponseHead {
pub status: u16,
pub headers: Vec<(String, String)>,
}
#[derive(Debug)]
pub enum LocalBodyEvent {
Chunk(Bytes),
End,
Error(String),
}
#[derive(Debug, Default)]
struct LocalWaitState {
response: Option<LocalResponseHead>,
error: Option<String>,
}
pub struct LocalStream {
pub id: u64,
proxy_conn_id: u64,
proxy_stream_id: u32,
wait_state: Mutex<LocalWaitState>,
headers_notify: Notify,
body_tx: mpsc::Sender<LocalBodyEvent>,
body_rx: Mutex<Option<mpsc::Receiver<LocalBodyEvent>>>,
terminal: AtomicBool,
}
impl LocalStream {
fn new(id: u64, proxy_conn_id: u64, proxy_stream_id: u32) -> Self {
let (body_tx, body_rx) = mpsc::channel(128);
Self {
id,
proxy_conn_id,
proxy_stream_id,
wait_state: Mutex::new(LocalWaitState::default()),
headers_notify: Notify::new(),
body_tx,
body_rx: Mutex::new(Some(body_rx)),
terminal: AtomicBool::new(false),
}
}
pub async fn wait_headers(&self, timeout: Duration) -> Result<LocalResponseHead, String> {
tokio::time::timeout(timeout, async {
loop {
let outcome = {
let state = self.wait_state.lock();
if let Some(response) = &state.response {
return Ok(response.clone());
}
state.error.clone()
};
if let Some(error) = outcome {
return Err(error);
}
self.headers_notify.notified().await;
}
})
.await
.map_err(|_| "timed out waiting for response headers".to_string())?
}
pub fn take_body_receiver(&self) -> Option<mpsc::Receiver<LocalBodyEvent>> {
self.body_rx.lock().take()
}
fn set_response_headers(&self, meta: protocol::ResponseMeta) {
let mut notify = false;
{
let mut state = self.wait_state.lock();
if state.response.is_none() && state.error.is_none() {
state.response = Some(LocalResponseHead {
status: meta.status,
headers: meta.headers,
});
notify = true;
}
}
if notify {
self.headers_notify.notify_waiters();
}
}
fn push_body_chunk(&self, payload: Bytes) -> bool {
if self.terminal.load(Ordering::Acquire) {
return false;
}
self.body_tx
.try_send(LocalBodyEvent::Chunk(payload))
.is_ok()
}
fn finish(&self) {
if self.terminal.swap(true, Ordering::AcqRel) {
return;
}
let mut notify = false;
{
let mut state = self.wait_state.lock();
if state.response.is_none() && state.error.is_none() {
state.error = Some("stream ended before response headers".to_string());
notify = true;
}
}
if notify {
self.headers_notify.notify_waiters();
}
let _ = self.body_tx.try_send(LocalBodyEvent::End);
}
fn fail(&self, error: impl Into<String>) {
if self.terminal.swap(true, Ordering::AcqRel) {
return;
}
let error = error.into();
let mut notify = false;
{
let mut state = self.wait_state.lock();
if state.response.is_none() && state.error.is_none() {
state.error = Some(error.clone());
notify = true;
}
}
if notify {
self.headers_notify.notify_waiters();
}
let _ = self.body_tx.try_send(LocalBodyEvent::Error(error));
}
}
pub struct HubRouter {
proxy_conns: RwLock<HashMap<String, Vec<Arc<ProxyConn>>>>,
proxy_conns_by_id: DashMap<u64, Arc<ProxyConn>>,
local_streams: DashMap<u64, Arc<LocalStream>>,
proxy_to_local: DashMap<(u64, u32), u64>,
next_conn_id: AtomicU64,
next_local_stream_id: AtomicU64,
control_plane: ControlPlaneClient,
}
impl HubRouter {
pub fn new(control_plane: ControlPlaneClient) -> Arc<Self> {
Arc::new(Self {
proxy_conns: RwLock::new(HashMap::new()),
proxy_conns_by_id: DashMap::new(),
local_streams: DashMap::new(),
proxy_to_local: DashMap::new(),
next_conn_id: AtomicU64::new(1),
next_local_stream_id: AtomicU64::new(1),
control_plane,
})
}
pub fn alloc_conn_id(&self) -> u64 {
self.next_conn_id.fetch_add(1, Ordering::Relaxed)
}
pub fn register_proxy(&self, conn: Arc<ProxyConn>) {
let node_id = conn.node_id.clone();
let node_name = conn.node_name.clone();
let conn_id = conn.id;
self.proxy_conns_by_id.insert(conn_id, conn.clone());
let pool_size = {
let mut map = self.proxy_conns.write();
map.entry(node_id.clone()).or_default().push(conn);
map.get(&node_id).map(|v| v.len()).unwrap_or(0)
};
info!(
node_id = %node_id,
node_name = %node_name,
conn_id = conn_id,
pool_size = pool_size,
"proxy connected"
);
self.notify_node_status(node_id, true, pool_size);
}
pub fn unregister_proxy(&self, conn_id: u64, node_id: &str) {
self.proxy_conns_by_id.remove(&conn_id);
let pool_size = {
let mut map = self.proxy_conns.write();
if let Some(conns) = map.get_mut(node_id) {
conns.retain(|c| c.id != conn_id);
if conns.is_empty() {
map.remove(node_id);
}
}
map.get(node_id).map(|v| v.len()).unwrap_or(0)
};
info!(
node_id = %node_id,
conn_id = conn_id,
remaining = pool_size,
"proxy disconnected"
);
self.cancel_streams_for_proxy(conn_id);
self.notify_node_status(node_id.to_string(), pool_size > 0, pool_size);
}
fn notify_node_status(&self, node_id: String, connected: bool, conn_count: usize) {
let control_plane = self.control_plane.clone();
tokio::spawn(async move {
if let Err(error) = control_plane
.push_node_status(&node_id, connected, conn_count)
.await
{
warn!(
node_id = %node_id,
connected = connected,
conn_count = conn_count,
error = %error,
"failed to push node status to app control plane"
);
}
});
}
fn get_proxy_conn(&self, node_id: &str) -> Option<Arc<ProxyConn>> {
let map = self.proxy_conns.read();
let conns = map.get(node_id)?;
conns
.iter()
.filter(|c| c.is_available())
.min_by_key(|c| c.stream_count.load(Ordering::Relaxed))
.cloned()
}
pub fn open_local_stream(
&self,
node_id: &str,
meta: &protocol::RequestMeta,
) -> Result<Arc<LocalStream>, String> {
let proxy_conn = self
.get_proxy_conn(node_id)
.ok_or_else(|| format!("no proxy connection for node {node_id}"))?;
let proxy_stream_id = proxy_conn
.alloc_stream_id()
.ok_or_else(|| format!("stream limit reached for node {node_id}"))?;
// Encode frames before registering the stream so that encoding failures
// (practically impossible but theoretically possible) don't leak a stream
// slot or orphan map entries.
let meta_json = match serde_json::to_vec(meta) {
Ok(json) => json,
Err(e) => {
proxy_conn.release_stream();
return Err(format!("failed to encode request metadata: {e}"));
}
};
let (meta_payload, meta_flags) = match protocol::compress_payload(&meta_json) {
Ok(result) => result,
Err(e) => {
proxy_conn.release_stream();
return Err(format!("failed to compress request metadata: {e}"));
}
};
let header_frame = protocol::encode_frame(
proxy_stream_id,
protocol::REQUEST_HEADERS,
meta_flags,
&meta_payload,
);
// Frames encoded successfully -- now register the stream.
let local_stream_id = self.next_local_stream_id.fetch_add(1, Ordering::Relaxed);
let local_stream = Arc::new(LocalStream::new(
local_stream_id,
proxy_conn.id,
proxy_stream_id,
));
self.local_streams
.insert(local_stream_id, local_stream.clone());
self.proxy_to_local
.insert((proxy_conn.id, proxy_stream_id), local_stream_id);
match proxy_conn.send(Message::Binary(header_frame.into())) {
SendStatus::Queued => Ok(local_stream),
SendStatus::Closed | SendStatus::Congested => {
self.cleanup_local_stream(local_stream_id);
proxy_conn.release_stream();
Err("proxy connection congested".to_string())
}
}
}
pub fn push_local_request_body(
&self,
local_stream_id: u64,
payload: Bytes,
end_stream: bool,
) -> Result<(), String> {
let stream = self
.local_streams
.get(&local_stream_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| "local stream not found".to_string())?;
let proxy_conn = self
.proxy_conns_by_id
.get(&stream.proxy_conn_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| "proxy connection unavailable".to_string())?;
let total_chunks = payload.len().div_ceil(MAX_REQUEST_BODY_FRAME_SIZE);
if total_chunks == 0 {
if end_stream {
self.send_request_body_frame(&proxy_conn, stream.proxy_stream_id, &[], true)?;
}
} else {
for (index, chunk) in payload.chunks(MAX_REQUEST_BODY_FRAME_SIZE).enumerate() {
let is_last_chunk = index + 1 == total_chunks;
self.send_request_body_frame(
&proxy_conn,
stream.proxy_stream_id,
chunk,
end_stream && is_last_chunk,
)?;
}
}
Ok(())
}
fn send_request_body_frame(
&self,
proxy_conn: &Arc<ProxyConn>,
proxy_stream_id: u32,
payload: &[u8],
end_stream: bool,
) -> Result<(), String> {
let (body_payload, body_flags) = protocol::compress_payload(payload)
.map_err(|e| format!("failed to compress request body: {e}"))?;
let body_frame = protocol::encode_frame(
proxy_stream_id,
protocol::REQUEST_BODY,
body_flags
| if end_stream {
protocol::FLAG_END_STREAM
} else {
0
},
&body_payload,
);
match proxy_conn.send(Message::Binary(body_frame.into())) {
SendStatus::Queued => Ok(()),
SendStatus::Closed | SendStatus::Congested => {
Err("proxy connection congested".to_string())
}
}
}
pub fn cancel_local_stream(&self, local_stream_id: u64, reason: &str) {
let Some((_, stream)) = self.local_streams.remove(&local_stream_id) else {
return;
};
self.proxy_to_local
.remove(&(stream.proxy_conn_id, stream.proxy_stream_id));
if let Some(pc) = self.proxy_conns_by_id.get(&stream.proxy_conn_id) {
pc.release_stream();
let frame = protocol::encode_stream_error(stream.proxy_stream_id, reason);
let _ = pc.send(Message::Binary(frame.into()));
}
stream.fail(reason.to_string());
}
fn cleanup_local_stream(&self, local_stream_id: u64) {
let Some((_, stream)) = self.local_streams.remove(&local_stream_id) else {
return;
};
self.proxy_to_local
.remove(&(stream.proxy_conn_id, stream.proxy_stream_id));
}
pub async fn handle_proxy_frame(&self, proxy_conn_id: u64, data: &mut [u8]) {
let header = match protocol::FrameHeader::parse(data) {
Some(h) => h,
None => return,
};
let expected_len = protocol::HEADER_SIZE + header.payload_len as usize;
if data.len() < expected_len {
return;
}
match header.msg_type {
protocol::RESPONSE_HEADERS => {
self.route_response_headers(proxy_conn_id, header, data);
}
protocol::RESPONSE_BODY => {
self.route_response_body(proxy_conn_id, header, data);
}
protocol::STREAM_END => {
self.finish_proxy_stream(proxy_conn_id, header.stream_id);
}
protocol::STREAM_ERROR => {
let message = protocol::decode_payload(data, &header)
.ok()
.and_then(|payload| String::from_utf8(payload).ok())
.unwrap_or_else(|| "stream error".to_string());
self.fail_proxy_stream(proxy_conn_id, header.stream_id, message);
}
protocol::HEARTBEAT_DATA => {
self.handle_heartbeat(proxy_conn_id, header.stream_id, data, &header)
.await;
}
protocol::PING => {
let payload = protocol::frame_payload_by_header(data, &header).unwrap_or(&[]);
let pong = protocol::encode_pong(payload);
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
let _ = pc.send(Message::Binary(pong.into()));
}
}
protocol::PONG => {}
protocol::GOAWAY => {
warn!(
proxy_conn_id = proxy_conn_id,
"received GOAWAY from proxy connection"
);
}
_ => {
debug!(
msg_type = header.msg_type,
proxy_conn_id = proxy_conn_id,
"unexpected frame type from proxy"
);
}
}
}
fn route_response_headers(
&self,
proxy_conn_id: u64,
header: protocol::FrameHeader,
data: &[u8],
) {
let Some(local_id) = self.lookup_local_stream(proxy_conn_id, header.stream_id) else {
return;
};
let Ok(payload) = protocol::decode_payload(data, &header) else {
self.fail_proxy_stream(
proxy_conn_id,
header.stream_id,
"failed to decode response headers",
);
return;
};
let Ok(meta) = serde_json::from_slice::<protocol::ResponseMeta>(&payload) else {
self.fail_proxy_stream(
proxy_conn_id,
header.stream_id,
"invalid response headers payload",
);
return;
};
if let Some(entry) = self.local_streams.get(&local_id) {
entry.value().set_response_headers(meta);
}
}
fn route_response_body(&self, proxy_conn_id: u64, header: protocol::FrameHeader, data: &[u8]) {
let Some(local_id) = self.lookup_local_stream(proxy_conn_id, header.stream_id) else {
return;
};
let Ok(payload) = protocol::decode_payload(data, &header) else {
self.fail_proxy_stream(
proxy_conn_id,
header.stream_id,
"failed to decode response body",
);
return;
};
let stream = match self.local_streams.get(&local_id) {
Some(entry) => entry.value().clone(),
None => return,
};
if !stream.push_body_chunk(Bytes::from(payload)) {
self.cancel_local_stream(local_id, "local relay response congested");
}
}
fn handle_stream_cleanup(
&self,
proxy_conn_id: u64,
proxy_stream_id: u32,
) -> Option<Arc<LocalStream>> {
let local_id = self
.proxy_to_local
.remove(&(proxy_conn_id, proxy_stream_id))
.map(|(_, local_id)| local_id)?;
let stream = self
.local_streams
.remove(&local_id)
.map(|(_, stream)| stream)?;
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
pc.release_stream();
}
Some(stream)
}
fn finish_proxy_stream(&self, proxy_conn_id: u64, proxy_stream_id: u32) {
if let Some(stream) = self.handle_stream_cleanup(proxy_conn_id, proxy_stream_id) {
stream.finish();
}
}
fn fail_proxy_stream(
&self,
proxy_conn_id: u64,
proxy_stream_id: u32,
error: impl Into<String>,
) {
if let Some(stream) = self.handle_stream_cleanup(proxy_conn_id, proxy_stream_id) {
stream.fail(error.into());
}
}
fn lookup_local_stream(&self, proxy_conn_id: u64, proxy_stream_id: u32) -> Option<u64> {
self.proxy_to_local
.get(&(proxy_conn_id, proxy_stream_id))
.map(|entry| *entry.value())
}
async fn handle_heartbeat(
&self,
proxy_conn_id: u64,
stream_id: u32,
data: &[u8],
header: &protocol::FrameHeader,
) {
let payload = match protocol::decode_payload(data, header) {
Ok(payload) => payload,
Err(error) => {
warn!(proxy_conn_id = proxy_conn_id, error = %error, "failed to decode heartbeat payload");
return;
}
};
let ack_payload = match self.control_plane.heartbeat_ack(&payload).await {
Ok(payload) => payload,
Err(error) => {
warn!(proxy_conn_id = proxy_conn_id, error = %error, "control-plane heartbeat callback failed");
b"{}".to_vec()
}
};
if let Some(pc) = self.proxy_conns_by_id.get(&proxy_conn_id) {
let frame = protocol::encode_frame(stream_id, protocol::HEARTBEAT_ACK, 0, &ack_payload);
let _ = pc.send(Message::Binary(frame.into()));
}
}
fn cancel_streams_for_proxy(&self, proxy_conn_id: u64) {
let mut cancelled = 0usize;
self.proxy_to_local.retain(|key, local_id| {
if key.0 != proxy_conn_id {
return true;
}
if let Some((_, stream)) = self.local_streams.remove(local_id) {
stream.fail("proxy disconnected".to_string());
}
cancelled += 1;
false
});
if cancelled > 0 {
warn!(
proxy_conn_id = proxy_conn_id,
streams_cancelled = cancelled,
"cancelled in-flight streams due to proxy disconnect"
);
}
}
pub fn stats(&self) -> HubStats {
let proxy_conns = self.proxy_conns.read();
let total_proxy = proxy_conns.values().map(|v| v.len()).sum();
let nodes = proxy_conns.len();
drop(proxy_conns);
HubStats {
proxy_connections: total_proxy,
nodes,
active_streams: self.local_streams.len(),
}
}
}
#[derive(serde::Serialize)]
pub struct HubStats {
pub proxy_connections: usize,
pub nodes: usize,
pub active_streams: usize,
}
#[cfg(test)]
mod tests {
use super::*;
fn build_meta() -> protocol::RequestMeta {
protocol::RequestMeta {
method: "GET".to_string(),
url: "https://example.com".to_string(),
headers: HashMap::new(),
timeout: 30,
}
}
#[tokio::test]
async fn cancel_local_stream_notifies_proxy() {
let hub = HubRouter::new(ControlPlaneClient::disabled());
let (proxy_tx, mut proxy_rx) = mpsc::channel(8);
let (proxy_close_tx, _) = watch::channel(false);
let proxy = Arc::new(ProxyConn::new(
100,
"node-1".to_string(),
"Node 1".to_string(),
proxy_tx,
proxy_close_tx,
16,
));
hub.register_proxy(proxy);
let stream = hub
.open_local_stream("node-1", &build_meta())
.expect("open local stream");
let _ = proxy_rx.try_recv().expect("headers frame");
hub.push_local_request_body(stream.id, Bytes::new(), true)
.expect("finish empty body");
let _ = proxy_rx.try_recv().expect("body frame");
hub.cancel_local_stream(stream.id, "client dropped");
let cancelled = proxy_rx.try_recv().expect("cancel frame");
let cancelled_data = match cancelled {
Message::Binary(data) => data.to_vec(),
other => panic!("unexpected message: {other:?}"),
};
let header = protocol::FrameHeader::parse(&cancelled_data).expect("cancel frame header");
assert_eq!(header.msg_type, protocol::STREAM_ERROR);
}
#[tokio::test]
async fn push_local_request_body_splits_large_payload_and_marks_end() {
let hub = HubRouter::new(ControlPlaneClient::disabled());
let (proxy_tx, mut proxy_rx) = mpsc::channel(8);
let (proxy_close_tx, _) = watch::channel(false);
let proxy = Arc::new(ProxyConn::new(
200,
"node-2".to_string(),
"Node 2".to_string(),
proxy_tx,
proxy_close_tx,
16,
));
hub.register_proxy(proxy);
let stream = hub
.open_local_stream("node-2", &build_meta())
.expect("open local stream");
let _ = proxy_rx.try_recv().expect("headers frame");
let payload = Bytes::from(vec![b'x'; MAX_REQUEST_BODY_FRAME_SIZE + 17]);
hub.push_local_request_body(stream.id, payload, true)
.expect("push request body");
let first = match proxy_rx.try_recv().expect("first body frame") {
Message::Binary(data) => data.to_vec(),
other => panic!("unexpected message: {other:?}"),
};
let first_header = protocol::FrameHeader::parse(&first).expect("first body header");
assert_eq!(first_header.msg_type, protocol::REQUEST_BODY);
assert_eq!(first_header.flags & protocol::FLAG_END_STREAM, 0);
let second = match proxy_rx.try_recv().expect("second body frame") {
Message::Binary(data) => data.to_vec(),
other => panic!("unexpected message: {other:?}"),
};
let second_header = protocol::FrameHeader::parse(&second).expect("second body header");
assert_eq!(second_header.msg_type, protocol::REQUEST_BODY);
assert_ne!(second_header.flags & protocol::FLAG_END_STREAM, 0);
}
}
+262
View File
@@ -0,0 +1,262 @@
use std::io;
use std::net::SocketAddr;
use std::time::Duration;
use async_stream::stream;
use axum::body::{Body, Bytes};
use axum::extract::{ConnectInfo, Path, Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode};
use axum::response::IntoResponse;
use bytes::BytesMut;
use futures_util::StreamExt;
use tracing::warn;
use crate::hub::{LocalBodyEvent, LocalStream};
use crate::protocol;
use crate::AppState;
pub const TUNNEL_ERROR_HEADER: &str = "x-aether-tunnel-error";
const MAX_RELAY_META_LEN: usize = 256 * 1024;
struct StreamGuard {
hub: std::sync::Arc<crate::hub::HubRouter>,
stream_id: u64,
finished: bool,
}
impl Drop for StreamGuard {
fn drop(&mut self) {
if !self.finished {
self.hub
.cancel_local_stream(self.stream_id, "local relay client dropped");
}
}
}
pub async fn relay_request(
Path(node_id): Path<String>,
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
request: Request,
) -> impl IntoResponse {
if !addr.ip().is_loopback() {
return tunnel_error_response(
StatusCode::FORBIDDEN,
"forbidden",
"local relay only accepts loopback requests",
);
}
let mut body_stream = request.into_body().into_data_stream();
let mut envelope_buf = BytesMut::new();
let mut meta: Option<protocol::RequestMeta> = None;
let mut stream: Option<std::sync::Arc<LocalStream>> = None;
while let Some(chunk_result) = body_stream.next().await {
let chunk = match chunk_result {
Ok(chunk) => chunk,
Err(error) => {
if let Some(active_stream) = &stream {
state
.hub
.cancel_local_stream(active_stream.id, "failed to read relay request body");
}
warn!(error = %error, "failed to read local relay request body");
return tunnel_error_response(
StatusCode::BAD_GATEWAY,
"relay",
"failed to read relay request body",
);
}
};
if stream.is_none() {
envelope_buf.extend_from_slice(&chunk);
let Some((parsed_meta, body_offset)) = (match try_decode_envelope_meta(&envelope_buf) {
Ok(result) => result,
Err(error) => {
return tunnel_error_response(StatusCode::BAD_REQUEST, "bad_request", &error);
}
}) else {
continue;
};
let opened_stream = match state.hub.open_local_stream(&node_id, &parsed_meta) {
Ok(stream) => stream,
Err(error) => {
return tunnel_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"connect",
&error,
);
}
};
if envelope_buf.len() > body_offset {
let first_body_chunk = Bytes::copy_from_slice(&envelope_buf[body_offset..]);
if let Err(error) =
state
.hub
.push_local_request_body(opened_stream.id, first_body_chunk, false)
{
state.hub.cancel_local_stream(opened_stream.id, &error);
return tunnel_error_response(
StatusCode::SERVICE_UNAVAILABLE,
"connect",
&error,
);
}
}
envelope_buf.clear();
meta = Some(parsed_meta);
stream = Some(opened_stream);
continue;
}
let Some(active_stream) = &stream else {
continue;
};
if let Err(error) = state
.hub
.push_local_request_body(active_stream.id, chunk, false)
{
state.hub.cancel_local_stream(active_stream.id, &error);
return tunnel_error_response(StatusCode::SERVICE_UNAVAILABLE, "connect", &error);
}
}
let (meta, stream) = match (meta, stream) {
(Some(meta), Some(stream)) => (meta, stream),
_ => {
return tunnel_error_response(
StatusCode::BAD_REQUEST,
"bad_request",
"relay envelope metadata truncated",
);
}
};
if let Err(error) = state
.hub
.push_local_request_body(stream.id, Bytes::new(), true)
{
state.hub.cancel_local_stream(stream.id, &error);
return tunnel_error_response(StatusCode::SERVICE_UNAVAILABLE, "connect", &error);
}
let request_guard = StreamGuard {
hub: state.hub.clone(),
stream_id: stream.id,
finished: false,
};
let wait_timeout = Duration::from_secs(meta.timeout.clamp(5, 300));
let response_head = match stream.wait_headers(wait_timeout).await {
Ok(response) => response,
Err(error) => {
state.hub.cancel_local_stream(stream.id, &error);
return tunnel_error_response(StatusCode::GATEWAY_TIMEOUT, "timeout", &error);
}
};
let Some(mut body_rx) = stream.take_body_receiver() else {
state
.hub
.cancel_local_stream(stream.id, "missing relay response body receiver");
return tunnel_error_response(
StatusCode::BAD_GATEWAY,
"relay",
"missing relay response body receiver",
);
};
let hub = state.hub.clone();
let stream_id = stream.id;
let body_stream = stream! {
let mut guard = request_guard;
guard.hub = hub;
guard.stream_id = stream_id;
while let Some(event) = body_rx.recv().await {
match event {
LocalBodyEvent::Chunk(chunk) => yield Ok::<Bytes, io::Error>(chunk),
LocalBodyEvent::End => {
guard.finished = true;
break;
}
LocalBodyEvent::Error(error) => {
guard.finished = true;
yield Err(io::Error::other(error));
break;
}
}
}
guard.finished = true;
};
let mut builder = Response::builder().status(response_head.status);
if let Some(headers) = builder.headers_mut() {
append_headers(headers, &response_head.headers);
}
match builder.body(Body::from_stream(body_stream)) {
Ok(response) => response,
Err(error) => {
warn!(error = %error, "failed to build relay response");
tunnel_error_response(
StatusCode::BAD_GATEWAY,
"relay",
"failed to build relay response",
)
}
}
}
fn try_decode_envelope_meta(
buffer: &BytesMut,
) -> Result<Option<(protocol::RequestMeta, usize)>, String> {
if buffer.len() < 4 {
return Ok(None);
}
let meta_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
if meta_len > MAX_RELAY_META_LEN {
return Err("relay metadata too large".to_string());
}
let meta_end = 4usize
.checked_add(meta_len)
.ok_or_else(|| "relay envelope length overflow".to_string())?;
if buffer.len() < meta_end {
return Ok(None);
}
let meta = serde_json::from_slice::<protocol::RequestMeta>(&buffer[4..meta_end])
.map_err(|e| format!("invalid relay metadata: {e}"))?;
Ok(Some((meta, meta_end)))
}
fn append_headers(target: &mut HeaderMap, headers: &[(String, String)]) {
for (name, value) in headers {
let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_str(value) else {
continue;
};
target.append(name, value);
}
}
fn tunnel_error_response(status: StatusCode, kind: &str, message: &str) -> Response<Body> {
let mut builder = Response::builder().status(status);
if let Some(headers) = builder.headers_mut() {
headers.insert(
HeaderName::from_static(TUNNEL_ERROR_HEADER),
HeaderValue::from_str(kind).unwrap_or_else(|_| HeaderValue::from_static("relay")),
);
headers.insert(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
}
builder
.body(Body::from(message.to_string()))
.unwrap_or_else(|_| Response::new(Body::from("relay error")))
}
+167
View File
@@ -0,0 +1,167 @@
mod control_plane;
mod hub;
mod local_relay;
mod protocol;
mod proxy_conn;
use std::net::SocketAddr;
use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::State;
use axum::response::{IntoResponse, Json};
use axum::routing::{get, post};
use axum::Router;
use clap::Parser;
use tracing::{info, warn};
use crate::control_plane::ControlPlaneClient;
use crate::hub::{ConnConfig, HubRouter};
use crate::local_relay::relay_request;
#[derive(Parser, Debug)]
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
struct Args {
/// Bind address
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
bind: String,
/// Proxy-side idle timeout in seconds (0 to disable)
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
proxy_idle_timeout: u64,
/// Ping interval in seconds (for both sides)
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
ping_interval: u64,
/// Max concurrent streams per proxy connection
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
max_streams: usize,
/// Per-connection outbound queue capacity before treating the socket as congested
#[arg(
long,
default_value_t = 128,
env = "TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY"
)]
outbound_queue_capacity: usize,
/// Local Aether app base URL for control-plane callbacks
#[arg(
long,
default_value = "http://127.0.0.1:8084",
env = "TUNNEL_HUB_APP_BASE_URL"
)]
app_base_url: String,
}
#[derive(Clone)]
pub struct AppState {
pub hub: std::sync::Arc<HubRouter>,
pub proxy_conn_cfg: ConnConfig,
pub max_streams: usize,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize tracing
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "aether_hub=info".into()),
)
.init();
let args = Args::parse();
let hub = HubRouter::new(ControlPlaneClient::new(args.app_base_url));
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
let ping_interval = Duration::from_secs(args.ping_interval);
let state = AppState {
hub,
proxy_conn_cfg: ConnConfig {
ping_interval,
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
outbound_queue_capacity,
},
max_streams: args.max_streams,
};
let app = Router::new()
.route("/health", get(health))
.route("/stats", get(stats))
.route("/proxy", get(ws_proxy))
.route("/local/relay/{node_id}", post(relay_request))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
info!(bind = %args.bind, "aether-hub started");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// HTTP endpoints
// ---------------------------------------------------------------------------
async fn health() -> impl IntoResponse {
Json(serde_json::json!({"status": "ok"}))
}
async fn stats(State(state): State<AppState>) -> impl IntoResponse {
Json(state.hub.stats())
}
// ---------------------------------------------------------------------------
// WebSocket endpoints
// ---------------------------------------------------------------------------
async fn ws_proxy(
ws: WebSocketUpgrade,
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let node_id = headers
.get("x-node-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.trim()
.to_string();
let node_name = headers
.get("x-node-name")
.and_then(|v| v.to_str().ok())
.unwrap_or(&node_id)
.trim()
.to_string();
let max_streams: usize = headers
.get("x-tunnel-max-streams")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(state.max_streams)
.clamp(64, 2048);
if node_id.is_empty() {
warn!("proxy connection rejected: missing X-Node-ID header");
return axum::http::StatusCode::BAD_REQUEST.into_response();
}
ws.max_frame_size(64 * 1024 * 1024)
.on_upgrade(move |socket| {
proxy_conn::handle_proxy_connection(
socket,
state.hub,
node_id,
node_name,
max_streams,
state.proxy_conn_cfg,
)
})
.into_response()
}
+176
View File
@@ -0,0 +1,176 @@
/// Tunnel binary frame protocol
///
/// Frame format (10-byte header + payload):
/// | stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
use std::io::Read;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
pub const HEADER_SIZE: usize = 10;
// Message types
pub const REQUEST_HEADERS: u8 = 0x01;
pub const REQUEST_BODY: u8 = 0x02;
pub const RESPONSE_HEADERS: u8 = 0x03;
pub const RESPONSE_BODY: u8 = 0x04;
pub const STREAM_END: u8 = 0x05;
pub const STREAM_ERROR: u8 = 0x06;
pub const PING: u8 = 0x10;
pub const PONG: u8 = 0x11;
pub const GOAWAY: u8 = 0x12;
pub const HEARTBEAT_DATA: u8 = 0x13;
pub const HEARTBEAT_ACK: u8 = 0x14;
// Flags
pub const FLAG_END_STREAM: u8 = 0x01;
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
#[derive(Debug, Clone, Copy)]
pub struct FrameHeader {
pub stream_id: u32,
pub msg_type: u8,
pub flags: u8,
pub payload_len: u32,
}
impl FrameHeader {
/// Parse frame header from raw bytes (must be >= HEADER_SIZE)
#[inline]
pub fn parse(data: &[u8]) -> Option<Self> {
if data.len() < HEADER_SIZE {
return None;
}
Some(Self {
stream_id: u32::from_be_bytes([data[0], data[1], data[2], data[3]]),
msg_type: data[4],
flags: data[5],
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
})
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RequestMeta {
pub method: String,
pub url: String,
pub headers: std::collections::HashMap<String, String>,
#[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
pub timeout: u64,
}
fn default_timeout() -> u64 {
60
}
fn deserialize_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TimeoutValue {
Int(u64),
Float(f64),
}
match <TimeoutValue as serde::Deserialize>::deserialize(deserializer)? {
TimeoutValue::Int(v) => Ok(v),
TimeoutValue::Float(v) => {
if !v.is_finite() || v < 0.0 {
return Err(serde::de::Error::custom(
"timeout must be a non-negative finite number",
));
}
if v.fract() != 0.0 {
return Err(serde::de::Error::custom("timeout must be integer seconds"));
}
if v > (u64::MAX as f64) {
return Err(serde::de::Error::custom("timeout is too large"));
}
Ok(v as u64)
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ResponseMeta {
pub status: u16,
pub headers: Vec<(String, String)>,
}
pub fn encode_frame(stream_id: u32, msg_type: u8, flags: u8, payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&stream_id.to_be_bytes());
buf.push(msg_type);
buf.push(flags);
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
buf
}
/// Encode a STREAM_ERROR frame for a given stream_id with an error message
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
encode_frame(stream_id, STREAM_ERROR, 0, msg.as_bytes())
}
/// Encode a PING frame (stream_id=0)
pub fn encode_ping() -> Vec<u8> {
encode_frame(0, PING, 0, &[])
}
/// Encode a PONG frame (stream_id=0, echo payload)
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
encode_frame(0, PONG, 0, payload)
}
/// Encode a GOAWAY frame (stream_id=0)
pub fn encode_goaway() -> Vec<u8> {
encode_frame(0, GOAWAY, 0, &[])
}
#[inline]
pub fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
let payload_len = header.payload_len as usize;
let end = HEADER_SIZE.checked_add(payload_len)?;
if data.len() < end {
return None;
}
Some(&data[HEADER_SIZE..end])
}
pub fn decode_payload(data: &[u8], header: &FrameHeader) -> Result<Vec<u8>, String> {
let payload = frame_payload_by_header(data, header)
.ok_or_else(|| "incomplete frame payload".to_string())?;
if header.flags & FLAG_GZIP_COMPRESSED != 0 {
let mut decoder = GzDecoder::new(payload);
let mut decoded = Vec::new();
decoder
.read_to_end(&mut decoded)
.map_err(|e| format!("failed to decompress payload: {e}"))?;
Ok(decoded)
} else {
Ok(payload.to_vec())
}
}
pub fn compress_payload(payload: &[u8]) -> Result<(Vec<u8>, u8), std::io::Error> {
maybe_recompress_payload(payload, true)
}
fn maybe_recompress_payload(
payload: &[u8],
prefer_gzip: bool,
) -> Result<(Vec<u8>, u8), std::io::Error> {
if !prefer_gzip {
return Ok((payload.to_vec(), 0));
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut encoder, payload)?;
let compressed = encoder.finish()?;
if compressed.len() < payload.len() {
Ok((compressed, FLAG_GZIP_COMPRESSED))
} else {
Ok((payload.to_vec(), 0))
}
}
+156
View File
@@ -0,0 +1,156 @@
/// Proxy-side WebSocket connection handler
///
/// Handles the lifecycle of a single aether-proxy connection:
/// accept -> authenticate (headers) -> read loop -> cleanup
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, watch};
use tracing::{debug, info, warn};
use crate::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
use crate::protocol;
/// Maximum single frame size: 64 MB
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
pub async fn handle_proxy_connection(
ws: WebSocket,
hub: Arc<HubRouter>,
node_id: String,
node_name: String,
max_streams: usize,
cfg: ConnConfig,
) {
let conn_id = hub.alloc_conn_id();
let (mut ws_tx, ws_rx) = ws.split();
let (tx, mut rx) = mpsc::channel::<Message>(cfg.outbound_queue_capacity);
let (close_tx, mut close_rx) = watch::channel(false);
let conn = Arc::new(ProxyConn::new(
conn_id,
node_id.clone(),
node_name.clone(),
tx,
close_tx,
max_streams,
));
hub.register_proxy(conn.clone());
let writer = tokio::spawn(async move {
loop {
tokio::select! {
msg = rx.recv() => match msg {
Some(msg) => {
if ws_tx.send(msg).await.is_err() {
break;
}
}
None => break,
},
changed = close_rx.changed() => {
if changed.is_err() || *close_rx.borrow() {
break;
}
}
}
}
let _ = ws_tx.close().await;
});
let ping_conn = conn.clone();
let ping_interval = cfg.ping_interval;
let ping_task = tokio::spawn(async move {
loop {
tokio::time::sleep(ping_interval).await;
let ping = protocol::encode_ping();
if !matches!(
ping_conn.send(Message::Binary(ping.into())),
SendStatus::Queued
) {
break;
}
}
});
let reader_hub = hub.clone();
let reader_conn = conn.clone();
let reader = tokio::spawn(async move {
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
});
let _ = reader.await;
ping_task.abort();
conn.request_close();
hub.unregister_proxy(conn_id, &node_id);
drop(conn);
tokio::time::sleep(Duration::from_millis(100)).await;
writer.abort();
let _ = writer.await;
}
async fn run_proxy_reader(
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
hub: Arc<HubRouter>,
conn: Arc<ProxyConn>,
idle_timeout: Duration,
) {
let idle_enabled = !idle_timeout.is_zero();
let mut oversized_count = 0u32;
loop {
let msg = if idle_enabled {
tokio::select! {
msg = ws_rx.next() => msg,
_ = tokio::time::sleep(idle_timeout) => {
warn!(conn_id = conn.id, node_id = %conn.node_id, "proxy idle timeout");
let _ = conn.send(Message::Binary(protocol::encode_goaway().into()));
conn.request_close();
break;
}
}
} else {
ws_rx.next().await
};
match msg {
Some(Ok(Message::Binary(data))) => {
let mut data = data.to_vec();
if data.len() > MAX_FRAME_SIZE {
oversized_count += 1;
warn!(
conn_id = conn.id,
size = data.len(),
"oversized frame from proxy"
);
if oversized_count >= 5 {
warn!(conn_id = conn.id, "too many oversized frames, closing");
conn.request_close();
break;
}
continue;
}
oversized_count = 0;
if data.len() < protocol::HEADER_SIZE {
debug!(conn_id = conn.id, "frame too small, skipping");
continue;
}
hub.handle_proxy_frame(conn.id, &mut data).await;
}
Some(Ok(Message::Close(_))) | None => {
info!(conn_id = conn.id, node_id = %conn.node_id, "proxy WebSocket closed");
break;
}
Some(Err(e)) => {
warn!(conn_id = conn.id, error = %e, "proxy WebSocket error");
break;
}
_ => {}
}
}
}
+6 -1
View File
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aether-proxy" name = "aether-proxy"
version = "0.2.0" version = "0.2.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
@@ -21,6 +21,9 @@ dependencies = [
"flate2", "flate2",
"futures-util", "futures-util",
"hex", "hex",
"http-body-util",
"hyper",
"hyper-util",
"libc", "libc",
"ratatui", "ratatui",
"reqwest", "reqwest",
@@ -33,8 +36,10 @@ dependencies = [
"tar", "tar",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-rustls",
"tokio-tungstenite", "tokio-tungstenite",
"toml", "toml",
"tower-service",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"url", "url",
+6 -1
View File
@@ -1,13 +1,17 @@
[package] [package]
name = "aether-proxy" name = "aether-proxy"
version = "0.2.0" version = "0.2.5"
edition = "2021" edition = "2021"
description = "Tunnel proxy for Aether" description = "Tunnel proxy for Aether"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "http2"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream", "http2"] }
hyper = { version = "1", features = ["client", "http1", "http2"] }
hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1", "http2", "tokio"] }
http-body-util = "0.1"
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
tokio-rustls = "0.26"
futures-util = "0.3" futures-util = "0.3"
base64 = "0.22" base64 = "0.22"
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive", "env"] }
@@ -31,6 +35,7 @@ libc = "0.2"
flate2 = "1" flate2 = "1"
tar = "0.4" tar = "0.4"
socket2 = { version = "0.5", features = ["all"] } socket2 = { version = "0.5", features = ["all"] }
tower-service = "0.3"
webpki-roots = "0.26" webpki-roots = "0.26"
[profile.release] [profile.release]
+5 -5
View File
@@ -19,11 +19,11 @@ docker compose up -d
<!-- DOWNLOAD_TABLE_START --> <!-- DOWNLOAD_TABLE_START -->
| Platform | Download | | Platform | Download |
|----------|----------| |----------|----------|
| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.0/aether-proxy-linux-amd64.tar.gz) | | Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-amd64.tar.gz) |
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.0/aether-proxy-linux-arm64.tar.gz) | | Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-arm64.tar.gz) |
| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.0/aether-proxy-macos-amd64.tar.gz) | | macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-amd64.tar.gz) |
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.0/aether-proxy-macos-arm64.tar.gz) | | macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-arm64.tar.gz) |
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.0/aether-proxy-windows-amd64.zip) | | Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-windows-amd64.zip) |
<!-- DOWNLOAD_TABLE_END --> <!-- DOWNLOAD_TABLE_END -->
## 快速开始 ## 快速开始
+6 -23
View File
@@ -13,8 +13,8 @@ use crate::config::{Config, ServerEntry};
use crate::net; use crate::net;
use crate::registration::client::AetherClient; use crate::registration::client::AetherClient;
use crate::runtime::{self, DynamicConfig}; use crate::runtime::{self, DynamicConfig};
use crate::safe_dns::SafeDnsResolver;
use crate::state::{AppState, ProxyMetrics, ServerContext}; use crate::state::{AppState, ProxyMetrics, ServerContext};
use crate::upstream_client;
use crate::{hardware, target_filter, tunnel}; use crate::{hardware, target_filter, tunnel};
/// Run the full application lifecycle after config has been parsed. /// Run the full application lifecycle after config has been parsed.
@@ -67,27 +67,10 @@ pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Resul
config.dns_cache_capacity, config.dns_cache_capacity,
)); ));
// Build reqwest client for tunnel upstream requests (shared). // Build Hyper client for tunnel upstream requests (shared).
// Inject SafeDnsResolver so reqwest only connects to addresses that were // DNS still flows through validated addresses from DnsCache, while the
// validated by validate_target() — this eliminates the DNS rebinding // custom connector exposes per-request connect/TLS timing when available.
// TOCTTOU gap where a second DNS lookup could return a private IP. let upstream_client = upstream_client::build_upstream_client(&config, Arc::clone(&dns_cache));
let safe_resolver = SafeDnsResolver::new(Arc::clone(&dns_cache));
let mut reqwest_builder = reqwest::Client::builder()
.dns_resolver(Arc::new(safe_resolver))
.pool_max_idle_per_host(config.upstream_pool_max_idle_per_host)
.pool_idle_timeout(Duration::from_secs(config.upstream_pool_idle_timeout_secs))
.connect_timeout(Duration::from_secs(config.upstream_connect_timeout_secs))
.tcp_nodelay(config.upstream_tcp_nodelay);
if config.upstream_tcp_keepalive_secs > 0 {
reqwest_builder = reqwest_builder.tcp_keepalive(Some(Duration::from_secs(
config.upstream_tcp_keepalive_secs,
)));
}
let reqwest_client = reqwest_builder
.build()
.expect("failed to build reqwest client");
// Register with each Aether server and build per-server contexts. // Register with each Aether server and build per-server contexts.
// Wrapped in Arc<Mutex> so retry_failed_registrations can append later. // Wrapped in Arc<Mutex> so retry_failed_registrations can append later.
@@ -160,7 +143,7 @@ pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Resul
let state = Arc::new(AppState { let state = Arc::new(AppState {
config: Arc::new(config), config: Arc::new(config),
dns_cache, dns_cache,
reqwest_client, upstream_client,
tunnel_tls_config, tunnel_tls_config,
}); });
+4 -6
View File
@@ -202,21 +202,19 @@ pub struct Config {
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)] #[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
pub log_json: bool, pub log_json: bool,
/// Deprecated: reconnect now uses a fixed 1s delay. Kept for config compatibility. /// Tunnel reconnect base delay in milliseconds (used by exponential backoff)
#[arg( #[arg(
long, long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS", env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
default_value_t = 500, default_value_t = 500
hide = true
)] )]
pub tunnel_reconnect_base_ms: u64, pub tunnel_reconnect_base_ms: u64,
/// Deprecated: reconnect now uses a fixed 1s delay. Kept for config compatibility. /// Tunnel reconnect max delay in milliseconds (cap for exponential backoff)
#[arg( #[arg(
long, long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS", env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
default_value_t = 30000, default_value_t = 30000
hide = true
)] )]
pub tunnel_reconnect_max_ms: u64, pub tunnel_reconnect_max_ms: u64,
+3 -3
View File
@@ -4,11 +4,11 @@ mod hardware;
mod net; mod net;
mod registration; mod registration;
mod runtime; mod runtime;
mod safe_dns;
mod setup; mod setup;
mod state; mod state;
mod target_filter; mod target_filter;
mod tunnel; mod tunnel;
mod upstream_client;
use std::path::PathBuf; use std::path::PathBuf;
@@ -136,8 +136,8 @@ async fn run_proxy(config: Config) -> anyhow::Result<()> {
// Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd). // Skip this check when we ARE the systemd service (INVOCATION_ID is set by systemd).
if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() { if std::env::var_os("INVOCATION_ID").is_none() && setup::service::is_service_active() {
eprintln!("Warning: systemd service is already running."); eprintln!("Warning: systemd service is already running.");
eprintln!("Use `aether-proxy stop` to stop it first, or manage via subcommands:"); eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:");
eprintln!(" aether-proxy status / logs / restart / stop"); eprintln!(" ./aether-proxy status / logs / restart / stop");
std::process::exit(1); std::process::exit(1);
} }
+5
View File
@@ -20,6 +20,8 @@ struct RegisterRequest {
hardware_info: Option<serde_json::Value>, hardware_info: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
estimated_max_concurrency: Option<u64>, estimated_max_concurrency: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
proxy_metadata: Option<serde_json::Value>,
tunnel_mode: bool, tunnel_mode: bool,
} }
@@ -107,6 +109,9 @@ impl AetherClient {
heartbeat_interval: config.heartbeat_interval, heartbeat_interval: config.heartbeat_interval,
hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()), hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()),
estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency), estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency),
proxy_metadata: Some(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
})),
tunnel_mode: true, tunnel_mode: true,
}; };
+8 -8
View File
@@ -21,7 +21,7 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
anyhow::bail!("systemd not available"); anyhow::bail!("systemd not available");
} }
if !is_root() { if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy setup"); anyhow::bail!("root required, use: sudo ./aether-proxy setup");
} }
let exe_path = std::env::current_exe()?.canonicalize()?; let exe_path = std::env::current_exe()?.canonicalize()?;
@@ -94,11 +94,11 @@ pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
eprintln!(); eprintln!();
eprintln!(" Commands:"); eprintln!(" Commands:");
eprintln!(" aether-proxy status # service status"); eprintln!(" ./aether-proxy status # service status");
eprintln!(" aether-proxy logs # tail logs"); eprintln!(" ./aether-proxy logs # tail logs");
eprintln!(" sudo aether-proxy restart # restart"); eprintln!(" sudo ./aether-proxy restart # restart");
eprintln!(" sudo aether-proxy stop # stop"); eprintln!(" sudo ./aether-proxy stop # stop");
eprintln!(" sudo aether-proxy uninstall # remove service"); eprintln!(" sudo ./aether-proxy uninstall # remove service");
eprintln!(); eprintln!();
Ok(()) Ok(())
@@ -166,7 +166,7 @@ pub fn is_service_active() -> bool {
fn ensure_service_installed() -> anyhow::Result<()> { fn ensure_service_installed() -> anyhow::Result<()> {
if !std::path::Path::new(UNIT_PATH).exists() { if !std::path::Path::new(UNIT_PATH).exists() {
anyhow::bail!("service not installed, run `sudo aether-proxy setup` first"); anyhow::bail!("service not installed, run `sudo ./aether-proxy setup` first");
} }
Ok(()) Ok(())
} }
@@ -174,7 +174,7 @@ fn ensure_service_installed() -> anyhow::Result<()> {
fn ensure_root_and_service() -> anyhow::Result<()> { fn ensure_root_and_service() -> anyhow::Result<()> {
ensure_service_installed()?; ensure_service_installed()?;
if !is_root() { if !is_root() {
anyhow::bail!("root required, use: sudo aether-proxy <command>"); anyhow::bail!("root required, use: sudo ./aether-proxy <command>");
} }
Ok(()) Ok(())
} }
+60 -22
View File
@@ -281,8 +281,17 @@ fn atomic_replace(new_binary: &Path) -> anyhow::Result<PathBuf> {
// ── Public entry point ─────────────────────────────────────────────────────── // ── Public entry point ───────────────────────────────────────────────────────
/// `aether-proxy upgrade [version]` -- self-upgrade from GitHub releases. #[derive(Clone, Copy)]
pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> { enum RestartMode {
BestEffort,
Required,
}
async fn execute_upgrade(
version: Option<&str>,
require_root: bool,
restart_mode: RestartMode,
) -> anyhow::Result<()> {
// Resolve exe path once; reuse throughout the function // Resolve exe path once; reuse throughout the function
let current_exe = std::env::current_exe()?.canonicalize()?; let current_exe = std::env::current_exe()?.canonicalize()?;
let exe_dir = current_exe let exe_dir = current_exe
@@ -290,8 +299,12 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
.ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?; .ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?;
let temp_path = exe_dir.join(".aether-proxy.upgrade.tmp"); let temp_path = exe_dir.join(".aether-proxy.upgrade.tmp");
// Check write permission to binary directory if require_root {
if !super::service::is_root() { if !super::service::is_root() {
anyhow::bail!("automatic upgrade requires root privileges");
}
} else if !super::service::is_root() {
// Check write permission to binary directory for manual upgrade mode.
let test_path = exe_dir.join(".aether-proxy.write-test"); let test_path = exe_dir.join(".aether-proxy.write-test");
match std::fs::File::create(&test_path) { match std::fs::File::create(&test_path) {
Ok(_) => { Ok(_) => {
@@ -311,7 +324,7 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
eprintln!(" Current version: {}", CURRENT_VERSION); eprintln!(" Current version: {}", CURRENT_VERSION);
let client = build_github_client()?; let client = build_github_client()?;
let release = fetch_release(&client, version.as_deref()).await?; let release = fetch_release(&client, version).await?;
let target_tag = &release.tag_name; let target_tag = &release.tag_name;
let target_semver = target_tag.strip_prefix("proxy-v").unwrap_or(target_tag); let target_semver = target_tag.strip_prefix("proxy-v").unwrap_or(target_tag);
@@ -341,26 +354,38 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
} }
}; };
// Restart systemd service if running. match restart_mode {
// Use best-effort: binary is already replaced, so a restart failure should RestartMode::BestEffort => {
// not abort the whole upgrade -- the user can restart manually. // Restart systemd service if running.
if super::service::is_service_active() { // Use best-effort: binary is already replaced, so a restart failure should
if super::service::is_root() { // not abort the whole upgrade -- the user can restart manually.
eprintln!(" Restarting systemd service..."); if super::service::is_service_active() {
match super::service::run_cmd("systemctl", &["restart", "aether-proxy"]) { if super::service::is_root() {
Ok(()) => eprintln!(" Service restarted."), eprintln!(" Restarting systemd service...");
Err(e) => { match super::service::run_cmd("systemctl", &["restart", "aether-proxy"]) {
eprintln!(" WARNING: failed to restart service: {}", e); Ok(()) => eprintln!(" Service restarted."),
eprintln!(" Run manually: sudo systemctl restart aether-proxy"); Err(e) => {
eprintln!(" WARNING: failed to restart service: {}", e);
eprintln!(" Run manually: sudo systemctl restart aether-proxy");
}
}
} else {
eprintln!(" Systemd service is active, but restart requires root.");
eprintln!(" Run: sudo systemctl restart aether-proxy");
eprintln!(" Skipping restart.");
} }
} else {
eprintln!(" No active systemd service detected, skipping restart.");
} }
} else {
eprintln!(" Systemd service is active, but restart requires root.");
eprintln!(" Run: sudo systemctl restart aether-proxy");
eprintln!(" Skipping restart.");
} }
} else { RestartMode::Required => {
eprintln!(" No active systemd service detected, skipping restart."); if !super::service::is_root() {
anyhow::bail!("automatic upgrade requires root privileges");
}
eprintln!(" Restarting systemd service...");
super::service::run_cmd("systemctl", &["restart", "aether-proxy"])?;
eprintln!(" Service restarted.");
}
} }
eprintln!(); eprintln!();
@@ -371,3 +396,16 @@ pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
); );
Ok(()) Ok(())
} }
/// `aether-proxy upgrade [version]` -- self-upgrade from GitHub releases.
pub async fn cmd_upgrade(version: Option<String>) -> anyhow::Result<()> {
execute_upgrade(version.as_deref(), false, RestartMode::BestEffort).await
}
/// Perform automatic upgrade to a specific version.
///
/// This path is designed for server-pushed upgrades in systemd/root scenarios:
/// it requires root and requires a successful `systemctl restart aether-proxy`.
pub async fn perform_upgrade(version: &str) -> anyhow::Result<()> {
execute_upgrade(Some(version), true, RestartMode::Required).await
}
+9 -4
View File
@@ -8,14 +8,15 @@ use crate::config::Config;
use crate::registration::client::AetherClient; use crate::registration::client::AetherClient;
use crate::runtime::SharedDynamicConfig; use crate::runtime::SharedDynamicConfig;
use crate::target_filter::DnsCache; use crate::target_filter::DnsCache;
use crate::upstream_client::UpstreamClient;
/// Central application state shared across all servers/tunnels. /// Central application state shared across all servers/tunnels.
pub struct AppState { pub struct AppState {
pub config: Arc<Config>, pub config: Arc<Config>,
/// DNS cache for upstream target resolution (shared). /// DNS cache for upstream target resolution (shared).
pub dns_cache: Arc<DnsCache>, pub dns_cache: Arc<DnsCache>,
/// Reqwest client for tunnel upstream requests (shared). /// Hyper client for tunnel upstream requests with validated DNS and connection timing.
pub reqwest_client: reqwest::Client, pub upstream_client: UpstreamClient,
/// Shared TLS config for tunnel WebSocket connections (avoids re-parsing root CAs on each reconnect). /// Shared TLS config for tunnel WebSocket connections (avoids re-parsing root CAs on each reconnect).
pub tunnel_tls_config: Arc<rustls::ClientConfig>, pub tunnel_tls_config: Arc<rustls::ClientConfig>,
} }
@@ -47,6 +48,8 @@ pub struct ServerContext {
/// Aggregate metrics for reporting to Aether. /// Aggregate metrics for reporting to Aether.
pub struct ProxyMetrics { pub struct ProxyMetrics {
pub total_requests: AtomicU64, pub total_requests: AtomicU64,
/// Cumulative connection-establishment latency in nanoseconds
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
pub total_latency_ns: AtomicU64, pub total_latency_ns: AtomicU64,
pub failed_requests: AtomicU64, pub failed_requests: AtomicU64,
pub dns_failures: AtomicU64, pub dns_failures: AtomicU64,
@@ -64,8 +67,10 @@ impl ProxyMetrics {
} }
} }
pub fn record_request(&self, elapsed: Duration) { /// Record a completed request with its connection-establishment latency
let nanos = u64::try_from(elapsed.as_nanos()).unwrap_or(u64::MAX); /// (DNS + TCP/TLS + TTFB, excludes response body streaming).
pub fn record_request(&self, connect_elapsed: Duration) {
let nanos = u64::try_from(connect_elapsed.as_nanos()).unwrap_or(u64::MAX);
self.total_requests.fetch_add(1, Ordering::Release); self.total_requests.fetch_add(1, Ordering::Release);
self.total_latency_ns.fetch_add(nanos, Ordering::Release); self.total_latency_ns.fetch_add(nanos, Ordering::Release);
} }
+13 -4
View File
@@ -14,7 +14,7 @@ use tracing::{debug, error, info, warn};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
use super::heartbeat::HeartbeatHandle; use super::heartbeat::HeartbeatHandle;
use super::protocol::{Frame, MsgType, RequestMeta}; use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
use super::stream_handler; use super::stream_handler;
use super::writer::FrameSender; use super::writer::FrameSender;
@@ -92,8 +92,15 @@ where
match frame.msg_type { match frame.msg_type {
MsgType::RequestHeaders => { MsgType::RequestHeaders => {
// Parse request metadata // Decompress if the frame is gzip-compressed, then parse metadata
let meta: RequestMeta = match serde_json::from_slice(&frame.payload) { let payload = match decompress_if_gzip(&frame) {
Ok(p) => p,
Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "frame decompress failed");
continue;
}
};
let meta: RequestMeta = match serde_json::from_slice(&payload) {
Ok(m) => m, Ok(m) => m,
Err(e) => { Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata"); warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");
@@ -175,7 +182,9 @@ where
MsgType::StreamEnd | MsgType::StreamError => { MsgType::StreamEnd | MsgType::StreamError => {
// Client-side cancellation or end // Client-side cancellation or end
streams.remove(&frame.stream_id); if let Some(tx) = streams.remove(&frame.stream_id) {
let _ = tx.send(frame).await;
}
} }
MsgType::Ping => { MsgType::Ping => {
+213 -18
View File
@@ -1,12 +1,14 @@
//! Tunnel heartbeat: sends metrics over the tunnel, processes ACKs. //! Tunnel heartbeat: sends metrics over the tunnel, processes ACKs.
use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use bytes::Bytes; use bytes::Bytes;
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{debug, warn}; use tracing::{debug, info, warn};
use crate::config::Config; use crate::config::Config;
use crate::registration::client::RemoteConfig; use crate::registration::client::RemoteConfig;
@@ -16,6 +18,18 @@ use crate::state::ServerContext;
use super::protocol::{Frame, MsgType}; use super::protocol::{Frame, MsgType};
use super::writer::FrameSender; use super::writer::FrameSender;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
static UPGRADE_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
static NON_ROOT_UPGRADE_WARNED: AtomicBool = AtomicBool::new(false);
enum AckDecision {
Accept {
heartbeat_id: Option<u64>,
upgrade_to: Option<String>,
},
Ignore,
}
/// Handle for the dispatcher to forward HeartbeatAck frames. /// Handle for the dispatcher to forward HeartbeatAck frames.
#[derive(Clone)] #[derive(Clone)]
pub struct HeartbeatHandle { pub struct HeartbeatHandle {
@@ -37,6 +51,15 @@ pub fn spawn_noop() -> HeartbeatHandle {
HeartbeatHandle { ack_tx } HeartbeatHandle { ack_tx }
} }
#[derive(Debug, Clone, Copy, Default)]
struct HeartbeatSnapshot {
requests: u64,
latency_ns: u64,
failed: u64,
dns_failures: u64,
stream_errors: u64,
}
/// Spawn the heartbeat task. Returns a handle for forwarding ACKs. /// Spawn the heartbeat task. Returns a handle for forwarding ACKs.
pub fn spawn( pub fn spawn(
_config: Arc<Config>, _config: Arc<Config>,
@@ -50,6 +73,19 @@ pub fn spawn(
// Read initial interval from dynamic config (may be updated by remote config). // Read initial interval from dynamic config (may be updated by remote config).
let initial_interval = Duration::from_secs(server.dynamic.load().heartbeat_interval); let initial_interval = Duration::from_secs(server.dynamic.load().heartbeat_interval);
let mut current_interval = initial_interval; let mut current_interval = initial_interval;
// At most one in-flight heartbeat snapshot is tracked at a time.
// Snapshot is only cleared after receiving an ACK, which avoids losing
// interval counters when ACK/frame delivery is temporarily unstable.
let mut pending: Option<(u64, HeartbeatSnapshot)> = None;
let mut next_heartbeat_id: u64 = 1;
let heartbeat_session_id = format!(
"{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
// Skip first immediate tick by sleeping first. // Skip first immediate tick by sleeping first.
tokio::time::sleep(current_interval).await; tokio::time::sleep(current_interval).await;
@@ -57,9 +93,30 @@ pub fn spawn(
loop { loop {
tokio::select! { tokio::select! {
_ = tokio::time::sleep(current_interval) => { _ = tokio::time::sleep(current_interval) => {
let payload = build_heartbeat_payload(&server); let (heartbeat_id, snapshot) = if let Some((id, snap)) = pending {
(id, snap)
} else {
let snap = collect_snapshot(&server);
let id = next_heartbeat_id;
next_heartbeat_id = next_heartbeat_id.wrapping_add(1);
if next_heartbeat_id == 0 {
next_heartbeat_id = 1;
}
pending = Some((id, snap));
(id, snap)
};
let payload = build_heartbeat_payload(
&server,
&heartbeat_session_id,
heartbeat_id,
snapshot
);
let frame = Frame::control(MsgType::HeartbeatData, payload); let frame = Frame::control(MsgType::HeartbeatData, payload);
if frame_tx.send(frame).await.is_err() { if frame_tx.send(frame).await.is_err() {
if let Some((_, snap)) = pending.take() {
restore_snapshot(&server, snap);
}
break; // Writer closed break; // Writer closed
} }
debug!("sent heartbeat data"); debug!("sent heartbeat data");
@@ -79,10 +136,34 @@ pub fn spawn(
} }
} }
Some(ack_payload) = ack_rx.recv() => { Some(ack_payload) = ack_rx.recv() => {
handle_ack(&server, &ack_payload); match handle_ack(&server, &ack_payload) {
AckDecision::Accept {
heartbeat_id: ack_id,
upgrade_to,
} => {
if let Some((pending_id, _)) = pending {
match ack_id {
Some(id) if id == pending_id => {
pending = None;
}
None => {
// Backward-compatible with servers that don't echo
// heartbeat_id in ACK payload yet.
pending = None;
}
_ => {}
}
}
maybe_trigger_upgrade(upgrade_to);
}
AckDecision::Ignore => {}
}
} }
_ = shutdown.changed() => { _ = shutdown.changed() => {
debug!("heartbeat task shutting down"); debug!("heartbeat task shutting down");
if let Some((_, snap)) = pending.take() {
restore_snapshot(&server, snap);
}
break; break;
} }
} }
@@ -92,36 +173,87 @@ pub fn spawn(
HeartbeatHandle { ack_tx } HeartbeatHandle { ack_tx }
} }
fn build_heartbeat_payload(server: &ServerContext) -> Bytes { fn collect_snapshot(server: &ServerContext) -> HeartbeatSnapshot {
HeartbeatSnapshot {
requests: server.metrics.total_requests.swap(0, Ordering::AcqRel),
latency_ns: server.metrics.total_latency_ns.swap(0, Ordering::AcqRel),
failed: server.metrics.failed_requests.swap(0, Ordering::AcqRel),
dns_failures: server.metrics.dns_failures.swap(0, Ordering::AcqRel),
stream_errors: server.metrics.stream_errors.swap(0, Ordering::AcqRel),
}
}
fn restore_snapshot(server: &ServerContext, snap: HeartbeatSnapshot) {
if snap.requests > 0 {
server
.metrics
.total_requests
.fetch_add(snap.requests, Ordering::Release);
}
if snap.latency_ns > 0 {
server
.metrics
.total_latency_ns
.fetch_add(snap.latency_ns, Ordering::Release);
}
if snap.failed > 0 {
server
.metrics
.failed_requests
.fetch_add(snap.failed, Ordering::Release);
}
if snap.dns_failures > 0 {
server
.metrics
.dns_failures
.fetch_add(snap.dns_failures, Ordering::Release);
}
if snap.stream_errors > 0 {
server
.metrics
.stream_errors
.fetch_add(snap.stream_errors, Ordering::Release);
}
}
fn build_heartbeat_payload(
server: &ServerContext,
heartbeat_session_id: &str,
heartbeat_id: u64,
snapshot: HeartbeatSnapshot,
) -> Bytes {
let node_id = server.node_id.read().unwrap().clone(); let node_id = server.node_id.read().unwrap().clone();
let interval_requests = server.metrics.total_requests.swap(0, Ordering::AcqRel); let avg_latency_ms = if snapshot.requests > 0 {
let interval_latency_ns = server.metrics.total_latency_ns.swap(0, Ordering::AcqRel); Some(snapshot.latency_ns as f64 / snapshot.requests as f64 / 1_000_000.0)
let interval_failed = server.metrics.failed_requests.swap(0, Ordering::AcqRel);
let interval_dns_failures = server.metrics.dns_failures.swap(0, Ordering::AcqRel);
let interval_stream_errors = server.metrics.stream_errors.swap(0, Ordering::AcqRel);
let avg_latency_ms = if interval_requests > 0 {
Some(interval_latency_ns as f64 / interval_requests as f64 / 1_000_000.0)
} else { } else {
None None
}; };
let payload = serde_json::json!({ let payload = serde_json::json!({
"node_id": node_id, "node_id": node_id,
"heartbeat_session_id": heartbeat_session_id,
"heartbeat_id": heartbeat_id,
"active_connections": server.active_connections.load(Ordering::Acquire), "active_connections": server.active_connections.load(Ordering::Acquire),
"total_requests": interval_requests, "total_requests": snapshot.requests,
"avg_latency_ms": avg_latency_ms, "avg_latency_ms": avg_latency_ms,
"failed_requests": interval_failed, "failed_requests": snapshot.failed,
"dns_failures": interval_dns_failures, "dns_failures": snapshot.dns_failures,
"stream_errors": interval_stream_errors, "stream_errors": snapshot.stream_errors,
"proxy_metadata": {
"version": CURRENT_VERSION,
},
}); });
Bytes::from(serde_json::to_vec(&payload).unwrap_or_default()) Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
} }
fn handle_ack(server: &ServerContext, payload: &[u8]) { fn handle_ack(server: &ServerContext, payload: &[u8]) -> AckDecision {
if payload.is_empty() { if payload.is_empty() {
return; return AckDecision::Accept {
heartbeat_id: None,
upgrade_to: None,
};
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -130,6 +262,10 @@ fn handle_ack(server: &ServerContext, payload: &[u8]) {
remote_config: Option<RemoteConfig>, remote_config: Option<RemoteConfig>,
#[serde(default)] #[serde(default)]
config_version: u64, config_version: u64,
#[serde(default)]
heartbeat_id: Option<u64>,
#[serde(default)]
upgrade_to: Option<String>,
} }
match serde_json::from_slice::<AckPayload>(payload) { match serde_json::from_slice::<AckPayload>(payload) {
@@ -137,9 +273,68 @@ fn handle_ack(server: &ServerContext, payload: &[u8]) {
if let Some(ref rc) = ack.remote_config { if let Some(ref rc) = ack.remote_config {
runtime::apply_remote_config(&server.dynamic, rc, ack.config_version); runtime::apply_remote_config(&server.dynamic, rc, ack.config_version);
} }
AckDecision::Accept {
heartbeat_id: ack.heartbeat_id,
upgrade_to: ack.upgrade_to.and_then(normalize_upgrade_target),
}
} }
Err(e) => { Err(e) => {
warn!(error = %e, "failed to parse heartbeat ACK"); warn!(error = %e, "failed to parse heartbeat ACK");
AckDecision::Ignore
} }
} }
} }
fn normalize_upgrade_target(raw: String) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let normalized = trimmed.strip_prefix("proxy-v").unwrap_or(trimmed);
if normalized == CURRENT_VERSION {
return None;
}
Some(normalized.to_string())
}
fn maybe_trigger_upgrade(version: Option<String>) {
let Some(target_version) = version else {
return;
};
if !crate::setup::service::is_root() {
if NON_ROOT_UPGRADE_WARNED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
warn!(
target_version = %target_version,
"remote upgrade skipped: root privileges are required"
);
}
return;
}
if UPGRADE_IN_PROGRESS
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
debug!(target_version = %target_version, "upgrade already in progress, ignoring");
return;
}
tokio::spawn(async move {
info!(target_version = %target_version, "received remote upgrade instruction");
match crate::setup::upgrade::perform_upgrade(&target_version).await {
Ok(()) => {
info!(target_version = %target_version, "remote upgrade finished");
}
Err(e) => {
warn!(
target_version = %target_version,
error = %e,
"remote upgrade failed"
);
UPGRADE_IN_PROGRESS.store(false, Ordering::Release);
}
}
});
}
+182 -5
View File
@@ -6,16 +6,26 @@ pub mod stream_handler;
pub mod writer; pub mod writer;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::watch; use tokio::sync::watch;
use tracing::{error, info}; use tracing::{error, info};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
/// Fixed reconnect delay -- short enough for fast recovery, long enough to /// If a tunnel stays connected at least this long, treat the next disconnect
/// avoid CPU spin when the network is completely down. /// as a non-failure and reset reconnect backoff.
const RECONNECT_DELAY: Duration = Duration::from_secs(1); const STABLE_SESSION_RESET_AFTER: Duration = Duration::from_secs(30);
/// Startup staggering step per secondary connection, used to avoid
/// simultaneous bursts when a pool of tunnels starts together.
const STARTUP_STAGGER_STEP_MS: u64 = 150;
/// Upper bound for startup staggering.
const MAX_STARTUP_STAGGER_MS: u64 = 1_500;
/// Keep a tiny floor for repeated reconnects; first retry is still immediate.
const MIN_RECONNECT_DELAY_MS: u64 = 50;
/// Even under sustained failures, keep probing frequently so recovery is fast
/// once cross-border network quality improves.
const RECONNECT_PROBE_MAX_DELAY_MS: u64 = 3_000;
/// Run the tunnel mode main loop (connect, dispatch, reconnect). /// Run the tunnel mode main loop (connect, dispatch, reconnect).
/// ///
@@ -28,8 +38,29 @@ pub async fn run(
mut shutdown: watch::Receiver<bool>, mut shutdown: watch::Receiver<bool>,
) { ) {
info!(server = %server.server_label, conn = conn_idx, "starting tunnel"); info!(server = %server.server_label, conn = conn_idx, "starting tunnel");
let reconnect_salt = compute_connection_salt(server, conn_idx);
let startup_delay = compute_startup_stagger(conn_idx, reconnect_salt);
if !startup_delay.is_zero() {
info!(
server = %server.server_label,
conn = conn_idx,
delay_ms = startup_delay.as_millis(),
"startup stagger before first connect"
);
tokio::select! {
_ = tokio::time::sleep(startup_delay) => {}
_ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during startup stagger");
return;
}
}
}
let mut consecutive_failures: u32 = 0;
loop { loop {
let started_at = Instant::now();
match client::connect_and_run(state, server, conn_idx, &mut shutdown).await { match client::connect_and_run(state, server, conn_idx, &mut shutdown).await {
Ok(client::TunnelOutcome::Shutdown) => { Ok(client::TunnelOutcome::Shutdown) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully"); info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully");
@@ -48,8 +79,31 @@ pub async fn run(
return; return;
} }
// Reset backoff after a stable session to keep recovery snappy when
// failures are only occasional.
let connected_for = started_at.elapsed();
if connected_for >= STABLE_SESSION_RESET_AFTER {
consecutive_failures = 0;
} else {
consecutive_failures = consecutive_failures.saturating_add(1);
}
let reconnect_delay = compute_reconnect_delay(
state.config.tunnel_reconnect_base_ms,
state.config.tunnel_reconnect_max_ms,
consecutive_failures,
reconnect_salt,
);
info!(
server = %server.server_label,
conn = conn_idx,
failures = consecutive_failures,
delay_ms = reconnect_delay.as_millis(),
"waiting before reconnect"
);
tokio::select! { tokio::select! {
_ = tokio::time::sleep(RECONNECT_DELAY) => {} _ = tokio::time::sleep(reconnect_delay) => {}
_ = shutdown.changed() => { _ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait"); info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait");
return; return;
@@ -57,3 +111,126 @@ pub async fn run(
} }
} }
} }
fn compute_connection_salt(server: &ServerContext, conn_idx: usize) -> u64 {
// FNV-1a style hash over server label + connection index.
let mut h: u64 = 0xcbf29ce484222325;
for &b in server.server_label.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h ^= conn_idx as u64;
mix_u64(h)
}
fn compute_startup_stagger(conn_idx: usize, salt: u64) -> Duration {
if conn_idx == 0 {
return Duration::ZERO;
}
let base = (conn_idx as u64).saturating_mul(STARTUP_STAGGER_STEP_MS);
let jitter = mix_u64(salt) % 301; // 0..=300ms
Duration::from_millis((base + jitter).min(MAX_STARTUP_STAGGER_MS))
}
fn compute_reconnect_delay(
base_ms: u64,
max_ms: u64,
consecutive_failures: u32,
salt: u64,
) -> Duration {
// First retry should be immediate to maximize recovery speed on transient
// blips (the user's primary expectation in poor networks).
if consecutive_failures <= 1 {
return Duration::ZERO;
}
// Keep a sane minimum for repeated failures.
let base_ms = base_ms.max(MIN_RECONNECT_DELAY_MS);
let max_ms = max_ms.max(base_ms);
let cap_ms = compute_reconnect_cap_ms(base_ms, max_ms, consecutive_failures)
.min(RECONNECT_PROBE_MAX_DELAY_MS.max(base_ms));
// Equal-jitter: randomize in [cap/2, cap], preventing synchronized reconnect
// storms while keeping reconnect latency bounded.
if cap_ms <= 1 {
return Duration::from_millis(cap_ms);
}
let half = cap_ms / 2;
let span = cap_ms - half;
let now_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let mixed = mix_u64(now_nanos ^ salt);
let jitter = if span == 0 { 0 } else { mixed % (span + 1) };
Duration::from_millis(half + jitter)
}
fn compute_reconnect_cap_ms(base_ms: u64, max_ms: u64, consecutive_failures: u32) -> u64 {
if consecutive_failures <= 1 {
return base_ms.min(max_ms);
}
let shift = (consecutive_failures - 1).min(31);
let factor = 1u64 << shift;
base_ms.saturating_mul(factor).min(max_ms)
}
fn mix_u64(mut x: u64) -> u64 {
// SplitMix64 finalizer - cheap bit mixing for pseudo-random jitter.
x ^= x >> 30;
x = x.wrapping_mul(0xbf58476d1ce4e5b9);
x ^= x >> 27;
x = x.wrapping_mul(0x94d049bb133111eb);
x ^ (x >> 31)
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
compute_reconnect_cap_ms, compute_reconnect_delay, compute_startup_stagger,
MAX_STARTUP_STAGGER_MS, RECONNECT_PROBE_MAX_DELAY_MS, STARTUP_STAGGER_STEP_MS,
};
#[test]
fn reconnect_cap_grows_exponentially_and_caps() {
let base = 500;
let max = 30_000;
assert_eq!(compute_reconnect_cap_ms(base, max, 0), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 1), 500);
assert_eq!(compute_reconnect_cap_ms(base, max, 2), 1_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 3), 2_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 4), 4_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 5), 8_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 6), 16_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 7), 30_000);
assert_eq!(compute_reconnect_cap_ms(base, max, 20), 30_000);
}
#[test]
fn startup_stagger_is_zero_for_primary_and_bounded_for_secondary() {
assert_eq!(compute_startup_stagger(0, 42), Duration::ZERO);
let d1 = compute_startup_stagger(1, 42);
let d2 = compute_startup_stagger(2, 42);
assert!(d1 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS));
assert!(d1 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
assert!(d2 >= Duration::from_millis(STARTUP_STAGGER_STEP_MS * 2));
assert!(d2 <= Duration::from_millis(MAX_STARTUP_STAGGER_MS));
}
#[test]
fn reconnect_delay_is_immediate_on_first_failure() {
assert_eq!(compute_reconnect_delay(700, 45_000, 1, 123), Duration::ZERO);
}
#[test]
fn reconnect_delay_stays_within_probe_ceiling_after_many_failures() {
let d = compute_reconnect_delay(500, 45_000, 100, 12345);
assert!(d <= Duration::from_millis(RECONNECT_PROBE_MAX_DELAY_MS));
}
}
+100 -1
View File
@@ -144,7 +144,7 @@ pub struct RequestMeta {
pub method: String, pub method: String,
pub url: String, pub url: String,
pub headers: std::collections::HashMap<String, String>, pub headers: std::collections::HashMap<String, String>,
#[serde(default = "default_timeout")] #[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
pub timeout: u64, pub timeout: u64,
} }
@@ -152,6 +152,36 @@ fn default_timeout() -> u64 {
60 60
} }
fn deserialize_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TimeoutValue {
Int(u64),
Float(f64),
}
match <TimeoutValue as serde::Deserialize>::deserialize(deserializer)? {
TimeoutValue::Int(v) => Ok(v),
TimeoutValue::Float(v) => {
if !v.is_finite() || v < 0.0 {
return Err(serde::de::Error::custom(
"timeout must be a non-negative finite number",
));
}
if v.fract() != 0.0 {
return Err(serde::de::Error::custom("timeout must be integer seconds"));
}
if v > (u64::MAX as f64) {
return Err(serde::de::Error::custom("timeout is too large"));
}
Ok(v as u64)
}
}
}
/// JSON payload for RESPONSE_HEADERS frames. /// JSON payload for RESPONSE_HEADERS frames.
#[derive(Debug, serde::Serialize)] #[derive(Debug, serde::Serialize)]
pub struct ResponseMeta { pub struct ResponseMeta {
@@ -159,3 +189,72 @@ pub struct ResponseMeta {
/// Header list preserving duplicates (e.g. multiple Set-Cookie). /// Header list preserving duplicates (e.g. multiple Set-Cookie).
pub headers: Vec<(String, String)>, pub headers: Vec<(String, String)>,
} }
// ---------------------------------------------------------------------------
// Tunnel frame compression helpers
// ---------------------------------------------------------------------------
/// Minimum payload size to attempt gzip compression (bytes).
const COMPRESS_MIN_SIZE: usize = 512;
/// If the frame has the GZIP_COMPRESSED flag, decompress the payload; otherwise
/// return a clone of the raw payload bytes.
pub fn decompress_if_gzip(frame: &Frame) -> Result<Bytes, std::io::Error> {
if frame.is_gzip() {
decompress_gzip(&frame.payload)
} else {
Ok(frame.payload.clone())
}
}
/// Gzip-compress `data` if it is large enough and compression actually shrinks
/// the payload. Returns `(payload, extra_flags)` where `extra_flags` contains
/// `GZIP_COMPRESSED` when compression was applied.
pub fn compress_payload(data: Bytes) -> (Bytes, u8) {
if data.len() >= COMPRESS_MIN_SIZE {
if let Ok(compressed) = compress_gzip(&data) {
if compressed.len() < data.len() {
return (compressed, flags::GZIP_COMPRESSED);
}
}
}
(data, 0)
}
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(data);
let mut buf = Vec::new();
decoder.read_to_end(&mut buf)?;
Ok(Bytes::from(buf))
}
fn compress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::fast());
encoder.write_all(data)?;
let compressed = encoder.finish()?;
Ok(Bytes::from(compressed))
}
#[cfg(test)]
mod tests {
use super::RequestMeta;
#[test]
fn request_meta_accepts_integer_timeout() {
let raw = br#"{"method":"GET","url":"https://example.com","headers":{},"timeout":15}"#;
let meta: RequestMeta = serde_json::from_slice(raw).expect("parse request meta");
assert_eq!(meta.timeout, 15);
}
#[test]
fn request_meta_accepts_integer_like_float_timeout() {
let raw = br#"{"method":"GET","url":"https://example.com","headers":{},"timeout":15.0}"#;
let meta: RequestMeta = serde_json::from_slice(raw).expect("parse request meta");
assert_eq!(meta.timeout, 15);
}
}
+272 -114
View File
@@ -3,19 +3,28 @@
//! Receives request frames, executes the upstream HTTP request, //! Receives request frames, executes the upstream HTTP request,
//! and sends response frames back through the writer channel. //! and sends response frames back through the writer channel.
use std::io;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bytes::Bytes; use bytes::Bytes;
use futures_util::stream;
use futures_util::StreamExt; use futures_util::StreamExt;
use http_body_util::BodyExt;
use hyper::body::Frame as BodyFrame;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::state::{AppState, ServerContext}; use crate::state::{AppState, ServerContext};
use crate::target_filter; use crate::target_filter;
use crate::upstream_client;
use super::protocol::{flags, Frame, MsgType, RequestMeta, ResponseMeta}; use super::protocol::{
compress_payload, decompress_if_gzip, flags, Frame as TunnelFrame, MsgType, RequestMeta,
ResponseMeta,
};
use super::writer::FrameSender; use super::writer::FrameSender;
/// Maximum response body chunk size per frame (32 KB). /// Maximum response body chunk size per frame (32 KB).
@@ -32,8 +41,18 @@ const MIN_TIMEOUT_SECS: u64 = 5;
const MAX_TIMEOUT_SECS: u64 = 300; const MAX_TIMEOUT_SECS: u64 = 300;
/// Headers that must not be forwarded to upstream (hop-by-hop or security-sensitive). /// Headers that must not be forwarded to upstream (hop-by-hop or security-sensitive).
///
/// `host` and `content-length` are managed by the HTTP client (reqwest/hyper):
/// - `host` → translated to `:authority` pseudo-header in HTTP/2; forwarding
/// the original `host` alongside `:authority` triggers PROTOCOL_ERROR on
/// strict H2 implementations (e.g. Google APIs).
/// - `content-length` → recalculated by hyper from the actual body; a stale
/// value from the tunnel (body may have been re-compressed) causes H2
/// PROTOCOL_ERROR when it mismatches the real frame length.
const BLOCKED_HEADERS: &[&str] = &[ const BLOCKED_HEADERS: &[&str] = &[
"connection", "connection",
"content-length",
"host",
"keep-alive", "keep-alive",
"proxy-authenticate", "proxy-authenticate",
"proxy-authorization", "proxy-authorization",
@@ -50,20 +69,22 @@ pub async fn handle_stream(
server: Arc<ServerContext>, server: Arc<ServerContext>,
stream_id: u32, stream_id: u32,
meta: RequestMeta, meta: RequestMeta,
mut body_rx: mpsc::Receiver<Frame>, body_rx: mpsc::Receiver<TunnelFrame>,
frame_tx: FrameSender, frame_tx: FrameSender,
) { ) {
let start = Instant::now();
server.active_connections.fetch_add(1, Ordering::Release); server.active_connections.fetch_add(1, Ordering::Release);
handle_stream_inner(&state, &server, stream_id, meta, &mut body_rx, &frame_tx).await; let connect_elapsed =
handle_stream_inner(&state, &server, stream_id, meta, body_rx, &frame_tx).await;
server.active_connections.fetch_sub(1, Ordering::Release); server.active_connections.fetch_sub(1, Ordering::Release);
server.metrics.record_request(start.elapsed()); if let Some(d) = connect_elapsed {
server.metrics.record_request(d);
}
} }
/// Send a frame to the writer with a timeout. Returns false if send failed. /// Send a frame to the writer with a timeout. Returns false if send failed.
async fn send_frame(tx: &FrameSender, frame: Frame) -> bool { async fn send_frame(tx: &FrameSender, frame: TunnelFrame) -> bool {
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await { match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
Ok(Ok(())) => true, Ok(Ok(())) => true,
Ok(Err(_)) => { Ok(Err(_)) => {
@@ -78,77 +99,23 @@ async fn send_frame(tx: &FrameSender, frame: Frame) -> bool {
} }
} }
/// Returns the connection-establishment duration (DNS + TCP/TLS + TTFB) if the
/// upstream request succeeded, or `None` if the request never reached the
/// response-headers stage.
async fn handle_stream_inner( async fn handle_stream_inner(
state: &AppState, state: &AppState,
server: &ServerContext, server: &ServerContext,
stream_id: u32, stream_id: u32,
meta: RequestMeta, meta: RequestMeta,
body_rx: &mut mpsc::Receiver<Frame>, body_rx: mpsc::Receiver<TunnelFrame>,
frame_tx: &FrameSender, frame_tx: &FrameSender,
) { ) -> Option<Duration> {
// Collect request body
let mut body_parts: Vec<Bytes> = Vec::new();
let mut body_done = false;
// Drain body frames
while !body_done {
match body_rx.recv().await {
Some(frame) => {
if frame.msg_type == MsgType::RequestBody {
let payload = if frame.is_gzip() {
match decompress_gzip(&frame.payload) {
Ok(d) => d,
Err(e) => {
send_error(
frame_tx,
stream_id,
&format!("gzip decompress failed: {e}"),
)
.await;
return;
}
}
} else {
frame.payload.clone()
};
if !payload.is_empty() {
body_parts.push(payload);
}
if frame.is_end_stream() {
body_done = true;
}
} else if frame.msg_type == MsgType::StreamEnd
|| frame.msg_type == MsgType::StreamError
{
body_done = true;
if frame.msg_type == MsgType::StreamError {
return; // Client cancelled
}
}
}
None => return, // Channel closed
}
}
let body: Bytes = if body_parts.is_empty() {
Bytes::new()
} else if body_parts.len() == 1 {
body_parts.into_iter().next().unwrap()
} else {
let total: usize = body_parts.iter().map(|b| b.len()).sum();
let mut combined = Vec::with_capacity(total);
for part in &body_parts {
combined.extend_from_slice(part);
}
Bytes::from(combined)
};
// Validate target // Validate target
let target_url = match url::Url::parse(&meta.url) { let target_url = match url::Url::parse(&meta.url) {
Ok(u) => u, Ok(u) => u,
Err(e) => { Err(e) => {
send_error(frame_tx, stream_id, &format!("invalid URL: {e}")).await; send_error(frame_tx, stream_id, &format!("invalid URL: {e}")).await;
return; return None;
} }
}; };
@@ -162,7 +129,7 @@ async fn handle_stream_inner(
&format!("unsupported URL scheme: {other}"), &format!("unsupported URL scheme: {other}"),
) )
.await; .await;
return; return None;
} }
} }
@@ -170,13 +137,13 @@ async fn handle_stream_inner(
Some(h) => h.to_string(), Some(h) => h.to_string(),
None => { None => {
send_error(frame_tx, stream_id, "missing host in URL").await; send_error(frame_tx, stream_id, "missing host in URL").await;
return; return None;
} }
}; };
let port = target_url.port_or_known_default().unwrap_or(443); let port = target_url.port_or_known_default().unwrap_or(443);
// DNS + target validation (populates dns_cache for SafeDnsResolver) // DNS + target validation (populates dns_cache for SafeDnsResolver)
let dns_start = Instant::now(); let connect_start = Instant::now();
{ {
let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports); let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports);
if let Err(e) = if let Err(e) =
@@ -184,73 +151,122 @@ async fn handle_stream_inner(
{ {
server.metrics.dns_failures.fetch_add(1, Ordering::Release); server.metrics.dns_failures.fetch_add(1, Ordering::Release);
send_error(frame_tx, stream_id, &format!("target blocked: {e}")).await; send_error(frame_tx, stream_id, &format!("target blocked: {e}")).await;
return; return None;
} }
} }
let dns_ms = dns_start.elapsed().as_millis() as u64; let dns_ms = connect_start.elapsed().as_millis() as u64;
// Execute upstream request // Execute upstream request
let client = &state.reqwest_client; let client = &state.upstream_client;
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)); let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
let request_body_size = Arc::new(AtomicUsize::new(0));
let request_body = build_streaming_request_body(body_rx, Arc::clone(&request_body_size));
let method: reqwest::Method = meta.method.parse().unwrap_or(reqwest::Method::GET); let method: hyper::Method = meta.method.parse().unwrap_or(hyper::Method::GET);
let mut req = client.request(method, &meta.url); let mut request = match hyper::Request::builder()
.method(method)
.uri(meta.url.as_str())
.body(request_body)
{
Ok(request) => request,
Err(e) => {
send_error(
frame_tx,
stream_id,
&format!("invalid upstream request: {e}"),
)
.await;
return None;
}
};
let headers = request.headers_mut();
for (k, v) in &meta.headers { for (k, v) in &meta.headers {
let k_lower = k.to_ascii_lowercase(); let k_lower = k.to_ascii_lowercase();
// Skip hop-by-hop and security-sensitive headers
if BLOCKED_HEADERS.contains(&k_lower.as_str()) { if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
continue; continue;
} }
// Validate header name/value are valid HTTP
if let (Ok(name), Ok(value)) = ( if let (Ok(name), Ok(value)) = (
reqwest::header::HeaderName::from_bytes(k.as_bytes()), hyper::header::HeaderName::from_bytes(k.as_bytes()),
reqwest::header::HeaderValue::from_str(v), hyper::header::HeaderValue::from_str(v),
) { ) {
req = req.header(name, value); headers.insert(name, value);
} }
} }
let body_size = body.len();
if !body.is_empty() { let mut captured_connection = upstream_client::capture_connection(&mut request);
req = req.body(body); let connection_start = Instant::now();
} let connection_capture = tokio::spawn(async move {
req = req.timeout(timeout); let connected = captured_connection.wait_for_connection_metadata().await;
connected
.as_ref()
.map(|_| connection_start.elapsed().as_millis() as u64)
});
let upstream_start = Instant::now(); let upstream_start = Instant::now();
let response = match req.send().await { let response = match tokio::time::timeout(timeout, client.request(request)).await {
Ok(r) => r, Ok(Ok(response)) => response,
Err(e) => { Ok(Err(e)) => {
connection_capture.abort();
server server
.metrics .metrics
.failed_requests .failed_requests
.fetch_add(1, Ordering::Release); .fetch_add(1, Ordering::Release);
let msg = if e.is_timeout() { let msg = if e.is_connect() {
"upstream timeout".to_string()
} else if e.is_connect() {
format!("upstream connect error: {e}") format!("upstream connect error: {e}")
} else { } else {
format!("upstream error: {e}") format!("upstream error: {e}")
}; };
send_error(frame_tx, stream_id, &msg).await; send_error(frame_tx, stream_id, &msg).await;
return; return None;
}
Err(_) => {
connection_capture.abort();
server
.metrics
.failed_requests
.fetch_add(1, Ordering::Release);
send_error(frame_tx, stream_id, "upstream timeout").await;
return None;
} }
}; };
// Capture connection-establishment duration (DNS + TCP/TLS + TTFB)
// before proceeding to stream the response body.
let connect_elapsed = connect_start.elapsed();
// Send RESPONSE_HEADERS // Send RESPONSE_HEADERS
let status = response.status().as_u16(); let status = response.status().as_u16();
let ttfb_ms = upstream_start.elapsed().as_millis() as u64; let ttfb_ms = upstream_start.elapsed().as_millis() as u64;
// Short timeout: on connection reuse hyper may never fire the connect
// callback, so avoid blocking indefinitely.
let connection_acquire_ms =
match tokio::time::timeout(Duration::from_millis(100), connection_capture).await {
Ok(Ok(ms)) => ms,
Ok(Err(_)) => None, // JoinError (task panicked / cancelled)
Err(_) => None, // timeout -- task is detached but lightweight
};
let request_timing =
upstream_client::resolve_request_timing(&response, connection_acquire_ms, ttfb_ms);
let mut resp_headers: Vec<(String, String)> = Vec::with_capacity(response.headers().len() + 1); let mut resp_headers: Vec<(String, String)> = Vec::with_capacity(response.headers().len() + 1);
for (k, v) in response.headers() { for (k, v) in response.headers() {
if let Ok(vs) = v.to_str() { if let Ok(vs) = v.to_str() {
resp_headers.push((k.as_str().to_string(), vs.to_string())); resp_headers.push((k.as_str().to_string(), vs.to_string()));
} }
} }
// Inject proxy timing (same format as delegate mode)
let timing = serde_json::json!({ let timing = serde_json::json!({
"dns_ms": dns_ms, "dns_ms": dns_ms,
"connection_acquire_ms": request_timing.connection_acquire_ms,
"connection_reused": request_timing.connection_reused,
"connect_ms": request_timing.connect_ms,
"tls_ms": request_timing.tls_ms,
"ttfb_ms": ttfb_ms, "ttfb_ms": ttfb_ms,
"upstream_ms": ttfb_ms, "upstream_ms": ttfb_ms,
"upstream_processing_ms": ttfb_ms.saturating_sub(dns_ms), "response_wait_ms": request_timing.response_wait_ms,
"body_size": body_size, "upstream_processing_ms": request_timing.response_wait_ms,
"timing_source": "instrumented_connector",
"total_ms": connect_elapsed.as_millis() as u64,
"body_size": request_body_size.load(Ordering::Relaxed),
"mode": "tunnel", "mode": "tunnel",
}); });
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string())); resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
@@ -258,43 +274,60 @@ async fn handle_stream_inner(
status, status,
headers: resp_headers, headers: resp_headers,
}; };
let meta_json = serde_json::to_vec(&resp_meta).unwrap_or_default(); let meta_json: Bytes = serde_json::to_vec(&resp_meta).unwrap_or_default().into();
let (meta_payload, meta_flags) = compress_payload(meta_json);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseHeaders, 0, meta_json), TunnelFrame::new(
stream_id,
MsgType::ResponseHeaders,
meta_flags,
meta_payload,
),
) )
.await .await
{ {
return; return Some(connect_elapsed);
} }
// Stream response body // Stream response body — relay upstream bytes through the tunnel.
let mut stream = response.bytes_stream(); // Apply tunnel-level frame compression for chunks that benefit from it
// (e.g. uncompressed SSE text). Already-compressed data (gzip/br from
// upstream Content-Encoding) won't shrink further and will be sent as-is
// thanks to the size check in compress_payload().
let mut stream = response.into_body().into_data_stream();
while let Some(chunk_result) = stream.next().await { while let Some(chunk_result) = stream.next().await {
match chunk_result { match chunk_result {
Ok(chunk) => { Ok(chunk) => {
if chunk.len() <= MAX_CHUNK_SIZE { if chunk.len() <= MAX_CHUNK_SIZE {
let (payload, extra_flags) = compress_payload(chunk);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, 0, chunk), TunnelFrame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
) )
.await .await
{ {
return; return Some(connect_elapsed);
} }
} else { } else {
// Split oversized chunks // Split oversized chunks, compress each slice
let mut offset = 0; let mut offset = 0;
while offset < chunk.len() { while offset < chunk.len() {
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len()); let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
let slice = chunk.slice(offset..end); let slice = chunk.slice(offset..end);
let (payload, extra_flags) = compress_payload(slice);
if !send_frame( if !send_frame(
frame_tx, frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, 0, slice), TunnelFrame::new(
stream_id,
MsgType::ResponseBody,
extra_flags,
payload,
),
) )
.await .await
{ {
return; return Some(connect_elapsed);
} }
offset = end; offset = end;
} }
@@ -304,7 +337,7 @@ async fn handle_stream_inner(
server.metrics.stream_errors.fetch_add(1, Ordering::Release); server.metrics.stream_errors.fetch_add(1, Ordering::Release);
warn!(stream_id, error = %e, "upstream body read error"); warn!(stream_id, error = %e, "upstream body read error");
send_error(frame_tx, stream_id, &format!("body read error: {e}")).await; send_error(frame_tx, stream_id, &format!("body read error: {e}")).await;
return; return Some(connect_elapsed);
} }
} }
} }
@@ -312,7 +345,7 @@ async fn handle_stream_inner(
// Send STREAM_END // Send STREAM_END
let _ = send_frame( let _ = send_frame(
frame_tx, frame_tx,
Frame::new( TunnelFrame::new(
stream_id, stream_id,
MsgType::StreamEnd, MsgType::StreamEnd,
flags::END_STREAM, flags::END_STREAM,
@@ -322,13 +355,14 @@ async fn handle_stream_inner(
.await; .await;
debug!(stream_id, status, "stream completed"); debug!(stream_id, status, "stream completed");
Some(connect_elapsed)
} }
async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) { async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
// Error frames use best-effort delivery — don't block if writer is congested // Error frames use best-effort delivery — don't block if writer is congested
let _ = send_frame( let _ = send_frame(
tx, tx,
Frame::new( TunnelFrame::new(
stream_id, stream_id,
MsgType::StreamError, MsgType::StreamError,
0, 0,
@@ -338,11 +372,135 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
.await; .await;
} }
fn decompress_gzip(data: &[u8]) -> Result<Bytes, std::io::Error> { fn build_streaming_request_body(
use flate2::read::GzDecoder; body_rx: mpsc::Receiver<TunnelFrame>,
use std::io::Read; body_size: Arc<AtomicUsize>,
let mut decoder = GzDecoder::new(data); ) -> upstream_client::UpstreamRequestBody {
let mut buf = Vec::new(); let body_stream = stream::unfold(
decoder.read_to_end(&mut buf)?; (body_rx, body_size, false),
Ok(Bytes::from(buf)) |(mut body_rx, body_size, finished)| async move {
if finished {
return None;
}
loop {
let frame = match body_rx.recv().await {
Some(frame) => frame,
None => return None,
};
match frame.msg_type {
MsgType::RequestBody => {
let end_stream = frame.is_end_stream();
let payload = match decompress_if_gzip(&frame) {
Ok(payload) => payload,
Err(error) => {
let err =
io::Error::other(format!("gzip decompress failed: {error}"));
return Some((Err(err), (body_rx, body_size, true)));
}
};
if payload.is_empty() {
if end_stream {
return None;
}
continue;
}
body_size.fetch_add(payload.len(), Ordering::Relaxed);
return Some((
Ok(BodyFrame::data(payload)),
(body_rx, body_size, end_stream),
));
}
MsgType::StreamError => {
let message = String::from_utf8(frame.payload.to_vec())
.unwrap_or_else(|_| "client cancelled request body".to_string());
return Some((Err(io::Error::other(message)), (body_rx, body_size, true)));
}
MsgType::StreamEnd => return None,
_ => continue,
}
}
},
);
upstream_client::stream_request_body(body_stream)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn streaming_request_body_yields_chunks_and_tracks_size() {
let (tx, rx) = mpsc::channel(4);
let body_size = Arc::new(AtomicUsize::new(0));
let mut body = build_streaming_request_body(rx, Arc::clone(&body_size));
tx.send(TunnelFrame::new(
1,
MsgType::RequestBody,
0,
Bytes::from_static(b"abc"),
))
.await
.expect("send first chunk");
tx.send(TunnelFrame::new(
1,
MsgType::RequestBody,
flags::END_STREAM,
Bytes::from_static(b"def"),
))
.await
.expect("send final chunk");
drop(tx);
let first = body
.frame()
.await
.expect("first frame")
.expect("first frame ok")
.into_data()
.expect("first data frame");
let second = body
.frame()
.await
.expect("second frame")
.expect("second frame ok")
.into_data()
.expect("second data frame");
assert_eq!(first, Bytes::from_static(b"abc"));
assert_eq!(second, Bytes::from_static(b"def"));
assert!(body.frame().await.is_none());
assert_eq!(body_size.load(Ordering::Relaxed), 6);
}
#[tokio::test]
async fn streaming_request_body_surfaces_client_cancel_as_error() {
let (tx, rx) = mpsc::channel(4);
let body_size = Arc::new(AtomicUsize::new(0));
let mut body = build_streaming_request_body(rx, Arc::clone(&body_size));
tx.send(TunnelFrame::new(
1,
MsgType::StreamError,
0,
Bytes::from_static(b"client cancelled"),
))
.await
.expect("send cancel frame");
drop(tx);
let err = body
.frame()
.await
.expect("error frame present")
.expect_err("body should surface cancellation error");
assert!(err.to_string().contains("client cancelled"));
assert!(body.frame().await.is_none());
assert_eq!(body_size.load(Ordering::Relaxed), 0);
}
} }
+444
View File
@@ -0,0 +1,444 @@
use std::future::Future;
use std::io;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use futures_util::Stream;
use http_body_util::combinators::UnsyncBoxBody;
use http_body_util::{BodyExt, StreamBody};
use hyper::body::Frame;
use hyper::rt;
use hyper::Response;
use hyper::Uri;
pub use hyper_util::client::legacy::connect::capture_connection;
use hyper_util::client::legacy::connect::dns::Name;
use hyper_util::client::legacy::connect::{Connected, Connection, HttpConnector};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use rustls::pki_types::ServerName;
use rustls::ClientConfig;
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use tower_service::Service;
use crate::config::Config;
use crate::target_filter::{self, DnsCache};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
type PlainStream = TokioIo<TcpStream>;
type TlsStream = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
pub type UpstreamRequestBody = UnsyncBoxBody<Bytes, io::Error>;
pub type UpstreamClient = Client<InstrumentedConnector, UpstreamRequestBody>;
pub fn stream_request_body<S>(stream: S) -> UpstreamRequestBody
where
S: Stream<Item = Result<Frame<Bytes>, io::Error>> + Send + 'static,
{
StreamBody::new(stream).boxed_unsync()
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ConnectTiming {
pub connect_ms: u64,
pub tls_ms: u64,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct RequestTiming {
pub connection_acquire_ms: u64,
pub connect_ms: u64,
pub tls_ms: u64,
pub response_wait_ms: u64,
pub connection_reused: bool,
}
#[derive(Clone)]
pub struct ValidatedResolver {
dns_cache: Arc<DnsCache>,
}
impl ValidatedResolver {
pub fn new(dns_cache: Arc<DnsCache>) -> Self {
Self { dns_cache }
}
}
pub struct ValidatedAddrs {
inner: std::vec::IntoIter<std::net::SocketAddr>,
}
impl Iterator for ValidatedAddrs {
type Item = std::net::SocketAddr;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
}
impl Service<Name> for ValidatedResolver {
type Response = ValidatedAddrs;
type Error = io::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Poll::Ready(Ok(()))
}
fn call(&mut self, name: Name) -> Self::Future {
let dns_cache = Arc::clone(&self.dns_cache);
let host = name.as_str().to_string();
Box::pin(async move {
if let Some(addrs) = dns_cache.get_by_host(&host).await {
return Ok(ValidatedAddrs {
inner: (*addrs).clone().into_iter(),
});
}
let resolved = target_filter::resolve_public_addrs(&host, 0, dns_cache.as_ref())
.await
.map_err(|err| io::Error::other(err.to_string()))?;
Ok(ValidatedAddrs {
inner: resolved.into_iter(),
})
})
}
}
#[derive(Clone)]
pub struct InstrumentedConnector {
http: HttpConnector<ValidatedResolver>,
tls_config: Arc<ClientConfig>,
}
impl Service<Uri> for InstrumentedConnector {
type Response = TimedConn;
type Error = BoxError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.http.poll_ready(cx).map_err(Into::into)
}
fn call(&mut self, dst: Uri) -> Self::Future {
let scheme = dst.scheme_str().map(|value| value.to_ascii_lowercase());
let tls_config = Arc::clone(&self.tls_config);
let connecting = self.http.call(dst.clone());
let connect_start = std::time::Instant::now();
Box::pin(async move {
match scheme.as_deref() {
Some("http") => {
let tcp = connecting.await.map_err(|err| Box::new(err) as BoxError)?;
let connect_ms = connect_start.elapsed().as_millis() as u64;
Ok(TimedConn::new(
MaybeHttpsStream::Http(tcp),
ConnectTiming {
connect_ms,
tls_ms: 0,
},
))
}
Some("https") => {
let server_name = resolve_server_name(&dst)?;
let tcp = connecting.await.map_err(|err| Box::new(err) as BoxError)?;
let connect_ms = connect_start.elapsed().as_millis() as u64;
let tls_start = std::time::Instant::now();
let tls_stream = TlsConnector::from(tls_config)
.connect(server_name, tcp.into_inner())
.await
.map_err(io::Error::other)?;
let tls_ms = tls_start.elapsed().as_millis() as u64;
Ok(TimedConn::new(
MaybeHttpsStream::Https(TokioIo::new(tls_stream)),
ConnectTiming { connect_ms, tls_ms },
))
}
Some(other) => Err(io::Error::other(format!("unsupported scheme {other}")).into()),
None => Err(io::Error::other("missing scheme").into()),
}
})
}
}
pub fn build_upstream_client(config: &Config, dns_cache: Arc<DnsCache>) -> UpstreamClient {
let mut http = HttpConnector::new_with_resolver(ValidatedResolver::new(dns_cache));
http.enforce_http(false);
http.set_connect_timeout(Some(Duration::from_secs(
config.upstream_connect_timeout_secs,
)));
http.set_nodelay(config.upstream_tcp_nodelay);
if config.upstream_tcp_keepalive_secs > 0 {
http.set_keepalive(Some(Duration::from_secs(
config.upstream_tcp_keepalive_secs,
)));
} else {
http.set_keepalive(None);
}
let connector = InstrumentedConnector {
http,
tls_config: build_tls_config(),
};
let mut builder = Client::builder(TokioExecutor::new());
builder.pool_max_idle_per_host(config.upstream_pool_max_idle_per_host);
builder.pool_idle_timeout(Duration::from_secs(config.upstream_pool_idle_timeout_secs));
builder.pool_timer(TokioTimer::new());
builder.build(connector)
}
pub fn resolve_request_timing<B>(
response: &Response<B>,
connection_acquire_ms: Option<u64>,
ttfb_ms: u64,
) -> RequestTiming {
let raw = response
.extensions()
.get::<ConnectTiming>()
.copied()
.unwrap_or_default();
let raw_connection_ms = raw.connect_ms.saturating_add(raw.tls_ms);
let measured_acquire_ms = connection_acquire_ms.unwrap_or(raw_connection_ms.min(ttfb_ms));
let likely_reused = measured_acquire_ms <= 5 && raw_connection_ms > 0;
let connector_matches_request = raw_connection_ms <= measured_acquire_ms.saturating_add(25);
let (connect_ms, tls_ms) = if likely_reused || !connector_matches_request {
(0, 0)
} else {
(raw.connect_ms, raw.tls_ms)
};
RequestTiming {
connection_acquire_ms: measured_acquire_ms,
connect_ms,
tls_ms,
response_wait_ms: ttfb_ms.saturating_sub(measured_acquire_ms),
connection_reused: likely_reused,
}
}
fn build_tls_config() -> Arc<ClientConfig> {
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Arc::new(config)
}
fn resolve_server_name(uri: &Uri) -> Result<ServerName<'static>, BoxError> {
let host = uri.host().ok_or_else(|| io::Error::other("missing host"))?;
let host = host.trim_start_matches('[').trim_end_matches(']');
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(ServerName::from(ip));
}
Ok(ServerName::try_from(host.to_string())?)
}
pub struct TimedConn {
inner: MaybeHttpsStream,
timing: ConnectTiming,
}
impl TimedConn {
fn new(inner: MaybeHttpsStream, timing: ConnectTiming) -> Self {
Self { inner, timing }
}
}
impl Connection for TimedConn {
fn connected(&self) -> Connected {
self.inner.connected().extra(self.timing)
}
}
impl rt::Read for TimedConn {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: rt::ReadBufCursor<'_>,
) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.inner).poll_read(cx, buf)
}
}
impl rt::Write for TimedConn {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
Pin::new(&mut self.inner).poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Result<(), io::Error>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
fn is_write_vectored(&self) -> bool {
self.inner.is_write_vectored()
}
fn poll_write_vectored(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<Result<usize, io::Error>> {
Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
}
}
pub enum MaybeHttpsStream {
Http(PlainStream),
Https(TlsStream),
}
impl Connection for MaybeHttpsStream {
fn connected(&self) -> Connected {
match self {
Self::Http(stream) => stream.connected(),
Self::Https(stream) => {
let (tcp, tls) = stream.inner().get_ref();
if tls.alpn_protocol() == Some(b"h2") {
tcp.connected().negotiated_h2()
} else {
tcp.connected()
}
}
}
}
}
impl rt::Read for MaybeHttpsStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: rt::ReadBufCursor<'_>,
) -> Poll<Result<(), io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_read(cx, buf),
Self::Https(stream) => Pin::new(stream).poll_read(cx, buf),
}
}
}
impl rt::Write for MaybeHttpsStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_write(cx, buf),
Self::Https(stream) => Pin::new(stream).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_flush(cx),
Self::Https(stream) => Pin::new(stream).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_shutdown(cx),
Self::Https(stream) => Pin::new(stream).poll_shutdown(cx),
}
}
fn is_write_vectored(&self) -> bool {
match self {
Self::Http(stream) => stream.is_write_vectored(),
Self::Https(stream) => stream.is_write_vectored(),
}
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<Result<usize, io::Error>> {
match Pin::get_mut(self) {
Self::Http(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
Self::Https(stream) => Pin::new(stream).poll_write_vectored(cx, bufs),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::Response;
#[test]
fn fresh_connection_uses_connector_breakdown() {
let mut response = Response::new(());
response.extensions_mut().insert(ConnectTiming {
connect_ms: 80,
tls_ms: 40,
});
let timing = resolve_request_timing(&response, Some(125), 600);
assert_eq!(timing.connection_acquire_ms, 125);
assert_eq!(timing.connect_ms, 80);
assert_eq!(timing.tls_ms, 40);
assert_eq!(timing.response_wait_ms, 475);
assert!(!timing.connection_reused);
}
#[test]
fn reused_connection_zeroes_stale_connect_timings() {
let mut response = Response::new(());
response.extensions_mut().insert(ConnectTiming {
connect_ms: 70,
tls_ms: 30,
});
let timing = resolve_request_timing(&response, Some(0), 310);
assert_eq!(timing.connection_acquire_ms, 0);
assert_eq!(timing.connect_ms, 0);
assert_eq!(timing.tls_ms, 0);
assert_eq!(timing.response_wait_ms, 310);
assert!(timing.connection_reused);
}
#[test]
fn falls_back_to_connector_timings_when_capture_missing() {
let mut response = Response::new(());
response.extensions_mut().insert(ConnectTiming {
connect_ms: 55,
tls_ms: 25,
});
let timing = resolve_request_timing(&response, None, 400);
assert_eq!(timing.connection_acquire_ms, 80);
assert_eq!(timing.connect_ms, 55);
assert_eq!(timing.tls_ms, 25);
assert_eq!(timing.response_wait_ms, 320);
assert!(!timing.connection_reused);
}
}
+31 -11
View File
@@ -3,13 +3,15 @@ Alembic 环境配置
用于数据库迁移的运行时环境设置 用于数据库迁移的运行时环境设置
""" """
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os import os
import sys import sys
from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from sqlalchemy import engine_from_config, pool, text
from alembic import context
# 添加项目根目录到 Python 路径 # 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
@@ -48,6 +50,11 @@ if config.config_file_name is not None:
# 目标元数据(包含所有表定义) # 目标元数据(包含所有表定义)
target_metadata = Base.metadata target_metadata = Base.metadata
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
# 使用会话级 advisory lock(pg_advisory_lock),在迁移完成后手动释放。
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
""" """
@@ -83,15 +90,28 @@ def run_migrations_online() -> None:
) )
with connectable.connect() as connection: with connectable.connect() as connection:
context.configure( try:
connection=connection, # 使用会话级 advisory lock(非事务级),避免干扰 Alembic 的事务管理。
target_metadata=target_metadata, # pg_advisory_lock 在会话结束时自动释放,不受 COMMIT/ROLLBACK 影响。
compare_type=True, # 比较列类型变更 if connection.dialect.name == "postgresql":
compare_server_default=True, # 比较默认值变更 connection.execute(
) text("SELECT pg_advisory_lock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
connection.commit()
with context.begin_transaction(): context.configure(
context.run_migrations() connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
transaction_per_migration=True, # 每个迁移文件独立事务,完成即提交
)
with context.begin_transaction():
context.run_migrations()
except Exception:
raise
# 根据模式选择运行方式 # 根据模式选择运行方式
@@ -10,8 +10,7 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa from sqlalchemy import text
from sqlalchemy import inspect
from alembic import op from alembic import op
@@ -24,33 +23,24 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None: def upgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
inspector = inspect(conn) # Use PostgreSQL native IF NOT EXISTS to avoid duplicate-column races
existing_columns = {col["name"] for col in inspector.get_columns("usage")} # when migrations are triggered concurrently (e.g. startup + manual run).
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body JSON"))
if "provider_request_body" not in existing_columns: conn.execute(
op.add_column("usage", sa.Column("provider_request_body", sa.JSON(), nullable=True)) text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body_compressed BYTEA")
if "provider_request_body_compressed" not in existing_columns: )
op.add_column( conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body JSON"))
"usage", sa.Column("provider_request_body_compressed", sa.LargeBinary(), nullable=True) conn.execute(
) text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body_compressed BYTEA")
if "client_response_body" not in existing_columns: )
op.add_column("usage", sa.Column("client_response_body", sa.JSON(), nullable=True))
if "client_response_body_compressed" not in existing_columns:
op.add_column(
"usage", sa.Column("client_response_body_compressed", sa.LargeBinary(), nullable=True)
)
def downgrade() -> None: def downgrade() -> None:
conn = op.get_bind() conn = op.get_bind()
inspector = inspect(conn)
existing_columns = {col["name"] for col in inspector.get_columns("usage")}
for col in ( for col in (
"client_response_body_compressed", "client_response_body_compressed",
"client_response_body", "client_response_body",
"provider_request_body_compressed", "provider_request_body_compressed",
"provider_request_body", "provider_request_body",
): ):
if col in existing_columns: conn.execute(text(f"ALTER TABLE usage DROP COLUMN IF EXISTS {col}"))
op.drop_column("usage", col)
@@ -33,11 +33,23 @@ depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool: def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind() bind = op.get_bind()
inspector = inspect(bind) insp = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)] columns = [c["name"] for c in insp.get_columns(table_name)]
return column_name in columns return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
return table_name in insp.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
def upgrade() -> None: def upgrade() -> None:
# --- 1. Add cache_creation columns --- # --- 1. Add cache_creation columns ---
if not column_exists("usage", "cache_creation_input_tokens_5m"): if not column_exists("usage", "cache_creation_input_tokens_5m"):
@@ -126,35 +138,39 @@ def upgrade() -> None:
) )
# --- 3. Create user_model_usage_counts table --- # --- 3. Create user_model_usage_counts table ---
op.create_table( if not table_exists("user_model_usage_counts"):
"user_model_usage_counts", op.create_table(
sa.Column("id", sa.String(36), primary_key=True), "user_model_usage_counts",
sa.Column( sa.Column("id", sa.String(36), primary_key=True),
"user_id", sa.Column(
sa.String(36), "user_id",
sa.ForeignKey("users.id", ondelete="CASCADE"), sa.String(36),
nullable=False, sa.ForeignKey("users.id", ondelete="CASCADE"),
), nullable=False,
sa.Column("model", sa.String(100), nullable=False), ),
sa.Column("usage_count", sa.Integer, nullable=False, server_default="0"), sa.Column("model", sa.String(100), nullable=False),
sa.Column( sa.Column("usage_count", sa.Integer, nullable=False, server_default="0"),
"created_at", sa.Column(
sa.DateTime(timezone=True), "created_at",
nullable=False, sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
), server_default=sa.func.now(),
sa.Column( ),
"updated_at", sa.Column(
sa.DateTime(timezone=True), "updated_at",
nullable=False, sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False,
), server_default=sa.func.now(),
sa.UniqueConstraint("user_id", "model", name="uq_user_model_usage_count"), ),
) sa.UniqueConstraint("user_id", "model", name="uq_user_model_usage_count"),
op.create_index("idx_user_model_usage_user", "user_model_usage_counts", ["user_id"]) )
op.create_index("idx_user_model_usage_model", "user_model_usage_counts", ["model"]) if not index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
op.create_index("idx_user_model_usage_user", "user_model_usage_counts", ["user_id"])
if not index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
op.create_index("idx_user_model_usage_model", "user_model_usage_counts", ["model"])
# Backfill from existing usage records # Backfill from existing usage records (truncate first for idempotency)
conn.execute(sa.text("DELETE FROM user_model_usage_counts"))
rows = conn.execute( rows = conn.execute(
sa.text( sa.text(
"SELECT user_id, model, COUNT(*) AS cnt FROM usage" "SELECT user_id, model, COUNT(*) AS cnt FROM usage"
@@ -179,18 +195,26 @@ def upgrade() -> None:
) )
# --- 4. Enforce models.global_model_id NOT NULL --- # --- 4. Enforce models.global_model_id NOT NULL ---
op.execute("DELETE FROM models WHERE global_model_id IS NULL") conn = op.get_bind()
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=False) insp = inspect(conn)
model_cols = {c["name"]: c for c in insp.get_columns("models")}
if model_cols.get("global_model_id", {}).get("nullable", True):
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=False)
def downgrade() -> None: def downgrade() -> None:
# Revert models.global_model_id to nullable # Revert models.global_model_id to nullable
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True) if column_exists("models", "global_model_id"):
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
# Drop user_model_usage_counts # Drop user_model_usage_counts
op.drop_index("idx_user_model_usage_model", table_name="user_model_usage_counts") if table_exists("user_model_usage_counts"):
op.drop_index("idx_user_model_usage_user", table_name="user_model_usage_counts") if index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
op.drop_table("user_model_usage_counts") op.drop_index("idx_user_model_usage_model", table_name="user_model_usage_counts")
if index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
op.drop_index("idx_user_model_usage_user", table_name="user_model_usage_counts")
op.drop_table("user_model_usage_counts")
# Drop cache_creation columns # Drop cache_creation columns
if column_exists("usage", "cache_creation_input_tokens_1h"): if column_exists("usage", "cache_creation_input_tokens_1h"):
@@ -31,7 +31,34 @@ def _table_exists(table_name: str) -> bool:
return table_name in insp.get_table_names() return table_name in insp.get_table_names()
def _enum_has_value(enum_name: str, value: str) -> bool:
"""检查 PostgreSQL 枚举类型是否包含指定值"""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid"
" WHERE t.typname = :enum_name AND e.enumlabel = :value"
),
{"enum_name": enum_name, "value": value},
)
return result.fetchone() is not None
def upgrade() -> None: def upgrade() -> None:
# proxy_nodes: 将已废弃的 unhealthy 状态迁移为 offline,然后从枚举中移除
if _enum_has_value("proxynodestatus", "unhealthy"):
op.execute("UPDATE proxy_nodes SET status = 'offline' WHERE status = 'unhealthy'")
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'offline')")
# 必须先移除旧枚举类型的 DEFAULT,否则 ALTER TYPE 会因无法转换默认值而报错
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
op.execute(
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
" USING status::text::proxynodestatus"
)
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
op.execute("DROP TYPE proxynodestatus_old")
# proxy_nodes: 新增错误指标字段 # proxy_nodes: 新增错误指标字段
if not _column_exists("proxy_nodes", "failed_requests"): if not _column_exists("proxy_nodes", "failed_requests"):
op.add_column( op.add_column(
@@ -102,6 +129,18 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# 恢复 proxynodestatus 枚举,加回 unhealthy
if not _enum_has_value("proxynodestatus", "unhealthy"):
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline')")
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
op.execute(
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
" USING status::text::proxynodestatus"
)
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
op.execute("DROP TYPE proxynodestatus_old")
if _table_exists("proxy_node_events"): if _table_exists("proxy_node_events"):
op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events") op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events")
op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events") op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events")
@@ -0,0 +1,49 @@
"""add_request_candidates_composite_indexes
Revision ID: 00b9161b8729
Revises: 48afe197cc15
Create Date: 2026-02-28 14:48:00.000000+00:00
"""
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision = "00b9161b8729"
down_revision = "48afe197cc15"
branch_labels = None
depends_on = None
def _index_exists(index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
indexes = insp.get_indexes("request_candidates")
return any(idx["name"] == index_name for idx in indexes)
def upgrade() -> None:
# (request_id, status) - fallback/retry 查询优化
if not _index_exists("idx_rc_request_id_status"):
op.create_index(
"idx_rc_request_id_status",
"request_candidates",
["request_id", "status"],
)
# (provider_id, status, created_at) - provider 聚合统计优化
if not _index_exists("idx_rc_provider_status_created"):
op.create_index(
"idx_rc_provider_status_created",
"request_candidates",
["provider_id", "status", "created_at"],
)
def downgrade() -> None:
if _index_exists("idx_rc_provider_status_created"):
op.drop_index("idx_rc_provider_status_created", table_name="request_candidates")
if _index_exists("idx_rc_request_id_status"):
op.drop_index("idx_rc_request_id_status", table_name="request_candidates")
@@ -0,0 +1,263 @@
"""vertex_ai_provider_type
Migrate legacy Vertex auth_type/provider_type into the new model:
- provider_type=vertex_ai
- auth_type=service_account (legacy vertex_ai renamed)
- fixed Vertex endpoints: gemini:chat + claude:chat
Revision ID: 2a624af8dd3a
Revises: 00b9161b8729
Create Date: 2026-02-28 15:00:00.000000+00:00
"""
from __future__ import annotations
import uuid
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "2a624af8dd3a"
down_revision = "00b9161b8729"
branch_labels = None
depends_on = None
_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"
_VERTEX_ENDPOINTS: tuple[tuple[str, str, str], ...] = (
("gemini:chat", "gemini", "chat"),
("claude:chat", "claude", "chat"),
)
_VERTEX_KEY_FORMATS_SA = '["gemini:chat","claude:chat"]'
_VERTEX_KEY_FORMATS_API_KEY = '["gemini:chat"]'
def _select_vertex_provider_ids(conn: sa.Connection) -> list[str]:
"""Collect providers that should be treated as Vertex after migration."""
rows = conn.execute(sa.text("""
SELECT DISTINCT p.id
FROM providers p
LEFT JOIN provider_api_keys pak ON pak.provider_id = p.id
WHERE lower(COALESCE(p.provider_type, '')) = 'vertex_ai'
OR pak.auth_type = 'vertex_ai'
"""))
return [str(row[0]) for row in rows if row[0]]
def _ensure_fixed_vertex_endpoints(conn: sa.Connection, provider_ids: list[str]) -> None:
"""Ensure every Vertex provider has fixed gemini:chat + claude:chat endpoints."""
for provider_id in provider_ids:
provider_max_retries = (
conn.execute(
sa.text("""
SELECT COALESCE(max_retries, 2)
FROM providers
WHERE id = :provider_id
"""),
{"provider_id": provider_id},
).scalar()
or 2
)
for api_format, api_family, endpoint_kind in _VERTEX_ENDPOINTS:
# Normalize existing fixed endpoint fields.
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET
api_family = :api_family,
endpoint_kind = :endpoint_kind,
base_url = :base_url,
custom_path = NULL,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :provider_id
AND api_format = :api_format
"""),
{
"provider_id": provider_id,
"api_format": api_format,
"api_family": api_family,
"endpoint_kind": endpoint_kind,
"base_url": _VERTEX_BASE_URL,
},
)
exists = conn.execute(
sa.text("""
SELECT 1
FROM provider_endpoints
WHERE provider_id = :provider_id
AND api_format = :api_format
LIMIT 1
"""),
{"provider_id": provider_id, "api_format": api_format},
).first()
if not exists:
conn.execute(
sa.text("""
INSERT INTO provider_endpoints (
id,
provider_id,
api_format,
api_family,
endpoint_kind,
base_url,
custom_path,
header_rules,
body_rules,
max_retries,
is_active,
config,
format_acceptance_config,
proxy,
created_at,
updated_at
)
VALUES (
:id,
:provider_id,
:api_format,
:api_family,
:endpoint_kind,
:base_url,
NULL,
NULL,
NULL,
:max_retries,
TRUE,
NULL,
NULL,
NULL,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
"""),
{
"id": str(uuid.uuid4()),
"provider_id": provider_id,
"api_format": api_format,
"api_family": api_family,
"endpoint_kind": endpoint_kind,
"base_url": _VERTEX_BASE_URL,
"max_retries": int(provider_max_retries),
},
)
# Vertex fixed-provider model: disable non-fixed endpoints.
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET
is_active = FALSE,
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :provider_id
AND api_format NOT IN ('gemini:chat', 'claude:chat')
"""),
{"provider_id": provider_id},
)
def _normalize_vertex_key_formats(conn: sa.Connection, provider_ids: list[str]) -> None:
"""Normalize key.api_formats for Vertex keys by auth type."""
for provider_id in provider_ids:
# Service Account (and legacy vertex_ai) keys: allow Gemini + Claude models.
conn.execute(
sa.text("""
UPDATE provider_api_keys
SET
api_formats = CAST(:api_formats AS json),
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :provider_id
AND auth_type IN ('service_account', 'vertex_ai')
"""),
{
"provider_id": provider_id,
"api_formats": _VERTEX_KEY_FORMATS_SA,
},
)
# API Key mode on Vertex 仅支持 Gemini(Google publisher)。
conn.execute(
sa.text("""
UPDATE provider_api_keys
SET
api_formats = CAST(:api_formats AS json),
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :provider_id
AND auth_type = 'api_key'
"""),
{
"provider_id": provider_id,
"api_formats": _VERTEX_KEY_FORMATS_API_KEY,
},
)
def upgrade() -> None:
conn = op.get_bind()
# 1) 收集目标 Provider(兼容重复执行,先识别 legacy/new 两种来源)。
provider_ids = _select_vertex_provider_ids(conn)
# 2) 先重命名 auth_type(legacy vertex_ai -> service_account)。
conn.execute(sa.text("""
UPDATE provider_api_keys
SET auth_type = 'service_account'
WHERE auth_type = 'vertex_ai'
"""))
if not provider_ids:
return
# 3) 归一 provider_type,并启用格式转换(Vertex 同时承载 Gemini/Claude)。
for provider_id in provider_ids:
conn.execute(
sa.text("""
UPDATE providers
SET
provider_type = 'vertex_ai',
enable_format_conversion = TRUE
WHERE id = :provider_id
"""),
{"provider_id": provider_id},
)
# 4) 固定端点落地:gemini:chat + claude:chat。
_ensure_fixed_vertex_endpoints(conn, provider_ids)
# 5) 归一 key 的 api_formats,避免调度命中旧格式。
_normalize_vertex_key_formats(conn, provider_ids)
def downgrade() -> None:
conn = op.get_bind()
provider_rows = conn.execute(sa.text("""
SELECT id
FROM providers
WHERE lower(COALESCE(provider_type, '')) = 'vertex_ai'
"""))
provider_ids = [str(row[0]) for row in provider_rows if row[0]]
if provider_ids:
for provider_id in provider_ids:
conn.execute(
sa.text("""
UPDATE provider_api_keys
SET auth_type = 'vertex_ai'
WHERE provider_id = :provider_id
AND auth_type = 'service_account'
"""),
{"provider_id": provider_id},
)
conn.execute(
sa.text("""
UPDATE providers
SET provider_type = 'custom'
WHERE id = :provider_id
"""),
{"provider_id": provider_id},
)
@@ -0,0 +1,199 @@
"""backfill_codex_compact_endpoint
Backfill Codex reverse-proxy endpoints:
- ensure `openai:cli` endpoint is pinned to force_stream
- ensure `openai:compact` endpoint exists
Revision ID: f0c3a7b9d1e2
Revises: 2a624af8dd3a
Create Date: 2026-03-01 17:00:00.000000+00:00
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f0c3a7b9d1e2"
down_revision = "2a624af8dd3a"
branch_labels = None
depends_on = None
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
_COMPACT_FORMAT = "openai:compact"
_CLI_FORMAT = "openai:cli"
_FORCE_STREAM = "force_stream"
def _find_codex_provider_ids(conn: sa.Connection) -> list[str]:
"""Find Codex providers (by provider_type or legacy base_url pattern)."""
rows = conn.execute(sa.text("""
SELECT DISTINCT p.id
FROM providers p
LEFT JOIN provider_endpoints pe ON pe.provider_id = p.id
WHERE lower(COALESCE(p.provider_type, '')) = 'codex'
OR (
lower(COALESCE(pe.api_format, '')) = 'openai:cli'
AND lower(COALESCE(pe.base_url, '')) LIKE '%/backend-api/codex%'
)
"""))
return [str(r[0]) for r in rows if r[0]]
def _get_cli_endpoint(conn: sa.Connection, provider_id: str) -> dict[str, Any] | None:
"""Load existing openai:cli endpoint for the provider."""
row = (
conn.execute(
sa.text("""
SELECT base_url, header_rules, body_rules, max_retries, proxy, config
FROM provider_endpoints
WHERE provider_id = :pid AND api_format = :fmt
LIMIT 1
"""),
{"pid": provider_id, "fmt": _CLI_FORMAT},
)
.mappings()
.first()
)
return dict(row) if row else None
def _pin_cli_force_stream(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
"""Set upstream_stream_policy=force_stream on existing cli endpoint."""
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
cfg.pop("upstreamStreamPolicy", None)
cfg.pop("upstream_stream", None)
cfg["upstream_stream_policy"] = _FORCE_STREAM
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET api_family = 'openai',
endpoint_kind = 'cli',
config = CAST(:config AS json),
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :pid AND api_format = :fmt
"""),
{
"pid": provider_id,
"fmt": _CLI_FORMAT,
"config": json.dumps(cfg, ensure_ascii=False),
},
)
def _ensure_compact_endpoint(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
"""Create openai:compact endpoint if missing (clone from cli)."""
exists = conn.execute(
sa.text(
"SELECT 1 FROM provider_endpoints WHERE provider_id = :pid AND api_format = :fmt LIMIT 1"
),
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
).first()
if exists:
# Already exists, just ensure api_family/endpoint_kind are set.
conn.execute(
sa.text("""
UPDATE provider_endpoints
SET api_family = 'openai', endpoint_kind = 'compact',
updated_at = CURRENT_TIMESTAMP
WHERE provider_id = :pid AND api_format = :fmt
"""),
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
)
return
# Clone from cli endpoint, strip stream policy.
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
for k in ("upstream_stream_policy", "upstreamStreamPolicy", "upstream_stream"):
cfg.pop(k, None)
def _json(val: Any) -> str | None:
return json.dumps(val, ensure_ascii=False) if val is not None else None
conn.execute(
sa.text("""
INSERT INTO provider_endpoints (
id, provider_id, api_format, api_family, endpoint_kind,
base_url, custom_path, header_rules, body_rules,
max_retries, is_active, config, format_acceptance_config,
proxy, created_at, updated_at
) VALUES (
:id, :pid, :fmt, 'openai', 'compact',
:base_url, NULL, CAST(:header_rules AS json), CAST(:body_rules AS json),
:max_retries, TRUE, CAST(:config AS json), NULL,
CAST(:proxy AS jsonb), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
"""),
{
"id": str(uuid.uuid4()),
"pid": provider_id,
"fmt": _COMPACT_FORMAT,
"base_url": cli.get("base_url") or _CODEX_BASE_URL,
"header_rules": _json(cli.get("header_rules")),
"body_rules": _json(cli.get("body_rules")),
"max_retries": cli.get("max_retries") or 2,
"config": _json(cfg or None),
"proxy": _json(cli.get("proxy")),
},
)
def _add_compact_to_key_formats(conn: sa.Connection, provider_id: str) -> None:
"""Ensure provider keys include openai:compact in api_formats."""
rows = (
conn.execute(
sa.text("SELECT id, api_formats FROM provider_api_keys WHERE provider_id = :pid"),
{"pid": provider_id},
)
.mappings()
.all()
)
for row in rows:
raw = row["api_formats"]
formats: list[str] = []
if isinstance(raw, list):
for item in raw:
v = str(item or "").strip().lower()
if v and v not in formats:
formats.append(v)
if _COMPACT_FORMAT in formats:
continue
# Insert compact right after cli, or at end.
if _CLI_FORMAT in formats:
idx = formats.index(_CLI_FORMAT) + 1
formats.insert(idx, _COMPACT_FORMAT)
else:
formats.append(_COMPACT_FORMAT)
conn.execute(
sa.text("""
UPDATE provider_api_keys
SET api_formats = CAST(:fmts AS json), updated_at = CURRENT_TIMESTAMP
WHERE id = :id
"""),
{"id": row["id"], "fmts": json.dumps(formats, ensure_ascii=False)},
)
def upgrade() -> None:
conn = op.get_bind()
for provider_id in _find_codex_provider_ids(conn):
cli = _get_cli_endpoint(conn, provider_id)
if not cli:
continue # No cli endpoint to clone from; skip.
_pin_cli_force_stream(conn, provider_id, cli)
_ensure_compact_endpoint(conn, provider_id, cli)
_add_compact_to_key_formats(conn, provider_id)
def downgrade() -> None:
# Data backfill: no-op to avoid deleting user-managed data.
return
@@ -0,0 +1,29 @@
"""add_proxy_metadata_to_proxy_nodes
Revision ID: 1d2e3f4a5b6c
Revises: f0c3a7b9d1e2
Create Date: 2026-03-02 13:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "1d2e3f4a5b6c"
down_revision: str | None = "f0c3a7b9d1e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE public.proxy_nodes ADD COLUMN IF NOT EXISTS proxy_metadata json")
op.execute(
"COMMENT ON COLUMN public.proxy_nodes.proxy_metadata IS 'aether-proxy 上报元数据(版本等)'"
)
def downgrade() -> None:
op.execute("ALTER TABLE public.proxy_nodes DROP COLUMN IF EXISTS proxy_metadata")
@@ -0,0 +1,72 @@
"""backfill_codex_default_body_rules
Backfill default body_rules for codex providers with openai:cli endpoints
that currently have body_rules IS NULL.
Rules:
- drop max_output_tokens
- drop temperature
- drop top_p
- set store = false
- set instructions = "You are GPT-5." (when instructions not exists)
Revision ID: dd0278c0a28c
Revises: 1d2e3f4a5b6c
Create Date: 2026-03-02 15:00:00.000000+00:00
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "dd0278c0a28c"
down_revision = "1d2e3f4a5b6c"
branch_labels = None
depends_on = None
_TARGET_FORMATS = ("openai:cli",)
_DEFAULT_BODY_RULES = [
{"action": "drop", "path": "max_output_tokens"},
{"action": "drop", "path": "temperature"},
{"action": "drop", "path": "top_p"},
{"action": "set", "path": "store", "value": False},
{
"action": "set",
"path": "instructions",
"value": "You are GPT-5.",
"condition": {"path": "instructions", "op": "not_exists"},
},
]
def upgrade() -> None:
conn = op.get_bind()
# 幂等性: 仅回填 codex 提供商中 body_rules 为空(SQL NULL 或 JSON null)的记录
rules_json = json.dumps(_DEFAULT_BODY_RULES, ensure_ascii=False)
result = conn.execute(
sa.text("""
UPDATE provider_endpoints pe
SET body_rules = CAST(:rules AS json),
updated_at = CURRENT_TIMESTAMP
FROM providers p
WHERE pe.provider_id = p.id
AND p.provider_type = :ptype
AND pe.api_format = :fmt
AND (pe.body_rules IS NULL OR pe.body_rules::text = 'null')
"""),
{"rules": rules_json, "ptype": "codex", "fmt": _TARGET_FORMATS[0]},
)
if result.rowcount:
print(f" backfilled body_rules for {result.rowcount} endpoint(s)")
def downgrade() -> None:
# Data backfill: no-op to avoid removing user-customized rules.
return
@@ -0,0 +1,45 @@
"""add_idx_usage_provider_key
Add composite index on usage(provider_id, provider_api_key_id) to support
the pool management page's per-key usage stats aggregation query.
Revision ID: 0ba031f328de
Revises: dd0278c0a28c
Create Date: 2026-03-03 10:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0ba031f328de"
down_revision = "dd0278c0a28c"
branch_labels = None
depends_on = None
INDEX_NAME = "idx_usage_provider_key"
TABLE = "usage"
COLUMNS = ["provider_id", "provider_api_key_id"]
def upgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if result:
return
op.create_index(INDEX_NAME, TABLE, COLUMNS)
def downgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if not result:
return
op.drop_index(INDEX_NAME, table_name=TABLE)
@@ -0,0 +1,45 @@
"""add_idx_usage_status_user_created
Add composite index on usage(status, user_id, created_at) to speed up
interval timeline and active usage analytics queries.
Revision ID: 5f1d2e3c4b5a
Revises: 0ba031f328de
Create Date: 2026-03-03 17:30:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "5f1d2e3c4b5a"
down_revision = "0ba031f328de"
branch_labels = None
depends_on = None
INDEX_NAME = "idx_usage_status_user_created"
TABLE = "usage"
COLUMNS = ["status", "user_id", "created_at"]
def upgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if result:
return
op.create_index(INDEX_NAME, TABLE, COLUMNS)
def downgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if not result:
return
op.drop_index(INDEX_NAME, table_name=TABLE)
@@ -0,0 +1,41 @@
"""add fingerprint column to provider_api_keys
Revision ID: 6a9b8c7d5e4f
Revises: 5f1d2e3c4b5a
Create Date: 2026-03-04 23:50:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6a9b8c7d5e4f"
down_revision: str | None = "5f1d2e3c4b5a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("provider_api_keys", "fingerprint"):
op.add_column(
"provider_api_keys",
sa.Column("fingerprint", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if column_exists("provider_api_keys", "fingerprint"):
op.drop_column("provider_api_keys", "fingerprint")
@@ -0,0 +1,65 @@
"""remove standalone api key locking
Revision ID: 7c91d2e4f8a1
Revises: 6f7a8b9c0d1e
Create Date: 2026-03-05 17:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7c91d2e4f8a1"
down_revision: str | None = "6f7a8b9c0d1e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT_NAME = "ck_api_keys_standalone_not_locked"
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [c["name"] for c in insp.get_columns(table_name)]
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
def upgrade() -> None:
if not (
_column_exists("api_keys", "is_standalone")
and _column_exists("api_keys", "is_locked")
and _column_exists("api_keys", "is_active")
):
return
op.execute(sa.text("""
UPDATE api_keys
SET is_active = FALSE,
is_locked = FALSE
WHERE is_standalone IS TRUE AND is_locked IS TRUE
"""))
if not _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
op.create_check_constraint(
_CONSTRAINT_NAME,
"api_keys",
"(NOT is_standalone) OR (NOT is_locked)",
)
def downgrade() -> None:
if _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
op.drop_constraint(_CONSTRAINT_NAME, "api_keys", type_="check")
@@ -0,0 +1,220 @@
"""tighten wallet transaction snapshots and remove wallet version
Revision ID: 8e71f2a4c9b0
Revises: 7c91d2e4f8a1
Create Date: 2026-03-07 13:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8e71f2a4c9b0"
down_revision: str | None = "7c91d2e4f8a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_WALLET_TX_BEFORE_CHECK = "ck_wallet_tx_balance_before_consistent"
_WALLET_TX_AFTER_CHECK = "ck_wallet_tx_balance_after_consistent"
_WALLET_LIMIT_MODE_INDEX = "idx_wallets_limit_mode"
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [c["name"] for c in insp.get_columns(table_name)]
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(index.get("name") == index_name for index in insp.get_indexes(table_name))
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
def _tighten_wallet_transaction_snapshots() -> None:
if not _table_exists("wallet_transactions"):
return
required_columns = {
"balance_before",
"balance_after",
"recharge_balance_before",
"recharge_balance_after",
"gift_balance_before",
"gift_balance_after",
}
existing_columns = {
column["name"] for column in inspect(op.get_bind()).get_columns("wallet_transactions")
}
if not required_columns.issubset(existing_columns):
return
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET recharge_balance_before = balance_before
WHERE recharge_balance_before IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET recharge_balance_after = balance_after
WHERE recharge_balance_after IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET gift_balance_before = 0
WHERE gift_balance_before IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET gift_balance_after = 0
WHERE gift_balance_after IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET balance_before = recharge_balance_before + gift_balance_before,
balance_after = recharge_balance_after + gift_balance_after
"""
)
)
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
op.create_check_constraint(
_WALLET_TX_BEFORE_CHECK,
"wallet_transactions",
"balance_before = recharge_balance_before + gift_balance_before",
)
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
op.create_check_constraint(
_WALLET_TX_AFTER_CHECK,
"wallet_transactions",
"balance_after = recharge_balance_after + gift_balance_after",
)
op.alter_column(
"wallet_transactions",
"recharge_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"recharge_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"gift_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"gift_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
def _drop_wallet_cleanup_artifacts() -> None:
if not _table_exists("wallets"):
return
if _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
op.drop_index(_WALLET_LIMIT_MODE_INDEX, table_name="wallets")
if _column_exists("wallets", "version"):
op.drop_column("wallets", "version")
def upgrade() -> None:
_tighten_wallet_transaction_snapshots()
_drop_wallet_cleanup_artifacts()
def downgrade() -> None:
if _table_exists("wallets"):
if not _column_exists("wallets", "version"):
op.add_column(
"wallets",
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
)
if not _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
op.create_index(_WALLET_LIMIT_MODE_INDEX, "wallets", ["limit_mode"])
if not _table_exists("wallet_transactions"):
return
if _column_exists("wallet_transactions", "recharge_balance_before"):
op.alter_column(
"wallet_transactions",
"recharge_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "recharge_balance_after"):
op.alter_column(
"wallet_transactions",
"recharge_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "gift_balance_before"):
op.alter_column(
"wallet_transactions",
"gift_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "gift_balance_after"):
op.alter_column(
"wallet_transactions",
"gift_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
op.drop_constraint(_WALLET_TX_AFTER_CHECK, "wallet_transactions", type_="check")
if _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
op.drop_constraint(_WALLET_TX_BEFORE_CHECK, "wallet_transactions", type_="check")
@@ -0,0 +1,80 @@
"""add missing foreign key indexes for cascade delete performance
Revision ID: 2d932114930d
Revises: 8e71f2a4c9b0
Create Date: 2026-03-07 16:28:48.633531+00:00
"""
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '2d932114930d'
down_revision = '8e71f2a4c9b0'
branch_labels = None
depends_on = None
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
def _create_index_if_not_exists(index_name: str, table_name: str, columns: list[str]) -> None:
if not _index_exists(table_name, index_name):
op.create_index(op.f(index_name), table_name, columns, unique=False)
def _drop_index_if_exists(index_name: str, table_name: str) -> None:
if _index_exists(table_name, index_name):
op.drop_index(op.f(index_name), table_name=table_name)
# (index_name, table_name, columns)
_INDEXES = [
# api_keys.user_id (CASCADE -> users.id)
('ix_api_keys_user_id', 'api_keys', ['user_id']),
# usage: wallet_id, provider_endpoint_id, provider_api_key_id (SET NULL)
('ix_usage_wallet_id', 'usage', ['wallet_id']),
('ix_usage_provider_endpoint_id', 'usage', ['provider_endpoint_id']),
('ix_usage_provider_api_key_id', 'usage', ['provider_api_key_id']),
# wallet_transactions.operator_id (SET NULL -> users.id)
('ix_wallet_transactions_operator_id', 'wallet_transactions', ['operator_id']),
# payment_callbacks.payment_order_id (SET NULL -> payment_orders.id)
('ix_payment_callbacks_payment_order_id', 'payment_callbacks', ['payment_order_id']),
# refund_requests: payment_order_id, requested_by, approved_by, processed_by (SET NULL)
('ix_refund_requests_payment_order_id', 'refund_requests', ['payment_order_id']),
('ix_refund_requests_requested_by', 'refund_requests', ['requested_by']),
('ix_refund_requests_approved_by', 'refund_requests', ['approved_by']),
('ix_refund_requests_processed_by', 'refund_requests', ['processed_by']),
# proxy_nodes.registered_by (SET NULL -> users.id)
('ix_proxy_nodes_registered_by', 'proxy_nodes', ['registered_by']),
# video_tasks: api_key_id, provider_id, endpoint_id, key_id, remixed_from_task_id
('ix_video_tasks_api_key_id', 'video_tasks', ['api_key_id']),
('ix_video_tasks_provider_id', 'video_tasks', ['provider_id']),
('ix_video_tasks_endpoint_id', 'video_tasks', ['endpoint_id']),
('ix_video_tasks_key_id', 'video_tasks', ['key_id']),
('ix_video_tasks_remixed_from_task_id', 'video_tasks', ['remixed_from_task_id']),
# user_preferences.default_provider_id (-> providers.id)
('ix_user_preferences_default_provider_id', 'user_preferences', ['default_provider_id']),
# announcements.author_id (SET NULL -> users.id)
('ix_announcements_author_id', 'announcements', ['author_id']),
# announcement_reads.announcement_id (-> announcements.id)
('ix_announcement_reads_announcement_id', 'announcement_reads', ['announcement_id']),
# request_candidates: user_id, api_key_id, endpoint_id, key_id (CASCADE)
('ix_request_candidates_user_id', 'request_candidates', ['user_id']),
('ix_request_candidates_api_key_id', 'request_candidates', ['api_key_id']),
('ix_request_candidates_endpoint_id', 'request_candidates', ['endpoint_id']),
('ix_request_candidates_key_id', 'request_candidates', ['key_id']),
]
def upgrade() -> None:
for index_name, table_name, columns in _INDEXES:
_create_index_if_not_exists(index_name, table_name, columns)
def downgrade() -> None:
for index_name, table_name, _columns in reversed(_INDEXES):
_drop_index_if_exists(index_name, table_name)
@@ -0,0 +1,224 @@
"""usage stats retention: SET NULL on delete and add name snapshots
Revision ID: 45b118150a78
Revises: 2d932114930d
Create Date: 2026-03-08 03:48:49.622091+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "45b118150a78"
down_revision = "2d932114930d"
branch_labels = None
depends_on = None
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
# ---------------------------------------------------------------------------
# Inline helpers
# ---------------------------------------------------------------------------
class _SchemaCache:
def __init__(self) -> None:
self._columns: dict[str, dict[str, str]] = {}
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
def load_columns(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._columns]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT table_name, column_name, data_type "
"FROM information_schema.columns "
"WHERE table_name = ANY(:tables) "
" AND table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for t in need:
self._columns.setdefault(t, {})
for table, col, dtype in rows:
self._columns[table][col] = dtype
def load_fk_rules(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._fk_loaded_tables]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
" AND rc.constraint_schema = tc.constraint_schema "
"WHERE tc.table_name = ANY(:tables) "
" AND tc.table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
def column_exists(self, table: str, column: str) -> bool:
return column in self._columns.get(table, {})
def fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def _replace_fk_if_needed(
cache: _SchemaCache,
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current or _fk_exists(constraint_name, table_name):
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
# ---------------------------------------------------------------------------
def upgrade() -> None:
c = _SchemaCache()
c.load_columns(_TABLES)
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
# --- Usage: add name snapshot columns ---
if not c.column_exists("usage", "username"):
op.add_column(
"usage", sa.Column("username", sa.String(100), nullable=True, comment="用户名快照")
)
if not c.column_exists("usage", "api_key_name"):
op.add_column(
"usage",
sa.Column("api_key_name", sa.String(200), nullable=True, comment="API Key 名称快照"),
)
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
_replace_fk_if_needed(
c,
"stats_user_daily_user_id_fkey",
"stats_user_daily",
"users",
["user_id"],
["id"],
"SET NULL",
)
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=True)
if not c.column_exists("stats_user_daily", "username"):
op.add_column(
"stats_user_daily",
sa.Column(
"username",
sa.String(100),
nullable=True,
comment="用户名快照(删除用户后仍可追溯)",
),
)
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
_replace_fk_if_needed(
c,
"stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
)
op.alter_column("stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=True)
if not c.column_exists("stats_daily_api_key", "api_key_name"):
op.add_column(
"stats_daily_api_key",
sa.Column(
"api_key_name",
sa.String(200),
nullable=True,
comment="API Key 名称快照(删除 Key 后仍可追溯)",
),
)
def downgrade() -> None:
c = _SchemaCache()
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
# --- Remove snapshot columns ---
if c.column_exists("stats_daily_api_key", "api_key_name"):
op.drop_column("stats_daily_api_key", "api_key_name")
if c.column_exists("stats_user_daily", "username"):
op.drop_column("stats_user_daily", "username")
if c.column_exists("usage", "api_key_name"):
op.drop_column("usage", "api_key_name")
if c.column_exists("usage", "username"):
op.drop_column("usage", "username")
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
_replace_fk_if_needed(
c,
"stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
"api_keys",
["api_key_id"],
["id"],
"CASCADE",
)
op.alter_column(
"stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=False
)
# --- StatsUserDaily: SET NULL -> CASCADE ---
_replace_fk_if_needed(
c,
"stats_user_daily_user_id_fkey",
"stats_user_daily",
"users",
["user_id"],
["id"],
"CASCADE",
)
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=False)
@@ -0,0 +1,257 @@
"""request_candidates/video_tasks retention: SET NULL and add snapshots
Revision ID: 13a4c8f6d9e0
Revises: 45b118150a78
Create Date: 2026-03-08 12:15:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "13a4c8f6d9e0"
down_revision = "45b118150a78"
branch_labels = None
depends_on = None
_TABLES = ["request_candidates", "video_tasks"]
# ---------------------------------------------------------------------------
# Inline helpers
# ---------------------------------------------------------------------------
class _SchemaCache:
def __init__(self) -> None:
self._columns: dict[str, dict[str, str]] = {}
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
def load_columns(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._columns]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT table_name, column_name, data_type "
"FROM information_schema.columns "
"WHERE table_name = ANY(:tables) "
" AND table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for t in need:
self._columns.setdefault(t, {})
for table, col, dtype in rows:
self._columns[table][col] = dtype
def load_fk_rules(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._fk_loaded_tables]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
" AND rc.constraint_schema = tc.constraint_schema "
"WHERE tc.table_name = ANY(:tables) "
" AND tc.table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
def column_exists(self, table: str, column: str) -> bool:
return column in self._columns.get(table, {})
def fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def _replace_fk_if_needed(
cache: _SchemaCache,
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current or _fk_exists(constraint_name, table_name):
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
# ---------------------------------------------------------------------------
def upgrade() -> None:
c = _SchemaCache()
c.load_columns(_TABLES)
c.load_fk_rules(_TABLES)
# --- request_candidates: add snapshot columns ---
if not c.column_exists("request_candidates", "username"):
op.add_column(
"request_candidates",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
)
if not c.column_exists("request_candidates", "api_key_name"):
op.add_column(
"request_candidates",
sa.Column(
"api_key_name",
sa.String(length=200),
nullable=True,
comment="API Key 名称快照",
),
)
# --- request_candidates: CASCADE -> SET NULL ---
_replace_fk_if_needed(
c,
"request_candidates_user_id_fkey",
"request_candidates",
"users",
["user_id"],
["id"],
"SET NULL",
)
_replace_fk_if_needed(
c,
"request_candidates_api_key_id_fkey",
"request_candidates",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
)
# --- video_tasks: add snapshot columns ---
if not c.column_exists("video_tasks", "username"):
op.add_column(
"video_tasks",
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
)
if not c.column_exists("video_tasks", "api_key_name"):
op.add_column(
"video_tasks",
sa.Column(
"api_key_name",
sa.String(length=200),
nullable=True,
comment="API Key 名称快照",
),
)
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
_replace_fk_if_needed(
c,
"video_tasks_user_id_fkey",
"video_tasks",
"users",
["user_id"],
["id"],
"SET NULL",
)
_replace_fk_if_needed(
c,
"video_tasks_api_key_id_fkey",
"video_tasks",
"api_keys",
["api_key_id"],
["id"],
"SET NULL",
)
def downgrade() -> None:
c = _SchemaCache()
c.load_columns(_TABLES)
c.load_fk_rules(_TABLES)
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
_replace_fk_if_needed(
c,
"video_tasks_api_key_id_fkey",
"video_tasks",
"api_keys",
["api_key_id"],
["id"],
"NO ACTION",
)
_replace_fk_if_needed(
c,
"video_tasks_user_id_fkey",
"video_tasks",
"users",
["user_id"],
["id"],
"NO ACTION",
)
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False)
if c.column_exists("video_tasks", "api_key_name"):
op.drop_column("video_tasks", "api_key_name")
if c.column_exists("video_tasks", "username"):
op.drop_column("video_tasks", "username")
# --- request_candidates: SET NULL -> CASCADE ---
_replace_fk_if_needed(
c,
"request_candidates_api_key_id_fkey",
"request_candidates",
"api_keys",
["api_key_id"],
["id"],
"CASCADE",
)
_replace_fk_if_needed(
c,
"request_candidates_user_id_fkey",
"request_candidates",
"users",
["user_id"],
["id"],
"CASCADE",
)
if c.column_exists("request_candidates", "api_key_name"):
op.drop_column("request_candidates", "api_key_name")
if c.column_exists("request_candidates", "username"):
op.drop_column("request_candidates", "username")
@@ -0,0 +1,230 @@
"""cost fields: Float -> Numeric(20,8) + provider_api_keys composite index
Revision ID: 2053ab8ed764
Revises: 13a4c8f6d9e0
Create Date: 2026-03-08 15:30:00.000000+00:00
"""
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Callable
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "2053ab8ed764"
down_revision = "13a4c8f6d9e0"
branch_labels = None
depends_on = None
# (table_name, column_name, nullable, server_default)
_COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
# api_keys
("api_keys", "total_cost_usd", True, "0.0"),
# usage
("usage", "input_cost_usd", True, "0.0"),
("usage", "output_cost_usd", True, "0.0"),
("usage", "cache_cost_usd", True, "0.0"),
("usage", "cache_creation_cost_usd", True, "0.0"),
("usage", "cache_read_cost_usd", True, "0.0"),
("usage", "request_cost_usd", True, "0.0"),
("usage", "total_cost_usd", True, "0.0"),
("usage", "actual_input_cost_usd", True, "0.0"),
("usage", "actual_output_cost_usd", True, "0.0"),
("usage", "actual_cache_creation_cost_usd", True, "0.0"),
("usage", "actual_cache_read_cost_usd", True, "0.0"),
("usage", "actual_request_cost_usd", True, "0.0"),
("usage", "actual_total_cost_usd", True, "0.0"),
("usage", "rate_multiplier", True, "1.0"),
("usage", "input_price_per_1m", True, None),
("usage", "output_price_per_1m", True, None),
("usage", "cache_creation_price_per_1m", True, None),
("usage", "cache_read_price_per_1m", True, None),
("usage", "price_per_request", True, None),
# providers
("providers", "monthly_quota_usd", True, None),
("providers", "monthly_used_usd", True, "0.0"),
# global_models
("global_models", "default_price_per_request", True, None),
# models
("models", "price_per_request", True, None),
# stats_hourly
("stats_hourly", "total_cost", False, "0.0"),
("stats_hourly", "actual_total_cost", False, "0.0"),
# stats_hourly_user
("stats_hourly_user", "total_cost", False, "0.0"),
# stats_hourly_model
("stats_hourly_model", "total_cost", False, "0.0"),
# stats_hourly_provider
("stats_hourly_provider", "total_cost", False, "0.0"),
# stats_daily
("stats_daily", "total_cost", False, "0.0"),
("stats_daily", "actual_total_cost", False, "0.0"),
("stats_daily", "input_cost", False, "0.0"),
("stats_daily", "output_cost", False, "0.0"),
("stats_daily", "cache_creation_cost", False, "0.0"),
("stats_daily", "cache_read_cost", False, "0.0"),
# stats_daily_model
("stats_daily_model", "total_cost", False, "0.0"),
# stats_daily_provider
("stats_daily_provider", "total_cost", False, "0.0"),
# stats_daily_api_key
("stats_daily_api_key", "total_cost", False, "0.0"),
# stats_summary
("stats_summary", "all_time_cost", False, "0.0"),
("stats_summary", "all_time_actual_cost", False, "0.0"),
# stats_user_daily
("stats_user_daily", "total_cost", False, "0.0"),
]
_ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
# ---------------------------------------------------------------------------
# Inline helpers
# ---------------------------------------------------------------------------
class _SchemaCache:
def __init__(self) -> None:
self._columns: dict[str, dict[str, str]] = {}
def load_columns(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._columns]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT table_name, column_name, data_type "
"FROM information_schema.columns "
"WHERE table_name = ANY(:tables) "
" AND table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for t in need:
self._columns.setdefault(t, {})
for table, col, dtype in rows:
self._columns[table][col] = dtype
def column_exists(self, table: str, column: str) -> bool:
return column in self._columns.get(table, {})
def column_type(self, table: str, column: str) -> str | None:
return self._columns.get(table, {}).get(column)
def is_numeric(self, table: str, column: str) -> bool:
return self.column_type(table, column) == "numeric"
def _index_exists(index_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_indexes "
"WHERE indexname = :name AND schemaname = current_schema()::text"
),
{"name": index_name},
)
return result.scalar() is not None
def _numeric_max(type_spec: str) -> float | None:
m = re.match(r"NUMERIC\((\d+),(\d+)\)", type_spec, re.IGNORECASE)
if not m:
return None
precision, scale = int(m.group(1)), int(m.group(2))
return 10 ** (precision - scale) - 10 ** (-scale)
def _batch_alter_type(
cache: _SchemaCache,
columns: list[tuple[str, str, bool, str | None]],
cast_suffix: str,
type_fn: Callable[[str], str],
) -> None:
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
for table, col, _nullable, _default in columns:
if not cache.column_exists(table, col):
continue
by_table[table].append((col, type_fn(col)))
bind = op.get_bind()
for table, col_types in by_table.items():
for col, target in col_types:
cap = _numeric_max(target)
if cap is not None:
bind.execute(
sa.text(
f"UPDATE {table} SET {col} = :cap "
f"WHERE {col} IS NOT NULL AND abs({col}) > :cap"
),
{"cap": cap},
)
parts = [
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
for col, target in col_types
]
if parts:
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))
# ---------------------------------------------------------------------------
def _type_spec(col: str) -> str:
"""Return the SQL type literal for a given column name."""
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
def upgrade() -> None:
c = _SchemaCache()
c.load_columns(_ALL_TABLES)
# -- 1. cost fields: Float -> Numeric (batched per table)
cols_to_convert = [
(t, col, n, d)
for t, col, n, d in _COST_COLUMNS
if c.column_exists(t, col) and not c.is_numeric(t, col)
]
_batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
# -- 2. provider_api_keys composite index
if not _index_exists("idx_provider_api_keys_provider_active"):
op.create_index(
"idx_provider_api_keys_provider_active",
"provider_api_keys",
["provider_id", "is_active"],
)
def downgrade() -> None:
# -- 2. drop composite index
if _index_exists("idx_provider_api_keys_provider_active"):
op.drop_index(
"idx_provider_api_keys_provider_active",
table_name="provider_api_keys",
)
# -- 1. Numeric -> Float (batched per table)
c = _SchemaCache()
c.load_columns(_ALL_TABLES)
cols_to_revert = [
(t, col, n, d)
for t, col, n, d in _COST_COLUMNS
if c.column_exists(t, col) and c.is_numeric(t, col)
]
_batch_alter_type(
c,
cols_to_revert,
cast_suffix="double precision",
type_fn=lambda _col: "DOUBLE PRECISION",
)
@@ -0,0 +1,127 @@
"""video_tasks.key_id: add ondelete SET NULL
Revision ID: d7649c1f8e21
Revises: 2053ab8ed764
Create Date: 2026-03-09 01:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d7649c1f8e21"
down_revision = "2053ab8ed764"
branch_labels = None
depends_on = None
_TABLE = "video_tasks"
_FK_NAME = "video_tasks_key_id_fkey"
# ---------------------------------------------------------------------------
# Inline helpers
# ---------------------------------------------------------------------------
class _SchemaCache:
def __init__(self) -> None:
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
def load_fk_rules(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._fk_loaded_tables]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
" AND rc.constraint_schema = tc.constraint_schema "
"WHERE tc.table_name = ANY(:tables) "
" AND tc.table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
def fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def _replace_fk_if_needed(
cache: _SchemaCache,
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current or _fk_exists(constraint_name, table_name):
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
# ---------------------------------------------------------------------------
def upgrade() -> None:
c = _SchemaCache()
c.load_fk_rules([_TABLE])
_replace_fk_if_needed(
c,
_FK_NAME,
_TABLE,
"provider_api_keys",
["key_id"],
["id"],
"SET NULL",
)
def downgrade() -> None:
c = _SchemaCache()
c.load_fk_rules([_TABLE])
_replace_fk_if_needed(
c,
_FK_NAME,
_TABLE,
"provider_api_keys",
["key_id"],
["id"],
"NO ACTION",
)
@@ -0,0 +1,42 @@
"""Strip request_results_window from health_by_format JSON.
This data is now maintained in process memory only, no longer persisted to DB.
Revision ID: a3f1b7c9d2e4
Revises: d7649c1f8e21
Create Date: 2026-03-10 12:00:00.000000+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "a3f1b7c9d2e4"
down_revision = "d7649c1f8e21"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
UPDATE provider_api_keys
SET health_by_format = (
SELECT jsonb_object_agg(
fmt_key,
fmt_value - 'request_results_window'
)
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
)
WHERE health_by_format IS NOT NULL
AND health_by_format != '{}'::jsonb
AND EXISTS (
SELECT 1
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
WHERE fmt_value ? 'request_results_window'
)
""")
def downgrade() -> None:
# No-op: window data is rebuilt from scratch on process start
pass
@@ -0,0 +1,159 @@
"""tighten usage billing state machine
Revision ID: 9e4f1a2b3c4d
Revises: a3f1b7c9d2e4
Create Date: 2026-03-11 19:00:00.000000+00:00
This migration does two things:
1. Change new `usage.billing_status` default from `settled` to `pending`.
2. Repair only the clearly-safe inconsistent historical rows for production:
- failed/cancelled zero-cost rows that were marked settled are converted to void
- terminal rows missing finalized_at are backfilled from created_at
Ambiguous positive-cost settled rows are intentionally left untouched for manual audit.
All data updates are batched (10000 rows per iteration) to avoid long-held locks
and excessive WAL generation on large usage tables.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9e4f1a2b3c4d"
down_revision: str | None = "a3f1b7c9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BATCH_SIZE = 10000
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [col["name"] for col in insp.get_columns(table_name)]
def upgrade() -> None:
if not _table_exists("usage"):
return
if _column_exists("usage", "billing_status"):
op.alter_column(
"usage",
"billing_status",
existing_type=sa.String(length=20),
server_default="pending",
existing_nullable=False,
)
required_columns = {
"billing_status",
"status",
"total_cost_usd",
"request_cost_usd",
"actual_total_cost_usd",
"actual_request_cost_usd",
"wallet_balance_after",
"finalized_at",
"created_at",
}
if not required_columns.issubset(
{col for col in required_columns if _column_exists("usage", col)}
):
return
conn = op.get_bind()
# Step 1: billing_status IS NULL -> 'pending' (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET billing_status = 'pending'
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
# Step 2: failed/cancelled zero-cost settled -> void (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status = 'settled'
AND status IN ('failed', 'cancelled')
AND COALESCE(total_cost_usd, 0) = 0
AND wallet_balance_after IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET billing_status = 'void',
finalized_at = COALESCE(usage.finalized_at, usage.created_at),
total_cost_usd = 0,
request_cost_usd = 0,
actual_total_cost_usd = 0,
actual_request_cost_usd = 0
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
# Step 3: backfill finalized_at for terminal rows (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status IN ('settled', 'void')
AND finalized_at IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET finalized_at = COALESCE(usage.finalized_at, usage.created_at)
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
def downgrade() -> None:
if not _table_exists("usage") or not _column_exists("usage", "billing_status"):
return
op.alter_column(
"usage",
"billing_status",
existing_type=sa.String(length=20),
server_default="settled",
existing_nullable=False,
)
@@ -0,0 +1,113 @@
"""add wallet daily usage ledgers
Revision ID: d4e5f6a7b8c9
Revises: 9e4f1a2b3c4d
Create Date: 2026-03-11 21:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d4e5f6a7b8c9"
down_revision: str | None = "9e4f1a2b3c4d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [col["name"] for col in insp.get_columns(table_name)]
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
def upgrade() -> None:
if not _table_exists("wallet_daily_usage_ledgers"):
op.create_table(
"wallet_daily_usage_ledgers",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("wallet_id", sa.String(length=36), nullable=False),
sa.Column("billing_date", sa.Date(), nullable=False),
sa.Column("billing_timezone", sa.String(length=64), nullable=False),
sa.Column("total_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("first_finalized_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_finalized_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"wallet_id",
"billing_date",
"billing_timezone",
name="uq_wallet_daily_usage_ledgers_wallet_date_tz",
),
)
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
op.create_index(
"idx_wallet_daily_usage_wallet_date",
"wallet_daily_usage_ledgers",
["wallet_id", "billing_date"],
)
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
op.create_index(
"idx_wallet_daily_usage_date",
"wallet_daily_usage_ledgers",
["billing_date"],
)
if (
_table_exists("usage")
and all(
_column_exists("usage", col) for col in ["billing_status", "finalized_at", "wallet_id"]
)
and not _index_exists("usage", "idx_usage_billing_finalized_wallet")
):
op.create_index(
"idx_usage_billing_finalized_wallet",
"usage",
["billing_status", "finalized_at", "wallet_id"],
)
def downgrade() -> None:
if _table_exists("usage") and _index_exists("usage", "idx_usage_billing_finalized_wallet"):
op.drop_index("idx_usage_billing_finalized_wallet", table_name="usage")
if _table_exists("wallet_daily_usage_ledgers"):
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
op.drop_index("idx_wallet_daily_usage_date", table_name="wallet_daily_usage_ledgers")
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
op.drop_index(
"idx_wallet_daily_usage_wallet_date",
table_name="wallet_daily_usage_ledgers",
)
op.drop_table("wallet_daily_usage_ledgers")
@@ -0,0 +1,59 @@
"""add provider_api_keys usage total columns
Revision ID: 9b7c6d5e4f3a
Revises: d4e5f6a7b8c9
Create Date: 2026-03-11 22:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9b7c6d5e4f3a"
down_revision: str | None = "d4e5f6a7b8c9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("provider_api_keys", "total_tokens"):
op.add_column(
"provider_api_keys",
sa.Column("total_tokens", sa.BigInteger(), nullable=False, server_default="0"),
)
if column_exists("provider_api_keys", "total_tokens"):
op.alter_column("provider_api_keys", "total_tokens", server_default=None)
if not column_exists("provider_api_keys", "total_cost_usd"):
op.add_column(
"provider_api_keys",
sa.Column(
"total_cost_usd",
sa.Numeric(20, 8),
nullable=False,
server_default="0.0",
),
)
if column_exists("provider_api_keys", "total_cost_usd"):
op.alter_column("provider_api_keys", "total_cost_usd", server_default=None)
def downgrade() -> None:
if column_exists("provider_api_keys", "total_cost_usd"):
op.drop_column("provider_api_keys", "total_cost_usd")
if column_exists("provider_api_keys", "total_tokens"):
op.drop_column("provider_api_keys", "total_tokens")
@@ -0,0 +1,116 @@
"""cleanup stale provider references after provider deletion
Revision ID: c1d2e3f4a5b6
Revises: 9b7c6d5e4f3a
Create Date: 2026-03-11 23:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c1d2e3f4a5b6"
down_revision = "9b7c6d5e4f3a"
branch_labels = None
depends_on = None
_users = sa.table(
"users",
sa.column("id", sa.String(36)),
sa.column("allowed_providers", sa.JSON()),
)
_api_keys = sa.table(
"api_keys",
sa.column("id", sa.String(36)),
sa.column("allowed_providers", sa.JSON()),
)
_user_preferences = sa.table(
"user_preferences",
sa.column("id", sa.String(36)),
sa.column("default_provider_id", sa.String(36)),
)
_video_tasks = sa.table(
"video_tasks",
sa.column("id", sa.String(36)),
sa.column("provider_id", sa.String(36)),
sa.column("endpoint_id", sa.String(36)),
)
_providers = sa.table("providers", sa.column("id", sa.String(36)))
_provider_endpoints = sa.table("provider_endpoints", sa.column("id", sa.String(36)))
def _load_valid_ids(conn: sa.Connection, table: sa.Table) -> set[str]:
return {str(row[0]) for row in conn.execute(sa.select(table.c.id)).fetchall() if row[0]}
def _cleanup_allowed_providers(
conn: sa.Connection,
table: sa.Table,
valid_provider_ids: set[str],
) -> None:
rows = conn.execute(
sa.select(table.c.id, table.c.allowed_providers).where(
table.c.allowed_providers.isnot(None)
)
).fetchall()
for row_id, allowed_providers in rows:
if not isinstance(allowed_providers, list):
continue
filtered = [
provider_id for provider_id in allowed_providers if provider_id in valid_provider_ids
]
if filtered == allowed_providers:
continue
conn.execute(table.update().where(table.c.id == row_id).values(allowed_providers=filtered))
def _nullify_missing_fk(
conn: sa.Connection,
table: sa.Table,
id_column: sa.ColumnElement[str],
fk_column: sa.ColumnElement[str],
valid_ids: set[str],
) -> None:
rows = conn.execute(sa.select(id_column, fk_column).where(fk_column.isnot(None))).fetchall()
invalid_row_ids = [row_id for row_id, fk_value in rows if fk_value not in valid_ids]
if not invalid_row_ids:
return
conn.execute(table.update().where(id_column.in_(invalid_row_ids)).values({fk_column.key: None}))
def upgrade() -> None:
conn = op.get_bind()
valid_provider_ids = _load_valid_ids(conn, _providers)
valid_endpoint_ids = _load_valid_ids(conn, _provider_endpoints)
_cleanup_allowed_providers(conn, _users, valid_provider_ids)
_cleanup_allowed_providers(conn, _api_keys, valid_provider_ids)
_nullify_missing_fk(
conn,
_user_preferences,
_user_preferences.c.id,
_user_preferences.c.default_provider_id,
valid_provider_ids,
)
_nullify_missing_fk(
conn,
_video_tasks,
_video_tasks.c.id,
_video_tasks.c.provider_id,
valid_provider_ids,
)
_nullify_missing_fk(
conn,
_video_tasks,
_video_tasks.c.id,
_video_tasks.c.endpoint_id,
valid_endpoint_ids,
)
def downgrade() -> None:
pass
@@ -0,0 +1,64 @@
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
Revision ID: b7c8d9e0f1a2
Revises: c1d2e3f4a5b6
Create Date: 2026-03-12 19:15:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b7c8d9e0f1a2"
down_revision = "c1d2e3f4a5b6"
branch_labels = None
depends_on = None
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def upgrade() -> None:
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.drop_constraint(
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
)
def downgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE request_candidates rc "
"SET key_id = NULL "
"WHERE key_id IS NOT NULL "
" AND NOT EXISTS ("
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
" )"
)
)
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.create_foreign_key(
"request_candidates_key_id_fkey",
"request_candidates",
"provider_api_keys",
["key_id"],
["id"],
ondelete="CASCADE",
)
@@ -0,0 +1,54 @@
"""add user rate_limit and backfill normal api key limits
Revision ID: b7e8f9a0c1d2
Revises: b7c8d9e0f1a2
Create Date: 2026-03-13 12:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b7e8f9a0c1d2"
down_revision: str | None = "b7c8d9e0f1a2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("users", "rate_limit"):
op.add_column("users", sa.Column("rate_limit", sa.Integer(), nullable=True))
# 普通 Key 新语义不再允许 NULL;存量 NULL 统一回填为 0(不限制)。
op.execute(sa.text("""
UPDATE api_keys
SET rate_limit = 0
WHERE is_standalone = FALSE
AND rate_limit IS NULL
"""))
def downgrade() -> None:
# 恢复普通 Key 的 rate_limit 为 NULL(与 upgrade 中回填 0 对应)
op.execute(sa.text("""
UPDATE api_keys
SET rate_limit = NULL
WHERE is_standalone = FALSE
AND rate_limit = 0
"""))
if column_exists("users", "rate_limit"):
op.drop_column("users", "rate_limit")
@@ -0,0 +1,87 @@
"""add user sessions table for device-level auth
Revision ID: f6e7d8c9b0a1
Revises: b7e8f9a0c1d2
Create Date: 2026-03-15 12:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6e7d8c9b0a1"
down_revision = "b7e8f9a0c1d2"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "user_sessions" in inspector.get_table_names():
return
op.create_table(
"user_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("client_device_id", sa.String(length=128), nullable=False),
sa.Column("device_label", sa.String(length=120), nullable=True),
sa.Column("device_type", sa.String(length=20), nullable=False, server_default="unknown"),
sa.Column("browser_name", sa.String(length=50), nullable=True),
sa.Column("browser_version", sa.String(length=50), nullable=True),
sa.Column("os_name", sa.String(length=50), nullable=True),
sa.Column("os_version", sa.String(length=50), nullable=True),
sa.Column("device_model", sa.String(length=100), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=1000), nullable=True),
sa.Column("client_hints", sa.JSON(), nullable=True),
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
sa.Column("prev_refresh_token_hash", sa.String(length=64), nullable=True),
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoke_reason", sa.String(length=100), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"], unique=False)
op.create_index(
"ix_user_sessions_client_device_id",
"user_sessions",
["client_device_id"],
unique=False,
)
op.create_index(
"idx_user_sessions_user_active",
"user_sessions",
["user_id", "revoked_at", "expires_at"],
unique=False,
)
op.create_index(
"idx_user_sessions_user_device",
"user_sessions",
["user_id", "client_device_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_user_sessions_user_device", table_name="user_sessions")
op.drop_index("idx_user_sessions_user_active", table_name="user_sessions")
op.drop_index("ix_user_sessions_client_device_id", table_name="user_sessions")
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
op.drop_table("user_sessions")
@@ -0,0 +1,41 @@
"""add status_snapshot column to provider_api_keys
Revision ID: c9d8e7f6a5b4
Revises: f6e7d8c9b0a1
Create Date: 2026-03-20 12:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c9d8e7f6a5b4"
down_revision: str | None = "f6e7d8c9b0a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("provider_api_keys", "status_snapshot"):
op.add_column(
"provider_api_keys",
sa.Column("status_snapshot", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if column_exists("provider_api_keys", "status_snapshot"):
op.drop_column("provider_api_keys", "status_snapshot")
@@ -0,0 +1,317 @@
"""usage token semantics v2
Revision ID: c3d4e5f6a7b8
Revises: c9d8e7f6a5b4
Create Date: 2026-03-24 14:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: str | None = "c9d8e7f6a5b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BACKFILL_BATCH_SIZE = 2000
# 最大批次数,防止因数据异常导致死循环(2000 * 500000 = 10亿行上限)
_MAX_BATCHES = 500000
# 使用子查询中间层展开 input_output_total_tokens 的计算,
# 确保 total_tokens 引用的是本次 SET 后的新值而非旧值。
_UPGRADE_BACKFILL_SQL = sa.text(
"""
UPDATE usage
SET
input_output_total_tokens = src.new_iot,
input_context_tokens = src.new_ict,
total_tokens = src.new_total,
cache_creation_cost_usd_5m = src.new_cc5m,
cache_creation_cost_usd_1h = src.new_cc1h,
actual_cache_creation_cost_usd_5m = src.new_acc5m,
actual_cache_creation_cost_usd_1h = src.new_acc1h,
actual_cache_cost_usd = src.new_accu,
cache_creation_price_per_1m_5m = src.new_cp5m,
cache_creation_price_per_1m_1h = src.new_cp1h,
cache_cost_usd = src.new_ccu
FROM (
SELECT
id,
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
AS new_iot,
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
AS new_ict,
/* total_tokens 引用本行计算出的 new_iot,避免依赖 SET 顺序 */
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
AS new_total,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_cc5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_cc1h,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_acc5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_acc1h,
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END AS new_cp5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END AS new_cp1h,
COALESCE(cache_creation_cost_usd, 0)
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
FROM usage
WHERE id IN (
SELECT id FROM usage
WHERE
input_output_total_tokens IS DISTINCT FROM
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
OR input_context_tokens IS DISTINCT FROM
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
OR total_tokens IS DISTINCT FROM (
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
)
OR cache_creation_cost_usd_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR cache_creation_cost_usd_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_creation_cost_usd_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_creation_cost_usd_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_cost_usd IS DISTINCT FROM (
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0)
)
OR cache_creation_price_per_1m_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END
)
OR cache_creation_price_per_1m_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END
)
OR cache_cost_usd IS DISTINCT FROM (
COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
)
ORDER BY id
LIMIT :batch_size
)
) AS src
WHERE usage.id = src.id
"""
)
_DOWNGRADE_BACKFILL_SQL = sa.text(
"""
UPDATE usage
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
WHERE id IN (
SELECT id
FROM usage
WHERE total_tokens IS DISTINCT FROM
COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
ORDER BY id
LIMIT :batch_size
)
"""
)
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def run_backfill_in_batches(sql: sa.TextClause, batch_size: int = BACKFILL_BATCH_SIZE) -> None:
context = op.get_context()
for _ in range(_MAX_BATCHES):
# Commit the preceding schema transaction before each batch so PostgreSQL
# does not keep ALTER TABLE locks for the entire data backfill.
with context.autocommit_block():
rowcount = op.get_bind().execute(sql, {"batch_size": batch_size}).rowcount
if rowcount == 0:
break
else:
raise RuntimeError(
f"Backfill did not converge after {_MAX_BATCHES} batches "
f"(batch_size={batch_size}). Possible infinite loop due to data anomaly."
)
def upgrade() -> None:
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
with op.batch_alter_table("usage") as batch_op:
batch_op.alter_column(
"total_tokens",
new_column_name="input_output_total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
with op.batch_alter_table("usage") as batch_op:
if not column_exists("usage", "input_context_tokens"):
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "total_tokens"):
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_cost_usd"):
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
run_backfill_in_batches(_UPGRADE_BACKFILL_SQL)
def downgrade() -> None:
run_backfill_in_batches(_DOWNGRADE_BACKFILL_SQL)
with op.batch_alter_table("usage") as batch_op:
if column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.drop_column("cache_creation_price_per_1m_1h")
if column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.drop_column("cache_creation_price_per_1m_5m")
if column_exists("usage", "actual_cache_cost_usd"):
batch_op.drop_column("actual_cache_cost_usd")
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
if column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.drop_column("cache_creation_cost_usd_1h")
if column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.drop_column("cache_creation_cost_usd_5m")
if column_exists("usage", "input_context_tokens"):
batch_op.drop_column("input_context_tokens")
if column_exists("usage", "total_tokens"):
batch_op.drop_column("total_tokens")
if column_exists("usage", "input_output_total_tokens"):
batch_op.alter_column(
"input_output_total_tokens",
new_column_name="total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
+1 -1
View File
@@ -15,7 +15,7 @@
### 用户系统 ### 用户系统
- **users**: 用户账户管理 - **users**: 用户账户管理
- **api_keys**: API 密钥管理 - **api_keys**: API 密钥管理
- **user_quotas**: 用户配额管理 - **wallets**: 统一钱包账户(充值余额/赠款余额/无限制模式)
- **user_preferences**: 用户偏好设置 - **user_preferences**: 用户偏好设置
### Provider 三层架构 ### Provider 三层架构
+221 -31
View File
@@ -2,46 +2,132 @@
# 智能部署脚本 - 自动检测依赖/代码/迁移变化 # 智能部署脚本 - 自动检测依赖/代码/迁移变化
# #
# 用法: # 用法:
# 部署/更新: ./deploy.sh (自动检测所有变化) # 部署/更新: ./deploy.sh (自动检测所有变化)
# 强制重建: ./deploy.sh --rebuild-base # 指定 Hub 版本: ./deploy.sh --hub-tag hub-v0.1.0
# 强制全部重建: ./deploy.sh --force # 更新 Hub: ./deploy.sh --update-hub
# GitHub 镜像: ./deploy.sh --mirror https://ghfast.top
# 强制重建: ./deploy.sh --rebuild-base
# 强制全部重建: ./deploy.sh --force
set -e set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
# 兼容 docker-compose 和 docker compose # 兼容 docker-compose 和 docker compose
if command -v docker-compose &> /dev/null; then if command -v docker-compose &> /dev/null; then
DC="docker-compose -f docker-compose.build.yml" DC="docker-compose -f docker-compose.build.yml"
USE_LEGACY_COMPOSE=true
else else
DC="docker compose -f docker-compose.build.yml" DC="docker compose -f docker-compose.build.yml"
USE_LEGACY_COMPOSE=false
fi fi
compose_up() {
if [ "$USE_LEGACY_COMPOSE" = true ]; then
$DC up -d --no-build "$@"
else
$DC up -d --no-build --pull never "$@"
fi
}
# 缓存文件 # 缓存文件
HASH_FILE=".deps-hash" HASH_FILE=".deps-hash"
CODE_HASH_FILE=".code-hash" CODE_HASH_FILE=".code-hash"
MIGRATION_HASH_FILE=".migration-hash" MIGRATION_HASH_FILE=".migration-hash"
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致): # Hub release 配置
# - [build-system] requires / build-backend GITHUB_REPO="fawney19/Aether"
# - [project] requires-python / dependencies HUB_TAG_STATE_FILE=".hub-tag"
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
pyproject_deps_fingerprint() {
python3 - <<'PY'
import json, pathlib, tomllib
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8")) usage() {
project = data.get("project") or {} cat <<'EOF'
build = data.get("build-system") or {} Usage: ./deploy.sh [options]
fingerprint = { Options:
"requires-python": project.get("requires-python"), --hub-tag <hub-vX.Y.Z> 指定 Hub Release tag(例如 hub-v0.1.0)
"dependencies": sorted(project.get("dependencies") or []), --update-hub 强制刷新 Hub 版本标记(下次构建会重新下载)
"build-backend": build.get("build-backend"), --mirror <url> GitHub 下载镜像(例如 https://ghfast.top)
"build-requires": sorted(build.get("requires") or []), --rebuild-base, -r 仅重建 base 镜像
--force, -f 强制重建全部(hub/base/app)并重启
-h, --help 显示帮助
EOF
} }
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":"))) FORCE_REBUILD_ALL=false
PY REBUILD_BASE_ONLY=false
FORCE_UPDATE_HUB=false
HUB_TAG="${HUB_TAG:-}"
GITHUB_MIRROR="${GITHUB_MIRROR:-}"
RESOLVED_HUB_TAG=""
while [ $# -gt 0 ]; do
case "$1" in
--hub-tag)
if [ $# -lt 2 ]; then
echo "❌ --hub-tag 需要一个值,例如 hub-v0.1.0"
exit 1
fi
HUB_TAG="$2"
shift 2
;;
--update-hub)
FORCE_UPDATE_HUB=true
shift
;;
--mirror)
if [ $# -lt 2 ]; then
echo "ERROR: --mirror needs a URL, e.g. https://ghfast.top"
exit 1
fi
GITHUB_MIRROR="$2"
shift 2
;;
--rebuild-base|-r)
REBUILD_BASE_ONLY=true
shift
;;
--force|-f)
FORCE_REBUILD_ALL=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "❌ 未知参数: $1"
usage
exit 1
;;
esac
done
if [ -n "$HUB_TAG" ]; then
case "$HUB_TAG" in
hub-v*) ;;
*) echo "❌ --hub-tag 格式应为 hub-vX.Y.Z,例如 hub-v0.1.0"; exit 1 ;;
esac
fi
# 提取 pyproject.toml 中会影响运行时依赖安装的字段指纹(纯 shell,无需 Python)
# 用 sed 提取 dependencies / requires 数组块和单值字段,排序后输出稳定文本
pyproject_deps_fingerprint() {
local file="pyproject.toml"
# 提取 "key = [..." 多行数组块(从 key 行到 ] 行)
extract_array() {
sed -n "/^$1[[:space:]]*=[[:space:]]*\[/,/\]/p" "$file" | grep '"' | sed 's/.*"\(.*\)".*/\1/' | sort
}
# 提取 "key = "value"" 单行值
extract_value() {
grep -m1 "^$1[[:space:]]*=" "$file" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/'
}
{
echo "requires-python=$(extract_value requires-python)"
echo "build-backend=$(extract_value build-backend)"
echo "dependencies:"
extract_array dependencies
echo "build-requires:"
extract_array requires
}
} }
# 计算依赖文件的哈希值(包含 Dockerfile.base.local) # 计算依赖文件的哈希值(包含 Dockerfile.base.local)
@@ -63,6 +149,66 @@ calc_code_hash() {
} | md5sum | cut -d' ' -f1 } | md5sum | cut -d' ' -f1
} }
# 获取最新 hub release tag
# 支持 GITHUB_TOKEN 环境变量以避免未认证 API 限流(60 次/小时 -> 5000 次/小时)
get_latest_hub_tag() {
local auth_args=()
if [ -n "${GITHUB_TOKEN:-}" ]; then
auth_args=(-H "Authorization: token ${GITHUB_TOKEN}")
fi
curl -sL "${auth_args[@]}" "https://api.github.com/repos/$GITHUB_REPO/releases" | \
python3 -c "
import json, sys
releases = json.load(sys.stdin)
for r in releases:
tag = r.get('tag_name', '')
if tag.startswith('hub-v') and not r.get('draft') and not r.get('prerelease'):
print(tag)
break
" 2>/dev/null
}
# 解析当前应使用的 Hub release tag(优先使用指定值,否则拉取最新)
resolve_hub_tag() {
local requested_tag="${1:-}"
local latest_tag
if [ -n "$requested_tag" ]; then
echo "$requested_tag"
return 0
fi
latest_tag="$(get_latest_hub_tag || true)"
if [ -n "$latest_tag" ]; then
echo "$latest_tag"
return 0
fi
if [ -f "$HUB_TAG_STATE_FILE" ]; then
echo "⚠️ 无法查询最新 Hub 版本,回退使用本地记录: $(cat "$HUB_TAG_STATE_FILE")" >&2
cat "$HUB_TAG_STATE_FILE"
return 0
fi
echo "❌ 无法获取 Hub Release tag,请检查网络或手动指定 --hub-tag" >&2
exit 1
}
# 确保本次构建的 Hub tag 已解析(默认追踪最新 release,也可通过 --hub-tag 固定版本)
ensure_hub_tag() {
local requested_tag="${1:-}"
RESOLVED_HUB_TAG="$(resolve_hub_tag "$requested_tag")"
if [ -f "$HUB_TAG_STATE_FILE" ] && [ "$(cat "$HUB_TAG_STATE_FILE")" = "$RESOLVED_HUB_TAG" ]; then
echo ">>> Hub 版本未变化: $RESOLVED_HUB_TAG"
return 1
fi
echo "$RESOLVED_HUB_TAG" > "$HUB_TAG_STATE_FILE"
echo ">>> 使用 Hub 版本: $RESOLVED_HUB_TAG"
return 0
}
# 计算迁移文件的哈希值 # 计算迁移文件的哈希值
calc_migration_hash() { calc_migration_hash() {
find alembic/versions -name "*.py" -type f 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1 find alembic/versions -name "*.py" -type f 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
@@ -92,6 +238,8 @@ check_code_changed() {
return 0 return 0
} }
# 检查迁移是否变化 # 检查迁移是否变化
check_migration_changed() { check_migration_changed() {
local current_hash=$(calc_migration_hash) local current_hash=$(calc_migration_hash)
@@ -112,10 +260,11 @@ save_migration_hash() { calc_migration_hash > "$MIGRATION_HASH_FILE"; }
# 构建基础镜像 # 构建基础镜像
build_base() { build_base() {
echo ">>> Building base image (dependencies)..." echo ">>> Building base image (dependencies)..."
docker build -f Dockerfile.base.local -t aether-base:latest . docker build --pull=false -f Dockerfile.base.local -t aether-base:latest .
save_deps_hash save_deps_hash
} }
# 生成版本文件 # 生成版本文件
generate_version_file() { generate_version_file() {
# 从 git 获取版本号 # 从 git 获取版本号
@@ -137,8 +286,27 @@ EOF
# 构建应用镜像 # 构建应用镜像
build_app() { build_app() {
echo ">>> Building app image (code only)..." echo ">>> Building app image (code only)..."
if [ -z "${RESOLVED_HUB_TAG:-}" ]; then
echo ">>> RESOLVED_HUB_TAG 为空,无法构建 app 镜像"
exit 1
fi
echo ">>> Build args: HUB_TAG=$RESOLVED_HUB_TAG"
generate_version_file generate_version_file
docker build -f Dockerfile.app.local -t aether-app:latest . local token_args=()
if [ -n "${GITHUB_TOKEN:-}" ]; then
token_args=(--build-arg "GITHUB_TOKEN=${GITHUB_TOKEN}")
fi
local mirror_args=()
if [ -n "${GITHUB_MIRROR:-}" ]; then
mirror_args=(--build-arg "GITHUB_MIRROR=${GITHUB_MIRROR}")
fi
docker build --pull=false \
--build-arg HUB_RELEASE_REPO="$GITHUB_REPO" \
--build-arg HUB_TAG="$RESOLVED_HUB_TAG" \
"${token_args[@]}" \
"${mirror_args[@]}" \
-f Dockerfile.app.local \
-t aether-app:latest .
save_code_hash save_code_hash
} }
@@ -186,11 +354,15 @@ print('Old version cleared')
} }
# 强制全部重建 # 强制全部重建
if [ "$1" = "--force" ] || [ "$1" = "-f" ]; then if [ "$FORCE_REBUILD_ALL" = true ]; then
echo ">>> Force rebuilding everything..." echo ">>> Force rebuilding everything..."
if [ "$FORCE_UPDATE_HUB" = true ]; then
rm -f "$HUB_TAG_STATE_FILE"
fi
ensure_hub_tag "$HUB_TAG" || true
build_base build_base
build_app build_app
$DC up -d --force-recreate compose_up --force-recreate
sleep 3 sleep 3
run_migration run_migration
docker image prune -f docker image prune -f
@@ -200,19 +372,25 @@ if [ "$1" = "--force" ] || [ "$1" = "-f" ]; then
fi fi
# 强制重建基础镜像 # 强制重建基础镜像
if [ "$1" = "--rebuild-base" ] || [ "$1" = "-r" ]; then if [ "$REBUILD_BASE_ONLY" = true ]; then
build_base build_base
echo ">>> Base image rebuilt. Run ./deploy.sh to deploy." echo ">>> Base image rebuilt. Run ./deploy.sh to deploy."
exit 0 exit 0
fi fi
# 拉取最新代码 # 更新 Hub 版本标记
echo ">>> Pulling latest code..." if [ "$FORCE_UPDATE_HUB" = true ]; then
git pull rm -f "$HUB_TAG_STATE_FILE"
ensure_hub_tag "$HUB_TAG" || true
echo ">>> Hub tag updated: $RESOLVED_HUB_TAG"
echo ">>> Run ./deploy.sh to build app image with the new Hub release."
exit 0
fi
# 标记是否需要重启 # 标记是否需要重启
NEED_RESTART=false NEED_RESTART=false
BASE_REBUILT=false BASE_REBUILT=false
HUB_UPDATED=false
# 检查基础镜像是否存在,或依赖是否变化 # 检查基础镜像是否存在,或依赖是否变化
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
@@ -229,6 +407,14 @@ else
echo ">>> Dependencies unchanged." echo ">>> Dependencies unchanged."
fi fi
# 解析/检查 Hub 版本(构建时由 Dockerfile 从 GitHub Release 下载)
if ensure_hub_tag "$HUB_TAG"; then
HUB_UPDATED=true
NEED_RESTART=true
else
echo ">>> Hub version unchanged."
fi
# 检查代码或迁移是否变化,或者 base 重建了(app 依赖 base) # 检查代码或迁移是否变化,或者 base 重建了(app 依赖 base)
# 注意:迁移文件打包在镜像中,所以迁移变化也需要重建 app 镜像 # 注意:迁移文件打包在镜像中,所以迁移变化也需要重建 app 镜像
MIGRATION_CHANGED=false MIGRATION_CHANGED=false
@@ -244,6 +430,10 @@ elif [ "$BASE_REBUILT" = true ]; then
echo ">>> Base image rebuilt, rebuilding app image..." echo ">>> Base image rebuilt, rebuilding app image..."
build_app build_app
NEED_RESTART=true NEED_RESTART=true
elif [ "$HUB_UPDATED" = true ]; then
echo ">>> Hub version updated, rebuilding app image..."
build_app
NEED_RESTART=true
elif check_code_changed; then elif check_code_changed; then
echo ">>> Code changed, rebuilding app image..." echo ">>> Code changed, rebuilding app image..."
build_app build_app
@@ -265,10 +455,10 @@ fi
# 有变化时重启,或容器未运行时启动 # 有变化时重启,或容器未运行时启动
if [ "$NEED_RESTART" = true ]; then if [ "$NEED_RESTART" = true ]; then
echo ">>> Restarting services..." echo ">>> Restarting services..."
$DC up -d compose_up
elif [ "$CONTAINERS_RUNNING" = false ]; then elif [ "$CONTAINERS_RUNNING" = false ]; then
echo ">>> Containers not running, starting services..." echo ">>> Containers not running, starting services..."
$DC up -d compose_up
else else
echo ">>> No changes detected, skipping restart." echo ">>> No changes detected, skipping restart."
fi fi
+11 -5
View File
@@ -11,10 +11,16 @@ set +a
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}" export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0 export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
# 启动 uvicorn(热重载模式) # 开发环境连接池低配(节省内存)
echo "🚀 启动本地开发服务器..." export DB_POOL_SIZE=${DB_POOL_SIZE:-5}
echo "📍 后端地址: http://localhost:8084" export DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-5}
echo "📊 数据库: ${DATABASE_URL}" export HTTP_MAX_CONNECTIONS=${HTTP_MAX_CONNECTIONS:-20}
export HTTP_KEEPALIVE_CONNECTIONS=${HTTP_KEEPALIVE_CONNECTIONS:-5}
# 启动 uvicorn(热重载模式,只监视 src 目录)
echo "=> 启动本地开发服务器..."
echo "=> 后端地址: http://localhost:8084"
echo "=> 数据库: ${DATABASE_URL}"
echo "" echo ""
uv run uvicorn src.main:app --reload --port 8084 uv run uvicorn src.main:app --reload --reload-dir src --port 8084
+6 -4
View File
@@ -1,7 +1,10 @@
# Aether 部署配置 - 本地构建 # Aether 部署配置 - 本地构建
# 使用方法: # 使用方法:
# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest . # 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
# Hub 二进制: cd aether-hub && ./build.sh(默认 binary 模式)供发布;deploy.sh 构建 app 时会自动下载
# Hub 镜像发布: cd aether-hub && ./build.sh --image --tag <tag> --push(可选)
# 启动服务: docker compose -f docker-compose.build.yml up -d --build # 启动服务: docker compose -f docker-compose.build.yml up -d --build
# 或使用: ./deploy.sh
services: services:
postgres: postgres:
@@ -26,9 +29,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: aether-redis container_name: aether-redis
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} command: redis-server --appendonly no --save "" --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
ports: ports:
- "${REDIS_PORT:-6379}:6379" - "${REDIS_PORT:-6379}:6379"
healthcheck: healthcheck:
@@ -42,6 +43,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.app.local dockerfile: Dockerfile.app.local
args:
GITHUB_TOKEN: ${GITHUB_TOKEN:-}
image: aether-app:latest image: aether-app:latest
container_name: aether-app container_name: aether-app
env_file: env_file:
@@ -67,4 +70,3 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data:
+4 -5
View File
@@ -22,9 +22,7 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: aether-redis container_name: aether-redis
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} command: redis-server --appendonly no --save "" --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck: healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 5s interval: 5s
@@ -33,7 +31,7 @@ services:
restart: unless-stopped restart: unless-stopped
app: app:
image: ghcr.io/fawney19/aether:latest image: ${APP_IMAGE:-ghcr.io/fawney19/aether:latest}
container_name: aether-app container_name: aether-app
env_file: env_file:
- .env - .env
@@ -58,4 +56,5 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data:
+20
View File
@@ -1,6 +1,26 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Wait for PostgreSQL to be ready
MAX_ATTEMPTS=30
ATTEMPT=0
until python -c "
from sqlalchemy import create_engine, text
import os
engine = create_engine(os.environ['DATABASE_URL'])
with engine.connect() as conn:
conn.execute(text('SELECT 1'))
" 2>/dev/null; do
ATTEMPT=$((ATTEMPT + 1))
if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
echo "Database not ready after $MAX_ATTEMPTS attempts, exiting."
exit 1
fi
echo "Waiting for database... (attempt $ATTEMPT/$MAX_ATTEMPTS)"
sleep 2
done
echo "Database is ready."
echo "Running database migrations..." echo "Running database migrations..."
alembic upgrade head alembic upgrade head
+7
View File
@@ -24,6 +24,7 @@
"marked": "^16.0.0", "marked": "^16.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinyin-pro": "^3.28.0",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"three": "^0.180.0", "three": "^0.180.0",
@@ -4718,6 +4719,12 @@
} }
} }
}, },
"node_modules/pinyin-pro": {
"version": "3.28.0",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.0.tgz",
"integrity": "sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==",
"license": "MIT"
},
"node_modules/pirates": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+1
View File
@@ -32,6 +32,7 @@
"marked": "^16.0.0", "marked": "^16.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinyin-pro": "^3.28.0",
"radix-vue": "^1.9.17", "radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"three": "^0.180.0", "three": "^0.180.0",
+66 -2
View File
@@ -5,12 +5,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onErrorCaptured } from 'vue' import { onMounted, onErrorCaptured, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ToastContainer from '@/components/ToastContainer.vue' import ToastContainer from '@/components/ToastContainer.vue'
import ConfirmContainer from '@/components/ConfirmContainer.vue' import ConfirmContainer from '@/components/ConfirmContainer.vue'
import apiClient from '@/api/client' import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants' import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import router from '@/router'
import { hasAuthIdentityChanged } from '@/utils/authToken'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -86,7 +88,59 @@ if (typeof window !== 'undefined') {
}) })
} }
async function syncExternalAuthState(nextToken: string | null): Promise<void> {
const previousToken = authStore.token
const previousUser = authStore.user
? {
id: authStore.user.id,
role: authStore.user.role,
}
: null
authStore.syncToken()
if (!nextToken) {
if (previousToken || previousUser) {
authStore.applyExternalLogout()
await router.replace('/')
}
return
}
const identityChanged = hasAuthIdentityChanged(previousToken, nextToken, previousUser)
if (!identityChanged && previousUser) {
return
}
const user = await authStore.fetchCurrentUser()
if (!user) {
return
}
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
await router.replace('/dashboard')
}
}
function handleAuthStorageChange(event: StorageEvent): void {
if (event.key !== 'access_token') {
return
}
syncExternalAuthState(event.newValue).catch((err) => log.error('syncExternalAuthState failed', err))
}
function handleLocalAuthStateChange(event: Event): void {
const authEvent = event as CustomEvent<{ token: string | null }>
syncExternalAuthState(authEvent.detail?.token ?? apiClient.getToken()).catch((err) => log.error('syncExternalAuthState failed', err))
}
onMounted(async () => { onMounted(async () => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleAuthStorageChange)
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleLocalAuthStateChange as (event: Event) => void)
}
// 延迟检查认证状态,让页面先加载 // 延迟检查认证状态,让页面先加载
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -97,4 +151,14 @@ onMounted(async () => {
} }
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL) }, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
}) })
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', handleAuthStorageChange)
window.removeEventListener(
AUTH_STATE_CHANGE_EVENT,
handleLocalAuthStateChange as (event: Event) => void,
)
}
})
</script> </script>
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
describe('apiClient auth state change event', () => {
beforeEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
afterEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
it('dispatches a same-tab auth change event when clearing auth', () => {
const handler = vi.fn()
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
apiClient.setToken('access-token')
apiClient.clearAuth()
expect(localStorage.getItem('access_token')).toBeNull()
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
expect(event.detail).toEqual({ token: null })
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
})
})
+93
View File
@@ -0,0 +1,93 @@
import apiClient from './client'
import type { PaymentOrder } from './wallet'
export interface PaymentCallbackRecord {
id: string
payment_order_id: string | null
payment_method: string
callback_key: string
order_no: string | null
gateway_order_id: string | null
payload_hash: string | null
signature_valid: boolean
status: string
payload: Record<string, unknown> | null
error_message: string | null
created_at: string
processed_at: string | null
}
export interface AdminPaymentOrderListResponse {
items: PaymentOrder[]
total: number
limit: number
offset: number
}
export interface AdminPaymentCallbacksResponse {
items: PaymentCallbackRecord[]
total: number
limit: number
offset: number
}
export interface AdminPaymentCreditRequest {
gateway_order_id?: string
pay_amount?: number
pay_currency?: string
exchange_rate?: number
gateway_response?: Record<string, unknown>
}
export const adminPaymentsApi = {
async listOrders(params?: {
status?: string
payment_method?: string
limit?: number
offset?: number
}): Promise<AdminPaymentOrderListResponse> {
const response = await apiClient.get<AdminPaymentOrderListResponse>('/api/admin/payments/orders', { params })
return response.data
},
async getOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.get<{ order: PaymentOrder }>(`/api/admin/payments/orders/${orderId}`)
return response.data
},
async expireOrder(orderId: string): Promise<{ order: PaymentOrder; expired: boolean }> {
const response = await apiClient.post<{ order: PaymentOrder; expired: boolean }>(
`/api/admin/payments/orders/${orderId}/expire`,
{}
)
return response.data
},
async failOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.post<{ order: PaymentOrder }>(
`/api/admin/payments/orders/${orderId}/fail`,
{}
)
return response.data
},
async creditOrder(
orderId: string,
payload: AdminPaymentCreditRequest
): Promise<{ order: PaymentOrder; credited: boolean }> {
const response = await apiClient.post<{ order: PaymentOrder; credited: boolean }>(
`/api/admin/payments/orders/${orderId}/credit`,
payload
)
return response.data
},
async listCallbacks(params?: {
payment_method?: string
limit?: number
offset?: number
}): Promise<AdminPaymentCallbacksResponse> {
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
return response.data
},
}
+247
View File
@@ -0,0 +1,247 @@
import apiClient from './client'
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
export interface AdminWallet extends WalletSummary {
user_id: string | null
api_key_id: string | null
owner_type: 'user' | 'api_key'
owner_name: string | null
created_at: string
}
export interface AdminWalletListResponse {
items: AdminWallet[]
total: number
limit: number
offset: number
}
export interface AdminWalletDetailResponse extends AdminWallet {
pending_refund_count: number
}
export interface AdminWalletTransactionsResponse {
wallet: AdminWallet
items: WalletTransaction[]
total: number
limit: number
offset: number
}
export interface AdminWalletRefundsResponse {
wallet: AdminWallet
items: RefundRequest[]
total: number
limit: number
offset: number
}
export interface AdminLedgerTransaction extends WalletTransaction {
wallet_id: string
owner_type: 'user' | 'api_key'
owner_name: string | null
wallet_status?: string | null
}
export interface AdminGlobalRefund extends RefundRequest {
wallet_id: string
owner_type: 'user' | 'api_key'
owner_name: string | null
wallet_status?: string | null
}
export interface AdminLedgerResponse {
items: AdminLedgerTransaction[]
total: number
limit: number
offset: number
}
export interface AdminGlobalRefundsListResponse {
items: AdminGlobalRefund[]
total: number
limit: number
offset: number
}
export interface ManualRechargeRequest {
amount_usd: number
payment_method?: string
description?: string
}
export interface WalletAdjustRequest {
amount_usd: number
balance_type?: 'recharge' | 'gift'
description?: string
}
export interface RefundFailRequest {
reason: string
}
export interface RefundCompleteRequest {
gateway_refund_id?: string
payout_reference?: string
payout_proof?: Record<string, unknown>
}
export const adminWalletApi = {
async listWallets(params?: {
status?: string
limit?: number
offset?: number
}): Promise<AdminWalletListResponse> {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params })
return response.data
},
async listAllWallets(params?: {
status?: string
}): Promise<AdminWallet[]> {
const items: AdminWallet[] = []
const limit = 200
const maxPages = 200
let offset = 0
let page = 0
while (page < maxPages) {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
params: {
...params,
limit,
offset,
},
})
const data = response.data
items.push(...data.items)
if (items.length >= data.total || data.items.length < limit) {
break
}
const nextOffset = offset + data.items.length
if (nextOffset <= offset) {
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
}
offset = nextOffset
page += 1
}
if (page >= maxPages) {
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
}
return items
},
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {
const response = await apiClient.get<AdminWalletDetailResponse>(`/api/admin/wallets/${walletId}`)
return response.data
},
async listLedger(params?: {
category?: string
reason_code?: string
owner_type?: string
limit?: number
offset?: number
}): Promise<AdminLedgerResponse> {
const response = await apiClient.get<AdminLedgerResponse>('/api/admin/wallets/ledger', { params })
return response.data
},
async listGlobalRefunds(params?: {
status?: string
owner_type?: string
limit?: number
offset?: number
}): Promise<AdminGlobalRefundsListResponse> {
const response = await apiClient.get<AdminGlobalRefundsListResponse>('/api/admin/wallets/refund-requests', {
params,
})
return response.data
},
async getWalletTransactions(
walletId: string,
params?: { limit?: number; offset?: number }
): Promise<AdminWalletTransactionsResponse> {
const response = await apiClient.get<AdminWalletTransactionsResponse>(
`/api/admin/wallets/${walletId}/transactions`,
{ params }
)
return response.data
},
async getWalletRefunds(
walletId: string,
params?: { limit?: number; offset?: number }
): Promise<AdminWalletRefundsResponse> {
const response = await apiClient.get<AdminWalletRefundsResponse>(
`/api/admin/wallets/${walletId}/refunds`,
{ params }
)
return response.data
},
async rechargeWallet(walletId: string, payload: ManualRechargeRequest): Promise<{
wallet: AdminWallet
payment_order: {
id: string
order_no: string
amount_usd: number
payment_method: string
status: string
created_at: string
credited_at: string | null
}
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/recharge`, payload)
return response.data
},
async adjustWallet(walletId: string, payload: WalletAdjustRequest): Promise<{
wallet: AdminWallet
transaction: WalletTransaction
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/adjust`, payload)
return response.data
},
async processRefund(walletId: string, refundId: string): Promise<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction
}> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/process`,
{}
)
return response.data
},
async failRefund(walletId: string, refundId: string, payload: RefundFailRequest): Promise<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction | null
}> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/fail`,
payload
)
return response.data
},
async completeRefund(
walletId: string,
refundId: string,
payload: RefundCompleteRequest
): Promise<{ refund: RefundRequest }> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/complete`,
payload
)
return response.data
},
}
+61 -282
View File
@@ -1,4 +1,6 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
// LDAP 配置导出结构 // LDAP 配置导出结构
export interface LDAPConfigExport { export interface LDAPConfigExport {
@@ -33,14 +35,38 @@ export interface OAuthProviderExport {
is_enabled?: boolean is_enabled?: boolean
} }
export interface SystemConfigExport {
key: string
value: unknown
description?: string | null
}
// 配置导出数据结构 // 配置导出数据结构
export interface ConfigExportData { export interface ConfigExportData {
version: string version: string
exported_at: string exported_at: string
global_models: GlobalModelExport[] global_models: GlobalModelExport[]
providers: ProviderExport[] providers: ProviderExport[]
proxy_nodes?: ProxyNodeExport[]
ldap_config?: LDAPConfigExport | null ldap_config?: LDAPConfigExport | null
oauth_providers?: OAuthProviderExport[] oauth_providers?: OAuthProviderExport[]
system_configs?: SystemConfigExport[]
}
export interface ProxyNodeExport {
id: string
name: string
ip: string
port: number
region?: string | null
is_manual: boolean
proxy_url?: string | null
proxy_username?: string | null
proxy_password?: string | null
tunnel_mode: boolean
heartbeat_interval: number
remote_config?: Record<string, unknown> | null
config_version: number
} }
// 用户导出数据结构 // 用户导出数据结构
@@ -53,31 +79,31 @@ export interface UsersExportData {
export interface UserExport { export interface UserExport {
email: string email: string
email_verified?: boolean
username: string username: string
password_hash: string password_hash: string
role: string role: string
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
model_capability_settings?: Record<string, Record<string, boolean>> model_capability_settings?: Record<string, Record<string, boolean>>
quota_usd?: number | null unlimited?: boolean
used_usd?: number wallet?: BillingSummary | null
total_usd?: number
is_active: boolean is_active: boolean
api_keys: UserApiKeyExport[] api_keys: UserApiKeyExport[]
} }
export interface UserApiKeyExport { export interface UserApiKeyExport {
key?: string | null
key_hash: string key_hash: string
key_encrypted?: string | null key_encrypted?: string | null
name?: string | null name?: string | null
is_standalone: boolean is_standalone: boolean
balance_used_usd?: number
current_balance_usd?: number | null
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制 rate_limit?: number | null // legacy/null 兼容;1.3+ standalone null = 跟随系统默认
concurrent_limit?: number | null concurrent_limit?: number | null
force_capabilities?: Record<string, boolean> force_capabilities?: Record<string, boolean>
is_active: boolean is_active: boolean
@@ -104,14 +130,18 @@ export interface ProviderExport {
name: string name: string
description?: string | null description?: string | null
website?: string | null website?: string | null
provider_type?: string
billing_type?: string | null billing_type?: string | null
monthly_quota_usd?: number | null monthly_quota_usd?: number | null
quota_reset_day?: number quota_reset_day?: number
rpm_limit?: number | null
provider_priority?: number provider_priority?: number
keep_priority_on_conversion?: boolean
enable_format_conversion?: boolean
is_active: boolean is_active: boolean
concurrent_limit?: number | null concurrent_limit?: number | null
max_retries?: number | null max_retries?: number | null
stream_first_byte_timeout?: number | null
request_timeout?: number | null
proxy?: Record<string, unknown> proxy?: Record<string, unknown>
config?: Record<string, unknown> config?: Record<string, unknown>
endpoints: EndpointExport[] endpoints: EndpointExport[]
@@ -122,19 +152,24 @@ export interface ProviderExport {
export interface EndpointExport { export interface EndpointExport {
api_format: string api_format: string
base_url: string base_url: string
headers?: Record<string, unknown> header_rules?: Record<string, unknown>[] | null
body_rules?: Record<string, unknown>[] | null
max_retries?: number max_retries?: number
is_active: boolean is_active: boolean
custom_path?: string | null custom_path?: string | null
config?: Record<string, unknown> config?: Record<string, unknown>
format_acceptance_config?: Record<string, unknown> | null
proxy?: Record<string, unknown> proxy?: Record<string, unknown>
} }
export interface ProviderKeyExport { export interface ProviderKeyExport {
api_key: string api_key: string
auth_type?: string
auth_config?: string | Record<string, unknown> | null
name?: string | null name?: string | null
note?: string | null note?: string | null
api_formats: string[] api_formats: string[]
supported_endpoints?: string[]
rate_multipliers?: Record<string, number> | null rate_multipliers?: Record<string, number> | null
internal_priority?: number internal_priority?: number
global_priority_by_format?: Record<string, number> | null global_priority_by_format?: Record<string, number> | null
@@ -143,7 +178,13 @@ export interface ProviderKeyExport {
capabilities?: Record<string, boolean> capabilities?: Record<string, boolean>
cache_ttl_minutes?: number cache_ttl_minutes?: number
max_probe_interval_minutes?: number max_probe_interval_minutes?: number
auto_fetch_models?: boolean
locked_models?: string[] | null
model_include_patterns?: string[] | null
model_exclude_patterns?: string[] | null
is_active: boolean is_active: boolean
proxy?: Record<string, unknown> | null
fingerprint?: Record<string, unknown> | null
} }
export interface ModelExport { export interface ModelExport {
@@ -285,6 +326,7 @@ export interface ConfigImportResponse {
message: string message: string
stats: { stats: {
global_models: { created: number; updated: number; skipped: number } global_models: { created: number; updated: number; skipped: number }
proxy_nodes?: { created: number; updated: number; skipped: number }
providers: { created: number; updated: number; skipped: number } providers: { created: number; updated: number; skipped: number }
endpoints: { created: number; updated: number; skipped: number } endpoints: { created: number; updated: number; skipped: number }
keys: { created: number; updated: number; skipped: number } keys: { created: number; updated: number; skipped: number }
@@ -304,14 +346,11 @@ export interface AdminApiKey {
name?: string name?: string
key_display?: string // 脱敏后的密钥显示 key_display?: string // 脱敏后的密钥显示
is_active: boolean is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额(仅独立Key)
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
total_requests?: number total_requests?: number
total_tokens?: number total_tokens?: number
total_cost_usd?: number total_cost_usd?: number
rate_limit?: number | null // null = 无限制 rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
allowed_providers?: string[] | null // 允许的提供商列表 allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表 allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表 allowed_models?: string[] | null // 允许的模型列表
@@ -327,9 +366,10 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null allowed_providers?: string[] | null
allowed_api_formats?: string[] | null allowed_api_formats?: string[] | null
allowed_models?: string[] | null allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制 rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期 expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
initial_balance_usd: number // 初始余额,必须设置 initial_balance_usd: number | null // 初始余额,null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
auto_delete_on_expiry?: boolean // 过期后是否自动删除 auto_delete_on_expiry?: boolean // 过期后是否自动删除
} }
@@ -340,102 +380,6 @@ export interface AdminApiKeysResponse {
skip: number skip: number
} }
export interface LeaderboardItem {
rank: number
id: string
name: string
value: number
requests: number
tokens: number
cost: number
}
export interface LeaderboardResponse {
items: LeaderboardItem[]
total: number
metric: string
start_date?: string | null
end_date?: string | null
}
export interface CostForecastResponse {
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
slope: number
intercept: number
start_date: string
end_date: string
}
export interface CostSavingsResponse {
cache_read_tokens: number
cache_read_cost: number
cache_creation_cost: number
estimated_full_cost: number
cache_savings: number
}
export interface QuotaUsageProvider {
id: string
name: string
quota_usd: number
used_usd: number
remaining_usd: number
usage_percent: number
quota_expires_at?: string | null
estimated_exhaust_at?: string | null
}
export interface QuotaUsageResponse {
providers: QuotaUsageProvider[]
}
export interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
export interface ErrorDistributionItem {
category: string
count: number
}
export interface ErrorTrendItem {
date: string
total: number
categories: Record<string, number>
}
export interface ErrorDistributionResponse {
distribution: ErrorDistributionItem[]
trend: ErrorTrendItem[]
}
export interface ComparisonMetric {
total_requests: number
total_tokens: number
total_cost: number
actual_total_cost: number
avg_response_time_ms: number
error_requests: number
}
export interface ComparisonResponse {
current: ComparisonMetric
comparison: ComparisonMetric
change_percent: Record<string, number | null>
current_start: string
current_end: string
comparison_start: string
comparison_end: string
}
export interface ApiKeyToggleResponse { export interface ApiKeyToggleResponse {
id: string // UUID id: string // UUID
is_active: boolean is_active: boolean
@@ -477,7 +421,10 @@ export const adminApi = {
}, },
// 更新独立余额Key // 更新独立余额Key
async updateApiKey(keyId: string, data: Partial<CreateStandaloneApiKeyRequest>): Promise<AdminApiKey & { message: string }> { async updateApiKey(
keyId: string,
data: Partial<CreateStandaloneApiKeyRequest>
): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.put<AdminApiKey & { message: string }>( const response = await apiClient.put<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}`, `/api/admin/api-keys/${keyId}`,
data data
@@ -501,27 +448,10 @@ export const adminApi = {
return response.data return response.data
}, },
// 切换API密钥锁定状态(锁定/解锁) // 切换用户普通 API Key 锁定状态(锁定/解锁)
async toggleLockApiKey(keyId: string): Promise<ApiKeyLockResponse> { async toggleUserApiKeyLock(userId: string, keyId: string): Promise<ApiKeyLockResponse> {
const response = await apiClient.patch<ApiKeyLockResponse>( const response = await apiClient.patch<ApiKeyLockResponse>(
`/api/admin/api-keys/${keyId}/lock` `/api/admin/users/${userId}/api-keys/${keyId}/lock`
)
return response.data
},
// 为独立余额Key调整余额
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}/balance`,
{ amount_usd: amountUsd }
)
return response.data
},
// 重置独立余额Key的已使用额度
async resetApiKeyUsage(keyId: string): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}/reset-usage`
) )
return response.data return response.data
}, },
@@ -727,142 +657,6 @@ export const adminApi = {
return response.data return response.data
}, },
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
params
})
return response.data
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
params
})
return response.data
},
async getLeaderboardModels(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
}): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
params
})
return response.data
},
async getCostForecast(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
days?: number
forecast_days?: number
}): Promise<CostForecastResponse> {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
params
})
return response.data
},
async getCostSavings(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
provider_name?: string
model?: string
}): Promise<CostSavingsResponse> {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
params
})
return response.data
},
async getQuotaUsage(): Promise<QuotaUsageResponse> {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
return response.data
},
async getPercentiles(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<PercentileItem[]> {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
params
})
return response.data
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
params
})
return response.data
},
async getComparison(params: {
current_start: string
current_end: string
comparison_type?: 'period' | 'year'
timezone?: string
tz_offset_minutes?: number
}): Promise<ComparisonResponse> {
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
params
})
return response.data
},
// 数据清空 // 数据清空
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'), purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'), purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
@@ -871,19 +665,4 @@ export const adminApi = {
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'), purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
purgeStats: () => purge<{ message: string }>('stats'), purgeStats: () => purge<{ message: string }>('stats'),
async getTimeSeries(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
}): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
return response.data
},
} }
+396
View File
@@ -0,0 +1,396 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
export interface AnalyticsScope {
kind: AnalyticsScopeKind
user_id?: string | null
api_key_id?: string | null
}
export interface AnalyticsTimeRange {
start_date?: string
end_date?: string
preset?: string
granularity?: AnalyticsGranularity
timezone?: string | null
tz_offset_minutes?: number
}
export interface AnalyticsFilters {
user_ids?: string[]
provider_names?: string[]
models?: string[]
target_models?: string[]
api_key_ids?: string[]
api_formats?: string[]
request_types?: string[]
statuses?: string[]
error_categories?: string[]
is_stream?: boolean | null
has_format_conversion?: boolean | null
}
export interface AnalyticsBaseRequest {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
filters?: AnalyticsFilters
}
export interface AnalyticsSummary {
requests_total: number
requests_success: number
requests_error: number
requests_stream: number
success_rate: number
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
cache_hit_rate: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
format_conversion_count: number
models_used_count: number
}
export interface AnalyticsCompositionSegment {
key: string
value: number
percentage: number
}
export interface AnalyticsOverviewResponse {
query_context: {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
}
summary: AnalyticsSummary
composition: {
token_segments: AnalyticsCompositionSegment[]
cost_segments: AnalyticsCompositionSegment[]
}
}
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
bucket_start: string
bucket_end: string
}
export interface AnalyticsTimeseriesResponse {
buckets: AnalyticsTimeseriesBucket[]
}
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
dimension: AnalyticsBreakdownDimension
metric?: AnalyticsBreakdownMetric
limit?: number
}
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
key: string
label: string
share_of_total_cost: number
share_of_total_tokens: number
}
export interface AnalyticsBreakdownResponse {
dimension: AnalyticsBreakdownDimension
metric: AnalyticsBreakdownMetric
rows: AnalyticsBreakdownRow[]
}
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
search?: {
text?: string | null
request_id?: string | null
}
pagination?: {
limit?: number
offset?: number
}
}
export interface AnalyticsRecord {
id: string
request_id: string
created_at: string | null
user_id: string | null
username: string | null
api_key_id: string | null
api_key_name: string | null
provider_api_key_name: string | null
provider_name: string | null
model: string
target_model: string | null
api_format: string | null
request_type: string | null
status: string
billing_status: string
is_stream: boolean
has_format_conversion: boolean | null
has_fallback?: boolean
has_retry?: boolean
status_code: number | null
error_message: string | null
error_category: string | null
response_time_ms: number | null
first_byte_time_ms: number | null
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
rate_multiplier: number
}
export interface AnalyticsRecordsResponse {
total: number
limit: number
offset: number
records: AnalyticsRecord[]
}
export interface AnalyticsFilterOption {
value: string
label: string
}
export interface AnalyticsFilterOptionsResponse {
providers: AnalyticsFilterOption[]
models: AnalyticsFilterOption[]
target_models: AnalyticsFilterOption[]
api_formats: AnalyticsFilterOption[]
request_types: AnalyticsFilterOption[]
error_categories: AnalyticsFilterOption[]
statuses: AnalyticsFilterOption[]
users?: AnalyticsFilterOption[]
api_keys?: AnalyticsFilterOption[]
}
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
limit?: number
}
export interface AnalyticsLeaderboardItem {
rank: number
id: string
label: string
requests_total: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd: number
metric_value: number
}
export interface AnalyticsLeaderboardResponse {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
items: AnalyticsLeaderboardItem[]
}
export interface AnalyticsPercentilePoint {
date: string
p50_response_time_ms: number | null
p90_response_time_ms: number | null
p99_response_time_ms: number | null
p50_first_byte_time_ms: number | null
p90_first_byte_time_ms: number | null
p99_first_byte_time_ms: number | null
}
export interface AnalyticsErrorCategory {
category: string
label: string
count: number
}
export interface AnalyticsErrorTrendItem {
date: string
total: number
}
export interface AnalyticsProviderHealthItem {
provider_name: string
requests_total: number
success_rate: number
error_rate: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
}
export interface AnalyticsPerformanceResponse {
latency: {
response_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
first_byte_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
}
percentiles: AnalyticsPercentilePoint[]
errors: {
total: number
rate: number
categories: AnalyticsErrorCategory[]
trend: AnalyticsErrorTrendItem[]
}
provider_health: AnalyticsProviderHealthItem[]
}
export interface AnalyticsActiveRequest {
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost?: number
total_cost_usd?: number
actual_cost?: number | null
actual_total_cost_usd?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
provider_name?: string | null
api_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}
export interface AnalyticsActiveRequestsResponse {
requests: AnalyticsActiveRequest[]
}
export const analyticsApi = {
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
return response.data
},
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
return response.data
},
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
return response.data
},
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
return response.data
},
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
return response.data
},
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
return response.data
},
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
return response.data
},
async getHeatmap(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
}): Promise<ActivityHeatmap> {
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
return response.data
},
60000,
)
},
async getActiveRequests(payload: {
scope: AnalyticsScope
ids?: string[]
}): Promise<AnalyticsActiveRequestsResponse> {
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
...payload,
ids: payload.ids ?? [],
})
return response.data
},
async analyzeCacheAffinityTTL(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
return response.data
},
async analyzeCacheAffinityHit(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
return response.data
},
}
+202
View File
@@ -33,6 +33,203 @@ export interface AuditFilters {
offset?: number offset?: number
} }
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
export interface MonitoringCpuMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
load_percent: number | null
core_count: number
message?: string | null
}
export interface MonitoringMemoryMetric {
status: MonitoringMetricStatus
label: string
used_percent: number | null
used_bytes: number | null
available_bytes: number | null
total_bytes: number | null
message?: string | null
}
export interface MonitoringServiceMetric {
status: MonitoringMetricStatus
label: string
latency_ms: number | null
memory_status?: MonitoringMetricStatus
memory_label?: string | null
used_memory_bytes?: number | null
peak_memory_bytes?: number | null
maxmemory_bytes?: number | null
memory_ceiling_bytes?: number | null
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
available_memory_bytes?: number | null
memory_percent?: number | null
message?: string | null
}
export interface MonitoringPostgresMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
pool_usage_percent?: number | null
checked_out: number
pool_size: number
overflow: number
max_capacity: number
pool_timeout: number
server_connections?: number | null
server_max_connections?: number | null
server_usage_percent?: number | null
storage_status?: MonitoringMetricStatus
storage_label?: string | null
storage_total_bytes?: number | null
storage_free_bytes?: number | null
storage_free_percent?: number | null
database_size_bytes?: number | null
storage_message?: string | null
message?: string | null
}
export interface MonitoringSystemStatus {
timestamp: string
users: {
total: number
active: number
}
providers: {
total: number
active: number
}
api_keys: {
total: number
active: number
}
today_stats: {
requests: number
tokens: number
cost_usd: number
}
recent_errors: number
system_metrics?: {
cpu: MonitoringCpuMetric
memory: MonitoringMemoryMetric
redis: MonitoringServiceMetric
postgres: MonitoringPostgresMetric
}
}
function parseNumericLike(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return 0
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function parseNullableNumericLike(value: unknown): number | null {
if (value == null) {
return null
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
return {
...data,
users: {
total: parseNumericLike(data.users?.total),
active: parseNumericLike(data.users?.active),
},
providers: {
total: parseNumericLike(data.providers?.total),
active: parseNumericLike(data.providers?.active),
},
api_keys: {
total: parseNumericLike(data.api_keys?.total),
active: parseNumericLike(data.api_keys?.active),
},
today_stats: {
requests: parseNumericLike(data.today_stats?.requests),
tokens: parseNumericLike(data.today_stats?.tokens),
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
},
recent_errors: parseNumericLike(data.recent_errors),
system_metrics: data.system_metrics ? {
cpu: {
status: data.system_metrics.cpu?.status ?? 'unknown',
label: data.system_metrics.cpu?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
message: data.system_metrics.cpu?.message ?? null,
},
memory: {
status: data.system_metrics.memory?.status ?? 'unknown',
label: data.system_metrics.memory?.label ?? '未知',
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
message: data.system_metrics.memory?.message ?? null,
},
redis: {
status: data.system_metrics.redis?.status ?? 'unknown',
label: data.system_metrics.redis?.label ?? '未知',
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
message: data.system_metrics.redis?.message ?? null,
},
postgres: {
status: data.system_metrics.postgres?.status ?? 'unknown',
label: data.system_metrics.postgres?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
storage_message: data.system_metrics.postgres?.storage_message ?? null,
message: data.system_metrics.postgres?.message ?? null,
},
} : undefined,
}
}
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse { function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[] const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? { const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
@@ -78,6 +275,11 @@ export const auditApi = {
return response.data return response.data
}, },
async getSystemStatus(): Promise<MonitoringSystemStatus> {
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
return normalizeMonitoringSystemStatus(response.data)
},
// 分析用户行为 (管理员) // 分析用户行为 (管理员)
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{ async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
analysis: Record<string, unknown> analysis: Record<string, unknown>
+21 -26
View File
@@ -9,7 +9,6 @@ export interface LoginRequest {
export interface LoginResponse { export interface LoginResponse {
access_token: string access_token: string
refresh_token?: string
token_type?: string token_type?: string
expires_in?: number expires_in?: number
user_id?: string // UUID user_id?: string // UUID
@@ -25,13 +24,6 @@ export interface UserPreferences {
[key: string]: unknown // 允许扩展其他偏好设置 [key: string]: unknown // 允许扩展其他偏好设置
} }
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest { export interface SendVerificationCodeRequest {
email: string email: string
} }
@@ -81,6 +73,7 @@ export interface RegistrationSettingsResponse {
enable_registration: boolean enable_registration: boolean
require_email_verification: boolean require_email_verification: boolean
email_configured: boolean email_configured: boolean
password_policy_level: string
} }
export interface AuthSettingsResponse { export interface AuthSettingsResponse {
@@ -89,32 +82,39 @@ export interface AuthSettingsResponse {
ldap_exclusive: boolean ldap_exclusive: boolean
} }
export interface BillingSummary {
id?: string | null
balance: number
recharge_balance: number
gift_balance: number
refundable_balance: number
currency: string
status: string
limit_mode: 'finite' | 'unlimited'
unlimited: boolean
total_recharged: number
total_consumed: number
total_refunded: number
total_adjusted: number
updated_at?: string | null
}
export interface User { export interface User {
id: string // UUID id: string // UUID
username: string username: string
email?: string email?: string
role: string // 'admin' or 'user' role: string // 'admin' or 'user'
is_active: boolean is_active: boolean
quota_usd?: number | null billing?: BillingSummary
used_usd?: number
total_usd?: number
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string created_at: string
last_login_at?: string last_login_at?: string
preferences?: UserPreferences preferences?: UserPreferences
stats?: UserStats
} }
export const authApi = { export const authApi = {
async login(credentials: LoginRequest): Promise<LoginResponse> { async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials) const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
apiClient.setToken(response.data.access_token) apiClient.setToken(response.data.access_token)
// 后端暂时没有返回 refresh_token
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data return response.data
}, },
@@ -136,14 +136,9 @@ export const authApi = {
return response.data return response.data
}, },
async refreshToken(refreshToken: string): Promise<LoginResponse> { async refreshToken(): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', { const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {})
refresh_token: refreshToken
})
apiClient.setToken(response.data.access_token) apiClient.setToken(response.data.access_token)
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data return response.data
}, },
+28 -4
View File
@@ -3,6 +3,7 @@
*/ */
import api from './client' import api from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface CacheStats { export interface CacheStats {
scheduler: string scheduler: string
@@ -287,7 +288,12 @@ export const cacheAnalysisApi = {
api_key_id?: string api_key_id?: string
hours?: number hours?: number
}): Promise<TTLAnalysisResponse> { }): Promise<TTLAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params }) const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data return response.data
}, },
@@ -299,7 +305,12 @@ export const cacheAnalysisApi = {
api_key_id?: string api_key_id?: string
hours?: number hours?: number
}): Promise<CacheHitAnalysisResponse> { }): Promise<CacheHitAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params }) const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data return response.data
}, },
@@ -314,8 +325,21 @@ export const cacheAnalysisApi = {
user_id?: string user_id?: string
include_user_info?: boolean include_user_info?: boolean
}): Promise<IntervalTimelineResponse> { }): Promise<IntervalTimelineResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) const cacheKey = buildCacheKey('cache-affinity:interval-timeline', params as Record<string, unknown> | undefined)
return response.data return cachedRequest(
cacheKey,
async () => {
const response = await api.post('/api/analytics/interval-timeline', {
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
user_id: params?.user_id ?? null,
hours: params?.hours,
limit: params?.limit,
include_user_info: params?.include_user_info ?? false,
})
return response.data
},
30000
)
} }
} }
+87 -48
View File
@@ -3,10 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosReq
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants' import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { isDemoMode } from '@/config/demo' import { isDemoMode } from '@/config/demo'
import { handleMockRequest, setMockUserToken } from '@/mocks' import { handleMockRequest, setMockUserToken } from '@/mocks'
import { getClientDeviceId } from '@/utils/deviceId'
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
// 在开发环境下使用代理,生产环境使用环境变量 // 在开发环境下使用代理,生产环境使用环境变量
const API_BASE_URL = import.meta.env.VITE_API_URL || '' const API_BASE_URL = import.meta.env.VITE_API_URL || ''
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
/** /**
* 判断请求是否为公共端点 * 判断请求是否为公共端点
@@ -31,17 +34,15 @@ function isAuthRequest(url?: string): boolean {
} }
/** /**
* 判断是否为可刷新的认证错误 * 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
*/ */
function isRefreshableAuthError(errorDetail: string): boolean { function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
const nonRefreshableErrors = [ if (status !== 403) return false
const accountErrors = [
'用户不存在或已禁用', '用户不存在或已禁用',
'需要管理员权限',
'权限不足',
'用户已禁用', '用户已禁用',
] ]
return accountErrors.some((msg) => errorDetail.includes(msg))
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
} }
/** /**
@@ -83,12 +84,21 @@ class ApiClient {
private client: AxiosInstance private client: AxiosInstance
private token: string | null = null private token: string | null = null
private isRefreshing = false private isRefreshing = false
private refreshPromise: Promise<AxiosResponse> | null = null private refreshPromise: Promise<string> | null = null
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
private readonly onStorageSync = (event: StorageEvent): void => {
if (event.key !== 'access_token') {
return
}
this.syncTokenState(event.newValue)
}
constructor() { constructor() {
this.client = axios.create({ this.client = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: NETWORK_CONFIG.API_TIMEOUT, timeout: NETWORK_CONFIG.API_TIMEOUT,
withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -99,6 +109,7 @@ class ApiClient {
this.client.defaults.adapter = createDemoAdapter(defaultAdapter) this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
this.setupInterceptors() this.setupInterceptors()
this.setupCrossTabAuthSync()
} }
/** /**
@@ -108,6 +119,10 @@ class ApiClient {
// 请求拦截器 - 仅处理认证 // 请求拦截器 - 仅处理认证
this.client.interceptors.request.use( this.client.interceptors.request.use(
(config) => { (config) => {
if (config.url?.includes('/api/')) {
config.headers['X-Client-Device-Id'] = getClientDeviceId()
}
const requiresAuth = !isPublicEndpoint(config.url, config.method) && const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
config.url?.includes('/api/') config.url?.includes('/api/')
@@ -129,6 +144,23 @@ class ApiClient {
) )
} }
private setupCrossTabAuthSync(): void {
if (typeof window !== 'undefined') {
window.addEventListener('storage', this.onStorageSync)
}
}
private emitAuthStateChange(token: string | null): void {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
detail: { token },
})
)
}
/** /**
* 处理响应错误 * 处理响应错误
*/ */
@@ -155,8 +187,22 @@ class ApiClient {
return Promise.reject(error) return Promise.reject(error)
} }
const status = error.response?.status ?? 0
// 处理 403 用户账号级别错误(被禁用/删除)
if (status === 403) {
const rawDetail = (error.response?.data as Record<string, unknown>)?.detail
const errorDetail = typeof rawDetail === 'string' ? rawDetail : ''
if (isAccountLevelForbidden(status, errorDetail)) {
log.info('User account issue detected, clearing auth', { errorDetail })
this.clearAuth()
window.location.href = '/'
return Promise.reject(error)
}
}
// 处理401错误 // 处理401错误
if (error.response?.status === 401) { if (status === 401) {
return this.handle401Error(error, originalRequest) return this.handle401Error(error, originalRequest)
} }
@@ -177,25 +223,7 @@ class ApiClient {
return Promise.reject(error) return Promise.reject(error)
} }
const errorDetail = (error.response?.data as Record<string, unknown>)?.detail as string || '' log.debug('Got 401 error, attempting token refresh')
log.debug('Got 401 error, attempting token refresh', { errorDetail })
// 检查是否为业务相关的401错误(用户被禁用/删除等)
if (!isRefreshableAuthError(errorDetail)) {
log.info('User account issue detected, logging out and redirecting to home', { errorDetail })
this.clearAuth()
// 跳转到首页
window.location.href = '/'
return Promise.reject(error)
}
// 获取refresh token
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
log.info('No refresh token available, clearing invalid token')
this.clearAuth()
return Promise.reject(error)
}
// 标记为已重试 // 标记为已重试
originalRequest._retry = true originalRequest._retry = true
@@ -210,8 +238,8 @@ class ApiClient {
// 如果正在刷新,等待刷新完成 // 如果正在刷新,等待刷新完成
if (this.isRefreshing) { if (this.isRefreshing) {
try { try {
await this.refreshPromise const accessToken = await this.refreshPromise
originalRequest.headers.Authorization = `Bearer ${this.getToken()}` originalRequest.headers.Authorization = `Bearer ${accessToken}`
return this.client.request(originalRequest) return this.client.request(originalRequest)
} catch { } catch {
return Promise.reject(error) return Promise.reject(error)
@@ -219,29 +247,27 @@ class ApiClient {
} }
// 开始刷新token // 开始刷新token
return this.refreshTokenAndRetry(refreshToken, originalRequest, error) return this.refreshTokenAndRetry(originalRequest, error)
} }
/** /**
* 刷新token并重试原始请求 * 刷新token并重试原始请求
*/ */
private async refreshTokenAndRetry( private async refreshTokenAndRetry(
refreshToken: string,
originalRequest: InternalAxiosRequestConfig, originalRequest: InternalAxiosRequestConfig,
originalError: import('axios').AxiosError originalError: import('axios').AxiosError
): Promise<AxiosResponse> { ): Promise<AxiosResponse> {
this.isRefreshing = true this.isRefreshing = true
this.refreshPromise = this.refreshToken(refreshToken) this.refreshPromise = this.coordinatedRefresh()
try { try {
const response = await this.refreshPromise const accessToken = await this.refreshPromise
this.setToken(response.data.access_token) this.setToken(accessToken)
localStorage.setItem('refresh_token', response.data.refresh_token)
this.isRefreshing = false this.isRefreshing = false
this.refreshPromise = null this.refreshPromise = null
// 重试原始请求 // 重试原始请求
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}` originalRequest.headers.Authorization = `Bearer ${accessToken}`
return this.client.request(originalRequest) return this.client.request(originalRequest)
} catch (refreshError: unknown) { } catch (refreshError: unknown) {
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError)) log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
@@ -252,15 +278,29 @@ class ApiClient {
} }
} }
setToken(token: string): void { private async coordinatedRefresh(): Promise<string> {
return this.refreshCoordinator.run(async () => {
const response = await this.refreshToken()
const accessToken = response.data.access_token
if (!accessToken) {
throw new Error('Refresh response missing access token')
}
return accessToken
})
}
private syncTokenState(token: string | null): void {
this.token = token this.token = token
localStorage.setItem('access_token', token)
// 同步到 mock handler
if (isDemoMode()) { if (isDemoMode()) {
setMockUserToken(token) setMockUserToken(token)
} }
} }
setToken(token: string): void {
this.syncTokenState(token)
localStorage.setItem('access_token', token)
}
getToken(): string | null { getToken(): string | null {
if (!this.token) { if (!this.token) {
this.token = localStorage.getItem('access_token') this.token = localStorage.getItem('access_token')
@@ -273,18 +313,17 @@ class ApiClient {
} }
clearAuth(): void { clearAuth(): void {
this.token = null const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
this.syncTokenState(null)
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token') // 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
// 同步清除 mock token if (hadAuth) {
if (isDemoMode()) { this.emitAuthStateChange(null)
setMockUserToken(null)
} }
} }
async refreshToken(refreshToken: string): Promise<AxiosResponse> { async refreshToken(): Promise<AxiosResponse> {
// refreshToken 会通过 adapter 处理 Demo 模式 return this.client.post('/api/auth/refresh', {})
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
} }
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理 // 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
-339
View File
@@ -1,339 +0,0 @@
import apiClient from './client'
export interface DashboardStat {
name: string
value: string
subValue?: string
change?: string
changeType?: 'increase' | 'decrease' | 'neutral'
extraBadge?: string
icon: string
}
export interface RecentRequest {
id: string // UUID
user: string
model: string
tokens: number
time: string
}
export interface ProviderStatus {
name: string
status: 'active' | 'inactive'
requests: number
}
// 系统健康指标(管理员专用)
export interface SystemHealth {
avg_response_time: number
error_rate: number
error_requests: number
fallback_count: number
total_requests: number
}
// 成本统计(管理员专用)
export interface CostStats {
total_cost: number
total_actual_cost: number
cost_savings: number
}
// 缓存统计
export interface CacheStats {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost?: number
cache_read_cost?: number
cache_hit_rate?: number
total_cache_tokens: number
}
// 用户统计(管理员专用)
export interface UserStats {
total: number
active: number
}
// Token 详细分类
export interface TokenBreakdown {
input: number
output: number
cache_creation: number
cache_read: number
}
export interface DashboardStatsResponse {
stats: DashboardStat[]
today?: {
requests: number
tokens: number
cost: number
actual_cost?: number
cache_creation_tokens?: number
cache_read_tokens?: number
}
api_keys?: {
total: number
active: number
}
tokens?: {
month: number
}
// 管理员专用字段
system_health?: SystemHealth
cost_stats?: CostStats
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {
requests: RecentRequest[]
}
export interface ProviderStatusResponse {
providers: ProviderStatus[]
}
// 视频/图像/音频计费信息
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number // 视频时长(秒)
resolution?: string // 分辨率
video_price_per_second?: number // 每秒单价
video_cost?: number // 视频费用
cost?: number // 总费用
rule_name?: string // 计费规则名称
expression?: string // 计费公式
status?: string // 计费状态
}
export interface RequestDetail {
id: string // UUID
request_id: string
user: {
id: string // UUID
username: string
email: string
}
api_key: {
id: string // UUID
name: string
display: string
}
provider: string
api_format?: string
model: string
target_model?: string | null // 映射后的目标模型名
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
// Additional token fields
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
// Additional cost fields
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
request_type: string
is_stream: boolean
status_code: number
status?: string // pending, streaming, completed, failed, cancelled
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
metadata?: Record<string, unknown>
// 阶梯计费信息
tiered_pricing?: {
total_input_context: number // 总输入上下文 (input + cache_read)
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_read_price_per_1m: number
}>
}>
} | null
// 视频/图像/音频计费信息
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
}
export interface ModelBreakdown {
model: string
requests: number
tokens: number
cost: number
}
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
avg_response_time: number
cost_per_request: number
tokens_per_request: number
}
export interface ProviderSummary {
provider: string
requests: number
tokens: number
cost: number
}
export interface DailyStat {
date: string // ISO date string
requests: number
tokens: number
cost: number
avg_response_time: number // in seconds
unique_models: number
unique_providers?: number // 仅管理员返回
model_breakdown: ModelBreakdown[]
}
export interface DailyStatsResponse {
daily_stats: DailyStat[]
model_summary: ModelSummary[]
provider_summary?: ProviderSummary[] // 仅管理员返回
period: {
start_date: string
end_date: string
days: number
}
}
export interface TimeRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
// 获取最近的请求记录
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
params: { limit }
})
return response.data.requests
},
// 获取提供商状态
async getProviderStatus(): Promise<ProviderStatus[]> {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
return response.data.providers
},
// 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`)
return response.data
},
// 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params
})
return response.data
},
// 获取 cURL 命令数据(含明文 API Key)
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
// 回放请求到提供商
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {}
)
return response.data
}
}
+10
View File
@@ -68,3 +68,13 @@ export async function deleteEndpoint(endpointId: string): Promise<{ message: str
const response = await client.delete(`/api/admin/endpoints/${endpointId}`) const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
return response.data return response.data
} }
/**
* 获取指定 API 格式的默认请求体规则
*/
export async function getDefaultBodyRules(apiFormat: string, providerType?: string): Promise<{ api_format: string; body_rules: BodyRule[] }> {
const params: Record<string, string> = {}
if (providerType) params.provider_type = providerType
const response = await client.get(`/api/admin/endpoints/defaults/${encodeURIComponent(apiFormat)}/body-rules`, { params })
return response.data
}
+16 -8
View File
@@ -1,4 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { import type {
GlobalModelCreate, GlobalModelCreate,
GlobalModelUpdate, GlobalModelUpdate,
@@ -27,16 +28,21 @@ export async function getGlobalModels(params?: {
is_active?: boolean is_active?: boolean
search?: string search?: string
}): Promise<GlobalModelListResponse> { }): Promise<GlobalModelListResponse> {
const response = await client.get('/api/admin/models/global', { params }) const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
return response.data return dedupedRequest(key, async () => {
const response = await client.get('/api/admin/models/global', { params })
return response.data
})
} }
/** /**
* 获取单个 GlobalModel 详情 * 获取单个 GlobalModel 详情
*/ */
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> { export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
const response = await client.get(`/api/admin/models/global/${id}`) return dedupedRequest(`global-models:detail:${id}`, async () => {
return response.data const response = await client.get(`/api/admin/models/global/${id}`)
return response.data
})
} }
/** /**
@@ -112,10 +118,12 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
providers: ModelCatalogProviderDetail[] providers: ModelCatalogProviderDetail[]
total: number total: number
}> { }> {
const response = await client.get( return dedupedRequest(`global-models:providers:${globalModelId}`, async () => {
`/api/admin/models/global/${globalModelId}/providers` const response = await client.get(
) `/api/admin/models/global/${globalModelId}/providers`
return response.data )
return response.data
})
} }
/** /**
+100 -5
View File
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience // Re-export types for convenience
export type { EndpointAPIKey, AllowedModels } export type { EndpointAPIKey, AllowedModels }
export interface GroupedFormatKey {
id: string
provider_id: string
name: string
auth_type?: string
api_key_masked: string
internal_priority: number
global_priority_by_format: Record<string, number> | null
format_priority: number | null
rate_multipliers: Record<string, number> | null
is_active: boolean
provider_active: boolean
pool_enabled: boolean
circuit_breaker_open: boolean
provider_name: string
api_format: string
api_formats: string[]
capabilities: string[]
health_score: number | null
success_rate: number | null
avg_response_time_ms: number | null
request_count: number
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/** /**
* 能力定义类型 * 能力定义类型
*/ */
@@ -56,7 +84,7 @@ export async function getModelCapabilities(modelName: string): Promise<ModelCapa
* 获取完整的 API Key(用于查看和复制) * 获取完整的 API Key(用于查看和复制)
*/ */
export interface RevealKeyResult { export interface RevealKeyResult {
auth_type: 'api_key' | 'vertex_ai' | 'oauth' auth_type: 'api_key' | 'service_account' | 'oauth'
api_key?: string api_key?: string
refresh_token?: string refresh_token?: string
auth_config?: string | Record<string, unknown> auth_config?: string | Record<string, unknown>
@@ -83,6 +111,20 @@ export async function deleteEndpointKey(keyId: string): Promise<{ message: strin
return response.data return response.data
} }
/**
* 批量删除 Keys
*/
export interface BatchDeleteKeysResult {
success_count: number
failed_count: number
failed: Array<{ id: string; error: string }>
}
export async function batchDeleteEndpointKeys(ids: string[]): Promise<BatchDeleteKeysResult> {
const response = await client.post('/api/admin/endpoints/keys/batch-delete', { ids })
return response.data
}
// ========== Provider 级别的 Keys API ========== // ========== Provider 级别的 Keys API ==========
@@ -111,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
return allKeys return allKeys
} }
/**
* 获取按 API 格式分组的 Key 列表
*/
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
const result: Record<string, GroupedFormatKey[]> = {}
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
if (!Array.isArray(keys)) continue
result[apiFormat] = keys.map((key) => ({
id: String(key.id || ''),
provider_id: String(key.provider_id || ''),
name: String(key.name || 'Unnamed Key'),
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
api_key_masked: String(key.api_key_masked || '***'),
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
global_priority_by_format:
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
? (key.global_priority_by_format as Record<string, number>)
: null,
format_priority: toNumberOrNull(key.format_priority),
rate_multipliers:
key.rate_multipliers && typeof key.rate_multipliers === 'object'
? (key.rate_multipliers as Record<string, number>)
: null,
is_active: key.is_active !== false,
provider_active: key.provider_active !== false,
pool_enabled: key.pool_enabled === true,
circuit_breaker_open: key.circuit_breaker_open === true,
provider_name: String(key.provider_name || 'Unknown Provider'),
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
health_score: toNumberOrNull(key.health_score),
success_rate: toNumberOrNull(key.success_rate),
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
request_count: toNumberOrNull(key.request_count) ?? 0,
}))
}
return result
}
/** /**
* 为 Provider 添加 Key * 为 Provider 添加 Key
*/ */
@@ -119,7 +206,7 @@ export async function addProviderKey(
data: { data: {
api_formats: string[] // 支持的 API 格式列表(必填) api_formats: string[] // 支持的 API 格式列表(必填)
api_key: string api_key: string
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型 auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON) auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
name: string name: string
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
@@ -147,7 +234,7 @@ export async function updateProviderKey(
data: Partial<{ data: Partial<{
api_formats: string[] // 支持的 API 格式列表 api_formats: string[] // 支持的 API 格式列表
api_key: string api_key: string
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型 auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_config: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON) auth_config: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
name: string name: string
rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers: Record<string, number> | null // 按 API 格式的成本倍率
@@ -197,8 +284,16 @@ export interface RefreshQuotaResult {
}> }>
} }
export async function refreshProviderQuota(providerId: string): Promise<RefreshQuotaResult> { export async function refreshProviderQuota(
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/refresh-quota`) providerId: string,
keyIds?: string[],
): Promise<RefreshQuotaResult> {
const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined
const response = await client.post(
`/api/admin/endpoints/providers/${providerId}/refresh-quota`,
body,
{ timeout: 5 * 60 * 1000 },
)
return response.data return response.data
} }
+170 -21
View File
@@ -1,4 +1,9 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
export interface PoolKeyStatus { export interface PoolKeyStatus {
key_id: string key_id: string
@@ -25,7 +30,7 @@ export interface PoolStatusResponse {
* 获取 Provider 的号池状态 * 获取 Provider 的号池状态
*/ */
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> { export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/pool-status`) const response = await client.get<PoolStatusResponse>(`/api/admin/providers/${providerId}/pool-status`)
return response.data return response.data
} }
@@ -36,7 +41,7 @@ export async function clearPoolCooldown(
providerId: string, providerId: string,
keyId: string, keyId: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const response = await client.post( const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`, `/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
) )
return response.data return response.data
@@ -49,7 +54,7 @@ export async function resetPoolCost(
providerId: string, providerId: string,
keyId: string, keyId: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const response = await client.post( const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`, `/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
) )
return response.data return response.data
@@ -73,19 +78,95 @@ export interface PoolOverviewResponse {
items: PoolOverviewItem[] items: PoolOverviewItem[]
} }
export interface PoolPresetModeMeta {
value: string
label: string
}
export interface PoolPresetMeta {
name: string
label: string
description: string
providers: string[]
modes?: PoolPresetModeMeta[] | null
default_mode?: string | null
mutex_group?: string | null
evidence_hint?: string | null
}
export interface PoolKeyDetail { export interface PoolKeyDetail {
key_id: string key_id: string
key_name: string key_name: string
is_active: boolean is_active: boolean
auth_type: string auth_type: string
oauth_expires_at?: number | null
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_plan_type?: string | null
oauth_account_id?: string | null
oauth_account_user_id?: string | null
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
status_snapshot?: ProviderKeyStatusSnapshot | null
quota_updated_at?: number | null
health_score?: number
circuit_breaker_open?: boolean
api_formats?: string[]
rate_multipliers?: Record<string, number> | null
internal_priority?: number
rpm_limit?: number | null
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
note?: string | null
allowed_models?: AllowedModels
capabilities?: Record<string, boolean> | null
auto_fetch_models?: boolean
locked_models?: string[] | null
model_include_patterns?: string[] | null
model_exclude_patterns?: string[] | null
proxy?: ProxyConfig | null
account_quota: string | null
cooldown_reason: string | null cooldown_reason: string | null
cooldown_ttl_seconds: number | null cooldown_ttl_seconds: number | null
cost_window_usage: number cost_window_usage: number
cost_limit: number | null cost_limit: number | null
request_count: number
total_tokens: number
total_cost_usd: string
sticky_sessions: number sticky_sessions: number
lru_score: number | null lru_score: number | null
created_at: string | null created_at: string | null
last_used_at: string | null last_used_at: string | null
scheduling_status?: 'available' | 'degraded' | 'blocked'
scheduling_reason?:
| 'available'
| 'manual_disabled'
| 'cooldown'
| 'circuit_open'
| 'cost_exhausted'
| 'cost_soft'
| 'cost'
| 'health_low'
| 'health_degraded'
| 'health'
| string
scheduling_label?: string
scheduling_reasons?: PoolSchedulingReason[]
}
export interface PoolSchedulingReason {
code: string
label: string
blocking: boolean
source: 'manual' | 'pool' | 'health' | 'policy' | string
ttl_seconds?: number | null
detail?: string | null
} }
export interface PoolKeysPageResponse { export interface PoolKeysPageResponse {
@@ -100,50 +181,118 @@ export interface PoolKeysQuery {
page_size?: number page_size?: number
search?: string search?: string
status?: 'all' | 'active' | 'cooldown' | 'inactive' status?: 'all' | 'active' | 'cooldown' | 'inactive'
quick_selectors?: string[]
search_scope?: 'name' | 'full'
} }
export interface PoolKeyImportItem { export interface PoolKeySelectionRequest {
name: string search?: string
api_key: string quick_selectors?: string[]
auth_type?: string
} }
export interface BatchImportResponse { export interface PoolKeySelectionItem {
imported: number key_id: string
skipped: number key_name: string
errors: { index: number; reason: string }[] auth_type: string
}
export interface PoolKeySelectionResponse {
total: number
items: PoolKeySelectionItem[]
} }
export interface PoolBatchAction { export interface PoolBatchAction {
key_ids: string[] key_ids: string[]
action: 'enable' | 'disable' | 'delete' | 'clear_cooldown' | 'reset_cost' action:
| 'enable'
| 'disable'
| 'delete'
| 'clear_cooldown'
| 'reset_cost'
| 'regenerate_fingerprint'
| 'clear_proxy'
| 'set_proxy'
payload?: Record<string, unknown> | null
} }
export async function getPoolOverview(): Promise<PoolOverviewResponse> { export async function getPoolOverview(): Promise<PoolOverviewResponse> {
const response = await client.get('/api/admin/pool/overview') return dedupedRequest('pool:overview', async () => {
return response.data const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
return response.data
})
}
export async function getPoolSchedulingPresets(): Promise<PoolPresetMeta[]> {
return dedupedRequest('pool:scheduling-presets', async () => {
const response = await client.get<PoolPresetMeta[]>('/api/admin/pool/scheduling-presets')
return response.data
})
} }
export async function listPoolKeys( export async function listPoolKeys(
providerId: string, providerId: string,
params: PoolKeysQuery = {}, params: PoolKeysQuery = {},
): Promise<PoolKeysPageResponse> { ): Promise<PoolKeysPageResponse> {
const response = await client.get(`/api/admin/pool/${providerId}/keys`, { params }) const normalizedParams = {
return response.data ...params,
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
}
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
return dedupedRequest(key, async () => {
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
return response.data
})
} }
export async function batchImportPoolKeys( export async function resolvePoolKeySelection(
providerId: string, providerId: string,
keys: PoolKeyImportItem[], body: PoolKeySelectionRequest,
): Promise<BatchImportResponse> { ): Promise<PoolKeySelectionResponse> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-import`, { keys }) const response = await client.post<PoolKeySelectionResponse>(
`/api/admin/pool/${providerId}/keys/resolve-selection`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data return response.data
} }
export async function batchActionPoolKeys( export async function batchActionPoolKeys(
providerId: string, providerId: string,
body: PoolBatchAction, body: PoolBatchAction,
): Promise<{ affected: number; message: string; task_id?: string }> {
const response = await client.post(
`/api/admin/pool/${providerId}/keys/batch-action`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data
}
export interface BatchDeleteTaskStatus {
task_id: string
status: 'pending' | 'running' | 'completed' | 'failed'
total: number
deleted: number
message: string
}
export async function getPoolBatchDeleteTask(
providerId: string,
taskId: string,
): Promise<BatchDeleteTaskStatus> {
const response = await client.get<BatchDeleteTaskStatus>(
`/api/admin/pool/${providerId}/keys/batch-delete-task/${taskId}`,
)
return response.data
}
export async function cleanupBannedPoolKeys(
providerId: string,
): Promise<{ affected: number; message: string }> { ): Promise<{ affected: number; message: string }> {
const response = await client.post(`/api/admin/pool/${providerId}/keys/batch-action`, body) const response = await client.post(
`/api/admin/pool/${providerId}/keys/cleanup-banned`,
undefined,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data return response.data
} }
+65 -1
View File
@@ -18,6 +18,8 @@ export interface ProviderOAuthCompleteResponse {
expires_at?: number | null expires_at?: number | null
has_refresh_token: boolean has_refresh_token: boolean
email?: string | null email?: string | null
account_state_recheck_attempted?: boolean
account_state_recheck_error?: string | null
} }
export interface ProviderOAuthCompleteResponseWithKey { export interface ProviderOAuthCompleteResponseWithKey {
@@ -28,6 +30,48 @@ export interface ProviderOAuthCompleteResponseWithKey {
email?: string | null email?: string | null
} }
export interface OAuthBatchImportResultItem {
index: number
status: 'success' | 'error'
key_id?: string
key_name?: string
auth_method?: string
error?: string
replaced?: boolean
}
export type OAuthBatchImportTaskStatus = 'submitted' | 'processing' | 'completed' | 'failed'
export interface OAuthBatchImportTaskStartResponse {
task_id: string
status: OAuthBatchImportTaskStatus
total: number
processed: number
success: number
failed: number
progress_percent: number
message?: string | null
}
export interface OAuthBatchImportTaskStatusResponse {
task_id: string
provider_id: string
provider_type: string
status: OAuthBatchImportTaskStatus
total: number
processed: number
success: number
failed: number
progress_percent: number
message?: string | null
error?: string | null
error_samples: OAuthBatchImportResultItem[]
created_at: number
started_at?: number | null
finished_at?: number | null
updated_at: number
}
export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> { export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuthCompleteResponse> {
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`) const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
return resp.data return resp.data
@@ -50,12 +94,32 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken( export async function importProviderRefreshToken(
providerId: string, providerId: string,
data: { refresh_token: string; name?: string; proxy_node_id?: string } data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
): Promise<ProviderOAuthCompleteResponseWithKey> { ): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data return resp.data
} }
export async function startBatchImportOAuthTask(
providerId: string,
credentials: string,
proxyNodeId?: string
): Promise<OAuthBatchImportTaskStartResponse> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import/tasks`, {
credentials,
proxy_node_id: proxyNodeId || undefined,
})
return resp.data
}
export async function getBatchImportOAuthTaskStatus(
providerId: string,
taskId: string
): Promise<OAuthBatchImportTaskStatusResponse> {
const resp = await client.get(`/api/admin/provider-oauth/providers/${providerId}/batch-import/tasks/${taskId}`)
return resp.data
}
// Device Authorization (AWS SSO OIDC) // Device Authorization (AWS SSO OIDC)
export interface DeviceAuthorizeRequest { export interface DeviceAuthorizeRequest {
+131 -12
View File
@@ -1,16 +1,39 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { import type {
ClaudeCodeAdvancedConfig, ClaudeCodeAdvancedConfig,
FailoverRulesConfig,
PoolAdvancedConfig, PoolAdvancedConfig,
ProviderWithEndpointsSummary, ProviderWithEndpointsSummary,
ProxyConfig, ProxyConfig,
} from './types' } from './types'
/** /**
* 获取 Providers 摘要(包含 Endpoints 统计) * 获取 Providers 摘要(分页)
*/ */
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> { export interface ProviderSummaryQuery {
const response = await client.get('/api/admin/providers/summary') page?: number
page_size?: number
search?: string
status?: string
api_format?: string
model_id?: string
}
export interface ProviderSummaryPageResponse {
total: number
page: number
page_size: number
items: ProviderWithEndpointsSummary[]
}
export async function getProvidersSummary(
params: ProviderSummaryQuery = {},
): Promise<ProviderSummaryPageResponse> {
const response = await client.get<ProviderSummaryPageResponse>(
'/api/admin/providers/summary',
{ params },
)
return response.data return response.data
} }
@@ -18,8 +41,10 @@ export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummar
* 获取单个 Provider 的详细信息 * 获取单个 Provider 的详细信息
*/ */
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> { export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
const response = await client.get(`/api/admin/providers/${providerId}/summary`) return dedupedRequest(`providers:detail:${providerId}`, async () => {
return response.data const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
return response.data
})
} }
/** /**
@@ -29,10 +54,11 @@ export async function updateProvider(
providerId: string, providerId: string,
data: Partial<{ data: Partial<{
name: string name: string
provider_type: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro' provider_type: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
description: string | null description: string | null
website: string website: string
provider_priority: number provider_priority: number
keep_priority_on_conversion: boolean
billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier' billing_type: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd: number monthly_quota_usd: number
quota_reset_day: number quota_reset_day: number
@@ -48,6 +74,7 @@ export async function updateProvider(
is_active: boolean is_active: boolean
claude_code_advanced: ClaudeCodeAdvancedConfig | null claude_code_advanced: ClaudeCodeAdvancedConfig | null
pool_advanced: PoolAdvancedConfig | null pool_advanced: PoolAdvancedConfig | null
failover_rules: FailoverRulesConfig | null
}> }>
): Promise<ProviderWithEndpointsSummary> { ): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data) const response = await client.patch(`/api/admin/providers/${providerId}`, data)
@@ -60,7 +87,7 @@ export async function updateProvider(
export async function createProvider( export async function createProvider(
data: { data: {
name: string name: string
provider_type?: 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro' provider_type?: 'custom' | 'vertex_ai' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
description?: string description?: string
website?: string website?: string
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier' billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
@@ -77,6 +104,7 @@ export async function createProvider(
proxy?: ProxyConfig | null proxy?: ProxyConfig | null
claude_code_advanced?: ClaudeCodeAdvancedConfig | null claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null pool_advanced?: PoolAdvancedConfig | null
failover_rules?: FailoverRulesConfig | null
} }
): Promise<{ id: string; name: string; message?: string }> { ): Promise<{ id: string; name: string; message?: string }> {
const response = await client.post('/api/admin/providers/', data) const response = await client.post('/api/admin/providers/', data)
@@ -86,8 +114,36 @@ export async function createProvider(
/** /**
* 删除 Provider * 删除 Provider
*/ */
export async function deleteProvider(providerId: string): Promise<{ message: string }> { export interface ProviderDeleteSubmitResponse {
const response = await client.delete(`/api/admin/providers/${providerId}`) task_id: string
status: string
message: string
}
export interface ProviderDeleteTaskResponse {
task_id: string
provider_id: string
status: string
stage: string
total_keys: number
deleted_keys: number
total_endpoints: number
deleted_endpoints: number
message: string
}
export async function deleteProvider(providerId: string): Promise<ProviderDeleteSubmitResponse> {
const response = await client.delete<ProviderDeleteSubmitResponse>(`/api/admin/providers/${providerId}`)
return response.data
}
export async function getProviderDeleteTask(
providerId: string,
taskId: string,
): Promise<ProviderDeleteTaskResponse> {
const response = await client.get<ProviderDeleteTaskResponse>(
`/api/admin/providers/${providerId}/delete-task/${taskId}`,
)
return response.data return response.data
} }
@@ -122,7 +178,68 @@ export interface TestModelResponse {
} }
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> { export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
const response = await client.post('/api/admin/provider-query/test-model', data) const response = await client.post('/api/admin/provider-query/test-model', data, {
timeout: 10 * 60 * 1000,
})
return response.data
}
/**
* 带故障转移的模型测试
*/
export interface TestModelFailoverRequest {
provider_id: string
mode: 'global' | 'direct'
model_name: string
api_format?: string
endpoint_id?: string
message?: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
request_id?: string
concurrency?: number
}
export interface TestAttemptDetail {
candidate_index: number
retry_index?: number
endpoint_api_format: string
endpoint_base_url: string
key_name: string | null
key_id: string
auth_type: string
effective_model?: string | null
status: 'success' | 'failed' | 'skipped' | 'cancelled' | 'pending' | 'streaming' | 'stream_interrupted' | 'available' | 'unused'
skip_reason?: string | null
error_message?: string | null
status_code?: number | null
latency_ms?: number | null
request_url?: string | null
request_headers?: Record<string, unknown> | null
request_body?: unknown
response_headers?: Record<string, unknown> | null
response_body?: unknown
}
export interface TestModelFailoverResponse {
success: boolean
model: string
provider: { id: string; name: string }
attempts: TestAttemptDetail[]
total_candidates: number
total_attempts: number
data?: Record<string, unknown> | null
error?: string | null
}
export async function testModelFailover(
data: TestModelFailoverRequest,
options: { signal?: AbortSignal } = {}
): Promise<TestModelFailoverResponse> {
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
timeout: 10 * 60 * 1000,
signal: options.signal,
})
return response.data return response.data
} }
@@ -169,6 +286,8 @@ export interface ProviderMappingPreviewResponse {
export async function getProviderMappingPreview( export async function getProviderMappingPreview(
providerId: string providerId: string
): Promise<ProviderMappingPreviewResponse> { ): Promise<ProviderMappingPreviewResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/mapping-preview`) return dedupedRequest(`providers:mapping-preview:${providerId}`, async () => {
return response.data const response = await client.get<ProviderMappingPreviewResponse>(`/api/admin/providers/${providerId}/mapping-preview`)
return response.data
})
} }
@@ -5,6 +5,7 @@ export const API_FORMATS = {
CLAUDE_CLI: 'claude:cli', CLAUDE_CLI: 'claude:cli',
OPENAI: 'openai:chat', OPENAI: 'openai:chat',
OPENAI_CLI: 'openai:cli', OPENAI_CLI: 'openai:cli',
OPENAI_COMPACT: 'openai:compact',
OPENAI_VIDEO: 'openai:video', OPENAI_VIDEO: 'openai:video',
GEMINI: 'gemini:chat', GEMINI: 'gemini:chat',
GEMINI_CLI: 'gemini:cli', GEMINI_CLI: 'gemini:cli',
@@ -19,6 +20,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI', [API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
[API_FORMATS.OPENAI]: 'OpenAI Chat', [API_FORMATS.OPENAI]: 'OpenAI Chat',
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI', [API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
[API_FORMATS.OPENAI_COMPACT]: 'OpenAI Compact',
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video', [API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
[API_FORMATS.GEMINI]: 'Gemini Chat', [API_FORMATS.GEMINI]: 'Gemini Chat',
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI', [API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
@@ -28,6 +30,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
CLAUDE_CLI: 'Claude CLI', CLAUDE_CLI: 'Claude CLI',
OPENAI: 'OpenAI Chat', OPENAI: 'OpenAI Chat',
OPENAI_CLI: 'OpenAI CLI', OPENAI_CLI: 'OpenAI CLI',
OPENAI_COMPACT: 'OpenAI Compact',
OPENAI_VIDEO: 'OpenAI Video', OPENAI_VIDEO: 'OpenAI Video',
GEMINI: 'Gemini Chat', GEMINI: 'Gemini Chat',
GEMINI_CLI: 'Gemini CLI', GEMINI_CLI: 'Gemini CLI',
@@ -38,6 +41,7 @@ export const API_FORMAT_LABELS: Record<string, string> = {
export const API_FORMAT_SHORT: Record<string, string> = { export const API_FORMAT_SHORT: Record<string, string> = {
[API_FORMATS.OPENAI]: 'O', [API_FORMATS.OPENAI]: 'O',
[API_FORMATS.OPENAI_CLI]: 'OC', [API_FORMATS.OPENAI_CLI]: 'OC',
[API_FORMATS.OPENAI_COMPACT]: 'OCP',
[API_FORMATS.OPENAI_VIDEO]: 'OV', [API_FORMATS.OPENAI_VIDEO]: 'OV',
[API_FORMATS.CLAUDE]: 'C', [API_FORMATS.CLAUDE]: 'C',
[API_FORMATS.CLAUDE_CLI]: 'CC', [API_FORMATS.CLAUDE_CLI]: 'CC',
@@ -47,6 +51,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
// legacy 兼容(仅用于展示历史数据) // legacy 兼容(仅用于展示历史数据)
OPENAI: 'O', OPENAI: 'O',
OPENAI_CLI: 'OC', OPENAI_CLI: 'OC',
OPENAI_COMPACT: 'OCP',
OPENAI_VIDEO: 'OV', OPENAI_VIDEO: 'OV',
CLAUDE: 'C', CLAUDE: 'C',
CLAUDE_CLI: 'CC', CLAUDE_CLI: 'CC',
@@ -59,6 +64,7 @@ export const API_FORMAT_SHORT: Record<string, string> = {
export const API_FORMAT_ORDER: string[] = [ export const API_FORMAT_ORDER: string[] = [
API_FORMATS.OPENAI, API_FORMATS.OPENAI,
API_FORMATS.OPENAI_CLI, API_FORMATS.OPENAI_CLI,
API_FORMATS.OPENAI_COMPACT,
API_FORMATS.OPENAI_VIDEO, API_FORMATS.OPENAI_VIDEO,
API_FORMATS.CLAUDE, API_FORMATS.CLAUDE,
API_FORMATS.CLAUDE_CLI, API_FORMATS.CLAUDE_CLI,
@@ -67,6 +73,62 @@ export const API_FORMAT_ORDER: string[] = [
API_FORMATS.GEMINI_VIDEO, API_FORMATS.GEMINI_VIDEO,
] ]
// Family 显示名称映射
export const API_FORMAT_FAMILY_LABELS: Record<string, string> = {
openai: 'OpenAI',
claude: 'Claude',
gemini: 'Gemini',
}
// Kind 显示名称映射
export const API_FORMAT_KIND_LABELS: Record<string, string> = {
chat: 'Chat',
cli: 'CLI',
compact: 'Compact',
video: 'Video',
}
// Family 排序顺序
const FAMILY_ORDER = ['openai', 'claude', 'gemini']
// 工具函数:从 API 格式中提取 family 和 kind
export function parseApiFormat(format: string): { family: string; kind: string } {
const idx = format.indexOf(':')
if (idx === -1) return { family: format.toLowerCase(), kind: '' }
return { family: format.slice(0, idx).toLowerCase(), kind: format.slice(idx + 1).toLowerCase() }
}
// 工具函数:按 family 分组并排序 API 格式数组
export interface ApiFormatGroup {
family: string
label: string
formats: string[]
}
export function groupApiFormats(formats: string[]): ApiFormatGroup[] {
const sorted = sortApiFormats(formats)
const groups = new Map<string, string[]>()
for (const f of sorted) {
const { family } = parseApiFormat(f)
if (!groups.has(family)) groups.set(family, [])
groups.get(family)?.push(f)
}
return [...groups.entries()]
.sort(([a], [b]) => {
const ai = FAMILY_ORDER.indexOf(a)
const bi = FAMILY_ORDER.indexOf(b)
if (ai === -1 && bi === -1) return 0
if (ai === -1) return 1
if (bi === -1) return -1
return ai - bi
})
.map(([family, fmts]) => ({
family,
label: API_FORMAT_FAMILY_LABELS[family] || family,
formats: fmts,
}))
}
// 工具函数:将 API 格式签名转为友好显示名称 // 工具函数:将 API 格式签名转为友好显示名称
export function formatApiFormat(format: string | null | undefined): string { export function formatApiFormat(format: string | null | undefined): string {
if (!format) return '-' if (!format) return '-'
+74 -14
View File
@@ -1,3 +1,5 @@
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
/** /**
* 代理配置类型 * 代理配置类型
* 支持两种模式: * 支持两种模式:
@@ -12,6 +14,13 @@ export interface ProxyConfig {
enabled?: boolean // 是否启用代理(false 时保留配置但不使用) enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
} }
export interface OAuthOrganizationInfo {
id?: string | null
title?: string | null
is_default?: boolean | null
role?: string | null
}
/** /**
* 请求头规则类型 * 请求头规则类型
* - set: 设置/覆盖请求头 * - set: 设置/覆盖请求头
@@ -35,8 +44,6 @@ export interface HeaderRuleRename {
to: string to: string
} }
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
/** /**
* 请求体规则类型 * 请求体规则类型
* - set: 设置/覆盖字段 * - set: 设置/覆盖字段
@@ -129,10 +136,28 @@ export type BodyRuleConditionOp =
| 'exists' | 'not_exists' | 'exists' | 'not_exists'
| 'in' | 'type_is' | 'in' | 'type_is'
export interface BodyRuleCondition { export interface BodyRuleConditionLeaf {
path: string path: string
op: BodyRuleConditionOp op: BodyRuleConditionOp
value?: unknown // exists / not_exists 不需要 value value?: unknown // exists / not_exists 不需要 value
source?: 'original' | 'current'
}
export interface BodyRuleConditionAll {
all: BodyRuleCondition[]
}
export interface BodyRuleConditionAny {
any: BodyRuleCondition[]
}
export type BodyRuleCondition =
| BodyRuleConditionLeaf
| BodyRuleConditionAll
| BodyRuleConditionAny
export type HeaderRule = (HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename) & {
condition?: BodyRuleCondition
} }
/** /**
@@ -204,7 +229,7 @@ export interface EndpointAPIKey {
api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat") api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat")
api_key_masked: string api_key_masked: string
api_key_plain?: string | null api_key_plain?: string | null
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型(必返回) auth_type: 'api_key' | 'service_account' | 'oauth' // 认证类型(必返回)
name: string // 密钥名称(必填,用于识别) name: string // 密钥名称(必填,用于识别)
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率 rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
internal_priority: number // Key 内部优先级 internal_priority: number // Key 内部优先级
@@ -264,8 +289,13 @@ export interface EndpointAPIKey {
oauth_email?: string | null // OAuth 授权的邮箱 oauth_email?: string | null // OAuth 授权的邮箱
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
oauth_account_id?: string | null // Codex ChatGPT 账号 ID oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳) oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_invalid_reason?: string | null // OAuth Token 失效原因 oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
status_snapshot?: ProviderKeyStatusSnapshot | null
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息) // 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理) // Key 级别代理配置(覆盖 Provider 级别代理)
@@ -284,10 +314,6 @@ export interface CodexUpstreamMetadata {
secondary_reset_seconds?: number // 5H限额重置剩余秒数 secondary_reset_seconds?: number // 5H限额重置剩余秒数
secondary_reset_at?: number // 5H限额重置时间(Unix 时间戳) secondary_reset_at?: number // 5H限额重置时间(Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小(分钟) secondary_window_minutes?: number // 5H限额窗口大小(分钟)
code_review_used_percent?: number // 代码审查限额使用百分比
code_review_reset_seconds?: number // 代码审查限额重置剩余秒数
code_review_reset_at?: number // 代码审查限额重置时间(Unix 时间戳)
code_review_window_minutes?: number // 代码审查限额窗口大小(分钟)
has_credits?: boolean // 是否有积分 has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额 credits_balance?: number // 积分余额
} }
@@ -351,7 +377,7 @@ export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表 api_formats?: string[] // 支持的 API 格式列表
name?: string name?: string
api_key?: string // 仅在需要更新时提供 api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型 auth_type?: 'api_key' | 'service_account' | 'oauth' // 认证类型
auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON) auth_config?: Record<string, unknown> // 认证配置(Vertex AI Service Account JSON)
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率 rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number internal_priority?: number
@@ -440,14 +466,12 @@ export interface PublicEndpointStatusMonitorResponse {
formats: PublicEndpointStatusMonitor[] formats: PublicEndpointStatusMonitor[]
} }
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro' export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro' | 'vertex_ai'
export interface ClaudeCodeAdvancedConfig { export interface ClaudeCodeAdvancedConfig {
// 会话数量控制:null/undefined 表示不限制 // 会话数量控制:null/undefined 表示不限制
max_sessions?: number | null max_sessions?: number | null
session_idle_timeout_minutes?: number | null session_idle_timeout_minutes?: number | null
// TLS 指纹模拟(模拟 Node.js/Claude Code 客户端指纹)
enable_tls_fingerprint?: boolean
// 会话 ID 伪装(固定 metadata.user_id 中 session 片段) // 会话 ID 伪装(固定 metadata.user_id 中 session 片段)
session_id_masking_enabled?: boolean session_id_masking_enabled?: boolean
// Cache TTL 统一(强制所有 cache_control 使用相同 TTL 类型) // Cache TTL 统一(强制所有 cache_control 使用相同 TTL 类型)
@@ -457,10 +481,29 @@ export interface ClaudeCodeAdvancedConfig {
cli_only_enabled?: boolean cli_only_enabled?: boolean
} }
export interface SchedulingPresetItem {
preset: string
enabled: boolean
mode?: string | null
}
export interface PoolAdvancedConfig { export interface PoolAdvancedConfig {
global_priority?: number | null
sticky_session_ttl_seconds?: number | null sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null load_threshold_percent?: number | null
// 旧字段(兼容读取)
lru_enabled?: boolean lru_enabled?: boolean
scheduling_mode?: 'lru' | 'multi_score' | null
// 新格式:对象列表;旧格式:字符串列表
scheduling_presets?: SchedulingPresetItem[] | string[] | null
scoring_weights?: {
lru?: number
latency?: number
health?: number
cost_remaining?: number
} | null
latency_window_seconds?: number | null
latency_sample_limit?: number | null
cost_window_seconds?: number | null cost_window_seconds?: number | null
cost_limit_per_key_tokens?: number | null cost_limit_per_key_tokens?: number | null
cost_soft_threshold_percent?: number | null cost_soft_threshold_percent?: number | null
@@ -469,6 +512,21 @@ export interface PoolAdvancedConfig {
proactive_refresh_seconds?: number | null proactive_refresh_seconds?: number | null
health_policy_enabled?: boolean health_policy_enabled?: boolean
unschedulable_rules?: Array<Record<string, unknown>> | null unschedulable_rules?: Array<Record<string, unknown>> | null
batch_concurrency?: number | null
probing_enabled?: boolean
probing_interval_minutes?: number | null
auto_remove_banned_keys?: boolean
}
export interface FailoverRuleItem {
pattern: string
description?: string
status_codes?: number[]
}
export interface FailoverRulesConfig {
success_failover_patterns: FailoverRuleItem[]
error_stop_patterns: FailoverRuleItem[]
} }
export interface ProviderWithEndpointsSummary { export interface ProviderWithEndpointsSummary {
@@ -506,6 +564,7 @@ export interface ProviderWithEndpointsSummary {
endpoint_health_details: EndpointHealthDetail[] endpoint_health_details: EndpointHealthDetail[]
claude_code_advanced?: ClaudeCodeAdvancedConfig | null claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null pool_advanced?: PoolAdvancedConfig | null
failover_rules?: FailoverRulesConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等) ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter) ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
created_at: string created_at: string
@@ -536,6 +595,7 @@ export interface HealthSummary {
total: number total: number
active: number active: number
unhealthy: number unhealthy: number
circuit_open?: number
} }
} }
@@ -0,0 +1,36 @@
export interface OAuthStatusSnapshot {
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
label?: string | null
reason?: string | null
expires_at?: number | null
invalid_at?: number | null
source?: string | null
requires_reauth?: boolean
expiring_soon?: boolean
}
export interface AccountStatusSnapshot {
code: string
label?: string | null
reason?: string | null
blocked: boolean
source?: string | null
recoverable?: boolean
}
export interface QuotaStatusSnapshot {
code: 'unknown' | 'ok' | 'exhausted'
label?: string | null
reason?: string | null
exhausted: boolean
usage_ratio?: number | null
updated_at?: number | null
reset_seconds?: number | null
plan_type?: string | null
}
export interface ProviderKeyStatusSnapshot {
oauth: OAuthStatusSnapshot
account: AccountStatusSnapshot
quota: QuotaStatusSnapshot
}
+38 -165
View File
@@ -1,6 +1,9 @@
import apiClient from './client' import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types' import type { TieredPricingConfig } from './endpoints/types'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
export type { UserSession }
export interface Profile { export interface Profile {
id: string // UUID id: string // UUID
@@ -8,9 +11,7 @@ export interface Profile {
username: string username: string
role: string role: string
is_active: boolean is_active: boolean
quota_usd: number | null billing: BillingSummary
used_usd: number
total_usd?: number // 累积消费总额
created_at: string created_at: string
updated_at?: string updated_at?: string
last_login_at?: string last_login_at?: string
@@ -22,8 +23,6 @@ export interface Profile {
export interface UserPreferences { export interface UserPreferences {
avatar_url?: string avatar_url?: string
bio?: string bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
theme: string theme: string
language: string language: string
timezone?: string timezone?: string
@@ -34,88 +33,6 @@ export interface UserPreferences {
} }
} }
// 提供商配置接口
export interface ProviderConfig {
provider_id: string
priority: number // 优先级(越高越优先)
weight: number // 负载均衡权重
enabled: boolean // 是否启用
}
// 使用记录接口
export interface UsageRecordDetail {
id: string
provider: string
model: string
input_tokens: number
output_tokens: number
total_tokens: number
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number
is_stream: boolean
created_at: string
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
status_code: number
error_message?: string
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
api_key?: {
id: string
name: string
display: string
}
}
// 模型统计接口
export interface ModelSummary {
model: string
requests: number
input_tokens: number
output_tokens: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
}
// 提供商统计接口
export interface ProviderSummary {
provider: string
requests: number
total_tokens: number
total_cost_usd: number
success_rate: number | null
avg_response_time_ms: number | null
}
// 使用统计响应接口
export interface UsageResponse {
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_tokens: number
total_cost: number // 官方费率
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
quota_usd: number | null
used_usd: number
summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
pagination?: {
total: number
limit: number
offset: number
has_more: boolean
}
records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null
}
export interface ApiKey { export interface ApiKey {
id: string // UUID id: string // UUID
name: string name: string
@@ -127,7 +44,7 @@ export interface ApiKey {
created_at: string created_at: string
total_requests?: number total_requests?: number
total_cost_usd?: number total_cost_usd?: number
allowed_providers?: ProviderConfig[] rate_limit?: number | null
force_capabilities?: Record<string, boolean> | null // 强制能力配置 force_capabilities?: Record<string, boolean> | null // 强制能力配置
} }
@@ -160,14 +77,36 @@ export const meApi = {
return response.data return response.data
}, },
async listSessions(): Promise<UserSession[]> {
const response = await apiClient.get<UserSession[]>('/api/users/me/sessions')
return response.data
},
async updateSessionLabel(sessionId: string, deviceLabel: string): Promise<UserSession> {
const response = await apiClient.patch<UserSession>(`/api/users/me/sessions/${sessionId}`, {
device_label: deviceLabel,
})
return response.data
},
async revokeSession(sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`)
return response.data
},
async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete('/api/users/me/sessions/others')
return response.data
},
// API密钥管理 // API密钥管理
async getApiKeys(): Promise<ApiKey[]> { async getApiKeys(): Promise<ApiKey[]> {
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys') const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
return response.data return response.data
}, },
async createApiKey(name: string): Promise<ApiKey> { async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name }) const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data return response.data
}, },
@@ -197,48 +136,14 @@ export const meApi = {
return response.data return response.data
}, },
// 使用统计 async updateApiKey(
async getUsage(params?: { keyId: string,
start_date?: string data: { name?: string; rate_limit?: number | null }
end_date?: string ): Promise<ApiKey & { message: string }> {
preset?: string const response = await apiClient.put<ApiKey & { message: string }>(
timezone?: string `/api/users/me/api-keys/${keyId}`,
tz_offset_minutes?: number data
search?: string // 通用搜索:密钥名、模型名 )
limit?: number
offset?: number
}): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
},
// 获取活跃请求状态(用于轮询更新)
async getActiveRequests(ids?: string): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
}>
}> {
const params = ids ? { ids } : {}
const response = await apiClient.get('/api/users/me/usage/active', { params })
return response.data
},
// 获取可用的提供商
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get('/api/users/me/providers')
return response.data return response.data
}, },
@@ -282,16 +187,6 @@ export const meApi = {
return response.data return response.data
}, },
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
// API密钥提供商关联
async updateApiKeyProviders(keyId: string, data: {
allowed_providers?: ProviderConfig[]
}): Promise<{ message: string }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
return response.data
},
// API密钥能力配置 // API密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: { async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null force_capabilities?: Record<string, boolean> | null
@@ -318,26 +213,4 @@ export const meApi = {
return response.data return response.data
}, },
// 获取请求间隔时间线(用于散点图)
async getIntervalTimeline(params?: {
hours?: number
limit?: number
}): Promise<{
analysis_period_hours: number
total_points: number
points: Array<{ x: string; y: number; model?: string }>
models?: string[]
}> {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
}
} }
+10
View File
@@ -5,6 +5,7 @@ export interface ProxyNodeRemoteConfig {
allowed_ports?: number[] allowed_ports?: number[]
log_level?: string log_level?: string
heartbeat_interval?: number heartbeat_interval?: number
upgrade_to?: string | null
} }
export interface ProxyNode { export interface ProxyNode {
@@ -37,6 +38,7 @@ export interface ProxyNode {
failed_requests: number failed_requests: number
dns_failures: number dns_failures: number
stream_errors: number stream_errors: number
proxy_metadata: Record<string, unknown> | null
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -109,6 +111,14 @@ export const proxyNodesApi = {
return response.data return response.data
}, },
async batchUpgrade(version: string): Promise<{ version: string; updated: number; skipped: number; node_ids: string[] }> {
const response = await apiClient.post<{ version: string; updated: number; skipped: number; node_ids: string[] }>(
'/api/admin/proxy-nodes/upgrade',
{ version }
)
return response.data
},
async testProxyUrl(data: { proxy_url: string; username?: string; password?: string }): Promise<ProxyNodeTestResult> { async testProxyUrl(data: { proxy_url: string; username?: string; password?: string }): Promise<ProxyNodeTestResult> {
const response = await apiClient.post<ProxyNodeTestResult>('/api/admin/proxy-nodes/test-url', data) const response = await apiClient.post<ProxyNodeTestResult>('/api/admin/proxy-nodes/test-url', data)
return response.data return response.data
+161
View File
@@ -0,0 +1,161 @@
import apiClient from './client'
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number
resolution?: string
video_price_per_second?: number
video_cost?: number
cost?: number
rule_name?: string
expression?: string
status?: string
}
export interface RequestDetail {
id: string
request_id: string
user: {
id: string | null
username: string | null
email: string | null
}
api_key: {
id: string | null
name: string | null
display: string | null
}
provider_api_key: {
id: string | null
name: string | null
}
provider: string
api_format?: string
model: string
target_model?: string | null
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number
request_type: string
is_stream: boolean
status_code: number
status?: string
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
tiered_pricing?: {
total_input_context: number
tier_index: number
tier_count: number
source?: 'provider' | 'global'
current_tier: {
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
}
export const requestDetailsApi = {
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {},
)
return response.data
},
}
+10 -3
View File
@@ -12,8 +12,9 @@ export interface CandidateRecord {
endpoint_name?: string // 端点显示名称(api_format) endpoint_name?: string // 端点显示名称(api_format)
key_id?: string key_id?: string
key_name?: string // 密钥名称 key_name?: string // 密钥名称
key_account_label?: string // 更适合展示的测试账号标签(优先 OAuth 邮箱)
key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回 key_preview?: string // 密钥脱敏预览(如 sk-***abc),OAuth 类型不返回
key_auth_type?: string // 密钥认证类型(api_key, oauth, vertex_ai 等) key_auth_type?: string // 密钥认证类型(api_key, service_account, oauth 等)
key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise) key_oauth_plan_type?: string // OAuth 账号套餐类型(free/plus/team/enterprise)
key_capabilities?: Record<string, boolean> | null // Key 支持的能力 key_capabilities?: Record<string, boolean> | null // Key 支持的能力
required_capabilities?: Record<string, boolean> | null // 请求实际需要的能力标签 required_capabilities?: Record<string, boolean> | null // 请求实际需要的能力标签
@@ -55,8 +56,14 @@ export const requestTraceApi = {
/** /**
* 获取特定请求的完整追踪信息 * 获取特定请求的完整追踪信息
*/ */
async getRequestTrace(requestId: string): Promise<RequestTrace> { async getRequestTrace(
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`) requestId: string,
options: { attemptedOnly?: boolean } = {},
): Promise<RequestTrace> {
const attemptedOnly = options.attemptedOnly ?? false
const response = await apiClient.get<RequestTrace>(`/api/admin/monitoring/trace/${requestId}`, {
params: { attempted_only: attemptedOnly },
})
return response.data return response.data
}, },

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