Compare commits

...
833 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
fawney19 54530faf03 feat(proxy): 节点状态简化、连接事件记录、可靠性指标与批量删除
- 移除 UNHEALTHY 中间状态,节点状态简化为 ONLINE/OFFLINE
- 新增 proxy_node_events 表记录 tunnel 连接/断开/错误事件
- 新增 failed_requests/dns_failures/stream_errors 可靠性指标(增量累加)
- tunnel 重连改为固定 1s 延迟,移除指数退避逻辑
- resolver/service 改为以 TunnelManager 内存状态判断节点可用性,避免 DB 竞态
- 修正 Claude cache_control 字段格式,使用 ttl 字段控制缓存时长
- 移除前端手动勾选 capability 的 UI,改为从价格配置自动推断
- 新增全局模型批量删除 API,替换前端并行单个删除
2026-02-28 13:52:32 +08:00
fawney19 ecb16d345a feat: 缓存计费细分、能力匹配优化、用户模型调用计数
1. 缓存创建 tokens 区分 5min/1h TTL,支持按缓存时长差异化计费
   - Usage 表新增 cache_creation_input_tokens_5m/1h 字段
   - Claude handler 解析新格式 (ephemeral_5m/1h, claude_cache_creation_5/1h)
   - 计费规则支持 cache_ttl_pricing 覆盖 cache_creation 价格

2. 能力匹配机制优化
   - COMPATIBLE 能力不再硬过滤,改为排序阶段通过 capability_miss_count 优先级处理
   - cache_1h 改为 COMPATIBLE + REQUEST_PARAM(自动检测请求体中的 ttl=1h)
   - gemini_files 改为 EXCLUSIVE + REQUEST_PARAM(自动检测 fileData.fileUri)
   - 移除前端模型偏好/能力配置 UI(不再需要用户手动配置)

3. 新增用户-模型维度调用次数计数器 (UserModelUsageCount)
   - 原子递增,避免从 Usage 表聚合查询
   - 前端模型目录和用户可用模型列表展示调用次数

4. 其他改进
   - global_model_id 改为必填(NOT NULL),清理孤立模型
   - 模型映射对话框支持从上游获取模型列表并分组折叠
   - 端点测试不再依赖端点启用状态
   - 异步任务页面对普通用户隐藏用户信息列
   - Dashboard 响应式布局断点调整 (sm -> lg)
   - 号池管理仅展示已启用号池的提供商
2026-02-28 11:45:04 +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
fawney19 82bbed2720 Merge pull request #191 from AAEE86/master
feat(providers): 资源统计按 provider_type 显示密钥/账号
2026-02-28 09:18:55 +08:00
AAEE86 90e804f608 feat(providers): 资源统计按 provider_type 显示密钥/账号 2026-02-28 08:58:12 +08:00
fawney19 e748277902 feat(proxy): 安全加固与架构优化
- 引入 SafeDnsResolver 消除 DNS rebinding TOCTTOU 漏洞,DNS 缓存改为多地址存储
- 扩展私有 IP 检测范围(CGNAT 100.64/10、基准测试 198.18/15、保留 240/4)
- 请求处理增加 hop-by-hop 头过滤、URL scheme 校验、超时范围限制
- 动态配置从 RwLock 切换到 ArcSwap 实现无锁读取
- 启动注册失败的服务器支持后台自动重试
- WebSocket 帧大小上限提升至 64MiB 匹配 Python 端
- 心跳支持动态间隔更新,新增 failed_requests/dns_failures/stream_errors 指标
- 配置启动校验、systemd UMask=0077、配置文件权限 600
- Python 端支持 per-connection max_streams(X-Tunnel-Max-Streams)
2026-02-28 01:32:28 +08:00
fawney19 2a0c684e88 feat: 新增全局模型批量管理功能,优化提供商更新接口
- 模型管理页面新增批量管理对话框,支持搜索、快捷筛选和批量删除
- 快捷筛选支持:无提供商、无活跃提供商、已禁用、未调用、无价格
- 提供商 PATCH 接口返回完整的 ProviderWithEndpointsSummary
- 修复系统级格式转换开关的 tooltip 文案和按钮高亮样式
2026-02-28 00:01:15 +08:00
fawney19 5bfaee47cc refactor(proxy): 端点检查统一使用 proxy_config 替代 proxy_param
将 endpoint_checker、handler_adapter_base、gemini adapter 及
provider_query 中的 proxy_param 参数替换为 proxy_config,
通过 build_proxy_client_kwargs 统一构建代理客户端参数,
以支持 tunnel 模式代理。同时清理未使用的 import。
2026-02-27 22:41:31 +08:00
fawney19 8de2f41924 fix(proxy-tunnel): 修复隧道连接池竞态与跨平台兼容问题
- 修复 TCP keepalive with_retries 在 Windows 上不可用的编译问题
- dispatcher 中 try_send 失败时记录警告日志而非静默丢弃
- 优化 handler_handles 清理策略,每 64 帧定期清理
- StreamState 记住原始连接引用,清理时避免连接池竞态
2026-02-27 21:41:43 +08:00
fawney19 ee356c5e6e feat(proxy-tunnel): 实现隧道连接池与 TCP 底层优化
Rust 端:
- 支持每个 server 多条并行 WebSocket 连接 (tunnel_connections 配置)
- 手动控制 TCP 连接: connect/handshake 超时、keepalive、NODELAY (socket2)
- 预构建共享 TLS ClientConfig 避免每次重连重新解析根证书
- 增加 stale timeout 检测无数据连接,智能 backoff 按连接存活时长重置
- 每条连接独立 reconnect 计数器,仅主连接 (conn_idx=0) 发送心跳
- dispatcher 的错误帧和 PONG 改用 try_send 避免阻塞读循环
- stream_handler 增加 frame 发送超时保护防止写阻塞

Python 端:
- TunnelManager 改为连接池,按 least-loaded 策略分配请求
- handle_incoming_frame 按连接实例路由,unregister 精确移除单条连接
- 心跳和 PONG 回复改为 fire-and-forget 避免阻塞主读循环
- send_frame 增加超时保护防止 TCP 写阻塞级联
- WebSocket 先 accept 再认证,auth 加超时
- 调整 idle timeout (90s) 和 ping 间隔 (15s)
2026-02-27 21:25:42 +08:00
fawney19 a174cf1b02 feat(proxy-tunnel): 增强隧道连接稳定性与恢复速度
- writer 增加 WebSocket Ping keepalive,防止中间代理空闲超时断开
- 服务端增加应用层 PING 循环(30s 间隔),空闲超时延长至 180s
- 重连基础延迟从 1000ms 降低到 500ms
- 节点缓存 TTL 缩短至 15s,不可用节点 TTL 缩短至 5s 加速恢复感知
- 心跳检测间隔从 30s 缩短到 15s
2026-02-27 19:51:38 +08:00
fawney19 934723f5f5 fix(frontend): 移除代理节点配置的号池模式限制
取消 Popover 组件的 v-if="provider.pool_advanced" 条件,
使代理节点配置在所有模式下均可使用。
2026-02-27 19:25:53 +08:00
fawney19 892c4235ec fix(frontend): 修正 system.ts 中 client 的导入方式为默认导入 2026-02-27 19:15:38 +08:00
fawney19 ddeb357c0e feat(proxy): 增加 0.1.x 到 0.2.0 配置自动迁移,放宽 max_retries 上限至 999
- proxy: 启动时检测旧版配置并自动迁移(delegate_* -> upstream_*、单服务器 -> [[servers]]),备份原文件为 .v1.bak
- proxy: 升级后 systemd 重启改为 best-effort,失败不中断升级流程
- provider: max_retries 上限从 10 放宽到 999(前端、后端模型同步调整)
2026-02-27 19:12:02 +08:00
github-actions[bot] 7cb007b445 chore(proxy): update download links for proxy-v0.2.0 2026-02-27 10:52:41 +00:00
fawney19 50262a2f02 chore(proxy): bump version to 0.2.0 2026-02-27 18:46:01 +08:00
fawney19 178fe4be4b Merge pull request #189 from AAEE86/fix
fix(frontend): 修复 Provider Key 列表只显示 100 条的问题
2026-02-27 18:37:16 +08:00
fawney19 4855e7cbca fix(task-poller): 捕获 CancelledError 避免关闭时产生 traceback
视频任务轮询在应用关闭时,Redis 异步操作被取消会抛出
CancelledError,由于其继承自 BaseException 而非 Exception,
原有的异常处理无法捕获,导致 APScheduler 打印完整 traceback。
拆分 poll_pending_tasks 为入口方法和 _do_poll 内部方法,
在入口层捕获 CancelledError 后静默返回。
2026-02-27 18:23:07 +08:00
fawney19 d2450cbdc1 feat(claude-code): 增加 TLS 指纹伪装、Cache TTL 统一、CLI 限制与流超时冷却
- 新增 curl_cffi Transport,支持真实浏览器 TLS 指纹伪装 (Chrome/Node.js)
- 增加 Cache TTL Override 功能,强制统一 cache_control 类型防止行为指纹差异
- 增加 CLI-only 客户端限制,支持仅允许 Claude Code CLI 访问
- 池健康策略增加 stream timeout 计数与自动冷却机制
- OAuth 账号 Region 选择改为从 AWS API 动态获取,支持搜索和自定义输入
- 前端 PoolConfigDialog 增加对应配置 UI
2026-02-27 18:10:27 +08:00
AAEE86 bfee1019ed fix(frontend): 修复 Provider Key 列表只显示 100 条的问题
- 在 getProviderKeys 中增加 skip/limit 自动分页拉取
  - 默认每页 1000,循环获取直到无更多数据
  - 避免后端默认 limit=100 导致账号展示被截断
2026-02-27 15:07:44 +08:00
fawney19 76a6d0ce8e feat(pool): 增加 OAuth 账号池管理功能
- 新增 pool manager / strategy / health_policy / cost_tracker / redis_ops 等核心模块
- 新增 pool admin API 路由与 schemas
- 新增 OAuth 账号类型解析 (oauth_plan)
- 前端增加 PoolManagement 页面、PoolConfigDialog、PoolImportDialog、PoolStatusCard 组件
- 补充 pool config / cost tracker / health policy / manager / strategy / trace 等测试
2026-02-27 13:58:58 +08:00
fawney19andAAEE86 579b5e4623 feat(provider): 增加 Claude Code 适配器、高级配置能力与 OAuth 账号类型统一解析
- 新增 Claude Code provider adapter (context, envelope, plugin, constants)
- 扩展 provider admin 路由,支持 Claude Code 高级配置 (CRUD)
- 统一 OAuth 账号类型解析逻辑,前后端对齐
- 重构 BatchAssignModelsDialog / ModelMappingDialog,简化组件逻辑
- handler 基类增强: request_builder 支持 Claude Code 信封格式
- CLI stream/sync mixin 适配 Claude Code 流式与同步模式
- 扩展 candidate builder / failover / scheduler 对 Claude Code 的支持
- 前端增加请求时间线可视化 (HorizontalRequestTimeline)
- 补充 Claude Code envelope / runtime controls / distributed sessions 等测试

Closes #183
Closes #185

Co-Authored-By: AAEE86 <[email protected]>
2026-02-27 13:54:46 +08:00
fawney19 f2f2a2dbc4 fix(proxy-node): 心跳和健康检查增加 tunnel 节点状态修正能力
- 心跳处理:收到心跳说明 tunnel 连通,若状态非 ONLINE 则修正
- 健康检查:移除 OFFLINE 过滤,允许检测 tunnel 重连后的状态恢复
- 前端配额刷新:就地更新 key metadata,避免重拉列表导致分页重置
2026-02-26 17:53:31 +08:00
fawney19 3fff147b9b Merge pull request #184 from rcdfrd/feat/new-api-checkin-apikey-support
feat(new-api): 签到支持 API Key 认证模式
2026-02-26 17:52:37 +08:00
rcdfrd a25f491f45 feat(new-api): 签到支持 API Key 认证模式
- 无 Cookie 时不再直接跳过,改为尝试以 API Key 发起签到请求
- 401/403 及认证失败类消息在 API Key 模式下静默跳过,不再误报 cookie_expired
- 将 message.lower() 预计算为 message_lower,避免重复调用
- 修正 already_indicators 与 auth_fail_indicators 中各条目也做 lower 处理,确保大小写不敏感比较一致
2026-02-26 15:42:42 +08:00
fawney19 8c2928c39d refactor(proxy-node): 移除非 tunnel 模式兼容,注册和心跳统一为 tunnel 模式
- 注册接口移除 tunnel_mode 参数,固定按 name upsert 并标记为 tunnel 模式
- 心跳接口拒绝非 tunnel 模式节点,返回升级提示
- 移除按 ip+port 查找的旧模式分支
2026-02-26 13:54:32 +08:00
fawney19 7e73e7fab2 fix(proxy-node): tunnel 模式节点注册不再覆盖由连接管理的状态
register_node 对 tunnel 模式已有节点不再写 status 字段,
状态完全由 _update_tunnel_status 和 health_scheduler 管理,
避免心跳注册将已连接的 ONLINE 状态覆盖为 UNHEALTHY。
2026-02-26 13:38:08 +08:00
fawney19 61535dfb7e feat(proxy-node): tunnel 节点支持连通性测试
通过 TunnelTransport 经 WebSocket tunnel 发送测试请求,
测量往返延迟并获取出口 IP,与手动代理节点测试语义一致。
2026-02-26 13:16:06 +08:00
fawney19 54332d31bc fix(proxy-node): 监控 writer task 退出以避免 tunnel 连接半关闭时 dispatcher 阻塞
当对端关闭连接导致 write half 退出但 read half 仍打开时,
dispatcher 会在 ws_stream.next() 上永久阻塞。通过在 tokio::select!
中监控 writer_handle,检测到 writer 退出后立即触发重连。
2026-02-26 13:07:42 +08:00
fawney19 67b8eb5c2f fix(proxy-node): tunnel 模式节点状态由连接管理,心跳不再覆盖
- resolver 中增加 tunnel 未连接节点的过滤,避免请求路由到不可达节点
- 心跳处理中 tunnel 模式节点仅更新指标,不改变在线状态
2026-02-26 12:50:08 +08:00
fawney19 27cef96789 refactor(proxy-node): 统一代理解析,全面支持 tunnel 模式
新增 resolve_ops_proxy_config 合并 proxy 和 tunnel_node_id 的解析,
避免各架构重复调用 _resolve_effective_node。所有架构连接器
(anyrouter/nekocode/sub2api/yescode) 和 HTTPClientPool 均适配
tunnel 模式,通过 TunnelTransport 替代传统代理。
2026-02-26 12:25:23 +08:00
fawney19 560345a889 feat(orchestration): 增加 AWS 账号被暂停 (suspended) 的错误识别和自动停用处理
- ErrorClassifier 新增 403 suspended 状态检测,归类为 ProviderAuthException
- ErrorHandlerService 新增 _is_account_suspended 静态方法,匹配多种 suspended 错误文本
- 403 suspended 的 OAuth key 自动标记为账号异常并停用
- 重构 _mark_oauth_key_blocked 支持自定义 reason 参数
- 将 _is_account_validation_required 调用改为静态方法调用
2026-02-26 11:32:31 +08:00
fawney19 f41bfecf0b feat(usage): 增强 cURL/Replay 对 Vertex AI、OAuth 和 envelope 提供商的支持
- _resolve_provider_auth 返回 decrypted_auth_config 用于 Vertex AI URL 构建
- _build_provider_url_safe 新增 API 格式默认路径回退及模板变量防护
- Replay 适配器基于 api_format 智能匹配端点和 Key
- Replay 适配器支持 envelope 包装(kiro/codex/antigravity 等特殊提供商)
- 复用 target_provider_obj 减少重复 Provider 查询
2026-02-26 10:32:29 +08:00
fawney19 80438e1d61 fix(proxy-node): 修复服务重启后 tunnel 连接状态不一致的问题
- 启动时重置 DB 中残留的 tunnel_connected=True 状态
- health_scheduler 以 TunnelManager 内存实际连接为准判断节点状态
- tunnel 模式注册时初始状态设为 UNHEALTHY,等 tunnel 连接后再上线
2026-02-26 09:15:00 +08:00
fawney19 19f1be03fa feat(nginx): 添加 WebSocket 隧道端点的 nginx 代理配置 2026-02-26 03:33:53 +08:00
fawney19andAAEE86 b6ca16e084 fix(headers): 兼容非 ASCII header 值,避免 httpx ASCII 编码报错
将 HeaderBuilder 中的 latin-1 透传方案替换为 ASCII 归一化策略:
- 对 x-codex-turn-metadata 做 JSON 重编码(ensure_ascii=true)保留语义
- 其他非 ASCII 头值仅转义非 ASCII 字符为 \uXXXX,保留 ASCII 字符原样
- 新增单测覆盖中文 header 与 codex turn metadata 场景

Close #180

Co-Authored-By: AAEE86 <[email protected]>
2026-02-26 02:56:54 +08:00
fawney19 c0b80c923a refactor(usage): 移除 RequestHeadersContent 中未使用的 diff props
移除 clientHeadersWithDiff 和 providerHeadersWithDiff 属性及相关计算逻辑
2026-02-26 02:36:37 +08:00
fawney19 61e0959a06 Merge branch 'feat/compare-view' 2026-02-26 02:32:29 +08:00
fawney19 0ecf8b703e feat(api-keys): 支持独立密钥额度重置(手动+定时自动)
- 新增手动重置接口 PATCH /api/admin/api-keys/{id}/reset-usage
- 前端 ApiKeys 页面增加重置按钮,支持确认后归零已使用额度
- 新增独立密钥额度定时自动重置任务,支持配置周期和执行时间
- 支持 all/selected 两种重置模式,selected 模式可指定密钥
- 移除已废弃的 CleanupScheduler 兼容别名
2026-02-26 02:16:48 +08:00
fawney19 1a1bb0a99c feat(admin): 新增数据管理模块,支持分类清空系统数据
添加 purge API 支持按类别清空配置、用户、使用记录、审计日志、请求体和统计数据,
前端系统设置页新增数据管理区块。
2026-02-26 01:20:24 +08:00
fawney19 5415057a5d fix(models): 模型创建对话框支持连续添加
创建模式下提交后保持对话框打开,方便批量添加模型;
编辑模式提交后仍正常关闭对话框。按钮文案调整为"添加"。
2026-02-25 23:33:16 +08:00
fawney19 a2493b4bc0 fix(compatibility): 同族格式透传不再依赖格式转换开关
将 data_format_id 相同的格式对(如 claude:chat / claude:cli)的透传判断
提前到三层开关检查之前,使其无需开关即可直接透传。
同时用 pytest.mark.parametrize 精简同族格式测试用例。
2026-02-25 23:06:25 +08:00
fawney19 fd9040b9aa refactor(proxy): 将 aether-proxy 从 HMAC 正向代理迁移到 WebSocket 隧道模式
移除 HMAC 认证、TLS 自签名证书、HTTP CONNECT 代理和代发(delegate)模式,
改为 aether-proxy 主动通过 WebSocket 连接 Aether 服务端建立隧道。

Aether 服务端新增:
- WebSocket 隧道端点 (proxy_tunnel.py)
- TunnelManager 管理隧道连接和请求分发
- TunnelTransport 作为 httpx 自定义 transport 层
- 基于二进制帧的隧道协议 (tunnel_protocol.py)

aether-proxy (Rust) 重构:
- 新增 tunnel 模块 (client/dispatcher/stream_handler/protocol)
- 支持多 Aether 服务端连接 ([[servers]] 配置)
- 移除 proxy/auth/delegate 模块和 hyper 依赖
- 改用 tokio-tungstenite 实现 WebSocket 客户端

同时:
- 添加浏览器指纹 Headers 绕过 Cloudflare 防护
- 删除节点时自动清理 Provider/Endpoint 的代理引用
- 数据库迁移: 新增 tunnel_mode/tunnel_connected/tunnel_connected_at 字段
2026-02-25 21:59:29 +08:00
LewisPen adaf9e4b93 feat(usage): 响应头 Tab 增加并排对比模式
泛化 RequestHeadersContent 组件支持任意 header 对,
响应头 Tab 复用同一组件实现客户端/提供商响应头 Diff 对比。
2026-02-24 15:25:59 +08:00
fawney19 39b036abd5 refactor(docs/home): 重构首页导航和文档结构
- README 新增架构图(明暗主题 SVG),更新 Proxy 描述
- 首页导航栏将"文档"链接从底部按钮区移至顶部导航
- 删除独立的 body-rules-spec 文档
- GuideLayout 路由切换时自动滚动到顶部
- 架构图组件移除网格背景
2026-02-24 12:15:14 +08:00
fawney19 837bed3d47 feat(provider): 支持 Provider 级别代理节点配置,修正配额分组匹配
- Provider 详情抽屉新增代理节点设置入口(Globe 图标),支持选择/清除代理
- 更新 Antigravity 配额分组:修正模型匹配规则以适配当前模型命名
- 简化上游元数据合并逻辑,以上游返回为准,不再保留已下架模型条目
2026-02-24 02:44:15 +08:00
fawney19 7e447fe54e feat(antigravity): 对齐 AM 客户端标识,前端 OAuth 增加 TOTP 支持
- User-Agent 改为 Electron 浏览器格式,新增 x-client-name/x-client-version/x-vscode-sessionid/x-goog-api-client header
- 移除 MODEL_ALIAS_MAP 别名映射,改为仅剥离 -online 后缀
- thinking 自动注入关键字新增 gemini-3.1-pro
- 新增定时 Antigravity UA 版本刷新任务(每 6 小时 + 启动时)
- 前端 OAuth 设备授权新增 TOTP 验证码生成(otpauth),Region 改为按钮选择
- SSO OIDC 轮询日志 authorization_pending/slow_down 降级为 debug
2026-02-24 01:52:53 +08:00
fawney19 cd70c9148e refactor(guide): 精简架构说明布局和样式细节
- 移除副标题描述段落,采用紧凑编号列表替代圆形大卡片布局
- 精简文案表述,缩减流程图间距和元素尺寸
- 移除架构图提供商标签的 hover 动效
- 调整核心调度条的字重和边框样式
2026-02-23 22:58:52 +08:00
fawney19 cd05ec770c fix(guide): 架构图标签改为产品名称(Claude/Gemini)并调整节点间距
- 将 Source/Output 标签从 Anthropic/Google 改为 Claude/Gemini
- 增大虚线框与引擎卡片的垂直间距,改善布局层次
- 同步调整连接线路径坐标和动画时长
2026-02-23 22:41:53 +08:00
fawney19 a7675606a1 fix(guide): 更新架构图核心模块标签和布局细节
- 新增"缓存亲和"功能标签
- 将"分布式智能路由抉择"改为"智能调度 / 故障转移"
- 调整标签间距从 gap-2 到 gap-1.5
2026-02-23 22:33:34 +08:00
fawney19 bdd9d0a3dd refactor(guide): 重构架构图为紧凑垂直布局,支持响应式缩放
将 ArchitectureDiagram 从 1300px 横向滚动画布改为 760x420 固定尺寸垂直流布局,
使用 CSS transform scale + ResizeObserver 实现自适应缩放,移除横向滚动条。
2026-02-23 21:56:20 +08:00
fawney19 e18a7ee435 refactor(guide): 重构指南页面为 Markdown 内容驱动架构
- 将各指南页面的内联硬编码内容抽取为独立 Markdown 文件
- 新增 MarkdownViewer 组件统一渲染 Markdown 内容
- 新增 ArchitectureDiagram 组件和架构演示组件
- 新增模块指南页面 (ModulesGuide)
- GuideLayout 支持子导航、页面过渡动画和图片 Lightbox
- guide-config 重构为支持 subItems 的导航结构
- 添加指南相关截图资源
2026-02-23 15:50:24 +08:00
fawney19 c43d22b0e4 fix(frontend): 移除缓存价格自动计算对 input_price > 0 的限制
当 input_price_per_1m 为 0 时,缓存价格(creation/read/1h)也应自动计算,
而非跳过赋值。
2026-02-22 15:26:40 +08:00
fawney19 1a45d0aa87 docs: 更新 README 项目定位描述,截图改为外链,新增 Aether Proxy 介绍段落
- 项目描述从"开源 AI API 网关"调整为"一站式 AI 基础设施平台"
- 移除内嵌截图,改为指向 GitHub Pages 的外链
- 新增 Aether Proxy 简介段落
- 精简部分 FAQ 内容
- aether-proxy 版本升至 0.1.6
2026-02-22 01:38:22 +08:00
github-actions[bot] 41835c8afe chore(proxy): update download links for proxy-v0.1.6 2026-02-21 17:21:11 +00:00
fawney19 5da9197eee feat(proxy): CI 添加 Docker 构建推送和 README 自动更新,HardwareTooltip 增强字段兼容
- build-proxy workflow 新增 docker job 构建多架构镜像推送 GHCR/Docker Hub
- build-proxy workflow 新增 update-readme job 自动更新下载链接表格
- Dockerfile 改用预编译二进制替代多阶段 Rust 编译
- 新增 docker-compose.yml 简化部署
- .env.example 精简为必要配置项
- README 补充 Docker 部署说明和下载表格标记
- HardwareTooltip 兼容 camelCase/snake_case 字段名,添加 pickRecord 辅助函数和原生 tooltip 回退
2026-02-22 01:13:03 +08:00
fawney19 cf2eee222e refactor: 前端全面替换 any 为 unknown 并统一错误处理,后端用量记录补写请求头/体
- 前端 API 层、stores、conversation 解析器、组件全面替换 any 为 unknown/具体类型
- 错误处理统一使用 parseApiError/getErrorStatus 替代 err.response?.data?.detail 模式
- 后端 handler/TaskService/UsageLifecycle/StreamTracker 链路传递 request_headers/request_body
- streaming/pending 状态更新时可补写客户端和提供商的请求头及请求体
- 新增 TaskService 和 UsageService 相关测试
2026-02-22 00:43:41 +08:00
fawney19 98e60e8f74 feat(kiro): 支持 AWS SSO OIDC 设备授权流程
为 Kiro provider 新增 Device Authorization 模式,替代原先禁用 OAuth 的限制:
- 后端实现 device-authorize / device-poll 端点,完成客户端注册、设备码签发、token 轮询和自动建 Key
- 前端 OAuthAccountDialog 新增设备授权 UI,支持 Start URL/Region 输入、验证链接跳转、倒计时和自动轮询
2026-02-21 22:30:38 +08:00
fawney19 1e4d0006b9 fix: 用量记录保留空 headers/body,格式转换支持同 normalizer 透传
- 用量记录和遥测写入中将 truthiness 检查改为 is not None,
  避免空 dict/list 被误丢弃
- FormatConversionRegistry 新增 _same_normalizer() 方法,
  共享 normalizer 的格式(如 claude:chat/claude:cli)视为同格式透传
- 前端请求详情抽屉按数据可用性智能选择默认数据源
- 视频任务 finalize 支持 headers/original_headers 等别名字段
2026-02-21 21:16:35 +08:00
fawney19 b649a69a5e fix: 迁移脚本补充 inspect 导入,HardwareTooltip 兼容多种硬件字段格式
- 迁移脚本缺少 inspect 导入导致运行时报错
- HardwareTooltip 使用 pickNumber/pickString 兼容不同节点上报的字段名
- 添加 tooltip 点击交互和 title 属性作为 fallback
2026-02-21 18:13:46 +08:00
fawney19 066fc01d2e fix(body_rules): 启用条件的规则跳过路径冲突校验
条件可能互斥,静态校验无法判断是否真正冲突,改为运行时处理。
2026-02-21 17:08:31 +08:00
fawney19 19dd297138 refactor(kiro): 拆分 auth_region/api_region,统一 region 解析逻辑
KiroAuthConfig 新增 auth_region(token 刷新端点)和 api_region(q.{region} 服务端点)字段,
通过 effective_auth_region() / effective_api_region() 方法统一各处散落的 region 回退逻辑,
与 kiro.rs 的 region 语义对齐。
2026-02-21 16:54:03 +08:00
fawney19 f470ab6ec8 fix: 刷新限额失败时异常消息为空则回退到异常类名 2026-02-21 15:47:22 +08:00
fawney19 414f4e40c7 refactor: Claude 适配器改用认证头区分 CLI/Chat 模式
将 build_claude_adapter 的判断依据从 x-app 自定义头改为标准认证头:
Bearer token (无 x-api-key) 走 CLI 模式,x-api-key 走 Chat 模式。
移除 ClaudeCliAdapter.get_cli_extra_headers 中不再需要的 x-app 头注入。
2026-02-21 15:39:59 +08:00
fawney19 bb7d393128 feat(body_rules): 支持通配符路径、范围索引、name_style action 和 $item 条件引用
- 路径语法新增 [*] 通配符(遍历所有元素)和 [N-M] 范围索引
- 新增 name_style action,支持 snake_case/camelCase/PascalCase/kebab-case/capitalize 风格转换
- condition 支持 $item.xxx 引用通配符当前元素,实现逐元素条件过滤
- 前端同步更新类型定义、表单 UI 和帮助文档
- 提取 isBodyRuleEffective 函数消除重复的规则有效性判断逻辑
2026-02-21 14:59:37 +08:00
fawney19 05177dffb3 修复数据库迁移脚本 2026-02-21 13:26:30 +08:00
fawney19 0fe5346f4d refactor: 提取 HandlerAdapterBase 基类,新增 api_family/endpoint_kind 结构化维度
- 从 ChatAdapterBase 和 CliAdapterBase 提取公共逻辑到 HandlerAdapterBase,
  消除头部处理、异常处理、计费策略等重复代码
- Usage 记录链路全程透传 api_family / endpoint_kind / provider_api_family /
  provider_endpoint_kind 四个结构化字段,替代从 api_format 字符串解析
- 删除冗余的 ClaudeCliNormalizer、GeminiCliNormalizer、ClaudeCliResponseParser、
  GeminiCliResponseParser,改用 data_format_id 回退机制自动复用 Chat 版本
- build_endpoint_url 签名统一扩展 request_data / model_name 参数
- 新增 Alembic 迁移,含历史数据回填
2026-02-21 13:17:11 +08:00
fawney19 54988916e3 fix: 统一 OAuth 重复账号检查逻辑,活跃账号一律拒绝覆盖
移除 Kiro 活跃重复账号的特殊放行,所有 Provider 类型行为一致:
失效账号允许覆盖,活跃重复账号拒绝并提示已存在。
2026-02-21 03:58:26 +08:00
fawney19 a1d972419d feat: Codex 请求转换引入 patch_for_variant 快速路径,Kiro 重复账号允许覆盖
- FormatNormalizer 新增 patch_for_variant 可选方法,同格式 + variant 场景
  跳过 internal 往返,直接在原始请求体上做最小补丁
- OpenAICliNormalizer 实现 Codex variant 快速路径,registry 优先尝试
- Codex request_patching 补充 stream=true、parallel_tool_calls=true、
  移除 previous_response_id
- Kiro 重复账号从拒绝改为允许覆盖(用户重新导入同一账号场景)
- Kiro Key 命名增加 auth_method 后缀,提取 _build_kiro_key_name 辅助函数
- Kiro token refresh 错误日志从 debug 提升为 warning,响应体截取增至 500 字符
2026-02-21 03:53:29 +08:00
fawney19 457fe83a4f fix(openai_cli): 支持非 function 类型 tool 的透传还原,保留 prompt_cache_key 字段 2026-02-21 02:32:32 +08:00
fawney19 314e4a497d feat: 拆分 usage 记录的请求体/响应体为客户端侧与提供商侧
将 request_body/response_body 语义明确为客户端原始请求体和提供商原始响应体,
新增 provider_request_body(格式转换后发给提供商的请求体)和 client_response_body
(格式转换后返回给客户端的响应体),支持跨格式转换场景下分别查看两侧数据。

- 数据库新增 provider_request_body/client_response_body 及对应压缩字段
- 全链路(telemetry/recording/handler/stream_context)传递新字段
- 维护调度器同步支持新字段的压缩与清理
- 前端请求详情抽屉支持请求体/响应体/响应头的客户端/提供商视图切换
2026-02-21 01:56:28 +08:00
fawney19 4c5dac603f refactor: 将流式转换逐次 debug 日志替换为流结束时的汇总日志 2026-02-20 22:04:46 +08:00
fawney19 d1724caab4 refactor: 引入 ExportMixin 统一模型导出逻辑,修复配置获取与 thinking 检测
- 新增 ExportMixin,基于排除列表自动收集可导出字段,应用于 Provider/Endpoint/GlobalModel/Model/ProviderAPIKey
- 修复系统配置获取:当 key 存在默认值时不再抛出 404
- 导出配置时增加 API Key / auth_config 解密失败的日志警告
- 修复 Gemini normalizer 的 thinking 检测,优先读取 internal.thinking 标准路径
2026-02-20 21:37:40 +08:00
fawney19 37758c2032 refactor: 完善跨格式 normalizer 转换精度,统一 CLI 流式 buffer flush 逻辑
- OpenAI normalizer: 修正 file/image 内容块的标准格式解析与输出,
  assistant 有 tool_calls 时 content 输出 null,非流式 tool_calls
  不再包含 index 字段,保留 system_fingerprint/service_tier roundtrip
- OpenAI CLI normalizer: 支持 reasoning/ThinkingConfig 双向转换,
  补全 parallel_tool_calls/required tool_choice,input_file 解析,
  function_call_output.output 强制字符串化,流式事件补全 item_id/
  output_index/content_index 字段
- Gemini normalizer: 扩展 finishReason 映射,自动检测 tool_use
  stop_reason,保留 safetySettings/cachedContent/generationConfig
  额外字段 roundtrip,error_from_internal 使用精确 HTTP status code
- Claude normalizer: 修正 tool_choice type="tool" 输出,扩展
  extra 提取白名单
- internal.py: 为所有 dataclass 补充跨格式映射文档和修改须知
- stream_bridge: aggregator 新增 open_count/final_count 诊断属性,
  build() 时 flush 未关闭的 open blocks
- CLI handler: 提取 _flush_buffer_with_conversion 统一 prefetch/
  stream 两条路径的 buffer + SSE parser flush 逻辑
- upstream_stream_bridge: 新增事件类型计数和聚合器状态诊断日志
- 新增 fixtures 和测试: roundtrip/to_internal/cross_format/error/
  stream 等多维度转换测试
2026-02-20 19:45:14 +08:00
fawney19 d7f0a555c0 fix(openai_cli): 完善 Responses API 响应格式,对齐 CLI 客户端协议要求
- 流式事件添加 sequence_number 递增序号
- output_text 内容补充 annotations 空数组
- 补充 content_part.added/done 和 function_call_arguments.done 事件
- 非流式响应回显原始请求字段(instructions/tools/reasoning 等)
- 补充 input_tokens_details/output_tokens_details 用量明细
- 响应结构添加 background/error/incomplete_details 等缺失字段
2026-02-19 22:43:04 +08:00
fawney19 5629edf487 refactor: 引入模块钩子系统,解耦认证逻辑,支持模块/normalizer/parser 自动发现
- 新增 HookDispatcher 钩子分发器,支持 FIRST_RESULT 和 COLLECT_ALL 两种策略
- LDAP 认证逻辑从 AuthService 移至 ldap 模块钩子实现
- Management Token 前缀认证从 pipeline 硬编码改为模块钩子注册
- src/modules/ 改为自动扫描子目录发现 ModuleDefinition
- normalizers 和 parsers 注册改为基于类属性自动发现
- OpenAI CLI 增加 /v1/responses/compact 端点和并行 tool_call 支持
- OpenAI CLI normalizer 支持 Chat Completions 格式自动回退
- Codex 适配器增加 compact 模式上下文传递和 header 调整
- HeaderBuilder 改进非 latin-1 字符处理(UTF-8 字节透传)
- Gunicorn 增加 graceful_timeout 防止僵尸进程
2026-02-19 21:26:18 +08:00
fawney19 7a81e56553 refactor: 统一 API 格式显示函数,补全 Usage/Trace 的 provider 链路信息
- 提取 formatApiFormat 为共享工具函数,替换各组件中分散的 API_FORMAT_LABELS 调用
- Trace API 返回密钥认证类型(key_auth_type)和 OAuth 套餐信息(key_oauth_plan_type)
- 请求时间线展示密钥认证方式标签(OAuth/Vertex AI/Kiro 等)
- Usage 记录补充 provider_id/endpoint_id/key_id,避免 curl 复现时缺失 provider 信息
- curl 复现兜底从 RequestCandidate 表查找 provider 信息
- Headers Diff 面板左右独立滚动并同步垂直滚动位置
2026-02-19 14:51:08 +08:00
fawney19 0d2cafaec3 refactor: 增强跨格式转换系统,支持 thinking/文件/音频/响应格式等完整转换
- 新增 ThinkingConfig/ResponseFormatConfig/FileBlock/AudioBlock 内部表示
- 实现 OpenAI reasoning_effort <-> Claude thinking <-> Gemini thinkingConfig 互转
- 支持 OpenAI web_search_options -> Claude web_search tool 转换
- 新增异步 convert_request_async,在转换阶段解析图片 URL 为 base64
- 将 GlobalModel.output_limit 传播至跨格式转换用于 max_tokens 默认值
- 前端模型表单增加最大输出 Token 和上下文窗口配置
- Claude normalizer 保留 cache_control 透传(system 数组和 content block)
- Gemini normalizer 支持内置工具(googleSearch/codeExecution/urlContext)
- OpenAI normalizer 补全采样参数、response_format、usage details 转换
- 各 normalizer 工具方法提取至基类消除重复代码
- 简化 Kiro model mapping 为直接透传
- 预置模型列表添加 claude-sonnet-4.6
2026-02-19 10:40:49 +08:00
fawney19 11f2ddbba2 chore: 将 Gunicorn workers 默认值从 4 调整为 2 2026-02-18 18:36:31 +08:00
fawney19 34f4cca1d2 fix(guide): 移除 URL 输入框多余的提示文字 2026-02-18 18:17:56 +08:00
fawney19 18b1dea8cf refactor(guide): 重构教程文档页面结构和布局
- 将 ProviderGuide/ModelGuide/UserKeyGuide 替换为 ArchitectureGuide/ConceptsGuide/StrategyGuide
- GuideLayout 迁移至 AppShell 组件,统一侧边栏导航和移动端菜单
- Overview 页面重写内容结构
- 更新路由配置和导航配置项
- homeGuard 添加 /guide 路径支持
2026-02-18 18:17:14 +08:00
fawney19 63870931af refactor: 调度器迁移至独立模块,消除 services->api 反向依赖
- 将调度器相关模块从 src/services/cache/ 迁移到 src/services/scheduling/
- 下沉类型定义到 core 层: AccessRestrictions, ProviderAuthInfo, ParsedChunk/StreamStats, 视频工具函数
- 提取 thinking_cache 签名缓存到 core/api_format/conversion/
- 提取 provider 认证逻辑到 services/provider/auth
- 提取遥测记录到 services/usage/telemetry
- 提取 models 列表缓存到 services/cache/model_list_cache
- 更新所有引用方的 import 路径及相关测试
2026-02-16 11:00:48 +08:00
fawney19andAAEE86 4dc401677d fix(system-settings): 修复定时任务时间选择器截断及添加取消按钮
Co-Authored-By: AAEE86 <[email protected]>
2026-02-15 20:19:35 +08:00
fawney19 25046d7c98 feat(system-settings): 添加右侧悬浮目录导航
为系统设置页面添加 TOC 侧边栏,支持点击跳转和滚动高亮,
使用 IntersectionObserver 实现滚动监听,圆点指示器标记当前章节。

Co-authored-by: AAEE86 <[email protected]>
Closes #177
2026-02-15 19:44:03 +08:00
fawney19 a7bc7f25b4 refactor: 引入 ExportMixin 统一配置导出,补全导入缺失字段
- 新增 ExportMixin 基于排除列表自动收集可导出列,新增字段无需修改导出代码
- GlobalModel/Model/Provider/ProviderEndpoint/ProviderAPIKey 混入 ExportMixin
- 导出逻辑改用 to_export_dict(),消除 GlobalModel N+1 查询
- 导入逻辑补全 provider_type、auth_config、body_rules、format_acceptance_config 等字段
2026-02-15 17:14:14 +08:00
fawney19 1c16b77a92 refactor: 拆分调度器为独立子模块,增强 Sub2API 多认证方式支持
调度器重构:
- 将 CacheAwareScheduler 拆分为 candidate_builder、candidate_sorter、
  concurrency_checker、restriction_checker、scheduling_config、schemas、utils 等独立模块
- 删除旧的 _candidate_builder.py 和 _candidate_sorter.py
- 新增调度并发拒绝 Prometheus 指标

Sub2API 架构增强:
- 支持账号密码登录和 Refresh Token 两种认证方式
- 实现 JWT 自动刷新和 Token Rotation 持久化
- 前端 ProviderAuthDialog 支持多认证方式切换和 credentials_schema 动态渲染
- 验证接口返回 updated_credentials 以同步轮换后的 token

其他改进:
- 并发管理器增加 RPM guard 和动态预留逻辑
- RequestCandidate 支持 mark_skipped 附加 extra_data
- TaskService 增强健壮性
- 补充相关单元测试和契约测试
2026-02-15 16:32:23 +08:00
fawney19 8a670f5524 refactor: 继续拆分大型模块并增强模块注册健壮性
后端:
- chat_handler_base 错误处理函数提取到 chat_error_utils 子模块
- CLI mixin 引入 CliHandlerProtocol 协议类改善类型标注
- aware_scheduler 拆分为 _candidate_builder 和 _candidate_sorter 子模块
- usage recording 拆分为 _billing_integration 和 _recording_helpers 子模块
- ModuleRegistry 添加循环依赖检测,将写操作从查询方法分离到 reconcile_module_state
- 修正 plugin manager 入度注释

前端:
- 路由守卫逻辑拆分为独立 guards 模块
- ProviderManagement 拆分为 TableHeader/TableRow/BalanceCell/MobileCard 子组件
- SystemSettings 拆分为多个 Section 子组件和 composables
- 提取 useEndpointStatus/useProviderBalance/useProviderFilters composables

测试适配重构后的子模块结构
2026-02-14 20:06:38 +08:00
fawney19 676e918edc feat(provider-ui): 支持供应商备注内联编辑
在供应商列表中添加备注的内联编辑功能,点击备注文字或"添加备注"
即可直接编辑,支持 Enter 保存、Escape 取消和点击外部自动取消。
桌面端和移动端均已适配。
2026-02-14 17:34:44 +08:00
fawney19 6ea33c6bb8 refactor: 拆分职责、引入 dataclass 封装并增强缓存健壮性
- ErrorClassifier 副作用操作分离为 ErrorHandlerService(缓存失效、健康记录、RPM 调整)
- chat_handler_base 提取 ProviderRequestResult dataclass 和 _prepare_provider_request 方法
- failover 提取 AttemptErrorOutcome dataclass 和辅助方法
- formula_engine 拆分 _resolve_mapping 为子方法,增加求值异常日志
- usage recording 引入 UsageCostInfo dataclass 封装成本参数
- 前端 types.ts 拆分为 types/ 子模块
- cache backend 工厂函数加锁防止并发重复创建,LocalCache 容量检查修正
- CacheSync 监听增加断线重连机制,publish 增加重试
- guide 页面修正 useSiteInfo() 调用顺序
2026-02-14 16:34:52 +08:00
fawney19 26ede849e2 refactor: 拆分大型模块为 mixin/子模块结构
- cli_handler_base.py 拆分为 7 个 mixin (event/monitor/prefetch/request/sse_helpers/stream/sync)
- usage/service.py 拆分为 6 个子模块 (types/active_requests/cache_analysis/lifecycle/pricing/query/recording)
- models/database 拆分为独立模型文件 (auth/misc/model/provider/stats/usage/user)
- DUMMY_THOUGHT_SIGNATURE 常量提升到 core/api_format/conversion/constants 统一管理
- task/service.py 内联导入提升为顶层导入
- 流处理函数签名移除冗余的 http_client 参数
2026-02-14 12:55:58 +08:00
fawney19andLewisPen f464f32e48 feat: 支持系统设置中自定义全站名称和副标题
新增 site_name 和 site_subtitle 两个系统配置项,允许管理员在后台自定义
站点品牌名称(默认 Aether)和副标题(默认 AI Gateway)。

- 后端:DEFAULT_CONFIGS 新增配置项,公开 /api/public/site-info 端点
- 后端:邮件发送 app_name 回退链增加 site_name
- 前端:新增 useSiteInfo composable 全局缓存站点信息
- 前端:首页、后台布局、登录页、指南页面全部改为动态读取
- 前端:系统设置页新增「站点信息」配置区块
- 前端:配置导出文件名跟随站点名称
- 优化:请求失败允许重试,保存后即时生效无需刷新页面
- 优化:document.title 随站点名称动态更新

Closes #176

Co-authored-by: LewisPen <[email protected]>
2026-02-13 21:50:05 +08:00
fawney19 c171d65d3f Merge pull request #175 from AAEE86/dev
fix: Kiro 导入授权时无法判断重复
2026-02-13 21:31:06 +08:00
fawney19 d01caaa4b1 refactor(provider-ui): 调整供应商信息布局,网站和描述展示方式互换
- 详情抽屉:移除描述内联编辑,网站地址改为独占整行显示完整 URL
- 列表/卡片视图:网站改为名称旁的 ExternalLink 图标,新增描述文本展示
- 移除不再使用的 formatWebsiteDisplay 工具函数和描述编辑相关代码
2026-02-13 21:27:30 +08:00
fawney19 941e599d36 feat(provider-ops): 新增 Sub2API 架构支持并优化余额分项显示
- 新增 Sub2ApiArchitecture 和 Sub2ApiBalanceAction,支持 Sub2API 风格中转站
- 前端 provider 列表支持 balance + points 分项显示
- 验证弹窗适配 Sub2API 余额和积分的分开展示
2026-02-13 21:03:21 +08:00
fawney19 d6b39babf5 refactor(provider-ops): 重构认证配置为 schema-driven 模式
后端架构类通过 get_credentials_schema() 返回带 x-* 扩展字段的 JSON Schema,
前端根据 schema 动态渲染表单、构建请求、验证和格式化显示。
新增架构只需后端一个文件,前端零改动。

主要变更:
- 删除前端手写模板文件(anyrouter.ts, cubence.ts, nekocode.ts, new-api.ts, yescode.ts)
- 新增 schema-utils.ts 实现 schema 到表单的转换、请求构建、验证和格式化
- 新增 field-hooks.ts 支持 schema 声明式字段联动钩子
- 后端 base.py 提供 parse_verify_response 默认实现,减少子类重复代码
- 各架构余额查询逻辑复用 balance.py 中的通用函数
- 删除废弃的 one_api.py 架构,新增架构 hidden 属性
- API 返回 credentials_schema 供前端消费
2026-02-13 16:41:30 +08:00
AAEE86 3f68821663 fix: Kiro 导入授权时无法判断重复
- 将 _fetch_kiro_email() 移到 _check_duplicate_oauth_account() 之前
2026-02-13 14:43:23 +08:00
fawney19 7e68882872 style: 修正 PR #174 引入的 import 排序问题 2026-02-13 14:18:51 +08:00
fawney19 e06f6c1935 Merge pull request #174 from hemo94931/dev
fix(conversion): 修复 openai cli tool 调用空 call_id 导致上游失败
2026-02-13 14:17:48 +08:00
fawney19andLewisPen 599a62d722 fix: 补充 OpenAI CLI normalizer 图片格式双向转换
- 将 _image_url_to_block 从 OpenAINormalizer 提升到基类 FormatNormalizer
- OpenAI CLI normalizer 新增 ImageBlock 的解析和输出支持
- 修复 Chat -> CLI 跨格式路由时图片内容丢失

Co-Authored-By: LewisPen <[email protected]>
2026-02-13 13:59:57 +08:00
fawney19 d690dcadc7 refactor: 修复候选分页逻辑并拆分配额检查模块
- list_all_candidates 返回 provider_batch_count,区分"无候选"与"无 Provider"
  避免分页在有 Provider 但无候选时提前终止
- 将配额检查逻辑从 aware_scheduler.py 拆分到 quota_skipper.py
- 提取 reorder_candidates 方法,支持跨页汇总后全局重排序
- CandidateResolver 分页循环改用 provider_batch_count 判断终止
- candidate extra_data 中新增 mapping_matched_model 字段
- 新增契约测试和分页行为测试
2026-02-13 13:28:40 +08:00
hemo94931 e80bcabccf fix(conversion): 修复 openai cli tool 调用空 call_id 导致上游失败
- 在 FormatConversionRegistry.convert_request 前统一修复 InternalRequest 中空的 tool_id/tool_use_id

- 为空 ID 自动生成 call_auto_N,并将 tool_result 关联到最近待匹配的 tool_call

- 修正 OpenAICliNormalizer 中 function_call_output 的内部角色为 USER,确保输出 openai:chat 时保留 tool_call_id

- 新增 openai:cli <-> openai:chat 空 call_id 回归测试,覆盖工具调用关联场景
2026-02-13 12:32:29 +08:00
fawney19 4c83780b5e chore: 调整 Nginx 代理超时配置并移除废弃的 concurrency_slot_ttl
- proxy_connect_timeout 从 600s 降至 60s
- proxy_send_timeout/proxy_read_timeout 从 600s 升至 3600s 以支持长时间流式响应
- 移除已无引用的 concurrency_slot_ttl 配置项
2026-02-12 11:57:42 +08:00
fawney19 be430ebdde perf: 降低 lru_cache 上限并限制流式响应块内存占用
- 缩减 model_permissions / tiktoken / formula_engine 的 lru_cache maxsize
- StreamUsageTracker 响应块增加 4MB 大小限制,超限后只计数不存储
- raw_chunks 改用 deque(maxlen=50) 避免无限增长
- HardwareTooltip 导入路径修正、tooltip 延迟归零、文案中文化
2026-02-12 11:41:42 +08:00
fawney19andAAEE86 483d536e2c perf: cherry-pick PR #172 性能优化(不含 orjson)
- tiktoken 编码器改为 @lru_cache 全局缓存,避免多实例重复初始化
- 前缀匹配按长度排序,避免短前缀抢先匹配
- AuthService 更新 last_used_at 时临时关闭 expire_on_commit,减少额外 SELECT
- UsageService.record_usage_batch 改用 bulk_insert_mappings 批量插入
- 已有记录查询增加 selectinload 预加载,避免 N+1
- Gemini normalizer 长行格式化

Co-Authored-By: AAEE86 <[email protected]>
2026-02-12 10:58:14 +08:00
fawney19 f80deea110 chore: bump aether-proxy version to 0.1.5 2026-02-11 23:35:23 +08:00
fawney19 6956536830 refactor: 将配置导入导出常量移至文件顶部 2026-02-11 23:27:48 +08:00
fawney19 01c725a2ee Merge pull request #170 from RWDai/fix-config-version
fix: 配置导入导出版本号统一常量管理,移除前端硬编码版本校验
2026-02-11 23:26:33 +08:00
fawney19 1c2a8119c0 feat: delegate 客户端替换为 hyper 原生实现,新增代理管理功能
aether-proxy:
- 用 hyper-util Client + 自定义 InstrumentedConnector 替换 reqwest delegate 客户端
- 支持 HTTP/HTTPS 自动 TLS,ALPN h2 协商,connect/tls 分阶段计时
- ConnectTiming 通过 hyper extensions 传递,上游响应细分 connect_ms/tls_ms/ttfb_ms
- upgrade 命令在非 root 下跳过 systemd restart 并提示手动操作

后端:
- 新增 /admin/proxy-nodes/test-url 接口,支持直接测试代理 URL 连通性
- 新增 /admin/proxy-nodes/hmac-key 接口,获取 HMAC Key 供部署使用
- 提取 _test_proxy_connectivity 公共函数,消除 test_node 中的重复代码
- candidate_resolver 在 extra_data 中输出 needs_conversion/provider_api_format
- stats_aggregator 小时聚合增加 IntegrityError 冲突重试

前端:
- 请求时间线组件展示代理 timing 细分(DNS/连接/TLS/TTFB/上游处理)
- 请求时间线增加格式转换分界标记和 conversion badge
- ProxyNodes 页面新增代理 URL 测试和 HMAC Key 复制功能
- HardwareTooltip 从 Popover 改为 Tooltip 组件
2026-02-11 23:24:59 +08:00
fawney19 943ca79951 chore(aether-proxy): bump version to 0.1.4 2026-02-11 17:29:37 +08:00
fawney19 62c05093a0 fix(docker): 统一 max-requests 默认值为 4000
PR #171 中 max-requests 的 fallback 默认值 (2000) 与 jitter 计算的默认值 (4000) 不一致,统一为 4000
2026-02-11 17:26:22 +08:00
fawney19 d71d4e24be Merge pull request #171 from AAEE86/dev
refactor(docker): 优化 Gunicorn 配置并提升代码可读性
2026-02-11 17:21:19 +08:00
fawney19 37e0785775 fix(frontend): HardwareTooltip 组件健壮性重构
- hardware_info 支持 string/object/null 类型的安全解析
- 用 computed 统一提取展示行,无硬件信息时显示提示文案
- 增加 button 的 title/aria-label 属性
2026-02-11 17:17:53 +08:00
fawney19 0ddeccf698 feat(aether-proxy): delegate gzip body 流式透传替代读取解压
- gzip 压缩请求体直接流式转发给上游,避免全量读取和解压
- 非 gzip 请求保持原有读取逻辑
- 移除 body_read_ms/decompress_ms 指标,新增 passthrough 标记
- 前端 timing 展示兼容旧版字段
- 同步更新 Cargo.lock
2026-02-11 17:17:45 +08:00
AAEE86 875ecca527 refactor(docker): 优化 Gunicorn 配置并提升代码可读性
使 Gunicorn 的 max-requests 参数可通过环境变量 MAX_REQUESTS 配置,
并自动计算 max-requests-jitter (默认为 MAX_REQUESTS 的 1/20),
同时统一规范化 Dockerfile 中多行字符串的缩进格式
2026-02-11 17:14:32 +08:00
RWDai 3a5d687f08 fix: 配置导入导出版本号统一常量管理,移除前端硬编码版本校验 2026-02-11 17:06:06 +08:00
fawney19 257077a59b chore: bump aether-proxy version to 0.1.3 2026-02-11 16:39:07 +08:00
fawney19 9647d95759 feat: delegate 协议改为 header 元数据 + gzip 压缩 body 直传
- delegate wire format 从 JSON body 改为 HTTP headers 传递元数据
  (X-Delegate-Method/Url/Headers),上游请求体 gzip 压缩后直传,
  减少跨国代理传输耗时
- Rust 侧新增 gzip 解压(含 decompression bomb 防护 50MB 上限)
- 升级模块从 GitHub API asset 下载改为公开 release URL 直链,
  GITHUB_TOKEN 变为可选
- 前端适配新 timing 字段,展示压缩率与 wire_size/body_size
- CI release notes 改用 generate_release_notes 自动生成
2026-02-11 16:34:18 +08:00
fawney19 2436ce45a2 chore: bump aether-proxy version to 0.1.2 2026-02-11 15:35:28 +08:00
fawney19 2a2a4b817e feat: 代理 timing 细分指标与前端展示优化
- aether-proxy 新增 auth_ms/body_read_ms/body_parse_ms/body_size 计时字段
- 链路追踪面板代理耗时改为分阶段展示,密钥和代理信息改为堆叠布局
- 硬件信息从 Tooltip 改为 Popover,新增 FD Limit 展示
- 新增区域代码格式化工具函数,统一显示区域中文名称
2026-02-11 15:33:01 +08:00
fawney19 d51234f202 fix(frontend): 链路追踪打开时自动选中进行中的尝试而非最后一个未执行的 2026-02-11 13:35:02 +08:00
fawney19 7abe7f1a87 chore: 更新 README 链接与清理 generate_keys 冗余输出
- 修正 aether-proxy README 中 Releases 为完整 URL,优化描述文案
- 移除 generate_keys.py 中不再需要的 hmac_key 配置提示
2026-02-11 13:24:28 +08:00
fawney19 2849a9bce5 fix(frontend): 密钥列表分页控件固定在底部 2026-02-11 12:57:52 +08:00
fawney19 2aee8b8c2c Merge pull request #169 from AAEE86/master
feat: 调度器根据上游配额自动跳过已耗尽的 Key (Kiro/Codex/Antigravity)
2026-02-11 12:55:52 +08:00
AAEE86 e77c28b89b feat: 调度器根据上游配额自动跳过已耗尽的 Key (Kiro/Codex/Antigravity)
在 _check_key_availability 末尾增加 _is_key_quota_exhausted 检查,读取 ProviderAPIKey.upstream_metadata 中各 Provider 的配额信息,配额耗尽时跳过该 Key 并返回可读的跳过原因。同时修正同函数内 logger 调用为 loguru {} 占位符风格。
2026-02-11 12:53:40 +08:00
fawney19 ed3f208dda feat(aether-proxy): 网络层全面优化与代理耗时追踪
aether-proxy:
- 新增 20+ 可配置参数:Aether API/delegate/CONNECT 各阶段超时、连接池、TCP keepalive/nodelay
- AetherClient 支持指数退避重试(可配置次数/延迟)和 HTTP/2
- 新增 DNS 缓存(TTL + 容量限制)避免重复解析
- 新增并发连接数限制(Semaphore,默认基于硬件估算)
- CONNECT 隧道增加建连超时和升级超时
- TLS 增加握手超时、会话缓存(session ticket + memory cache)、ALPN 协商
- 新增 ProxyMetrics 收集请求计数和延迟,通过心跳上报
- delegate 响应注入 X-Proxy-Timing 头(dns_ms/upstream_ms/total_ms)

后端:
- StreamContext 和 handler 提取 X-Proxy-Timing 写入 proxy_info 追踪数据

前端:
- 请求时间线展示代理分阶段耗时(DNS/上游)
- Endpoint 添加按钮改为文字按钮样式
2026-02-11 11:30:29 +08:00
fawney19 68f5e7e502 fix(frontend): 链路追踪打开时自动选中有效状态的节点
修复 Provider 组状态提升逻辑,之前仅在 success 时更新组状态,
导致组内有 streaming/failed 等状态的 key 时组仍显示为 available,
自动选择逻辑无法识别有效节点。引入状态优先级机制正确提升组状态,
并扩大 fallback 有效状态范围,避免默认选中未执行的节点。
2026-02-11 10:06:59 +08:00
fawney19 11b0026995 feat: OAuth 重复账号失效时允许覆盖更新,优化前端分页与刷新体验
- OAuth 去重逻辑改为:已失效的重复账号自动覆盖更新而非拒绝,
  适用于单个导入、批量导入、Kiro 导入等所有入口
- 新增 _update_existing_oauth_key 函数统一处理覆盖更新逻辑
- useSmartPagination 新增 fixedHeight 属性防止翻页时容器高度跳动
- ProviderDetailDrawer OAuth 刷新后仅重载 keys 数据避免整表刷新
2026-02-11 10:01:28 +08:00
fawney19 5cfae4f8f0 Merge pull request #168 from AAEE86/master
refactor(frontend): 优化模型/提供商组件的数据刷新逻辑
2026-02-11 09:20:35 +08:00
fawney19 bda03d187e refactor: 统一代理配置优先级链(key>provider>系统默认)并复用 HTTP 连接池
- 引入 resolve_proxy_param / build_proxy_client_kwargs 工具函数,统一
  httpx 客户端的代理+SSL+超时配置,替换各模块中零散的 get_ssl_context() 调用
- 所有涉及上游请求的模块(provider_query, usage replay, endpoint check,
  model fetch, OAuth, Vertex Auth, Gemini Files/Video 等)改用
  resolve_effective_proxy 按 key > provider > 系统默认优先级解析代理
- 流式请求改用 HTTPClientPool.get_upstream_client 复用连接池,移除各处
  http_client.aclose() 避免关闭共享客户端
- StreamProcessor._cleanup 不再关闭池中客户端,仅清理响应上下文
- 前端 EndpointFormDialog 增加 body_rules 帮助说明 Popover
- Mock handler 补充 OAuth 字段、endpoint extras 及新增 mock 路由
2026-02-11 03:16:53 +08:00
AAEE86 0fa15604dc refactor(frontend): 优化模型/提供商组件的数据刷新逻辑
- ModelDetailDrawer: 移除 handleMappingsUpdate 中冗余的 refreshRoutingData 调用,路由刷新改由 @refresh 事件统一处理
- ModelMappingsTab: saveMappings 成功后始终 emit update 和 refresh 事件,确保数据一致性
- BatchAssignModelsDialog: 为批量添加/导入操作增加 try-catch 错误处理,部分操作失败时仍通知父组件刷新
- ProviderDetailDrawer: 移除 modelsTabRef 直接调用子组件 reload 的模式,统一通过 emit('refresh') 由父组件协调刷新
2026-02-11 03:09:26 +08:00
fawney19 cd0acf1e6b fix: OAuth 账号去重改用 user_id 替代 account_id,避免同 team 成员误判重复 2026-02-11 01:13:01 +08:00
fawney19 9e183a2033 Merge pull request #167 from AAEE86/dev
feat: Codex/Kiro 配额自动刷新增加 5 分钟过期检查
2026-02-11 01:10:19 +08:00
AAEE86 76e8e48400 fix(frontend): 移除 Dashboard onMounted 中重复的 daily-stats 请求
TimeRangePicker 初始化时已通过 watch immediate 触发数据加载,
无需在 onMounted 中重复调用 loadDailyStats()
2026-02-11 01:03:09 +08:00
fawney19 3d6d4a48a5 feat: body_rules 支持 condition 条件触发
为每条 body_rule 新增可选 condition 字段,支持 eq/neq/gt/lt/gte/lte/
starts_with/ends_with/contains/matches/exists/not_exists/in/type_is
共 14 种操作符,规则仅在条件满足时执行。

后端: 新增 _evaluate_condition 条件评估器与 _validate_condition 校验逻辑
前端: EndpointFormDialog 增加条件编辑行(IF 面板)与 Filter 按钮切换
测试: 新增 TestConditionalBodyRules 覆盖全部操作符及链式触发场景
2026-02-11 00:50:08 +08:00
AAEE86 5d0240e675 fix(frontend): TimeRangePicker 防止重复触发 update:modelValue
- 添加 lastEmittedValue 记录上次 emit 的值
- 提取 buildEmitValue 构建 emit 数据
- 使用 getValueKey 比较核心字段,忽略 timezone 等动态值
- 外部设置值时同步更新 lastEmittedValue 避免回环触发
2026-02-11 00:37:15 +08:00
AAEE86 cabaa9f1df refactor(frontend): 统一 routing 数据加载,消除子组件重复请求
将 Model 和 Provider 详情抽屉中的 routing/mapping 数据加载逻辑
从各子组件(RoutingTab、ModelMappingsTab、ModelsTab、ModelMappingTab)
提升到父组件统一管理,通过 props 向下传递数据,避免多个子组件独立发起相同的 API 请求,提升页面加载性能。
2026-02-10 23:47:47 +08:00
AAEE86 347bd3345c feat: Codex/Kiro 配额自动刷新增加 5 分钟过期检查
- Codex: 新增 Token 即将过期检查 + 配额数据过期检查
- Kiro: 新增配额数据过期检查
- 三个 OAuth Provider 的自动刷新逻辑现在完全一致
2026-02-10 21:12:31 +08:00
fawney19 262bc6e1f7 feat: body_rules set 操作支持 {{$original}} 占位符引用原值
后端 request_builder 新增递归检测与解析逻辑,完全匹配时保留原始类型,
部分匹配时转为字符串拼接。前端 EndpointFormDialog 增加 sentinel 替换
机制,使含占位符的值能通过 JSON 校验,并在提交时还原为 {{$original}}。
2026-02-10 21:01:45 +08:00
fawney19 e06152b58b Merge pull request #166 from AAEE86/dev
fix: Claude SSE 流式输出补充 event 类型行
2026-02-10 18:56:21 +08:00
AAEE86 d8addec077 fix: Claude SSE 流式输出补充 event 类型行 2026-02-10 18:42:50 +08:00
fawney19 5456596b29 style: 优先级管理弹窗间距与样式微调 2026-02-10 18:32:51 +08:00
fawney19andAAEE86 8949ad6d9e feat: 动态 block 索引分配、OAuth 自动启停联动与 Antigravity 签名注入增强
- Gemini/OpenAI normalizer 改为按实际出现顺序延迟分配 thinking/text block 索引
- OAuth 失效标记时自动停用 Key,清除/刷新成功时自动启用
- Antigravity thought signature 注入支持 tool-specific 签名优先,再回退 session 级签名
- Claude normalizer 对非字符串 thinking block 降级为 UnknownBlock
- Gemini normalizer 反序列化时为 Antigravity 目标补充 signature
- signature_cache _prune 增加超限驱逐
- model/provider 包 __init__ 改为惰性导入避免循环依赖
- build_antigravity_url 复制 query_params 防止修改调用方原始字典
- upstream_fetcher build_all_format_configs 兼容非 EndpointFetchConfig 对象
- Antigravity HTTP 状态判定扩展与 Gemini endpoint check 支持 v1internal
- 新增 thought signature 注入与 signature cache 驱逐测试

Closes #165

Co-authored-by: AAEE86 <[email protected]>
2026-02-10 17:53:04 +08:00
fawney19 0bb17a1502 chore: Docker 基础镜像从 Python 3.14 降级到 3.13 2026-02-10 17:41:36 +08:00
fawney19 0c52341dec feat: TTFB 追踪记录与前端展示 + 缓存调度器代码整理
- CLI handler 记录上游请求 TTFB 并注入 proxy_info
- 前端请求时间线展示 TTFB 耗时
- 调度器提升局部 import 到模块顶部,提取 _affinity_hash/_merge_restriction_sets 辅助方法
- 修复缓存亲和性写入的缩进层级
- Python 版本回退至 3.13
2026-02-10 17:37:53 +08:00
fawney19 4276e7835c refactor: 自适应 RPM 从边界记忆改为多次观察确认 + 置信度衰减
将自适应 RPM 限制算法从"单次 429 即设限"重构为基于置信度的学习机制:

- 收到 429 时仅记录观察(本地 RPM + 上游 header 限制值),不立即设限
- 有 header 的观察需 2 次一致确认,无 header 需 3 次一致确认
- confidence 随时间自然衰减,确保限制永远不会固化
- confidence 低于阈值时停止本地 RPM 限制,429 直接透传客户端
- 统一 get_effective_limit 入口,消除多处重复的限制获取逻辑
- Admin API 增加置信度、观察数量等诊断字段
2026-02-10 14:58:15 +08:00
fawney19 fc1ad44346 feat: Antigravity 上游模型按 tier 排序逐个获取与前端单 Key 查询
- 后端 Antigravity Provider 获取上游模型改为按 tier/可用性排序后逐个尝试,成功即停止
- 新增 Provider 级别缓存避免重复请求
- 前端编辑 Key 允许模型时改为传入当前 Key ID 查询对应可用模型
2026-02-10 11:24:22 +08:00
fawney19 91cf93d133 fix: Antigravity function_call/function_response 驼峰转换与 thinking budget 非正值过滤
Close #161
2026-02-10 10:34:28 +08:00
fawney19 457de0919a Merge pull request #163 from AAEE86/master
fix: Antigravity 跨格式转换链 tool call ID 完整传递
2026-02-10 10:27:43 +08:00
AAEE86 d4fd3518bf fix: Antigravity Claude tool ID 匹配与 thinking budget 输出约束
- 重构 _inject_claude_tool_ids_request 采用两遍扫描算法:
  * 第一遍收集所有 functionCall 的 ID(按 name 分组)
  * 第二遍为 functionResponse 从对应队列中取出匹配的 ID
  * 解决多工具调用场景下 ID 匹配错乱问题

- 增强 _process_thinking_budget 确保 maxOutputTokens > thinkingBudget:
  * 新增 OUTPUT_OVERHEAD/OUTPUT_OVERHEAD_IMAGE 常量(对齐 AM)
  * 自动调整 maxOutputTokens 为 budget + overhead
  * 超过 MODEL_MAX_OUTPUT_LIMIT 时减少 budget 而非超限
2026-02-10 05:12:21 +08:00
fawney19 f9adf5938e feat: 请求记录 cURL 导出与回放功能
- 后端新增 /{usage_id}/curl 接口,重建完整 cURL 命令(含明文 API Key)
- 后端新增 /{usage_id}/replay 接口,支持向原始或指定提供商回放请求
- 回放支持 OAuth/Vertex AI/API Key 多种认证方式和跨格式请求体转换
- 前端新增 ReplayDialog 组件,支持选择目标提供商/Key 并查看响应
- RequestDetailDrawer 添加回放按钮和 cURL 复制按钮
- 调整 Tab 内容区布局,表头栏与内容区融为一体
2026-02-10 03:07:49 +08:00
AAEE86 5d36fd7f99 fix: Antigravity 跨格式转换链 tool call ID 完整传递
修复通过 Antigravity 适配器发送请求到 Claude 模型时 tool_use 块缺少必需 id 字段的问题。

根本原因:在跨格式转换链 (Gemini → Internal → Claude/Gemini) 中,
functionCall 和 functionResponse 的 id 字段没有被正确传递。

修复内容:
- internal.py: ToolResultBlock 增加 tool_name 字段,区分工具名称和 call id
- gemini.py: 读取/输出 functionCall 和 functionResponse 时保留 id 字段
- gemini.py: 流式响应中正确传递 tool_id
- envelope.py: 同时支持 camelCase/snake_case 两种命名风格
2026-02-10 02:50:45 +08:00
fawney19 9f5dd6f658 feat: 跨格式 thinking/reasoning 透传与 Antigravity 适配器增强
- 内部表示层新增 ThinkingBlock,统一 Claude thinking / Gemini thought / OpenAI reasoning_content
- Claude/Gemini/OpenAI/OpenAI CLI normalizer 全面支持 thinking 内容的解析、流式处理和跨格式转换
- schema_utils 重写为完整的 JSON Schema 清洗逻辑($ref 展开、allOf 合并、anyOf 折叠、白名单过滤)
- Antigravity envelope 新增模型别名映射、Google Search 注入、thoughtSignature 注入、图像生成配置解析

Close #161
2026-02-10 02:05:35 +08:00
fawney19 de40bd4705 fix: Claude API 模型列表查询兼容 Bearer Token 认证
第三方代理可能使用 Bearer Token 而非 x-api-key 认证,
在请求头中同时发送两种认证方式以提高兼容性。
2026-02-10 01:01:05 +08:00
AAEE86 6c5c41e8ea fix: 修复 Antigravity beta 参数和 Kiro 403 状态处理
- Antigravity: 移除 v1internal 请求中不支持的 beta 查询参数
- Kiro: 将 403 状态码统一视为账户异常,而非仅检测特定关键词
2026-02-10 00:54:43 +08:00
AAEE86 bd9c264375 fix: 修复 Antigravity 反代 systemInstruction oneof 冲突
当客户端发送 snake_case 格式的 system_instruction 字段时,
_inject_system_instruction 函数会额外设置 camelCase 格式的
systemInstruction,导致 Gemini API 报错 oneof 字段冲突。

现在在注入前先统一 snake_case 到 camelCase,确保只有一个字段存在
2026-02-10 00:09:14 +08:00
fawney19 1d625916ff refactor: 模块管理支持内置工具展示
将邮件配置、IP 安全、审计日志等系统功能从固定菜单移至模块管理页面,以内置工具形式统一展示
2026-02-09 22:52:46 +08:00
fawney19 80d65ad2e4 fix: Claude CLI 模型列表使用正确的 Bearer 认证方式 2026-02-09 22:45:21 +08:00
fawney19 b11e9de6a7 feat: 全局模型新增活跃 Provider 数量统计
- 后端优化查询,使用条件聚合同时获取总 Provider 数和活跃数
- 前端展示格式改为「活跃数/总数」,便于了解可用 Provider 情况
2026-02-09 22:26:30 +08:00
AAEE86 7b28680cda fix: Kiro 导入支持区分 Social 和 IdC 登录方式
修改 _check_duplicate_oauth_account 函数的重复检查逻辑:
- Kiro 使用 email + auth_method 组合判断重复
- 同一邮箱通过 Social 和 IdC 两种方式登录视为不同账号
- 移除冗余的 profile_arn 单独检查
- 其他 OAuth Provider 保持原有逻辑(仅 email 判断)
2026-02-09 21:57:50 +08:00
fawney19 853c489471 refactor: 优化智能分页检测和密钥复制交互
- 智能分页统一使用防抖检测避免与 ResizeObserver 双重触发
- 密钥名称仅在非空时启用复制功能
- 移除 API Key 不必要的危险字符校验
2026-02-09 19:05:29 +08:00
fawney19 c6cd7d5ae6 Merge pull request #159 from AAEE86/master
refactor: 优化智能分页 DOM 检测机制并添加密钥名称复制功能
2026-02-09 19:04:19 +08:00
AAEE86 8e120e2665 refactor: 优化智能分页 DOM 检测机制并添加密钥名称复制功能
- useSmartPagination 使用 ResizeObserver 替代 nextTick 确保 DOM 稳定后检测
- 添加防抖机制避免频繁触发检测
- 组件卸载时清理 ResizeObserver 资源
- ProviderDetailDrawer 密钥名称支持点击复制
2026-02-09 19:01:14 +08:00
fawney19 46ff120ab2 fix: 优化 Alembic 迁移脚本的列存在性检查
修复端点数据迁移到 provider 时的 SQL 执行逻辑,动态检查列是否存在后再构建 UPDATE 语句,避免在列不存在时引用导致迁移失败。
2026-02-09 18:40:08 +08:00
fawney19 7f7f569148 refactor: 优化优先级管理和路由展示功能
- 支持按 API 格式设置不同的全局优先级,替换单一 global_priority 字段为 global_priority_by_format
- 路由标签根据调度模式动态显示(缓存亲和/负载均衡/固定顺序)
- 优先级管理对话框 UI 优化:紧凑布局、活跃/停用状态分组排序、快捷启用/禁用开关
- 优先级管理对话框包含所有 Key(含停用的),支持快速切换状态
- 优化配置更新流程:先保存优先级数据,再切换调度模式,避免瞬态不一致
- 调度相关配置缓存 TTL 从 60 秒降至 5 秒,确保多 Worker 快速收敛
- 配置更新时立即同步当前 Worker 的 Scheduler 状态
2026-02-09 18:21:34 +08:00
fawney19andAAEE86 b14fb31933 feat: 新增 Antigravity/Kiro 账户禁用封禁状态监控与展示
- 前端:为 Antigravity 和 Kiro 上游元数据添加禁用/封禁状态字段
- 前端:在密钥详情抽屉中展示账户禁用/封禁警告信息,合并重复的时间格式化函数
- 后端:优化 Antigravity 和 Kiro 适配器的状态检测与上报逻辑

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

Closes #158
2026-02-09 17:24:48 +08:00
fawney19 65658e58d5 refactor: 移除 API Key 最小长度限制并放宽最大长度至 10000 字符 2026-02-09 17:00:48 +08:00
fawney19 c5d1d2c21e fix: Nginx 白名单路由补充 v1beta 和 upload 路径前缀 2026-02-09 16:45:56 +08:00
fawney19 f22d2efb9d refactor: 优化 CI 缓存隔离与镜像标签策略,重构 Nginx 为白名单路由模式
- CI: 按 scope 隔离 base/app 构建缓存,app 层使用 no-cache-filters 确保前端每次重建
- CI: 镜像标签改为 semver 优先,支持 pre/fix 预发布标签,启用 latest=auto
- Nginx: 从 try_files + @backend 回退模式改为白名单路由(api/v1/health → 后端,其余 → SPA)
2026-02-09 16:04:54 +08:00
fawney19 d0b9dc7a24 feat: 非 custom 提供商新建密钥时默认开启自动获取上游模型
- 前端 KeyFormDialog 根据提供商类型自动设置 auto_fetch_models 默认值
- OAuth 创建密钥时传入 auto_fetch_models=True
- OAuth 完成、refresh token 导入、批量导入后自动触发模型获取
2026-02-09 13:50:27 +08:00
fawney19 0da34f729e refactor: 优化提供商管理界面交互与术语,增强 OAuth 编辑体验
- 模型映射对话框统一术语为"客户端模型/提供商模型",修复编辑时已有映射不可见的问题
- 密钥表单包含/排除规则改为双列布局
- OAuth 密钥编辑对话框增加 dirty 状态跟踪,关闭时提示未保存更改
- 调整密钥操作按钮顺序,代理节点配置移至编辑按钮前
- preset_models.py 添加 ModelsFetcherFunc 类型别名
2026-02-09 13:24:42 +08:00
fawney19 57f3ed71a1 Merge pull request #157 from AAEE86/master
feat: 新增 OAuth 密钥专用编辑对话框 & 统一预设模型管理
2026-02-09 13:02:13 +08:00
fawney19 34cdc26e6f fix: 修复候选调度中 priority 为 0 时被错误回退为默认值的问题
使用显式 is not None 判断替代 or 运算符,避免优先级为 0 时被当作 falsy 值处理
2026-02-09 12:58:27 +08:00
fawney19 8684548072 refactor: 用 EndpointFetchConfig 纯数据类替代 ORM 对象传递,统一上游模型缓存管理
- 引入 EndpointFetchConfig dataclass 替代直接传递 ProviderEndpoint ORM 对象,
  避免 DB session 关闭后 DetachedInstanceError
- 新增 build_format_to_config() 统一构建 api_format -> EndpointFetchConfig 映射
- KeyAllowedModelsDialog 改用 useUpstreamModelsCache composable 管理上游模型获取
- useUpstreamModelsCache 增加 error 字段透传部分格式获取失败的 warning
- 删除废弃的 queryProviderUpstreamModels API 函数
- ProviderCandidate 添加 __lt__ 方法支持排序比较
2026-02-09 12:47:47 +08:00
AAEE86 ea5509f864 feat: 新增 OAuth 密钥专用编辑对话框 & 统一预设模型管理
- 新增 OAuthKeyEditDialog 组件,支持编辑 OAuth 账号的名称、备注、优先级、RPM 限制、缓存 TTL、熔断探测等配置
- OAuth 账号现在也支持自动获取模型功能,包含模型过滤规则配置
- 移除 OAuth 密钥编辑/权限按钮的 v-if 限制,统一操作入口
- 新增 preset_models.py 模块,统一管理 Kiro/Codex 等无 /v1/models 端点的预设模型
- 重构 Kiro 和 Codex 插件,改用统一的 create_preset_models_fetcher 工厂函数
2026-02-09 12:44:32 +08:00
fawney19 8702786fa2 feat: 模型获取支持 CLI 端点回退,同族优先 chat 后降级 cli 2026-02-09 11:37:41 +08:00
fawney19 8673ed1459 refactor: 优化 kiro token 刷新逻辑和 build_all_format_configs
- kiro auth: 拆分无缓存 token 和占位符 key 的判断逻辑,避免不必要的解密操作
- kiro auth: 简化 effective_token 获取逻辑
- build_all_format_configs: 只对实际配置了端点的格式构建请求配置,
  不再用某个端点的 base_url 尝试其他未配置的格式
- get_adapter_for_format: 简化为单行表达式
- 修复 f-string 日志为 loguru 风格占位符
- 新增 build_all_format_configs 单元测试
2026-02-09 10:11:38 +08:00
fawney19 1b8e73bb5c Merge pull request #156 from AAEE86/master
fix(kiro): 修复请求时 403 bearer token invalid 错误
2026-02-09 09:39:42 +08:00
AAEE86 6ffdd38c3a fix(kiro): 修复测试模型时 403 bearer token invalid 错误
问题:
Kiro 测试模型时返回 403,原因是 get_provider_auth() 直接使用 key.api_key 作为 access_token,但新导入的 key 其 api_key 是占位符,且当 expires_at 未设置时不会触发刷新。

修复:
1. 如果没有缓存的 access_token 或 api_key 是占位符,强制刷新
2. Kiro 类型优先使用 auth_config 中缓存的 access_token
2026-02-09 03:44:13 +08:00
fawney19 a1c5aa4e04 Merge pull request #155 from AAEE86/master
feat: 删除 getKeyDisplayName 前端统一使用 key.name 显示
2026-02-09 03:06:26 +08:00
AAEE86 d2f2d7f92d feat: 删除 getKeyDisplayName 前端统一使用 key.name 显示 2026-02-09 03:02:36 +08:00
fawney19 3e1a046140 fix(kiro): 导入时将获取到的 email 写回 auth_config 以确保持久化 2026-02-09 03:01:13 +08:00
fawney19 35b57542d1 Merge pull request #154 from AAEE86/master
fix(kiro): 统一 Kiro 导入时的 name 字段为邮箱格式
2026-02-09 02:58:11 +08:00
fawney19 cfa768eebd feat: 请求体规则扩展(append/insert/regex_replace)、OAuth 代理节点支持与列表分页
- 请求体规则新增 append、insert、regex_replace 三种操作,路径语法支持数组索引
- OAuth 授权/导入/批量导入支持指定代理节点(proxy_node_id),Key 级代理避免 IP 污染
- 密钥列表、模型映射、模型列表添加智能分页(useSmartPagination)
- AdvancedGuide 新增请求体规则使用指南与示例
- 简化 Codex enrich_codex 实现,README 添加 QQ 群二维码
2026-02-09 02:56:21 +08:00
AAEE86 dc76721d2c fix(kiro): 统一 Kiro 导入时的 name 字段为邮箱格式
- 删除 _generate_kiro_key_name() 函数
- 新增 _fetch_kiro_email() 通过 getUsageLimits API 获取邮箱
- Kiro 导入时 name 字段与 Codex/Antigravity 保持一致,使用邮箱
- 获取邮箱失败时 fallback 到 "账号_{timestamp}" 格式
2026-02-09 02:40:21 +08:00
fawney19andAAEE86 2f8af7c96b fix(codex): 移除未使用的 user_id 字段,修复请求上下文未清理的问题
Co-Authored-By: AAEE86 <[email protected]>
2026-02-09 01:11:24 +08:00
AAEE86 3153c60291 fix: Codex 反代添加 chatgpt-account-id 请求头
通过 contextvars 将 OAuth 认证配置中的 account_id 从 wrap_request()传递到 extra_headers(),修复实际请求时缺少 chatgpt-account-id 头导致的错误。
2026-02-09 01:07:38 +08:00
fawney19andAAEE86 b34dd12863 feat: 新增 Kiro 适配器、OAuth 改进与多项功能增强
- 新增 Kiro provider 适配器(EventStream 协议解析、令牌管理、用量追踪)
- 重构 OAuth 账户管理与统一配额机制
- 重构 Handler 基类(CLI adapter/handler、请求构建器、流处理器)
- 增强缓存监控后端 API 与前端可视化
- 改进 Gemini 格式标准化器与请求头处理
- Antigravity/Codex 适配器更新,移除旧 metadata_collector
- 新增数据库迁移:proxy provider API keys
- 前端 UI 多项优化

Co-Authored-By: AAEE86 <[email protected]>
2026-02-09 01:05:48 +08:00
fawney19 e324ffdcd6 fix(proxy): 修复连通性测试调用 build_hmac_proxy_url 参数不匹配
- 移除 _build_test_proxy_url 中多余的 node.id 位置参数,与函数签名保持一致
2026-02-08 17:14:56 +08:00
fawney19 183e7c60e1 refactor(proxy): 简化 HMAC 认证、移除明文 HTTP 代理并优化 setup 流程
- HMAC 签名移除 node_id,仅使用 timestamp,消除重注册时的认证竞态问题
- 删除 plain HTTP forward proxy(plain.rs),仅保留 CONNECT 隧道和 delegate,非支持方法返回 405
- 提取共享 BoxBody 类型和 empty_box_body() 到 proxy/mod.rs
- CLI 解析重构为 clap 原生 subcommand,启用 subcommand_negates_reqs
- setup 向导返回 SetupOutcome 枚举,支持保存后自动启动 proxy
- setup TUI 增加未保存变更的退出确认(pending_quit)
- validate_target 改为 async,使用 tokio::net::lookup_host 避免阻塞 DNS
- 显式初始化 rustls ring CryptoProvider
- ConfigFile 新增 inject_env_override() 用于 setup 后重载配置
- Python 端 HMAC 签名同步移除 node_id,缓存时间桶从 120s 调整为 240s
2026-02-08 16:10:13 +08:00
fawney19 f07cae540e feat: aether-proxy 自升级功能
新增 `aether-proxy upgrade [version]` 命令,支持从 GitHub Releases
下载指定或最新版本的二进制,校验 SHA256 后原子替换当前二进制,
并在 systemd 服务运行时自动重启。
2026-02-08 14:54:10 +08:00
fawney19 abd85f777f fix: aether-proxy 作为 systemd 服务启动时跳过自身活跃检测,避免无限重启 2026-02-08 14:19:48 +08:00
fawney19 519ad67eb1 refactor: 代理节点架构重构与功能增强
aether-proxy:
- 重构 main.rs,拆分为 app/state/hardware/net 模块
- setup.rs 拆分为 setup/tui.rs + setup/service.rs,支持 systemd 服务管理子命令
- 新增 delegate 端点,支持后端通过代理节点转发请求而非传统 CONNECT 代理
- 注册时上报硬件信息(CPU/内存/fd_limit)和估算最大并发数
- 心跳上报活跃连接数,支持远程下发 node_name 配置
- HTTP 转发时剥离 X-Forwarded-* 等敏感头部
- 切换到 rustls-tls,降低日志级别减少噪音

后端:
- 从 http_client.py 提取代理相关逻辑至 proxy_node/resolver.py
- 从 routes.py 提取业务逻辑至 proxy_node/service.py
- handler 支持 delegate 模式(通过代理节点 HTTP 端点转发而非 CONNECT 隧道)
- ProxyNode 模型新增 hardware_info 和 estimated_max_concurrency 字段

前端:
- 新增 HardwareTooltip 组件展示节点硬件信息
- 远程配置支持下发 node_name
2026-02-08 13:33:08 +08:00
fawney19 254d30d32d feat: 请求候选记录中追踪代理节点信息并在前端 timeline 展示 2026-02-08 01:18:00 +08:00
fawney19 4a1029961a feat: 请求日志和 usage 记录中追踪代理节点信息
- 新增 resolve_proxy_info/get_proxy_label 提取脱敏的代理摘要
- StreamContext 增加 proxy_info 字段,流式/非流式全路径写入 metadata
- ProxyNode 缓存补充 name 字段,日志输出代理节点标识
- aether-proxy 连接和转发日志从 debug 提升为 info 级别
- TaskService 代理不可用日志补充 node_id 细节
2026-02-08 00:49:43 +08:00
fawney19 5384ffd403 feat: aether-proxy TLS 双栈支持与自签名证书自动生成
- aether-proxy 新增 TLS 模块:自签名证书生成、TLS acceptor 构建、证书 SHA-256 指纹计算
- 代理服务器支持 HTTP+TLS 双栈模式,通过 peek 首字节区分 TLS ClientHello 与普通 HTTP
- 注册与心跳上报 tls_enabled 和 tls_cert_fingerprint 字段
- Python 侧 httpx 代理适配:TLS 代理使用 httpx.Proxy + CERT_NONE ssl_context
- ProxyNode 模型新增 tls_enabled/tls_cert_fingerprint 字段及对应迁移
2026-02-07 23:18:58 +08:00
fawney19 10bd14c223 fix: generate_keys 补充 PROXY_HMAC_KEY 生成,CLI handler 增加 ReadError 容错
- generate_keys.py 新增 PROXY_HMAC_KEY 生成并输出 aether-proxy.toml 配置示例
- .env.example 完善 PROXY_HMAC_KEY 注释说明
- cli_handler_base.py 两处流式处理增加 httpx.ReadError 异常捕获,
  代理连接中断时与 RemoteProtocolError 保持一致的降级处理
2026-02-07 20:35:25 +08:00
fawney19 31bc452374 feat: aether-proxy 远程配置下发、连通性测试与 setup TUI
- 后端新增远程配置管理 API (PUT /config) 和连通性测试 API (POST /test)
- 前端新增远程配置编辑对话框和节点连通性测试按钮
- aether-proxy 支持通过心跳接收并热加载远程配置 (端口白名单、日志级别、心跳间隔、时间戳容差)
- aether-proxy 新增 TOML 配置文件支持和交互式 setup TUI
- aether-proxy 心跳 404 时自动重注册节点
- plain proxy 响应改为流式传输,减少内存缓冲
- 新增 remote_config 和 config_version 数据库字段及迁移
2026-02-07 19:20:09 +08:00
fawney19 3b8398b2e5 fix: deploy.sh 容器未运行时自动启动,避免无变更时跳过启动 2026-02-07 14:53:46 +08:00
fawney19 02906dad0e fix: 增强代理密码脱敏策略并补充异常日志
- 密码脱敏阈值从 4 提升至 8,短密码全部遮蔽
- 为系统代理获取、proxy_node 解析、代理 URL 构建添加 warning 日志
2026-02-07 14:37:44 +08:00
fawney19 db96c9a46e feat: 手动代理节点支持、系统默认代理与跨格式流式 usage 提取
代理节点:
- 支持手动添加代理节点(HTTP/HTTPS/SOCKS5),含地址、认证信息和区域标签
- 新增手动节点的 CRUD API 和前端管理界面
- 提供商代理配置从 URL 字符串迁移至代理节点选择器(proxy_node_id)
- 新增系统默认代理节点设置,未单独配置代理的提供商自动回退使用
- 删除节点时自动清除系统默认代理引用并失效缓存
- 健康检查跳过手动节点(无心跳,始终在线)

流式处理:
- CLI handler 跨格式转换时委托基类解析 Provider 原始事件的 usage
- StreamProcessor 新增 _extract_usage_from_converted_event 从转换后事件补充提取 usage
- 支持 Claude/OpenAI/OpenAI Responses/Gemini 多种 usage 格式
2026-02-07 14:30:46 +08:00
fawney19 86dbbe83c2 fix(ci): vendored OpenSSL for cross-compilation
cross 的 Docker 镜像缺少 libssl-dev,改用 native-tls-vendored 静态链接 OpenSSL
2026-02-07 12:52:25 +08:00
fawney19 6d95822e19 fix(ci): macOS amd64 改用 macos-latest 交叉编译
macos-13 (Intel) runner 已被 GitHub 下线
2026-02-07 12:52:25 +08:00
fawney19 01653efa84 ci: 添加 aether-proxy 多平台自动编译与 README 文档
- 新增 GitHub Actions workflow,推送 proxy-v* tag 自动编译 5 个平台
  (linux-amd64, linux-arm64, macos-amd64, macos-arm64, windows-amd64)
- 编译产物自动发布到 GitHub Releases 并生成 SHA256 校验文件
- 新增 aether-proxy README,包含部署、配置、后台运行等完整说明
2026-02-07 12:52:25 +08:00
fawney19 1180634269 feat: ProxyNode 代理节点管理系统与 OpenAI Responses API 解析增强
ProxyNode 系统:新增 aether-proxy(Rust)海外 VPS 代理组件,后端实现节点注册/心跳/
HMAC 认证/健康检测调度器/模块化集成,前端新增代理节点管理页面。ProxyConfig 支持
node_id 模式,http_client 支持 HMAC 签名代理 URL 构建与 TTL 缓存。

OpenAI CLI 解析器:适配 Responses API 格式,支持 input_tokens/output_tokens 提取、
output[].content[].text 文本解析、response.completed 流式事件 usage 嵌套结构。
2026-02-07 12:52:25 +08:00
fawney19 62f852b851 feat: Antigravity 端点签名迁移至 gemini:chat,流式 usage 提取重构与请求详情自动刷新
- Antigravity 端点签名从 gemini:cli 统一为 gemini:chat,保留向后兼容,含 DB 迁移
- 流式处理中 usage/completion/text 提取抽离为 _update_ctx_from_provider_event,
  支持 envelope 解包后提取,避免格式转换流程中重复计数
- 新增 has_format_conversion 属性,区分真正的格式转换与 envelope rewrite,
  修正 usage 展示层的格式转换标记
- 请求详情抽屉对未完成请求支持自动轮询刷新,关闭时自动停止
- 按次计费样式调整;实际成本仅在倍率非 1.0 时显示;缓存日志降级为 trace
2026-02-07 02:59:02 +08:00
fawney19 ea22f07046 test: 添加 OpenAI chat completions 测试脚本 2026-02-06 23:07:23 +08:00
fawney19 18d0af3dbf feat: 格式转换支持三层开关控制(全局/Provider/端点)
- _get_convertible_formats 始终返回所有可转换格式,由下游精确过滤
- get_compatible_provider_formats 增加 Provider 级别 enable_format_conversion 判断
- Provider 设置更新后自动失效相关缓存(模型列表、解析缓存、Provider 缓存)
- 前端 toggleFormatConversion 使用接口返回的完整对象更新本地状态
2026-02-06 23:01:56 +08:00
fawney19 a1ea060cd9 fix: 流式响应上游连接异常时优雅降级,避免丢失 usage 和 telemetry
上游 httpx.StreamClosed/HTTPError 发生时,若已向客户端输出数据则
best-effort flush 残留 SSE 并标记 502 结束流,保证 StreamingResponse
背景任务正常执行;若尚未输出则 re-raise 以触发 failover。
2026-02-06 21:57:05 +08:00
fawney19 8b6a5d3824 feat: OAuth 导入导出、提供商筛选、Gemini 图像生成支持与流式处理增强
- OAuth: 支持通过 Refresh Token 导入账号(文件拖拽/粘贴),OAuth Key 可导出为 JSON
- OAuth: 所有 OAuth 端点添加 require_admin 鉴权
- 提供商管理: 新增状态/API格式/模型三级筛选,后端返回 global_model_ids
- Gemini: 新增图像生成模型适配(finalize_provider_request 钩子 + envelope 跳过不兼容字段)
- 流式处理: buffer 残留数据 flush 与 token 兜底估算
- 上游元数据: 提取 merge_upstream_metadata,配额耗尽模型保留与深度合并
- Antigravity 配额: 无 quotaInfo 时视为耗尽,移除 Other 兜底分组
- README: 新增升级备份与回滚指南
2026-02-06 21:52:22 +08:00
fawney19 62dae22a2c fix: 流式响应健壮性增强与候选排序稳定性修复
- test-model 接口优先使用流式请求,失败自动回退非流式
- CLI 流式处理在 StreamClosed/RemoteProtocolError 时 flush 残余 SSE 数据,捕获尾部 usage
- 流未正常完成时兜底估算 tokens,避免 usage 记录为 0
- ProviderCandidate 添加 __lt__ 解决 tuple 排序 TypeError
- sorted() 添加显式 key 参数避免隐式比较候选对象
- 异常日志改用 logger.opt(exception=e) 替代手动 traceback
2026-02-06 18:06:38 +08:00
fawney19 b88fb6273b refactor: Antigravity/Codex 服务重构为插件化适配器架构
- 将 Antigravity 和 Codex 从独立模块迁移至 src/services/provider/adapters/ 插件体系
- 新增 provider_types 和 oauth_token 模块,移除 maintenance_scheduler 中的 OAuth 定时刷新
- 增强 admin API:扩展 keys 和 provider_query 端点,新增 dashboard 路由
- 大幅增强 ProviderDetailDrawer 组件,新增 AntigravityQuotaDialog
- 改进 handler 基类(chat/cli)和错误分类器
- 优化 fetch_scheduler 和 upstream_fetcher
- 前端 UI 组件清理和优化
- 更新测试以匹配新模块结构
2026-02-06 16:37:06 +08:00
fawney19 e01dfee41d feat: body rules 支持嵌套路径并增强前端验证体验
- 后端 apply_body_rules 支持点号分隔的嵌套路径(如 metadata.user.name)
- 支持 \. 转义字面量点号(如 config\.v1.enabled)
- 配置导出导入升级至 v2.2,新增 SystemConfig 支持
- 前端请求体规则编辑器改用 JSON 格式输入,增加实时验证指示
- 用量记录表格新增移动端卡片视图,优化筛选器响应式布局
2026-02-05 20:13:41 +08:00
fawney19 a9b24c9161 fix: Codex 刷新限额功能优化
- 修复并发协程中直接操作 SQLAlchemy Session 的问题,改为收集后统一更新
- 抽取硬编码模型名为 CODEX_QUOTA_REFRESH_MODEL 常量
- 前端刷新限额前添加确认对话框,提示会产生 API 调用费用
- 修正 loguru 日志格式为 {} 占位符风格
2026-02-05 18:09:40 +08:00
AAEE86 93090e227f feat: Codex 提供商批量刷新限额功能 (#147)
- 后端 (keys.py):新增 POST /providers/{provider_id}/refresh-quota API,向 Codex 提供商的所有活跃 Key 发送测试请求,从响应头解析限额信息并更新数据库
- 前端 (ProviderDetailDrawer.vue):在 Codex 类型提供商的密钥管理区域添加"刷新限额"按钮,带加载动画和结果提示
- 前端 API (keys.ts):新增 refreshProviderQuota 接口调用及 RefreshQuotaResult 类型定义
- 其他:description 字段允许 null,公开 ensure_collectors_registered 函数
2026-02-05 18:09:14 +08:00
fawney19 5b9ba06af6 feat: base image hash 改用依赖指纹,上游流式策略改为三态按钮
CI 和 deploy.sh 的 base image hash 计算从整文件 cat 改为
tomllib 提取 pyproject.toml 依赖指纹,避免仅改注释或工具配置
触发不必要的 base 重建;前端将上游流式策略从 Select 下拉改为
头部三态循环按钮(跟随请求/固定流式/固定非流),点击即保存。
2026-02-05 17:20:34 +08:00
fawney19 8ad53b9490 fix: 修复配置导入时 api_formats 与 endpoint 格式比较不一致的 bug
原代码使用 .upper() 转大写与小写的 endpoint_formats 比较,
导致所有格式都被误判为"未配置对应 Endpoint"。
改为统一使用 normalize_signature_key 归一化后比较。
2026-02-05 16:51:52 +08:00
fawney19 b35ddcba3f feat: 限制新建提供商类型为自定义和 Codex
新建模式下只显示自定义和 Codex 两个选项,编辑模式保留所有类型以兼容已有数据
2026-02-05 16:34:28 +08:00
fawney19 5be7813ab8 refactor: 将访问令牌功能迁移至模块系统
- 新增 management_tokens 模块定义,支持通过环境变量控制可用性
- 从静态路由中移除 management-tokens,改由模块动态注册
- 前端路由添加模块激活检查,未激活时重定向到仪表盘
- 认证服务添加模块激活检查,未激活时禁止令牌认证
- 导航菜单中移除访问令牌入口(保留直接 URL 访问)
- 修复 alembic 迁移脚本格式和错误处理
2026-02-05 16:32:27 +08:00
fawney19 440721368f feat: Antigravity 和 Codex 服务支持
- 新增 Antigravity 服务:签名缓存、URL 可用性检测、信封处理
- 新增 Codex 服务:信封处理、元数据收集器
- 重构 provider transport 支持新的服务架构
- 新增 stream_bridge 和 upstream_stream_bridge 处理流式响应
- 优化 OAuth 工具函数
- 添加相关测试用例
2026-02-05 15:59:41 +08:00
fawney19 ed2ff5c1d7 feat: 性能监控基础设施、解密缓存及计费简化
- 新增 PerfRecorder 性能记录工具,支持采样率与慢请求日志
- 在请求管道中埋点:auth、body_read、json_parse、context_build、authorize、handle
- 流处理器增加 parse/conversion 耗时追踪与 perf_metrics 落库
- 解密服务添加 LRU 缓存,降低高频解密 CPU 开销
- 格式转换分层开关设计:全局 OFF 时回退到端点配置,而非一刀切拒绝
- 移除 shadow billing 模块,统一使用新计费引擎
- 新增 Codex 网关请求适配器(store=false、role 映射、include 补齐)
- endpoint 创建接口支持 body_rules 参数
2026-02-05 14:22:11 +08:00
fawney19 e72e5370c4 refactor: 提取 is_codex_url 到公共 utils 模块
将重复的 Codex URL 判断逻辑提取到 src/utils/url_utils.py,
避免 transport.py 和 adapter.py 中的代码重复。
2026-02-05 11:17:00 +08:00
fawney19 bb125a1ddc Merge pull request #146 from AAEE86/master
fix: Codex 端点 URL 使用 /responses 而非 /v1/responses
2026-02-05 11:15:08 +08:00
AAEE86 fecf48e180 fix: Codex 端点 URL 使用 /responses 而非 /v1/responses
Codex OAuth 端点(chatgpt.com/backend-api/codex)不走标准 /v1 前缀,之前 build_provider_url() 统一使用 /v1/responses 导致 404 错误。

修改内容:
- transport.py: build_provider_url() 对 Codex URL 做特判,使用 /responses
- EndpointFormDialog.vue: placeholder 对 Codex 端点显示正确路径
- guide-config.ts: 文档说明两种路径格式
2026-02-05 09:48:37 +08:00
fawney19 9c19a72d67 refactor: 移除 KeyFormDialog 中的 OAuth 认证逻辑
OAuth 流程已由独立的 OAuthAccountDialog 处理,移除 KeyFormDialog 中冗余的
OAuth 表单、授权流程 UI 和状态管理代码。同时清理 provider_oauth.ts 中不再
使用的接口和函数,修复 ProviderOAuthCompleteRequest 重复声明问题。
2026-02-05 02:56:33 +08:00
fawney19 d9d3a1afcf feat: 提供商详情头部布局优化及行内描述编辑
- 重构详情抽屉头部:网站链接改为图标按钮,布局更紧凑
- 新增行内描述编辑功能,支持点击编辑、回车保存、Esc 取消
- 表单对话框移除描述字段,名称字段改为独占一行
- 修复次级限额重置时间的缩进问题
2026-02-05 02:23:41 +08:00
fawney19 34272af711 feat: 跨格式转换时按 ApiFamily 优先级排序端点
- 为 ApiFamily 枚举添加 priority 属性 (OpenAI=1, Claude=2, Gemini=3)
- 新增 _sort_endpoints_by_family_priority 函数按优先级排序端点
- 在调度器中对各分组内的端点应用优先级排序
- 修正测试文件的 type ignore 注解 (attr-defined -> method-assign)
- 新增 7 个端点排序相关的单元测试
2026-02-05 02:01:06 +08:00
fawney19 9a8f25d1a9 feat: OAuth Token 自动刷新调度、OAuth 对话框优化及 OpenAI CLI normalizer 重构
- 系统设置新增 OAuth Token 自动刷新任务开关,支持动态调度
- OAuth 授权对话框简化步骤布局,移除编号圆圈样式
- 重构 OpenAI CLI normalizer:将 if-else 链替换为事件处理器映射表,
  将 stream_event_from_internal 拆分为独立方法
- 维护调度器在禁用刷新时移除已调度的 job
- .gitignore 新增 CLIProxyAPI/ 和 sub2api/ 排除项
- 补充 noop/unknown 事件处理器的测试覆盖
2026-02-05 01:07:47 +08:00
fawney19 ba4d88e8ce Merge pull request #141 from AAEE86/dev
feat: 支持固定类型 Provider OAuth 授权
2026-02-05 00:08:58 +08:00
fawney19 73249f09e4 Merge remote-tracking branch 'origin/master' into dev
# Conflicts:
#	src/api/handlers/base/request_builder.py
#	src/api/handlers/openai_cli/adapter.py
#	src/services/system/maintenance_scheduler.py
2026-02-05 00:07:29 +08:00
fawney19 4d6e7c094f feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示
- 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查
- 增强端点健康检查器,支持更多检测策略
- 重构 codex 服务为 metadata_collectors 模块
- 优化 OpenAI CLI normalizer 代码结构
- 前端: 改进使用量表格、统计图表、指南页面和异步任务管理
- 扩展多个数据库字符串列为 TEXT 类型
- 新增倒计时 composable 和 provider OAuth API 端点
2026-02-04 23:59:45 +08:00
fawney19 24c9105628 feat: 扩展多个字符串列为 TEXT 类型并添加 tls-client 可选依赖
数据库迁移:
- 扩展 provider_api_keys.api_key 为 TEXT(OAuth tokens 可能很长)
- 扩展 ldap_configs 的 bind_dn、base_dn、user_search_filter 为 TEXT
- 扩展 oauth_providers.client_id 为 TEXT
- 添加 SQLite 兼容支持(batch 模式)
- 添加表/列存在性检查

依赖:
- 添加 tls-client 作为可选依赖 [tls]
2026-02-04 16:45:05 +08:00
fawney19 c996078f30 refactor(frontend): 优化提供商表单布局和固定类型端点管理
- 提供商表单调整字段顺序:提供商类型放到名称前面,描述和主站链接放在同一行
- 简化固定类型提示文本
- 固定类型 Provider 端点管理中隐藏删除按钮
- 移除多余的锁定提示文本
2026-02-04 16:44:26 +08:00
AAEE86 4c1ec66365 feat: OAuth 密钥编辑时显示到期时间和重新授权按钮
- 后端 EndpointAPIKeyResponse 新增 oauth_expires_at 字段
- 从加密的 auth_config 中解密并提取 OAuth Token 到期时间
- 前端编辑已授权的 OAuth 密钥时显示"重新授权"按钮
- 在按钮旁显示 Token 到期时间
2026-02-04 16:05:25 +08:00
Yorhaandfawney19 26a0f99f8f fix: 统一缓存token计费口径,避免OpenAI错误计费 (#144)
* fix: 统一缓存token计费口径,避免OpenAI错误计费

* fix: 添加 Gemini 缓存 token 归一化支持,优化代码结构

- Gemini 的 promptTokenCount 包含 cachedContentTokenCount,需要扣除
- 合并重复的 helper 函数为 _get_api_family()
- 将 import 移到文件顶部
- 补充 Gemini 测试用例

---------

Co-authored-by: fawney19 <[email protected]>
2026-02-04 16:04:09 +08:00
fawney19 f64631a3a3 feat: 导入上游模型时自动匹配或创建 GlobalModel
- 导入模型时按名称精确匹配已有的 GlobalModel
- 如果没有匹配到则自动创建新的 GlobalModel
- 导入完成后清除 /v1/models 缓存使模型参与路由
2026-02-04 15:28:57 +08:00
AAEE86 cb4407ba49 feat: 新增 Codex 上游兼容整流器
- 强制 store=false
- 确保 instructions 字段存在(缺失/None -> "")
- 强制 parallel_tool_calls=true,并确保 include 包含 reasoning.encrypted_content
- 删除 Codex 会拒绝的一些字段:max_output_tokens/max_completion_tokens/max_tokens/temperature/top_p/service_tier
- 将 input[] 里 role=system 的 message 改成 developer
- 在发送上游请求前注入整流(覆盖“同格式透传”和“跨格式转换”两条链路)
2026-02-04 15:09:51 +08:00
fawney19 bf4e2d7a88 Merge pull request #143 from hemo94931/master
fix: 修复一些测试模型相关的bug
2026-02-04 14:09:31 +08:00
fawney19 065b1caaf9 feat: support more request statuses in timeline 2026-02-04 13:32:42 +08:00
fawney19 2516460a0d fix: show cancelled requests in timeline 2026-02-04 13:26:33 +08:00
fawney19 d15b294c38 refactor: 重新设计定时任务 UI
- 使用模板化结构渲染定时任务列表,便于扩展
- 统一时间选择器样式:时钟图标 + 独立的时/分下拉框
- 优化卡片布局:开关、图标、标题、时间配置在同一行
- 未启用时图标无背景色,启用时显示主题色背景
- 额外配置项收纳在展开区域中
2026-02-04 13:14:11 +08:00
AAEE86 8c8a431428 feat: OAuth HTTP 请求添加重试机制和增强错误日志
- 新增 _redact_url 函数移除 URL 敏感参数
- 新增 _format_exc_chain 函数格式化异常链
- _httpx_post 对 ConnectError/TimeoutException 增加重试(最多 2 次)
- 增强日志输出:记录代理配置、主机、异常链等调试信息
2026-02-04 12:46:51 +08:00
hemo94931 44964916db fix: align openai cli default path 2026-02-04 12:18:39 +08:00
hemo94931 a80ddea93c fix: 修复测试规则保护头部解析 2026-02-04 12:18:39 +08:00
hemo94931 f1656c4960 fix: 测试模型中使用请求头和请求体规则修改请求 2026-02-04 12:18:39 +08:00
hemo94931 3b4ff95854 fix: OPENAI_CLI端点默认url修改 2026-02-04 12:18:39 +08:00
hemo94931 316d7055f5 fix: 修复测试模型时OPENAI_CLI请求包格式 2026-02-04 12:18:39 +08:00
fawney19 9ffe4b4ce8 Merge branch 'pr-140' 2026-02-04 12:17:46 +08:00
fawney19 3ffc6f21c4 refactor: 限制外部 API 仅接受 GlobalModel.name
移除调度器和模型映射器对映射名解析的支持,外部请求必须使用精确的
GlobalModel.name。同时新增 is_active 检查确保停用的模型被正确拒绝。

- 使用 get_global_model_by_name 替代 resolve_global_model_by_name_or_mapping
- 添加模型名称规范化(去除首尾空格)
- 统一检查 GlobalModel.is_active 状态
2026-02-04 11:49:47 +08:00
AAEE86 e4fdc65e52 feat: 支持固定类型 Provider OAuth 授权
- 新增 provider_type 字段区分自定义/预置 Provider 类型(claude_code/codex/gemini_cli/antigravity)
- 实现完整 OAuth 2.0 授权流程:start(生成授权 URL + PKCE)、complete(换取 token)、refresh
- 前端 KeyFormDialog 添加 OAuth 授权 UI,支持开始授权、粘贴回调 URL、完成授权、强制刷新
- 请求时自动检测 token 过期并刷新(120s 预留窗口 + Redis 分布式锁防并发)
- 固定类型 Provider 自动创建预置端点并锁定 base_url/custom_path
- 数据库迁移:添加 providers.provider_type,扩展 api_key 列为 TEXT
- 可选依赖 tls-client 用于 Claude token 请求的 TLS 指纹伪装
2026-02-04 10:24:25 +08:00
fawney19 f6dac1c38a feat: 请求入口提前创建 pending Usage 记录
在 chat/CLI handler 的流式和非流式请求入口处提前创建 pending 状态的
Usage 记录,让前端可以立即看到"处理中"的请求,提升用户体验。

- 新增 BaseMessageHandler._create_pending_usage() 方法
- ChatHandlerBase 流式/非流式入口调用
- CliMessageHandlerBase 流式/非流式入口调用
- 使用 try-except 包裹,创建失败不影响主流程
2026-02-04 03:27:58 +08:00
fawney19 7f09f191a1 feat: 优化用量状态同步机制和 OpenAI CLI 流式转换
- 增加状态回退保护,防止异步响应覆盖已知状态
- 添加轮询并发保护 (pollInFlight),避免重复请求
- 支持 cancelled 状态筛选和显示
- 前端 mergeRecordStatus 保护活跃记录状态
- 后端 streaming 状态同步更新改为使用当前 DB 会话
- OpenAI CLI 流式转换增加工具调用事件支持
- 轮询接口新增 target_model 字段返回
- 修复迁移脚本 inspector 缓存问题,改用 information_schema
2026-02-04 03:17:55 +08:00
fawney19 f3e2f84b38 feat: 统计数据优化 - 支持细粒度时间范围和多维度分析
- 新增 StatsHourly/StatsDaily 预聚合表,支持任意时区的精确统计
- 实现 UTC datetime 范围查询策略,边界数据实时聚合
- 新增统计 API:用户/API Key 维度、成本分析、性能百分位、错误分类
- 新增前端页面:成本分析、性能分析、用户统计
- 新增 TimeRangePicker 组件和统计可视化组件
- 优化 Dashboard 和 Usage 页面支持时间范围筛选

Close #135
2026-02-04 02:04:54 +08:00
YorhaL 5d94dcc94d fix: 移除用户配额重置任务中的角色限制 2026-02-04 00:56:04 +08:00
YorhaL 381fc6b2cf style: 统一定时任务布局 2026-02-04 00:38:02 +08:00
YorhaL bac0e7fb20 feat: 添加用户配额自动重置功能及相关配置 2026-02-04 00:38:02 +08:00
fawney19 6f363a7703 perf: 优化流式响应内存占用和正则预编译
- 添加 record_parsed_chunks 标志,仅在 FULL 日志级别时累积 parsed_chunks
- 预编译 CJK 和敏感信息正则表达式,避免重复编译
- 优化 header 脱敏循环,预计算 lowercase 集合
- 移除 consumer_streams.py 中未使用的 message_fields 变量
- 将 SystemConfigService import 移至文件顶部
2026-02-03 21:32:53 +08:00
fawney19 d6597121f5 feat: Provider 签到任务时间可配置
- 前端添加签到时间选择器(HH:MM 格式)
- 后端支持动态更新调度器执行时间
- 新增 reschedule_cron_job 方法支持运行时重调度
2026-02-03 19:03:55 +08:00
fawney19andCC ddf3f5c107 feat: 添加请求体规则功能和 UI 优化
- 添加 body_rules 支持(set/drop/rename 操作)
- 后端 apply_body_rules 函数处理请求体修改
- 保护 model/stream 字段不被修改
- 前端 UI 重构:合并请求头/请求体规则为统一区域
- 使用左边框颜色区分规则类型(H=主题色,B=灰色)

Closes #139

Co-authored-by: CC <[email protected]>
2026-02-03 18:49:17 +08:00
fawney19 00442c41fa feat: 视频计费增强与影子计费系统 2026-02-03 18:48:39 +08:00
fawney19 ed68aebfb0 refactor: 统一任务框架 Phase 3 - 用 TaskService/FailoverEngine 替代 FallbackOrchestrator
核心重构:
- 移除 FallbackOrchestrator,用 TaskService + FailoverEngine 替代
- TaskService 作为统一入口,支持 SYNC/ASYNC 两种任务模式
- FailoverEngine 实现候选遍历、重试、故障转移逻辑
- 新增 AttemptFunc/AttemptResult 协议,统一尝试结果表示

功能改进:
- 流式响应首字节探测(30s 超时,空流触发故障转移)
- 流式取消归因优化(区分客户端断连 vs 服务端中断)
- 新增 OpenAI Sora 视频取消路由 POST /v1/videos/{task_id}/cancel
- OpenAI 流式请求自动添加 stream_options.include_usage

代码规范:
- 修复 loguru 日志格式(%s → {})
- 新增 FORMAT_CONVERSION_ENABLED 环境变量说明

测试覆盖:
- test_failover_engine.py: FailoverEngine 单元测试
- test_task_service_async_execute.py: TaskService ASYNC 模式测试
- test_video_cancel_e2e.py: 视频取消端到端测试
2026-02-02 21:16:28 +08:00
fawney19 ebe1a8d3e3 fix: 修复测试文件兼容性问题
- 重命名 golden 测试数据文件,将 `:` 替换为 `_` 以兼容 Windows 文件系统
- 修复 test_compatibility.py 中的参数名 global_conversion_enabled -> effective_conversion_enabled
2026-02-02 10:37:28 +08:00
fawney19 26493ff0ab refactor: 移除迁移脚本中自动创建 video endpoint 的逻辑
- 不再从 openai:chat/gemini:chat 自动复制创建 video endpoint
- 不再自动补齐 api_formats 中的 video 变体
- 不再复制 rate_multipliers/global_priority_by_format 到 video
2026-02-02 09:16:57 +08:00
fawney19 63bae5a31c fix: 修复迁移脚本中 Inspector 缓存导致的 DuplicateColumn 错误
在 table_exists、column_exists、index_exists、unique_constraint_exists
函数中添加 inspector.clear_cache() 调用,确保每次检查时从数据库获取
最新的 schema 信息,避免因缓存过期导致重复添加已存在的列/表/索引。
2026-02-02 03:20:10 +08:00
fawney19 9e31efe26c refactor: 重构异步任务系统和计费服务架构
- 重构任务系统:新增 lifecycle (TaskStatus/BillingStatus)、context、application 模块
- 将 video tasks 泛化为 async tasks,支持更通用的异步任务管理
- 新增 Gemini Files 管理模块和管理界面
- 重构 billing 服务:拆分 schema.py 和 service.py
- 新增 candidate 服务模块用于请求候选管理
- 数据库迁移:添加 billing_status、request_id、gemini_file_mappings 表和索引
- 移除废弃的 video_telemetry、task orchestrator 等模块
2026-02-02 03:16:52 +08:00
fawney19 feb7484fda fix: 迁移脚本创建 video endpoint 时补充 created_at/updated_at 字段
修复 create_video_endpoints() 函数 INSERT 语句缺少时间戳字段导致的
NOT NULL 约束错误。
2026-02-01 17:36:48 +08:00
fawney19 4ac8e63c94 feat: 添加视频生成功能增强和多维度计费系统适配
- 视频生成: 增强 video_handler,重构 task_poller,新增 telemetry
- 计费系统: 适配新的 signature 格式,支持 video 任务类型回退
- 数据库迁移: 添加 api_family/endpoint_kind 字段和 video_formats
2026-02-01 17:28:27 +08:00
fawney19 7b66505634 refactor: 全局适配 ApiFamily/EndpointKind 结构化标识体系
将新的 (ApiFamily, EndpointKind) / `family:kind` 签名体系应用到整个代码库:
- API Handlers: 所有 adapter/handler 使用新的签名格式
- Services: provider, model, usage, cache, auth 等服务层适配
- Database: ProviderEndpoint 新增 api_family/endpoint_kind 字段
- Frontend: Provider 管理、Usage 表格等组件适配
- Tests: 更新所有相关测试用例
2026-02-01 17:28:00 +08:00
fawney19 c246ccfc91 refactor(api_format): 重构为结构化的 (ApiFamily, EndpointKind) 标识体系
将扁平的 APIFormat 枚举 (CLAUDE, OPENAI, GEMINI, *_CLI) 重构为二维结构:
- ApiFamily: 协议族 (openai, claude, gemini)
- EndpointKind: 端点变体 (chat, cli, video, image)

新增 EndpointSignature 数据类和 `family:kind` 签名键工具函数,
统一 JSON dict/metrics/logs 中的字符串标识格式。
2026-02-01 17:25:28 +08:00
fawney19 2101a957ce fix: 优化 Redis 连接超时的错误日志输出
将 Redis 超时和连接错误改为 WARNING 级别的简短日志,
避免系统休眠恢复时打印大量堆栈信息。
2026-01-31 20:10:42 +08:00
fawney19 97b15afe7c feat: 添加多维度计费系统和视频任务管理功能
计费系统:
- 新增 BillingRule 和 DimensionCollector 数据模型
- 实现 FormulaEngine 安全表达式求值引擎 (AST 白名单)
- 支持 dimension/matrix/tiered/constant 多种维度映射
- BillingRuleService 支持 Provider Model -> GlobalModel 规则回退
- CLI task_type 在计费域自动映射为 chat

视频任务增强:
- 添加 request_metadata 字段记录候选 key 和计费规则快照
- 后台轮询支持并发控制 (Semaphore + 独立 session)
- 任务终态自动写入 Usage 记录并计算成本
- 新增视频任务管理 API 和前端界面

其他改进:
- UsageService 新增 record_usage_with_custom_cost 方法
- StandardizedUsage 支持 dimensions 字段 (兼容 extra)
- 配置新增 BILLING_REQUIRE_RULE 和 BILLING_STRICT_MODE
2026-01-31 19:11:25 +08:00
fawney19 dc4bb25cc2 feat: 添加 Vertex AI Claude 模型支持和动态格式识别
- 新增 get_vertex_ai_effective_format 函数,根据模型名自动判断 API 格式
- 支持通过 auth_config.model_format_mapping 自定义模型格式映射
- 修改 Vertex AI URL 构建逻辑,Claude 模型使用 anthropic publisher 和 rawPredict
- 修复 auth_config 可能是未加密 dict 的兼容性问题
2026-01-31 00:30:07 +08:00
fawney19 772cb90f64 feat: 添加视频生成 API 支持和认证抽象层重构
- 新增 Video Generation API 路由和处理器(支持 Gemini Veo 和 OpenAI Sora 兼容格式)
- 新增 AuthHandler 策略模式,统一 API key 提取逻辑(Bearer/ApiKey/GoogApiKey/OAuth2/QueryKey)
- 新增 RequestContext 三维度检测(数据格式/端点类型/认证方式)
- 新增 EndpointType 和 AuthMethod 枚举
- Gemini/OpenAI normalizer 添加视频格式转换(InternalVideoRequest/Task/PollResult)
- 新增视频任务轮询服务和数据库迁移(video_tasks 表)
- 代码格式化:修复 black 行宽限制,调整 import 排序,target-version 降级至 py313
2026-01-30 22:41:42 +08:00
fawney19 16fb06ff4c perf: 使用 BuildKit 缓存加速 Docker 构建
- 添加 # syntax=docker/dockerfile:1 启用 BuildKit 新语法
- apt-get 使用 --mount=type=cache 缓存包下载
- pip install 使用 --mount=type=cache 缓存依赖
- npm ci 使用 --mount=type=cache 缓存模块
- 添加 --no-install-recommends 减少安装包体积

缓存命中后构建时间从 ~40s 降至 ~1.5s
2026-01-30 15:03:58 +08:00
fawney19 5603c72f40 fix: 修复 mypy 类型检查错误并升级到 Python 3.14
主要变更:
- 修复 1483 个 mypy 类型检查错误
- 添加缺失的类型注解 (Any, Callable, Session 等)
- 修复隐式 Optional 类型 (param: Type = None -> param: Type | None = None)
- 修复 __new__ 单例模式返回类型
- 添加 type: ignore 注释处理第三方库类型问题
- 更新 pyproject.toml 依赖到 Python 3.14 兼容版本
- 更新 mypy/black 配置为 Python 3.14
2026-01-30 14:30:57 +08:00
fawney19 7066166757 style: 统一使用 PEP 604/585 现代类型语法
- Optional[X] → X | None
- Dict[X, Y] → dict[X, Y]
- List[X] → list[X]
- Tuple[X, Y] → tuple[X, Y]
- Set[X] → set[X]

涉及 10 个文件,共约 50 处改动。
2026-01-30 13:06:34 +08:00
fawney19 041a61f89c fix: 补充合并后遗漏的 from __future__ import annotations
合并 fix/python314-upgrade 后,部分文件仍使用 Optional/Dict 等旧式类型
但未添加 future annotations 导入,导致运行时 NameError。
2026-01-30 13:01:50 +08:00
fawney19 cd1b103223 Merge branch 'fix/python314-upgrade'
# Conflicts:
#	src/api/handlers/base/base_handler.py
#	src/api/handlers/base/request_builder.py
#	src/models/endpoint_models.py
#	src/services/orchestration/candidate_resolver.py
#	src/services/orchestration/fallback_orchestrator.py
2026-01-30 12:59:52 +08:00
fawney19 f8ca3773be fix: 恢复被误删的 database_extensions 导入
原 PR 误删了 database.py 末尾的导入语句,导致 ApiKeyProviderMapping
和 ProviderUsageTracking 模型不再被加载到 SQLAlchemy 注册表中。
2026-01-30 12:48:46 +08:00
fawney19 13487fe4e5 fix: 添加 from __future__ import annotations 修复运行时类型错误
原 PR 将 Optional[X] 改为 X | None 后,如果 X 在 TYPE_CHECKING 块中导入,
会导致运行时 NameError。添加 future annotations 使类型注解延迟求值。

影响文件:
- src/services/auth/service.py
- src/core/api_format/utils.py
- src/core/api_format/detection.py
- 及其他 15 个使用 TYPE_CHECKING 的文件
2026-01-30 12:43:08 +08:00
fawney19andAAEE86 a90f065ab0 fix: 对齐 requires-python 版本声明至 >=3.12
代码已使用 PEP 604 (T | None) 等 3.10+ 语法,
且 Docker 镜像已升级至 Python 3.14,
但 requires-python 仍声明 >=3.9 会导致在 3.9/3.10/3.11 上安装后运行时报错。

- requires-python: ">=3.9" → ">=3.12"
- 移除 classifiers 中 3.9/3.10/3.11 的声明

Co-authored-by: AAEE86 <[email protected]>
2026-01-30 12:38:29 +08:00
fawney19 c33cd07ea9 feat: add Gemini Files API proxy with key capability support
- Add gemini_files_api capability for key-level Files API support
- Implement Gemini Files API proxy endpoints (upload/list/get/delete)
- Add file-to-key mapping cache for correct key routing
- Support preferred_key_ids to prioritize keys that uploaded files
- Enhance logging for file mapping diagnostics
2026-01-30 11:37:34 +08:00
AAEE86 897ea2e7a4 perf: 优化 Provider 预加载和 bcrypt 密码验证性能
- 使用 selectinload 预加载 endpoints 避免 N+1 查询
- 将 bcrypt 密码验证移至线程池执行,避免阻塞事件循环
2026-01-30 10:55:30 +08:00
AAEE86 d0ecdcd8aa feat: 增加签到状态识别关键词"已经签到" 2026-01-30 08:42:36 +08:00
AAEE86 24d24f6829 chore: 升级到 Python 3.14 并现代化代码
- 升级 Docker 基础镜像从 Python 3.12 到 3.14
- 更新 pyproject.toml 支持 Python 3.13/3.14
- 移除 Python 3.8/3.9/3.10/3.11 分类器
- 更新 black 和 mypy 配置目标版本
- 将 get_event_loop() 替换为 get_running_loop() 加上 RuntimeError 处理
- 简化 compute_cost_sync 中的 asyncio.run 使用
- Dict/List/Tuple/Set → dict/list/tuple/set (PEP 585)
- Optional[T] → T | None (PEP 604)
- Union[A, B] → A | B (PEP 604)
- 移除废弃的 typing 导入
- 移除不必要的字符串引号注解
2026-01-30 03:10:21 +08:00
fawney19 32b293ef3e feat: add Vertex AI authentication support for provider API keys
- Add auth_type field to ProviderAPIKey model (api_key or vertex_ai)
- Implement Vertex AI OAuth token generation with service account
- Update transport layer to handle Vertex AI authentication
- Add Vertex AI endpoint URL generation in request builder
- Update frontend KeyFormDialog to support auth_type selection
- Add migration for auth_type column in provider_api_keys table
2026-01-30 02:43:50 +08:00
fawney19 3e75bc8964 fix(docker): sync Dockerfile.app.local with Dockerfile.app
- Add /auth to SPA routes
- Add /docs|redoc|openapi.json location block
- Add GUNICORN_WORKERS=4 default env var
2026-01-29 23:26:13 +08:00
fawney19 0b6763745c Merge pull request #128 from AAEE86/master
fix(frontend): 修复端点配置对话框中多个 Select 下拉框同时打开的冲突问题
2026-01-29 23:25:05 +08:00
fawney19 4487c35486 Merge pull request #127 from NyaDoo/master
fix: CLI 模型测试请求添加 x-app: cli 头部
2026-01-29 23:05:25 +08:00
fawney19 61abb47d22 refactor: 重构上游模型获取与展示逻辑
- 后端支持遍历所有活跃 API Key 聚合模型,按 model id 合并 api_formats
- 前端移除按 API 格式分组,改为统一展示并内联显示格式标签
- 改进 401 错误处理,账户异常时清除认证并重定向首页
2026-01-29 22:58:37 +08:00
AAEE86 b0b1bdf158 feat: 添加Gunicorn配置优化并改进Docker构建配置
- 新增 gunicorn_conf.py 配置文件,实现:
  - GC冻结优化以改善Copy-on-Write内存共享
  - Worker RSS内存监控日志
  - 添加 --max-requests 和 --max-requests-jitter 防止内存泄漏

- 改进 Dockerfile 配置:
  - 修复 Nginx 静态文件目录权限问题

- 简化 docker-compose 配置:
  - 移除冗余的 PYTHONIOENCODING/LANG/LC_ALL 环境变量
2026-01-29 22:53:42 +08:00
AAEE86 c26598c773 feat(frontend): 调整统一模型表单对话框尺寸
- 将对话框尺寸从 3xl 增大到 4xl
- 将创建模式的内容高度从 500px 增加到 600px
- 为模型选择和配置区域提供更多展示空间
2026-01-29 17:41:12 +08:00
AAEE86 2140c1443a fix(frontend): 修复端点配置对话框中多个 Select 下拉框同时打开的冲突问题 2026-01-29 17:16:30 +08:00
LewisPen ed2f4c1c34 fix: CLI 模型测试请求添加 x-app: cli 头部
CLI adapter 的 check_endpoint 测试请求缺少 x-app: cli 头部,
导致上游 Aether 节点选择了错误的 adapter 提取 API Key,
返回 401 认证失败。
2026-01-29 14:35:10 +08:00
fawney19 297da59448 refactor(guide): 重构教程页面为管理员视角
- 重新规划教程结构:概览、供应商管理、模型管理、用户与密钥、高级功能、常见问题
- 新增详细的功能解释和配置说明
- 删除旧的 CLI/SDK 用户指南页面
- 修复路由配置,添加 meta.requiresAuth: false
2026-01-29 13:58:06 +08:00
fawney19 ec9aea1f0e refactor(frontend): 优化首页 Header 响应式布局
分离移动端和桌面端的 Header 布局结构,提升不同屏幕尺寸下的显示效果
2026-01-29 12:11:53 +08:00
fawney19 013c807f0d feat(frontend): 新增配置教程页面并优化暗色模式样式
- 添加 Guide 配置教程页面及路由
- 统一暗色模式配色方案,使用协调的暖色调
- 重新设计功能卡片,区分已完成与开发中状态
- 更新功能卡片描述,反映当前开发重点
2026-01-29 11:41:55 +08:00
fawney19 407fcfff3e refactor(frontend): 代码格式化与移动端响应式优化
- 修正 Vue 组件 defineProps/defineEmits 声明顺序
- 优化首页移动端布局,调整 Logo 位置与透明度
- 为 Aether 标题添加打字机动画效果
- 修复 HTML 属性中引号的实体转义
2026-01-29 11:06:30 +08:00
fawney19 557e64c77f fix: 响应中保留用户请求的原始模型名
- 非流式响应:通过 requested_model 参数传递用户请求的模型名
- 流式响应:StreamState 初始化时使用 ctx.model 而非 ctx.mapped_model
- 流式转换:normalizers 保留初始 model 值,不被上游事件覆盖
- parsed_chunks:格式转换时记录转换后数据,确保与客户端收到的一致
2026-01-29 10:32:36 +08:00
fawney19 2e9c9e80d1 fix: 修复响应解析器中 usage 为 null 时的处理问题
将 response.get("usage", {}) 改为 response.get("usage") or {},
避免 usage 显式为 None 时导致后续属性访问失败。
2026-01-29 09:44:12 +08:00
fawney19 ac73f6fbac chore: 提升请求/响应体记录大小限制至 5MB 2026-01-29 09:33:11 +08:00
fawney19 06a46383a1 perf: 优化同步方法性能,避免事件循环开销
- pipeline.py: 日志格式改为 %-style 懒惰计算
- cost.py: get_model_price/get_tiered_pricing 改用直接数据库查询
- usage/service.py: 批量记录改为并行准备,提取 build_params 减少重复代码
2026-01-29 02:00:00 +08:00
fawney19 b4bbf4eeb1 feat: 添加 NekoCode 中转站架构支持
- 使用 Cookie 认证(session)
- 支持余额查询和每日配额显示
- 显示订阅状态和有效期
2026-01-28 23:51:06 +08:00
fawney19 6b34a7e3f5 feat: 多项功能优化与问题修复
- 添加高频轮询端点日志抑制,减少 debug 日志噪音
- Gemini 格式转换支持 responseModalities、thinkingConfig 透传和图片生成输出
- OpenAI 格式转换支持流式图片内容块,区分 URL 引用和 base64 内嵌图片
- Provider 余额缓存认证失败时使用短 TTL,避免前端无限加载中
- Usage 服务支持更新 pending/streaming 记录,处理重复 request_id 冲突
- Usage 超时检测增强,避免错误标记已完成请求为超时
2026-01-28 23:07:58 +08:00
fawney19 91986baa57 feat: 添加流式请求客户端断连检测
在等待上游首字节期间检测客户端是否已断开连接,若检测到断连则及时取消请求并清理资源,避免资源浪费。

- 新增 ClientDisconnectedException 和 wait_for_with_disconnect_detection 工具函数
- ChatHandlerBase 和 CliMessageHandlerBase 均支持 http_request 参数用于断连检测
- 流式传输期间使用后台任务主动检测断连状态
- 断连时设置状态码 499 并记录日志
2026-01-28 17:10:21 +08:00
fawney19 bae278a0c1 feat: 添加提供商格式转换优先级保持配置
- 新增 Provider.keep_priority_on_conversion 字段,控制格式转换时是否保持优先级
- 新增全局配置 KEEP_PRIORITY_ON_CONVERSION,可全局启用优先级保持
- 调度器根据配置决定是否降级需要格式转换的候选
- 前端 Provider 表单添加"保持优先级"开关
- 调整 HTTP 读写超时默认值为 3600 秒
2026-01-28 15:53:33 +08:00
fawney19 5e81a0b581 refactor: 移除 ModelsTab 单个删除按钮,简化表格样式
Close #119
2026-01-28 13:17:50 +08:00
fawney19 e7fda0eadd fix: 统一格式兼容性检查,透传格式也需开关启用
- 同族格式(如 CLAUDE/CLAUDE_CLI)现在也需要全局开关和端点开关启用才能透传
- 格式转换关闭时,Models API 只返回客户端格式本身
- 添加测试用例验证透传格式受开关限制
2026-01-28 13:08:14 +08:00
fawney19 95fc848e59 feat: 配置导出/导入支持 LDAP 和 OAuth Provider
- 新增配置格式 v2.1,支持导出/导入 LDAP 配置和 OAuth Providers
- 导出时解密敏感字段(bind_password, client_secret, provider_ops credentials)
- 导入时加密敏感字段后存储
- 前端支持预览和显示 LDAP/OAuth 导入结果
2026-01-28 12:56:37 +08:00
fawney19 4cd196f665 fix: 隐藏普通用户的模型映射信息
对普通用户隐藏使用记录中的 target_model 字段,只有管理员可见。
避免向普通用户暴露内部模型映射策略。
2026-01-28 12:41:29 +08:00
fawney19 40e54097b0 feat: 添加 Zeabur 部署文档,Models API 支持 CLI 格式
- 新增 Zeabur 平台部署指南文档
- Models API 格式列表增加对应的 CLI 格式支持
2026-01-28 12:06:48 +08:00
fawney19 bd843bca61 feat: 使用量轮询接口添加 API 格式转换字段
- 前端:添加 api_format、endpoint_api_format、has_format_conversion 类型定义
- 前端:轮询时同步更新格式转换相关字段
- 后端:查询时返回格式转换字段,包含历史数据兼容逻辑
- 后端:优化 import 位置提升性能
2026-01-28 11:24:37 +08:00
fawney19 5c80be92c9 refactor: 使用 data_format_id 重构格式兼容性判断逻辑
- 在 ApiFormatDefinition 中新增 data_format_id 字段标识数据格式
- 新增 can_passthrough() 函数判断格式间是否可透传
- 重构 compatibility.py 使用 data_format_id 替代 get_base_format()
- 使用量记录支持从 endpoint 回填格式信息(兼容历史数据)
- 前端优化格式转换显示逻辑,区分跨格式转换和同族格式差异
- 首页 header 响应式布局优化
2026-01-28 11:07:23 +08:00
fawney19 c1d7b13e7a feat: 使用量记录添加格式转换字段
- cli_handler_base 添加 needs_conversion 变量跟踪格式转换状态
- 用户使用量查询响应新增 endpoint_api_format 和 has_format_conversion 字段
2026-01-28 10:01:19 +08:00
fawney19 865be3a6bf feat: 添加异步使用量记录队列系统
- 新增 QueueTelemetryWriter 支持通过内存队列异步记录使用量
- 新增 UsageQueueConsumer 消费者,支持批量处理和流式消费
- 重构 StreamTelemetryRecorder 支持队列写入模式
- UsageService 新增 record_usage_batch 批量记录方法
- 新增 USAGE_QUEUE_ENABLED 和 USAGE_QUEUE_INCLUDE_BODIES 配置项
- 状态值新增 cancelled 类型支持
2026-01-28 09:54:18 +08:00
fawney19 b97e029c82 refactor: 重构 CleanupScheduler 为 MaintenanceScheduler,新增 Provider 自动签到任务
- 将 cleanup_scheduler.py 重命名为 maintenance_scheduler.py
- CleanupScheduler 重命名为 MaintenanceScheduler,保留向后兼容别名
- 新增 Provider 自动签到定时任务(每天凌晨 1:05 执行)
- 前端新增定时任务配置区域,支持开关 Provider 自动签到
- 新增 enable_provider_checkin 系统配置项

Close #112
2026-01-28 02:04:45 +08:00
fawney19 91a30adfc4 feat: 用户模型目录支持格式转换
为 /api/users/me/available-models 端点添加格式转换支持,
确保显示所有可通过任意客户端格式访问的模型。

- 新增 _get_all_available_provider_ids 方法
- 检查 OPENAI/CLAUDE/GEMINI 三种格式的兼容性
- 优化 DB 查询从 6 次减少到 2 次
- 与 /v1/models API 逻辑保持一致
2026-01-28 01:45:19 +08:00
fawney19 af0daa91ad refactor: 将格式转换全局开关迁移到环境变量配置
- 新增 FORMAT_CONVERSION_ENABLED 环境变量(默认开启)
- 移除数据库中的 format_conversion_enabled 系统配置
- 移除前端系统设置页面的格式转换开关 UI
- 统一同族格式转换(OPENAI/OPENAI_CLI)逻辑,也需检查全局开关和端点配置
- 流生成器使用 ctx.needs_conversion 替代 _needs_format_conversion 方法调用
- 更新测试用例适配新逻辑
- 清理 .env.example 中过时的超时和连接池配置注释
2026-01-28 01:22:39 +08:00
fawney19 1e0255d0ed feat: 优化批量余额查询和用户模型权限检查
- 添加批量余额查询并发限制,避免数据库连接池耗尽
- 支持余额加载 pending 状态和前端自动重试机制
- 添加用户可用模型 API,统一使用 AccessRestrictions
- 修复用户表单编辑时数组引用共享导致的数据覆盖问题
- 添加数据库连接池配置说明到 .env.example
2026-01-28 01:01:05 +08:00
fawney19 8e0695e9d9 Merge branch 'NyaDoo/master' 2026-01-28 00:10:00 +08:00
fawney19 0bd3fe32e2 fix: 移除 RegisterDialog 中重复的用户名验证检查 2026-01-28 00:09:56 +08:00
fawney19andAAEE86 c732a03263 feat: 添加格式转换追踪、模型过滤规则和 Provider 超时配置
1. Usage 格式转换追踪
   - 新增 endpoint_api_format 和 has_format_conversion 字段
   - 在用量记录中显示格式转换信息(请求格式 → 端点格式)
   - 兼容历史数据的回填逻辑

2. Provider API Key 模型过滤规则
   - 新增 model_include_patterns 和 model_exclude_patterns 字段
   - 支持 * 和 ? 通配符,不区分大小写
   - 自动获取模型时应用过滤规则

3. Provider 超时配置
   - 新增 stream_first_byte_timeout 和 request_timeout 字段
   - 支持每个 Provider 单独配置超时时间
   - 优先使用 Provider 配置,否则回退到全局配置

Close #122, Close #123

Co-Authored-By: AAEE86 <[email protected]>
2026-01-28 00:06:49 +08:00
fawney19 3c0bf5fdae fix: 修复同族 API 格式转换判断逻辑
- OPENAI 和 OPENAI_CLI 使用不同协议(Chat Completions vs Responses API),需要转换
- CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 格式相同仅认证不同,可透传
- 添加 max_completion_tokens 参数支持(OpenAI 新参数名)
- 移除登录表单冗余的 keyup.enter 事件处理
2026-01-27 21:59:12 +08:00
NyaDoo 0c8c774642 Merge branch 'fawney19:master' into master 2026-01-27 14:03:06 +08:00
fawney19 6f340b7b91 fix: 修复跨格式转换时 usage 提取不准确的问题
- CLI Handler: 跨格式转换时使用 Provider 格式的解析器提取原始 usage
- CLI Handler: 新增从转换后事件中提取 usage 的补充方法
- Chat Handler: 移除不必要的格式自动检测转换代码(跨格式转换由 CLI Handler 统一处理)
- OpenAI Normalizer: 流式请求自动添加 stream_options 以获取 usage 统计
2026-01-27 12:39:36 +08:00
LewisPen 9fefa94134 feat: 用户名验证支持点号,统一错误消息,完善前端验证
- 后端正则改为 ^[a-zA-Z0-9_.\-]+$ 支持点号
- 统一错误消息使用"连字符"
- 前端 UserFormDialog 和 RegisterDialog 添加实时验证
- 输入框显示格式提示和错误信息
2026-01-27 12:17:19 +08:00
NyaDoo e24534668a Merge branch 'fawney19:master' into master 2026-01-27 11:26:09 +08:00
fawney19 add477fece refactor: 重构格式转换系统为 Hub-and-Spoke Normalizer 架构
- 移除旧的 converters 模块,改用基于 Internal 中间表示的 Normalizer 模式
- 新增 internal.py 定义统一的内部数据结构(InternalRequest/Response/Error)
- 新增 normalizer.py 定义 FormatNormalizer 基类接口
- 实现 OpenAI/Claude/Gemini 及其 CLI 格式的 Normalizer
- 新增 stream_events.py 和 stream_state.py 支持流式转换
- 重构 registry.py 使用 Normalizer 进行格式转换
- 更新 handlers 适配新的转换架构
- 前端移除 CLI 格式转换按钮的限制
- 添加 golden data 测试确保转换正确性
2026-01-27 02:17:18 +08:00
fawney19 6cf251b19a test: 添加 Gemini 流式解析和格式转换相关测试
- 添加 Gemini JSON-array 兜底解析测试
- 添加 Gemini stream_parser 单元测试
- 添加 provider transport URL 构建测试
2026-01-23 23:36:08 +08:00
fawney19 76ed7e9fcb feat: 增强 Gemini 流式响应兼容性,支持 JSON-array 格式解析
- 添加 Gemini JSON-array/chunks 格式的解析支持(兼容非 SSE 返回)
- 在上游请求中添加 alt=sse 参数,优先使用 SSE 格式
- 为 OpenAI 客户端统一补齐 [DONE] 流结束标记
- 更新 Gemini finishReason 判断逻辑,采用更宽松的结束判定
2026-01-23 23:34:38 +08:00
fawney19 c3be83bf94 fix: 将 UsageRecord.provider 字段改为可选类型
配合 PR #121 后端移除普通用户 API 中的 provider 字段
2026-01-23 18:59:21 +08:00
NyaDooandLewisPen cddccd2b99 fix: 移除后端普通用户使用统计API中的提供商信息/修复下拉菜单悬停效果 (#121)
* fix: 移除普通用户使用统计页面的提供商信息

普通用户不应该看到后端使用的提供商信息,此前前端筛选框和后端 API
都泄露了这些数据。

- 前端:为提供商筛选下拉菜单添加 isAdmin 条件
- 后端:移除 summary_by_provider 和 records 中的 provider 字段
- 补充:添加遗漏的 first_byte_time_ms 字段

* fix: 修复 SelectItem 下拉菜单悬停高亮效果

将 hover:bg-primary/10 改为 data-[highlighted]:bg-accent,与
DropdownMenuItem 保持一致。radix-vue 使用 data-highlighted 属性
控制高亮状态,这样键盘导航和鼠标悬停的高亮行为才能正确同步。

---------

Co-authored-by: LewisPen <[email protected]>
2026-01-23 18:57:41 +08:00
fawney19 2256ad1233 feat: 完善跨格式转换支持,添加 Gemini 双向转换器
- 实现 Claude/OpenAI -> Gemini 的请求和流式响应转换
- 实现 Gemini -> Claude/OpenAI 的请求转换
- 新增 ClaudeStreamConversionState 和 OpenAIStreamConversionState 状态类
- 添加 model_in_body 和 stream_in_body 元数据字段区分格式特性
- 格式转换后自动设置目标格式所需的 model/stream 字段
- 修复流式转换中 delta/choices 等字段的空值防护
2026-01-23 18:50:38 +08:00
LewisPen ac758216ca fix: 修复 SelectItem 下拉菜单悬停高亮效果
将 hover:bg-primary/10 改为 data-[highlighted]:bg-accent,与
DropdownMenuItem 保持一致。radix-vue 使用 data-highlighted 属性
控制高亮状态,这样键盘导航和鼠标悬停的高亮行为才能正确同步。
2026-01-23 18:05:27 +08:00
NyaDoo 6f00c3cd09 Merge branch 'fawney19:master' into master 2026-01-23 17:29:24 +08:00
fawney19 f45fd06fb0 fix: 修复 Redis SCAN cursor 类型问题并优化缓存清除日志
- CacheService.delete_pattern 中 cursor 可能为 int 或 str,统一转为 int 比较
- 模型缓存清除时区分有/无缓存的日志级别(info/debug)
2026-01-23 16:23:17 +08:00
fawney19 6f597a5f54 fix: 修复模型权限正则匹配逻辑及相关缓存清除
- 修复 model_mappings 正则匹配不再受 candidate_models 限制
- Provider 启用/禁用/删除时清除 GlobalModel 解析缓存
- 优先级管理对话框中启用的 Provider 排在禁用的前面
- 修复请求时间线 skipped 状态颜色适配主题
2026-01-23 16:10:53 +08:00
LewisPen 39f64fc0f9 fix: 移除普通用户使用统计页面的提供商信息
普通用户不应该看到后端使用的提供商信息,此前前端筛选框和后端 API
都泄露了这些数据。

- 前端:为提供商筛选下拉菜单添加 isAdmin 条件
- 后端:移除 summary_by_provider 和 records 中的 provider 字段
- 补充:添加遗漏的 first_byte_time_ms 字段
2026-01-23 15:57:53 +08:00
fawney19 3963e424f8 Merge branch 'NyaDoo/master' 2026-01-23 14:44:47 +08:00
fawney19 86b9bc3036 feat: 模型列表 API 支持格式转换兼容性过滤
- 新增 get_compatible_provider_formats 函数,基于端点 format_acceptance_config 过滤兼容的 Provider
- 模型列表查询根据客户端格式和全局转换开关返回可用模型
- 缓存 key 增加 client_format 维度,避免不同格式的缓存混用
- GlobalModel 解析支持 provider_model_mappings 和 model_mappings 匹配
- 修复格式兼容性检查中 config 类型校验缺失问题
2026-01-23 14:33:42 +08:00
LewisPen 3ee3aa4035 style: 统一模型映射对话框样式与批量管理模型对话框一致
- 搜索栏移到边框外,高度统一为 h-9
- 徽章高度改为 h-7,圆角改为 rounded-md
- 分组标题样式统一为 px-3 py-2 bg-muted
- 添加刷新按钮,未加载时显示闪电图标,已加载时显示刷新图标
- 仅在有 key 配置了自动获取时才自动请求上游模型
2026-01-23 12:50:25 +08:00
LewisPen 302c4e7456 style: 统一模型权限对话框样式与批量管理模型对话框一致
- 搜索栏移到边框外,高度统一为 h-9
- 刷新按钮样式统一,未加载时显示闪电图标,已加载时显示刷新图标
- 分组标题样式统一为 px-3 py-2 bg-muted
- 非自动获取模式下默认展开所有分组
- 刷新上游模型成功后收缩所有分组
- 仅在开启自动获取模式时才自动请求上游模型
2026-01-23 12:19:01 +08:00
LewisPen f8ea1b0ac1 fix: 修复 Toast 通知长文本溢出问题 2026-01-23 11:10:37 +08:00
LewisPen bfb4c2fa3c style: 统一 DropdownMenu 组件样式与 Select 组件一致
- 背景色从 bg-popover (纯白) 改为 bg-card (暖色米白)
- 圆角从 rounded-xl 改为 rounded-2xl
- 阴影从 shadow-lg 改为 shadow-2xl
- 添加 backdrop-blur-xl 毛玻璃效果
- 悬停状态使用 data-[highlighted] 替代 hover
2026-01-23 11:08:07 +08:00
fawney19 08a3148ab3 refactor: 简化模型权限检查,移除 resolved_model_name 参数
- 从 check_model_allowed 和 check_model_allowed_with_mappings 移除 resolved_model_name 参数
- 优化匹配逻辑:先精确匹配 model_name,再检查 candidate_models 交集,最后进行映射匹配
- 更新 CacheAwareScheduler 中的相关调用,传入 provider_model_names 作为 candidate_models
- 同步更新测试用例
2026-01-23 00:57:47 +08:00
fawney19 7ef0488419 refactor: 完善拖拽排序逻辑并新增格式转换开关
- 修复 PriorityManagementDialog 中 provider 和 key 的拖拽排序逻辑
- 修复 ProviderDetailDrawer 中 key 的拖拽排序逻辑
- 被拖动项单独成组,其他同组项保持在一起
- 在系统设置中新增"启用格式自动转换"配置项
2026-01-23 00:07:07 +08:00
fawney19 1da70d0518 refactor: 统一测试请求构建逻辑,支持多格式测试
- 重构 adapter 基类的 build_request_body 方法,使用 converter_registry 自动处理格式转换
- 后端 test_model 接口增加 endpoint_id 和 api_format 参数支持
- 前端模型映射测试支持根据 Key 和端点配置动态显示可用格式下拉菜单
2026-01-22 23:25:51 +08:00
fawney19 6c7b40764f refactor: 重构优先级拖拽排序逻辑,支持整组移动与归一化
- 将拖拽逻辑从简单的两项交换改为整组移动并重新归一化优先级值
- 修复对话框内容区域高度限制(min-h 改为 max-h)
- 优化 API 调用,只更新实际发生变化的 key
2026-01-22 20:54:19 +08:00
fawney19 69335ad974 refactor: 使用 DropdownMenu 组件重构模型测试按钮
- 新增 DropdownMenu 组件系列(DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem)
- 重构 ModelsTab 中的格式选择菜单,替换原有的手动实现
- 移除 handleClickOutside 等手动管理菜单状态的代码
2026-01-22 20:01:55 +08:00
fawney19 ee5ba42b79 feat: 增强 Gemini 认证支持与 URL 敏感信息脱敏
- 新增 extract_client_api_key_with_query 支持从 URL query 参数提取 API Key
- Gemini 格式遵循 Google SDK 行为:query 参数优先于 header
- 添加 redact_url_for_log 函数对日志中的敏感 URL 参数脱敏
- 上游请求始终使用 header 认证,清除 Gemini query 中的 key 参数
- 放宽 API Key 最小长度限制至 3 字符
- 完善认证方式相关代码注释
2026-01-22 19:11:38 +08:00
fawney19 1d27284575 Merge pull request #118 from AAEE86/fix2
fix: 链路预览只显示白名单匹配的 Endpoint 和 Key
2026-01-22 14:54:52 +08:00
AAEE86 f5373d9b7f refactor: 重构端点状态判断逻辑,消除重复代码 (#117)
- 新增 EndpointStatus 类型和 getEndpointStatus() 统一端点状态判断
- 提取 getHealthScoreColor() 复用健康分数颜色映射逻辑
- 简化 isEndpointAvailable/getEndpointDotColor/getEndpointTooltip 函数
- 移除函数中未使用的 provider 参数
2026-01-22 14:51:50 +08:00
fawney19 af1828dd32 feat: 新增 Thinking 整流器处理跨 Provider 签名错误 (#115)
当 Provider A 生成的 thinking 块被发送到 Provider B 时,签名验证会失败。
本次更新实现了自动整流机制,在遇到签名错误时自动清洗 thinking 块后重试。

主要更改:
- 新增 ThinkingRectifier 整流器,移除 thinking 块和 signature 字段
- 新增 ThinkingSignatureException 异常类型
- ErrorClassifier 新增 Thinking 错误模式检测
- FallbackOrchestrator 支持整流后在当前候选重试
- Handler 层传递 request_body_ref 容器支持请求体动态修改
- Usage API 新增 has_rectified 字段标识整流过的请求
- 新增 THINKING_RECTIFIER_ENABLED 配置项控制功能开关

其他改进:
- CacheAwareScheduler 支持 exact/convertible 候选分组排序
- StreamProcessor 预读阶段新增格式转换试验
- ProviderAPIKey.api_formats 改为可空(None 表示支持所有格式)
- Dockerfile 修复 entrypoint.sh 换行符问题

Closes #115
Co-Authored-By: FredericMN <[email protected]>
2026-01-22 14:23:33 +08:00
AAEE86 42aa0cbcee fix: 链路预览只显示白名单匹配的 Endpoint 和 Key
修复链路预览显示不相关 Endpoint 的问题:
- 新增 is_key_model_allowed 函数检查 Key 白名单是否匹配当前 GlobalModel
- 过滤掉白名单不匹配的 Key
- 跳过没有任何匹配 Key 的 Endpoint
- 简化 active_keys 计算逻辑
2026-01-22 10:33:03 +08:00
fawney19 cc5db20c58 feat: 实现跨 API 格式自动转换功能
- 新增端点级 format_acceptance_config 配置,控制是否接受跨格式请求
- 重构 EndpointFormDialog 为卡片式布局,支持内联编辑和格式转换开关
- StreamProcessor 实现流式响应的跨格式转换,支持 OpenAI/Claude/Gemini 互转
- CacheAwareScheduler 按端点格式筛选候选,同格式优先于跨格式
- 健康度/熔断按 Provider 端点格式分桶,而非客户端请求格式
- 新增 format_conversion_total 和 format_conversion_duration_seconds 指标
- 新增全局配置 format_conversion_enabled 控制总开关
- Input 组件新增 size="sm" 尺寸选项
2026-01-22 01:48:56 +08:00
fawney19 99388bfa33 refactor: 重构 API 格式相关代码到统一的 api_format 模块
- 新增 src/core/api_format/ 模块,整合所有 API 格式相关功能
  - enums.py: APIFormat 枚举定义
  - metadata.py: API 格式元数据注册
  - headers.py: 请求头处理逻辑
  - detection.py: API 格式检测
  - conversion/: 格式转换器(Claude/OpenAI/Gemini 互转)
- 删除分散在各处的旧实现
  - src/core/api_format_metadata.py
  - src/core/headers.py
  - src/api/handlers/*/converter.py
  - src/api/handlers/base/format_converter_registry.py
- 更新所有引用路径,使用新的统一模块
- 新增 cli_handler_base 中的格式转换支持
2026-01-21 18:07:10 +08:00
fawney19andAAEE86 856077a46c feat(ui): 重构端点健康状态为进度条可视化
- 将端点标签改为网格布局的进度条样式
- 显示 API 格式缩写和健康分数百分比
- 优化端点提示区分禁用、无可用密钥和未配置密钥状态
- 优化优先级管理对话框布局以显示更多内容
- 后端添加 total_keys 字段支持密钥状态区分

Closes #113

Co-authored-by: AAEE86 <[email protected]>
2026-01-21 01:10:35 +08:00
fawney19 4eeba69381 feat: 新增 cancelled 状态区分客户端主动断开连接
- 新增 cancelled 请求状态,与系统失败 (failed) 区分
- 客户端主动断开连接 (HTTP 499) 不计入失败率统计
- 前后端同步更新状态枚举、UI 样式和统计逻辑
2026-01-20 15:40:16 +08:00
fawney19 ffca11bc18 fix: 优化模型获取错误状态处理逻辑
只有在所有端点都失败时才记录错误,部分端点失败不影响整体状态
2026-01-20 11:11:21 +08:00
fawney19 8e7582d8b9 feat: 新增签到 Cookie 失效警告功能
- 签到认证失败时返回 cookie_expired 标记而非静默跳过
- 前端显示"签到 Cookie 已失效"警告提示
- 支持 auth_expired 状态,余额数据仍可正常显示
- 认证失败(auth_failed)时清除余额缓存以便重试
2026-01-20 02:44:22 +08:00
fawney19 c8b256ded1 refactor: 优化仪表盘权限和 UI 样式
- 普通用户仪表盘隐藏提供商统计相关信息
- 为普通用户新增每日使用趋势折线图
- 登录对话框 UI 样式优化(Logo 放大、圆角统一)
- 输入框组件样式微调
- 将项目名称从 "API Gateway" 改为 "AI Gateway"
2026-01-20 01:58:55 +08:00
fawney19 dd7cd68b51 fix: 修复 NewApiBalanceAction 中 Cookie 检测方式
将 Cookie 检测从 httpx client headers 检查改为 service 层注入的
_has_cookie 配置标志,解决 Cookie 在实际请求中存在但检测失败的问题
2026-01-20 01:36:45 +08:00
fawney19 24ef24558c refactor: 优化端点管理表单和上游模型获取逻辑
- 端点表单布局优化,支持 provider website 作为默认 Base URL
- 模型路由始终显示 provider_model_name
- 无端点时显示添加按钮
- 上游模型获取只针对基础 API 格式,跳过 CLI 格式
2026-01-20 01:32:47 +08:00
fawney19 edf8c663f1 refactor: 重构供应商操作基类为抽象类并支持 New API Cookie 认证
主要更改:
- 重构 BalanceAction/CheckinAction 为抽象基类,子类实现具体查询逻辑
- 重构 ProviderArchitecture 认证相关方法为抽象方法
- New API 支持 Cookie 认证(与 api_key+user_id 二选一)
- 添加 NewApiBalanceAction,支持查询余额前自动签到
- 修复 session cookie gob 编码解析逻辑
- 前端认证表单支持 inline 布局、字段联动和 onFieldChange 回调
2026-01-20 00:55:52 +08:00
fawney19 c24f3d47f7 Merge branch 'feat/dashboard-provider-cost-stats' 2026-01-19 20:47:38 +08:00
fawney19 1874214927 fix: 供应商统计排除 unknown 及表格列宽响应式优化
- 从 Dashboard 供应商成本统计中排除 unknown 提供商
- 优化供应商管理表格列宽为百分比宽度配合最小宽度

Closes #110
Co-authored-by: RWDai <[email protected]>
2026-01-19 20:47:00 +08:00
fawney19andRWDai c29d57622f feat: 添加 Dashboard 供应商成本统计功能
- 新增 stats_daily_provider 表存储每日供应商统计数据
- 实现供应商维度的数据聚合服务
- Dashboard API 返回 provider_summary 供应商汇总数据
- 前端新增 DoughnutChart 环形图组件
- Dashboard 新增供应商成本分布可视化卡片
- 移除重复的请求次数/费用趋势折线图

Closes #110

Co-authored-by: RWDai <[email protected]>
2026-01-19 20:23:55 +08:00
AAEE86 6ae862980d feat: 优先级管理对话框显示 API 格式标签和余额信息 (#109) 2026-01-19 19:44:45 +08:00
fawney19 c66ecd7517 fix: 使用 sortApiFormats 对上游模型 API 格式进行排序 2026-01-19 19:43:13 +08:00
AAEE86 6b8c1fcf47 feat: 上游模型列表显示 API 格式标签 (#108)
- KeyAllowedModelsEditDialog 按模型 ID 聚合上游模型
- 每个模型显示其支持的所有 API 格式缩写标签
- 移除按 Key API 格式过滤的限制,展示全部模型
2026-01-19 19:41:45 +08:00
fawney19 427f173b38 feat: OAuth 绑定安全增强及限流配置优化
- 新增一次性绑定令牌机制,避免在 URL 中暴露 access_token
- 绑定流程改为新标签页打开,完成后自动刷新状态
- 放宽认证相关接口的 IP 限流配置
- 登录添加 429 限流错误的友好提示
- OAuth 绑定列表添加 Provider 图标显示

Close #106
2026-01-19 19:34:31 +08:00
fawney19andAAEE86 b53e3f14d6 feat: 将 CreateUserRequest 邮箱字段改为可选
Closes #107

Co-authored-by: AAEE86 <[email protected]>
2026-01-19 18:38:02 +08:00
fawney19 ffdc9a9585 feat: 为 Provider 认证添加代理配置支持
- 前端新增可折叠的代理配置字段组(URL/用户名/密码)
- 更新 anyrouter/cubence/new-api/yescode 模板支持代理
- 后端 provider_ops 服务验证和数据获取时支持代理
- 移除 Dockerfile 中的 ca-certificates(回退之前改动)
2026-01-19 18:30:52 +08:00
fawney19 b6a9969aee fix: 添加 ca-certificates 解决 Docker 容器内 SSL 证书验证失败
python:3.12-slim 镜像缺少系统级 CA 证书,导致 httpx 请求外部
HTTPS 服务时出现 CERTIFICATE_VERIFY_FAILED 错误。
2026-01-19 18:02:04 +08:00
fawney19 0265849ed3 feat: 统一 SSL 证书配置并支持用户创建时设置访问权限
- 新增 ssl_utils.py 模块,统一使用 certifi 证书
- 将所有 httpx 客户端的 verify 参数改为使用 get_ssl_context()
- 管理员创建用户时支持设置 allowed_providers/api_formats/models
- 修复 provider_ops 敏感字段列表缺失 session_cookie
2026-01-19 17:56:02 +08:00
fawney19 6bc9cdc69d feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强
- 新增三个 provider 架构:Anyrouter、Cubence、YesCode
- 前端新增对应的认证配置模板
- 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示
- 架构基类新增 prepare_verify_config 异步预处理方法
- API 返回 ops_architecture_id 字段用于前端展示
2026-01-19 17:18:03 +08:00
fawney19 3d88dfd98a feat: 添加 OAuth 认证支持及相关改进
- 新增 OAuth 模块,支持 LinuxDo/GitHub/Google 等第三方登录
- 用户邮箱改为可选字段,支持无邮箱注册
- 新增模块配置验证状态 (config_validated/config_error)
- 系统设置界面改为分块独立保存
- 用户设置新增 OAuth 绑定管理和首次密码设置
- 登录界面支持 OAuth 按钮展示
- 邮箱验证设置移至邮件设置页面
2026-01-19 03:19:17 +08:00
fawney19 e2e14fd09c feat: ProviderOpsConfig 支持独立存储 base_url
将 base_url 作为 ProviderOpsConfig 的独立字段存储,
获取时优先读取配置中的值,否则回退到 endpoint/provider。
2026-01-18 13:20:14 +08:00
fawney19 aa57f78561 feat: 验证 API 成功后预填充余额缓存
验证认证成功时,复用返回的 quota 信息缓存余额,
避免后续重复查询余额接口。
2026-01-18 13:00:11 +08:00
fawney19 0f7ba38655 fix: 新增 API 验证增加响应完整性检查
- 站点地址字段在无官方地址时改为必填
- 验证成功时检查用户信息和余额是否完整返回
2026-01-18 11:40:59 +08:00
fawney19 1e71d355fc fix: 自动同步模式下禁用上游模型手动选择
- 添加"自动同步"标签提示
- 禁用全选/取消按钮
- 禁用模型项点击交互
- 调整视觉样式降低模型项不透明度
2026-01-18 01:56:16 +08:00
fawney19 0c5c6b3288 refactor: 提取模型可用性查询到独立模块 ModelAvailabilityQuery
Close #101

- 新增 src/services/model/availability.py,集中管理系统级模型可用性查询条件
- 重构 models_service.py,复用 ModelAvailabilityQuery 的查询方法
- 删除 find_model_by_id 的 provider_model_name 回退逻辑
- 支持 model_mappings 正则匹配(使用 check_model_allowed_with_mappings)
- 修复 test_auth.py 中缺少的 is_locked mock 属性
- 新增 ModelAvailabilityQuery 单元测试
2026-01-18 01:45:25 +08:00
fawney19 b98fc8497f feat: 自适应 RPM 未学习时不限制,改为碰壁学习策略
- 移除自适应模式的默认初始限制(RPMDefaults.INITIAL_LIMIT)
- 未学习到边界前不限制 RPM,让系统自由运行直到遇到 429
- 前端自适应 Key 显示学习到的值或"探测中"状态
- 批量余额查询改为并行执行,提升性能
2026-01-17 23:44:53 +08:00
fawney19andAAEE86 f002f8de03 feat: Key 删除时自动解除不再匹配的 GlobalModel 关联
- 新增 auto_disassociate_provider_by_key_whitelist 方法检测并解除关联
- 删除 Key 后检查 Provider 剩余 Key 白名单,移除不再匹配的 Model
- 前端删除密钥后并行刷新端点列表、模型列表和模型映射

Closes #102

Co-authored-by: AAEE86 <[email protected]>
2026-01-17 22:05:09 +08:00
fawney19andAAEE86 4fa0201b7e feat: 审计日志搜索从用户ID改为用户名模糊匹配
Closes #104

Co-authored-by: AAEE86 <[email protected]>
2026-01-17 20:47:13 +08:00
fawney19andAAEE86 8053cbd854 feat: 分页组件支持 pageSize 浏览器缓存
- 为 Pagination 组件添加 cacheKey 属性,支持将 pageSize 缓存到 localStorage
- 组件挂载时自动恢复缓存的 pageSize 设置
- 为各页面的分页组件配置独立的缓存键
- 仅在显示 pageSize 选择器时启用缓存恢复

Closes #105

Co-Authored-By: AAEE86 <[email protected]>
2026-01-17 20:21:03 +08:00
fawney19 c71027c466 feat: 添加 Provider Ops 扩展操作系统,支持余额监控
主要更改:
- 新增 Provider Ops 服务框架,支持通过架构配置执行余额查询等扩展操作
- 后端:添加 provider_ops 服务层和 API 路由
- 前端:添加 ProviderAuthDialog 组件配置认证信息
- 前端:添加 providerOps API 和认证模板系统

UI/组件优化:
- Input 组件:新增 masked 属性,使用 CSS 遮蔽敏感信息,避免触发密码管理器
- Pagination 组件:移除首页/末页/上下页按钮,改为页码跳转输入框
- KeyFormDialog:使用 masked 属性简化 API Key 输入逻辑
- ProviderManagement:重新设计表格布局,显示余额监控数据
2026-01-17 19:50:35 +08:00
fawney19 3cdf471473 fix: 修复端点 header_rules 无法清空的问题
- updateEndpoint 接口的 header_rules 类型改为支持 null
- rulesToHeaderRules 返回 null 而非 undefined 以正确清除规则
- 将 header 规则操作类型文案"设置"改为"覆写"
2026-01-16 20:06:20 +08:00
fawney19 b807c16395 fix: 修复对话轮次分组器的类型安全问题
- 移除 grouper.ts 中的 as any 类型断言,使用正确的类型守卫
- 导入具体的 RenderBlock 类型替代 import() 语法
- 合并 ConversationView.vue 中的两个 watch 避免竞争条件
- 使用 lastRenderResultId 追踪变化防止重复初始化
2026-01-16 19:53:54 +08:00
fawney19 aa59862a70 Merge branch 'NyaDoo/master' 2026-01-16 19:47:45 +08:00
fawney19 45f071abd9 fix: 在 Provider/Endpoint/Key 变更时清除 /v1/models 列表缓存
- 将 on_key_allowed_models_changed 改为 async 函数
- 在 Provider/Endpoint/Key 的增删改操作后调用 invalidate_models_list_cache
- 优化 invalidate_models_list_cache 使用 delete_pattern 通配符删除
2026-01-16 19:42:35 +08:00
fawney19 019e148ddb refactor: 将上游模型缓存从前端迁移到后端 Redis
- 后端:定时任务刷新时将上游模型写入 Redis 缓存
- 后端:provider_query 优先从缓存读取,支持 force_refresh 参数
- 后端:auto_fetch_models 开启时改为同步获取,确保前端能立即看到数据
- 前端:移除本地缓存,只保留并发请求去重逻辑
- 前端:KeyAllowedModelsEditDialog 添加刷新上游模型按钮
2026-01-16 19:10:59 +08:00
fawney19 b2d08f964d feat: 删除端点时自动清理关联密钥的 API 格式配置
- 端点删除添加确认弹窗,提示用户关联密钥将受影响
- 密钥表单添加 canSave 验证,禁用按钮替代弹窗提示
- 后端删除端点时自动从关联密钥移除对应 API 格式
2026-01-16 18:24:52 +08:00
fawney19 d5d74339dd refactor: 将 global_priority 和 rate_multiplier 改为按 API 格式配置
- 新增 global_priority_by_format 字段,支持按 API 格式设置全局优先级
- 移除已废弃的 rate_multiplier 字段,统一使用 rate_multipliers
- 移除已废弃的 timeout 字段(providers 和 provider_endpoints 表)
- 更新调度器以支持按格式的优先级排序
- 同步更新前后端类型定义和 API 接口
- 添加数据库迁移脚本,自动迁移现有数据
2026-01-16 17:53:27 +08:00
NyaDoo 9285dc896c Merge branch 'fawney19:master' into master 2026-01-16 16:24:41 +08:00
fawney19 6c98816f9f feat: 实现功能模块系统,支持模块的动态注册与启用控制
- 新增 ModuleRegistry 核心,支持模块注册、可用性检查和启用状态管理
- 新增模块管理 API(管理端和公共端),提供模块状态查询和启用控制
- 将 LDAP 功能迁移到模块系统,支持通过环境变量和数据库配置控制
- 前端新增模块管理页面和 store,支持模块启用/禁用操作
- 路由守卫集成模块状态检查,未激活模块的页面自动重定向
- 面包屑导航支持模块配置页面的层级显示
- Switch 组件新增 disabled 属性支持
2026-01-16 16:14:12 +08:00
LewisPen ed1d828f85 feat: 实现对话轮次折叠视图
- 新增轮次分组逻辑,将消息按 user/assistant 配对分组
- 新增 TurnCard 组件,支持折叠/展开单个轮次
- 新增 TurnBadges 组件,显示思考/工具/图片/错误图标
- 历史轮次默认折叠,最新轮次默认展开
- 折叠时显示用户和助手消息摘要
- 统一 UI 设计规范:边距、字体、图标、圆角
2026-01-16 15:54:21 +08:00
fawney19 50343d5459 refactor: 移除 Provider/Endpoint 级别的 timeout 配置,改用全局环境变量
- 废弃 Provider.timeout 和 ProviderEndpoint.timeout 字段(保留数据库列以兼容)
- 非流式请求超时由 HTTP_REQUEST_TIMEOUT 环境变量控制(默认 300 秒)
- 流式请求首字节超时由 STREAM_FIRST_BYTE_TIMEOUT 环境变量控制(默认 30 秒)
- 移除前端表单中的超时配置输入框
- 简化 settings.py 中的 TTFB 超时解析逻辑
- 更新 API 请求/响应模型,移除 timeout 字段
- 更新导入导出功能,不再包含 timeout 配置
2026-01-16 13:08:39 +08:00
LewisPen 691a71ac05 refactor: 将 BlockRenderer 和 ConversationView 样式改为 Tailwind
移除手写 CSS,统一使用 Tailwind 原子类,符合项目设计规范:
- BlockRenderer: 删除 260 行 scoped CSS,改用 Tailwind 类
- ConversationView: 删除 38 行 scoped CSS,改用 Tailwind 类
- 使用样式映射对象管理消息块的角色样式
2026-01-16 11:22:11 +08:00
fawney19 e6a31a61db fix: 增强嵌入式错误的状态码提取与处理
- ParsedResponse 新增 embedded_status_code 字段存储嵌套状态码
- 新增 _extract_embedded_status_code 函数支持从多种格式提取状态码
- 非流式响应增加嵌套错误检测逻辑
- 统一使用 embedded_status_code 替代从 error_type 解析数字的方式
- fallback_orchestrator 使用嵌入式状态码进行错误记录
2026-01-16 11:01:05 +08:00
fawney19 adc82532eb refactor: 将对话解析器拆分为独立模块
将 lib/conversationParser 重构为 conversation/ 目录:
- 按 API 格式拆分解析器 (claude.ts, openai.ts, gemini.ts)
- 提取公共渲染逻辑到 render.ts
- 添加解析器注册表支持自动格式检测
- 更新组件导入路径
2026-01-16 03:11:41 +08:00
fawney19 02770d56df refactor: 重构请求详情对话视图的渲染架构
- 新增 BlockRenderer 组件,支持递归渲染多种内容块类型
- 将 ConversationView 改为使用 RenderResult 和 BlockRenderer
- 删除旧的 messageExtractor 工具,改用 conversationParser 库
- 更新 RequestDetailDrawer 中的对话解析和复制逻辑
2026-01-16 02:53:47 +08:00
fawney19 1366c0b973 fix: 优化请求详情抽屉在窄屏的响应式布局
- 窄屏时抽屉占满全宽,宽屏时保持 800px
- 减小窄屏时的内边距 (px-3/py-3 vs px-6/py-4)
- 阶梯定价标题行在窄屏时改为垂直布局
- Tab 按钮内边距响应式调整,工具栏不被压缩
2026-01-16 01:53:59 +08:00
fawney19 8bd3a894f1 fix: 修复请求链路追踪宽度不足时节点被遮挡的问题
使用 justify-content: safe center 替代 center,
当内容溢出时自动回退到靠左对齐,确保左侧节点可滚动访问
2026-01-16 01:43:52 +08:00
fawney19 6cc66e404b fix: 将请求详情抽屉的请求头默认数据源改为提供商 2026-01-16 01:38:59 +08:00
fawney19 b1cf348abb fix: 修复迁移脚本对非对象类型 headers 的处理
在 headers -> header_rules 迁移时,添加 jsonb_typeof 类型检查,
避免 jsonb_each_text 在非对象类型数据上报错。
2026-01-16 01:26:45 +08:00
fawney19 8e11f3864a feat: 添加 API Key 锁定功能和请求头规则系统
1. API Key 锁定功能
- 管理员可锁定/解锁用户的 API Key
- 锁定后用户无法使用、修改或删除该 Key
- 前端显示锁定状态并禁用相关操作

2. 请求头规则系统
- 将 endpoint.headers 升级为 header_rules
- 支持 set(设置)、drop(删除)、rename(重命名)操作
- 前端提供可视化规则编辑界面
- 包含数据迁移脚本

Close #37 Close #86 Close #88
2026-01-16 01:18:54 +08:00
fawney19 ea45918561 refactor: 统一请求头处理逻辑到 headers.py
- 新增 src/core/headers.py 集中管理头部处理函数
- 扩展 api_format_metadata.py 添加 extra_headers 和 protected_keys 配置
- 将各 adapter 中重复的头部方法提取到基类,统一调用 headers.py
- 移除 transport.py 中未使用的 build_provider_headers 函数
- 添加 headers 模块的单元测试
2026-01-15 22:25:56 +08:00
fawney19 80887dd2cc fix: 修复请求详情抽屉的默认状态问题
- viewMode 默认改为 formatted,避免按钮被禁用
- currentExpandDepth 默认改为 0,与收缩状态一致
2026-01-15 19:23:01 +08:00
fawney19andLewisPen b2731eddf3 fix: 修复对话视图合并后的问题
- 移除未使用的变量 index
- 修复展开/收缩按钮在非请求体Tab被禁用的问题
- 删除未使用的 copyJsonToClipboard 函数

Co-Authored-By: LewisPen <[email protected]>
2026-01-15 19:18:32 +08:00
fawney19 18ac4fe2f3 Merge branch 'pr-99' 2026-01-15 19:16:01 +08:00
fawney19 81b7b74fd4 refactor: 简化优先级拖拽交换逻辑并优化用户配额处理
- 优先级拖拽改为直接交换优先级值,而非重新编号
- 添加 Provider 优先级的点击编辑功能
- 创建用户时由后端统一处理默认配额逻辑
- 移除前端 getDefaultQuota API 调用和后端对应端点

Close #97
2026-01-15 18:59:01 +08:00
LewisPen 38607332b9 feat(usage): 请求详情页面添加对话视图功能 2026-01-15 18:37:59 +08:00
fawney19 dd387735d4 feat: LDAP 用户隐藏密码修改功能并应用默认配额
- /me API 返回 auth_source 字段
- 前端 Settings 页面对 LDAP 用户隐藏密码修改卡片
- LDAP 用户首次登录创建时使用系统默认配额

Close #98
2026-01-15 17:57:58 +08:00
fawney19 d9645fa062 feat: 用户创建和注册时使用系统配置的默认配额
- 新增 /api/admin/users/defaults/quota 接口获取默认配额
- 用户注册时从系统配置读取默认配额
- 管理员创建用户时自动获取并使用系统默认配额
2026-01-15 17:41:04 +08:00
fawney19 026f4b9c0e fix: 修复模型测试时正则映射支持及缓存装饰器实例方法兼容
- ModelsTab 加载时并行获取模型映射预览数据
- 测试模型时自动识别正则映射并使用映射名称和指定 key
- 密钥变更后并行刷新模型列表和模型映射
- 修复 cache_decorator 对实例方法的 context 参数解析
2026-01-15 17:26:52 +08:00
fawney19 a223819dd7 refactor: 提取正则工具模块并清理废弃代码
前端:
- 新增 model-mapping-regex.ts 工具模块,统一正则验证和 LRU 缓存
- ModelMappingsTab/RoutingTab 重构为使用 computed 缓存匹配结果
- 移除多个组件中未使用的变量和函数
- 添加 HTMLImageElement/HTMLIFrameElement 到 ESLint 全局类型
- 修复 vitest 需要 --experimental-require-module 的问题

后端:
- 移除废弃的异步数据库支持 (get_async_db, AsyncSession)
- 将 async_utils 从 database/ 迁移到 utils/
- 改进 database.py 类型标注
- 修复 email 模块的 aiosmtplib 可选导入类型问题
2026-01-15 17:03:19 +08:00
fawney19 bc16c0eec8 style(usage): 调整使用记录表格类型列宽度 2026-01-15 13:44:22 +08:00
fawney19 0d26fd8c5a fix(usage): 修复活跃请求中成本字段为 0 时被错误置为 None 的问题
将 actual_cost 和 rate_multiplier 的判断条件从 `if value else None`
改为 `if value is not None else None`,确保数值 0 不会被误判为 False
2026-01-15 13:40:52 +08:00
fawney19 bc2b430ec3 style(usage): 请求链路节点改回居中布局并修复 TypeScript 兼容性
- 将节点布局从两端对齐改回居中展示
- 将 findLastIndex 替换为向后兼容的 for 循环实现
2026-01-15 13:29:22 +08:00
fawney19 6de113b852 refactor(update): 改用 GitHub Tags API 检查更新
- 从 Releases API 切换到 Tags API 获取版本信息
- 从 annotated tag 的 message 中获取更新日志
- 支持从 tagger 或 commit 获取发布时间
2026-01-15 12:30:05 +08:00
fawney19 abe65ad01b fix(usage): 解耦活跃请求轮询与全局刷新逻辑
1秒轮询始终用于活跃请求的实时更新,不再受全局刷新状态影响
2026-01-15 12:19:46 +08:00
fawney19 76f648b4eb feat(usage): 活跃请求轮询增加缓存与成本字段
- 后端 getActiveRequests 返回新增 cache_creation/read_input_tokens、actual_cost、rate_multiplier
- 前端轮询时同步更新这些字段到记录中
- 优化刷新逻辑:全局刷新开启时停止1秒轮询,关闭时恢复
2026-01-15 12:13:47 +08:00
fawney19 514bf7e3ed feat(usage): 改进请求追踪与缓存预热
- 区分请求重试与故障转移:新增 has_retry 标识亲和缓存重试
- 修正候选 TTFB 计算:记录候选自身的首字节时间而非全局时间
- Streaming 状态同步 rate_multiplier,支持按 API 格式配置
- 新增缓存预热服务:启动时预热仪表盘统计、热力图、每日统计
- 优化缓存 TTL 配置:仪表盘统计 2 分钟、热力图和每日统计 10 分钟
- 自动刷新间隔从 10 秒调整为 5 秒
2026-01-15 11:45:46 +08:00
fawney19 24b65d76b5 feat(models): Key 白名单变更时自动关联 GlobalModel
- Key 创建/更新/自动获取模型后,检查 allowed_models 变化
- 根据 GlobalModel 的映射规则自动创建 Provider 关联
- 添加缓存失效机制确保映射规则能匹配新白名单
2026-01-15 10:32:48 +08:00
fawney19 5a81be0756 fix(routing): 有效 Keys 计数考虑模型白名单权限
路由预览中计算有效 Keys 数量时,除了检查 is_active 状态外,
还需检查 Key 的白名单是否允许当前模型(包括模型映射匹配)。
2026-01-15 09:21:30 +08:00
fawney19 dc2996988b fix(routing): 移除 Key 的 RPM 显示并修复有效提供商计数
- 移除链路预览中 Key 健康度后面的 RPM 数值显示
- 修复有效提供商计数逻辑,无 Key 的提供商不再计入有效数量
2026-01-15 02:59:15 +08:00
fawney19andAAEE86 c51f94ee87 style(ui): 将请求链路轨道布局从居中改为两端对齐
fix #94

Co-Authored-By: AAEE86 <[email protected]>
2026-01-15 02:44:06 +08:00
fawney19 5d34426428 feat(models): 路由预览显示 Key 白名单的模型映射匹配
- 后端返回 GlobalModel 的 model_mappings 正则模式
- 前端用映射模式匹配 Key 白名单,显示匹配的模型名
2026-01-15 02:36:00 +08:00
fawney19 4835ca13f5 feat(models): 模型映射支持按提供商分组并自动关联
- 映射匹配结果按提供商分组显示,支持单个关联
- 保存映射规则时自动批量关联未关联的提供商
- 优化模型详情加载逻辑,并行获取最新数据
- 移除冗余的调度器日志输出
2026-01-15 02:11:44 +08:00
fawney19 84f46d5ea7 fix: 修复移动端菜单背景样式不一致问题
closes #93
2026-01-15 00:39:38 +08:00
fawney19 19209181dc feat(models): 添加全局 Key 白名单数据支持模型映射实时匹配
- 新增 GlobalKeyWhitelistItem 类型定义
- 路由预览 API 返回 all_keys_whitelist 字段,包含所有活跃 Provider 的 Key 白名单
- 前端 ModelMappingsTab 改用全局白名单数据进行实时匹配
2026-01-15 00:35:28 +08:00
fawney19 154ec94c58 fix: 关闭 auto_fetch_models 时保留锁定的模型 2026-01-14 21:31:05 +08:00
fawney19 8909e6741a Merge PR #92: feat(keys): trigger immediate model fetch when auto_fetch_models is enabled 2026-01-14 21:30:29 +08:00
fawney19 45a58a4a03 fix: 修复模型测试使用记录中 API 格式显示错误
将 check_endpoint 方法中的 api_format 参数从 cls.name 改为 cls.FORMAT_ID,
确保使用记录存储标准枚举值(如 GEMINI)而非内部名称(如 gemini.chat)
2026-01-14 21:19:34 +08:00
fawney19 0348b7d915 refactor: 废弃 rate_multiplier 字段,改进成本倍率回退逻辑
- 在前后端模型中为 rate_multiplier 添加 @deprecated 标记
- 修复 rate_multipliers 回退逻辑:当 rate_multipliers 存在但未配置当前格式时使用 1.0
- ModelMappingsTab: 删除映射后自动保存,简化空状态提示
- ModelManagement: 添加 refreshModel 事件在映射更新后刷新模型数据
- CacheAwareScheduler: 添加映射匹配调试日志,移除 candidate_models 限制
2026-01-14 21:07:28 +08:00
AAEE86 c61c3147ba feat(keys): clear allowed_models when auto_fetch_models is disabled
Add logic to automatically set allowed_models to null when auto_fetch_models  is turned off for a key. Also include auto_fetch_models and locked_models fields in config export/import, and trigger model fetching for keys with auto_fetch_models enabled during import.
2026-01-14 20:05:52 +08:00
fawney19 bec9c3a989 refactor: 将模型别名(alias)统一重命名为模型映射(mapping)
- 重命名 API: alias-mapping-preview -> mapping-preview
- 重命名类型: AliasMatchedModel -> MappingMatchedModel 等
- 重命名字段: model_aliases -> model_mappings, alias_matched_model -> mapping_matched_model
- 重命名前端组件: ModelAliasesTab -> ModelMappingsTab
- 重命名验证函数: validate_model_aliases -> validate_model_mappings
- 同步更新相关测试用例
- 补充 ModelService 中新增/批量创建模型时的缓存失效逻辑
2026-01-14 19:35:41 +08:00
fawney19 24ece00e93 test(aware-scheduler): 添加模型支持检查逻辑的单元测试
测试 _check_model_support_for_global_model 方法的以下场景:
- Provider 没有配置模型时返回 False
- Provider 只有其他模型时返回 False
- Provider 有匹配模型时返回 True
- Provider 模型未激活时返回 False
2026-01-14 18:22:22 +08:00
AAEE86 1becf39269 feat(keys): trigger immediate model fetch when auto_fetch_models is enabled 2026-01-14 18:18:31 +08:00
fawney19 b272109055 refactor(model-permissions): 简化 allowed_models 为纯列表格式
移除按 API 格式区分的字典模式(Dict[str, List[str]]),统一使用简单列表格式。

- 删除 normalize_allowed_models 的 api_format 参数
- 删除 check_model_allowed 的 api_format 参数
- 简化 merge_allowed_models 为直接列表交集
- 移除前端的字典模式兼容代码和警告 UI
- 删除 is_format_mode、convert_to_format_mode 等辅助函数
2026-01-14 17:57:21 +08:00
fawney19 133f3108f0 chore(config): 移除冗余的超时配置注释说明 2026-01-14 17:31:24 +08:00
fawney19 0ed053b02f fix(model-resolution): 修复模型解析优先级和别名匹配范围
- 调整 ModelCacheService 解析优先级:优先直接匹配 GlobalModel.name,
  避免被 provider_model_name 误导到错误的 GlobalModel
- 为 check_model_allowed_with_aliases 新增 candidate_models 参数,
  限制别名匹配只能落到 Provider 实际配置的模型名上
- 对 allowed_set 排序以确保别名匹配结果的确定性
- 调整 HTTP_READ_TIMEOUT 默认值从 300s 改为 60s
- 优化 ModelMappingTab 组件布局,将正则表达式提取到 Key 级别展示
- 补充 .env.example 超时配置文档
2026-01-14 17:29:04 +08:00
fawney19 97d66a8d0c feat(trace): 在请求链路追踪中记录并展示首字时间(TTFB)
将 first_byte_time_ms 添加到候选记录的 extra_data 中,
并在前端时间线组件中展示该指标。
2026-01-14 16:41:58 +08:00
fawney19 ec3c5994d9 fix(orchestration): 处理流式响应中的嵌入式错误
- 新增对 EmbeddedErrorException 的处理逻辑
- 客户端错误(如 prompt too long)停止重试并抛出异常
- 服务端错误允许重试或故障转移
- 将 _is_client_error 改为公开方法 is_client_error
2026-01-14 15:43:02 +08:00
fawney19 1f6e384d2b fix(ui): 居中对齐时间线轨道内容 2026-01-14 15:43:01 +08:00
fawney19 5e4099a588 fix(proxy): 新增响应头过滤函数,避免透传不兼容的上游头
添加 filter_proxy_response_headers 函数过滤 content-length、content-encoding、
transfer-encoding 等 hop-by-hop 和 body-dependent 头,防止客户端解码失败。
统一在 chat_handler_base、cli_handler_base、stream_telemetry 和 usage recorder
中使用该函数处理响应头透传。
2026-01-14 14:50:33 +08:00
fawney19 68737b9ed6 refactor(ui): 重构模型映射功能,简化交互并提取独立组件
- ModelMappingDialog: 简化为单栏选择面板布局,移除拖拽排序和优先级编辑
- ProviderDetailDrawer: 移出别名映射预览,使用独立 ModelMappingTab 组件
- 新增 ModelMappingTab 组件: 统一展示精确映射和正则映射,支持连接测试
2026-01-14 14:28:13 +08:00
fawney19 a8813cfb89 feat(keys): 新增密钥自动获取模型功能
- 添加 auto_fetch_models 字段支持定时从上游 API 获取可用模型
- 添加 locked_models 字段支持锁定模型,刷新时不会被删除
- 新增 ModelFetchScheduler 调度器定期执行模型获取任务
- 前端 KeyFormDialog 添加自动获取模型开关
- 前端 KeyAllowedModelsEditDialog 支持模型锁定操作
- ProviderDetailDrawer 显示密钥同步状态
- 数据库迁移添加新字段
2026-01-14 13:12:19 +08:00
fawney19 3b194d3c23 fix(ui): 移除未声明的组件 props 和 emits 消除 Vue 警告
- ModelManagement: 移除 ModelDetailDrawer 未使用的 providers、loading-providers props 和 refresh-providers 事件
- ProviderDetailDrawer: 移除 BatchAssignModelsDialog 未声明的 provider-identifier prop
- 删除不再使用的 refreshSelectedModelProviders 函数
2026-01-14 00:28:28 +08:00
fawney19 5374add5d8 feat(update): 增强更新检查功能,展示发布日志和发布时间
- 后端从 GitHub Tags API 改为 Releases API,获取更丰富的发布信息
- 新增 release_notes 和 published_at 字段
- 前端更新对话框展示发布时间和 Markdown 格式的更新日志
- 使用 DOMPurify 对 Markdown 渲染结果进行 XSS 防护
- 简化 GlobalModel 缓存失效逻辑,合并同步/异步调用
2026-01-13 20:29:16 +08:00
fawney19andhtmambo 8c4c5fa394 feat(ui): 增加模型连接性测试功能并优化对话框 ESC 键处理
- 模型测试: 新增测试按钮,支持多 API 格式选择
- ESC 键: 重构为全局栈管理,支持嵌套对话框
- 修复: check_endpoint 使用 FORMAT_ID 替代 name
- 优化: Redis 连接池添加健康检查

Co-authored-by: htmambo <[email protected]>
2026-01-13 20:11:09 +08:00
fawney19 6fdb944b1e fix(ui): correct node-line positioning for horizontal scroll
Add proper absolute positioning (right: -64px, top: 50%) to ensure
connection lines correctly span the gap between timeline nodes.
2026-01-13 19:17:53 +08:00
AAEE86 d488f809d5 feat(ui): add horizontal scrolling to request timeline 2026-01-13 19:16:08 +08:00
AAEE86 5ed3695c48 feat(ui): add inline editing for API key rate multipliers
Add click-to-edit functionality for rate multipliers in the provider detail drawer. Users can now click on any multiplier value to edit it inline, with support for keyboard shortcuts (Enter to save, Escape to cancel) and automatic focus management. The changes are immediately saved to the backend and reflected in the UI.
2026-01-13 18:59:53 +08:00
AAEE86 18f39af769 docs(config): add CORS configuration example to env file (#80) 2026-01-13 18:46:23 +08:00
fawney19 1a0a3de2fc feat(ui): 优化熔断状态显示和健康度格式
- 健康度格式统一为 0-1 小数格式(前后端同步)
- 添加熔断倒计时显示,支持实时更新
- 新增 useCountdownTimer composable 用于倒计时逻辑
- 添加 Key 健康恢复功能(按 API 格式恢复)
- 优化 UI 布局:简化链路预览标题、调整 Key 信息为两行显示
- 修复错误处理顺序(先刷新数据再显示成功提示)
- 减少模板中函数重复调用
2026-01-13 18:42:15 +08:00
fawney19 e536b7bc35 fix(ui): 修复提供商展开时左侧节点位置偏移问题
将上半段连线从自适应高度改为固定高度,确保节点圆点始终与卡片头部对齐
2026-01-13 16:06:52 +08:00
fawney19 85decd7487 feat: 实现 GlobalModel 别名匹配系统
主要更改:
- GlobalModel 支持 model_aliases 配置,允许使用正则表达式定义别名规则
- Provider Key 的 allowed_models 现在可以通过别名规则匹配 GlobalModel
- 新增 ModelAliasesTab 组件用于管理模型别名配置
- Provider 详情页新增别名映射预览功能,展示 Key 白名单与 GlobalModel 别名的匹配关系
- 路由预览 API 返回 Key 的 allowed_models 信息

安全特性:
- 使用 regex 库的原生超时保护(100ms)防止 ReDoS 攻击
- 别名规则数量限制(50 条/模型)和长度限制(200 字符)
- 别名映射预览 API 添加超时保护和结果截断

其他改进:
- GlobalModel 更新/删除时使用行级锁防止并发竞态
- 缓存失效逻辑优化,支持异步清理和正则缓存清空
- 路由 Tab 布局重构,使用 flexbox 替代绝对定位
2026-01-13 16:04:15 +08:00
fawney19 9fea71a70c feat(ui): 新增模型请求链路预览功能
- 添加后端 API 获取 GlobalModel 的请求链路信息 (/api/admin/models/global/{id}/routing)
- 新增 RoutingTab 组件展示模型的请求链路树状结构
- 支持全局 Key 优先和提供商优先两种模式的可视化
- 重构批量模型管理对话框为单列勾选模式
- 修复 errorParser.ts 字符串拼接格式问题
- 若干 Vue 模板格式优化
2026-01-12 23:28:37 +08:00
fawney19 bde5bf4c76 fix(ui): 简化模型定价标签显示
将"输入/输出"改为"入/出"以节省空间
2026-01-12 09:10:40 +08:00
fawney19 94abab3260 refactor(ui): 重构批量模型管理对话框为单列勾选模式
- BatchAssignModelsDialog: 从左右双栏改为单列勾选,支持统一保存
- KeyAllowedModelsEditDialog: 添加分组折叠功能,优化布局
- Dialog: 调整移动端内边距适配
- ProviderDetailDrawer: 密钥列表改为两行显示
- KeyFormDialog: 缩小对话框尺寸
- ModelsTab: 调整表格列宽
2026-01-11 19:33:16 +08:00
fawney19 76ed136228 refactor(ui): 简化模型权限编辑对话框交互
- 将模型权限对话框从双栏穿梭框重构为单面板多选模式
- 简化对话框尺寸,优化布局为单列显示避免模型名称截断
- 模型列表移除能力列,新增快捷添加映射按钮
- 支持从模型列表直接跳转到模型映射对话框并预选模型
2026-01-11 02:06:48 +08:00
fawney19 8d8b20aa47 feat(ui): 优化上游模型查询错误提示
新增 parseUpstreamModelError 函数,将后端返回的原始错误信息
转换为用户友好的中文提示,支持解析 HTTP 状态码和 JSON 错误体
2026-01-11 00:41:41 +08:00
fawney19 aec0326d40 feat(ui): 密钥管理支持拖拽调整优先级
- 添加拖拽手柄和视觉反馈效果
- 拖拽到组内(2+同优先级)时加入该组
- 拖拽到单独密钥时交换优先级
- 同组内拖拽忽略操作
2026-01-11 00:22:21 +08:00
fawney19 7faca5512a feat(ui): 优化密钥添加和仪表盘空状态体验
- KeyFormDialog: 添加模式下保存后不关闭对话框,清除表单以便连续添加
- KeyFormDialog: 按钮文案根据编辑/添加模式动态显示
- Dashboard: 优化统计卡片加载状态和空数据占位显示
2026-01-10 19:32:36 +08:00
fawney19 ad84272084 fix: 修复普通用户无法访问仪表盘接口的权限问题
将 DashboardAdapter 的 mode 从 ApiMode.ADMIN 改为 ApiMode.USER,
允许普通用户访问 /api/dashboard/stats 和 /api/dashboard/daily-stats 接口。
2026-01-10 19:31:19 +08:00
fawney19 09e0f594ff refactor: 重构限流系统和健康监控,支持按 API 格式区分
- 将 adaptive_concurrency 重命名为 adaptive_rpm,从并发控制改为 RPM 控制
- 健康监控器支持按 API 格式独立管理健康度和熔断器状态
- 新增 model_permissions 模块,支持按格式配置允许的模型
- 重构前端提供商相关表单组件,新增 Collapsible UI 组件
- 新增数据库迁移脚本支持新的数据结构
2026-01-10 18:48:35 +08:00
fawney19 dd2fbf4424 style(ui): 调整模型详情抽屉关联提供商表格列宽 2026-01-08 13:37:41 +08:00
fawney19 99b12a49c6 Merge pull request #78 from fawney19/perf/optimize
perf: 优化 HTTP 客户端连接池复用
2026-01-08 13:37:13 +08:00
fawney19 ea35efe440 perf: 优化 HTTP 客户端连接池复用
- 新增 get_proxy_client() 方法,相同代理配置复用同一客户端
- 添加 LRU 淘汰策略,代理客户端上限 50 个防止内存泄漏
- 新增 get_default_client_async() 异步线程安全版本
- 使用模块级锁避免类属性初始化竞态条件
- 优化 ConcurrencyManager 使用 Redis MGET 批量获取减少往返
- 添加 get_pool_stats() 连接池统计信息接口
2026-01-08 13:34:59 +08:00
fawney19 bf09e740e9 fix(ui): 优化提供商详情页的交互体验
- 模型列表删除按钮仅在 hover 时显示红色
- 批量关联模型对话框:只有全局模型时展开,有多个分组时全部折叠
2026-01-08 11:25:52 +08:00
fawney19 60c77cec56 Merge pull request #77 from AAEE86/ui
style(ui): improve text visibility in dark mode for model badges
2026-01-08 10:52:54 +08:00
fawney19 0e4a1dddb5 refactor(ui): 优化批量端点创建的 UI 和性能
- 调整布局: API URL 移至顶部, API 格式选择移至下方
- 优化 checkbox 样式: 使用自定义勾选框替代原生样式
- API 格式按列排序: 基础格式和对应 CLI 格式上下对齐
- 请求配置改为 4 列布局, 更紧凑
- 使用 Promise.allSettled 并发创建端点, 提升性能
- 改进错误提示: 失败时直接展示具体错误信息给用户
- 清理未使用的 Select 组件导入和 selectOpen 变量
2026-01-08 10:50:25 +08:00
AAEE86 1cf18b6e12 feat(ui): support batch endpoint creation with multiple API formats (#76)
Replace single API format selector with multi-select checkbox interface in endpoint creation dialog. Users can now select multiple API formats to create multiple endpoints simultaneously with shared configuration (URL, path, timeout, etc.).

- Change API format selection from dropdown to checkbox grid layout
- Add selectedFormats array to track multiple format selections
- Implement batch creation logic with individual error handling
- Update submit button to show endpoint count being created
- Adjust form layout to improve visual hierarchy
- Display appropriate success/failure messages for batch operations
- Reset selectedFormats on form reset
2026-01-08 10:42:14 +08:00
AAEE86 f9a8be898a style(ui): improve text visibility in dark mode for model badges 2026-01-08 10:26:58 +08:00
fawney19 1521ce5a96 feat: 添加负载均衡调度模式
- 新增 load_balance 调度模式,同优先级内随机轮换
- 前端支持三种调度模式切换:缓存亲和、负载均衡、固定顺序
2026-01-08 03:20:04 +08:00
fawney19 f2e62dd197 feat: 添加版本更新检查功能
- 后端新增 /api/admin/system/check-update 接口,从 GitHub Tags 获取最新版本
- 前端新增 UpdateDialog 组件,管理员登录后自动检查更新并弹窗提示
- 同一会话内只检查一次,点击"稍后提醒"后 24 小时内不再提示
- CI 和 deploy.sh 自动生成 _version.py 版本文件
2026-01-08 03:01:54 +08:00
fawney19 d378630b38 perf: 添加多层缓存优化减少数据库查询
- 新增 ProviderCacheService 缓存 Provider 和 ProviderAPIKey 数据
- SystemConfigService 添加进程内缓存(TTL 60秒)
- API Key last_used_at 更新添加节流策略(60秒间隔)
- HTTP 连接池配置改为可配置,支持根据 Worker 数量自动计算
- 前端优先级管理改用 health_score 显示健康度
2026-01-08 02:34:59 +08:00
fawney19 d9e6346911 fix: 降低 API Key 最小长度限制至 3 个字符 2026-01-08 01:53:16 +08:00
fawney19 238788e0e9 fix: 统一端点默认重试次数为 2
同步前端表单、mock 数据和后端导入配置中端点的默认重试次数
2026-01-08 01:40:40 +08:00
fawney19 68ff828505 feat: 容器启动时自动执行数据库迁移
- 添加 entrypoint.sh 在容器启动前执行 alembic upgrade head
- 更新 Dockerfile.app 和 Dockerfile.app.local 使用新入口脚本
- 移除手动迁移脚本 migrate.sh
- 简化 README 部署说明
2026-01-08 01:28:36 +08:00
fawney19 59447fc12b fix: 固定容器内部端口为 8084,避免 PORT 环境变量导致端口冲突 2026-01-07 21:51:55 +08:00
fawney19 c8033fb6ab chore: bump version to 0.2.5 2026-01-07 20:19:49 +08:00
fawney19 e33d5b952c feat: 所有计费模板支持按次计费,调整端点默认重试次数为 2
- 为 Claude、OpenAI、豆包、Gemini 计费模板添加 request 维度
- 支持通过 price_per_request 配置按次计费(如图片生成模型)
- 将端点 max_retries 默认值从 3 改为 2(请求一次 + 重试一次)
2026-01-07 20:15:30 +08:00
fawney19 4345ac2ba2 fix: 添加系统配置项默认值,避免前端获取配置时报错
添加以下配置项到 DEFAULT_CONFIGS:
- email_suffix_mode/email_suffix_list (邮箱后缀限制)
- audit_log_retention_days (审计日志保留天数)
- smtp_* (SMTP 邮件服务器配置)
2026-01-07 20:01:17 +08:00
fawney19 a12b43ce5c refactor: 清理数据库字段命名歧义
- users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
- api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
- providers 表:删除 rate_limit 字段(与 rpm_limit 重复)
- usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)

同步更新前后端所有相关代码
2026-01-07 19:53:32 +08:00
fawney19 6885cf1f6d refactor: 使用 asyncio.wait_for 控制请求整体超时
将 endpoint.timeout 从 httpx 的 read 超时改为 asyncio.wait_for 控制,
更精确地管理"建立连接 + 获取首字节"阶段的整体超时。

主要改动:
- HTTP 超时配置改用全局 config 参数
- endpoint.timeout 作为 asyncio.wait_for 的整体超时
- 增加 asyncio.TimeoutError 处理和连接清理逻辑
- 增加防御性空值检查
2026-01-07 18:17:35 +08:00
fawney19 00f6fafcfc feat: 添加 API 文档路由、扩展用户列表字段、修复 CORS 配置
- Dockerfile.app: 添加 /docs、/redoc、/openapi.json 的 nginx 代理规则
- routes.py: 管理员用户列表接口增加 allowed_providers/endpoints/models 字段
- main.py: 修复 CORS_ORIGINS=* 时 credentials 配置冲突问题
2026-01-07 17:31:31 +08:00
fawney19 42dc64246c feat: 添加 GUNICORN_WORKERS 环境变量配置 2026-01-07 16:38:46 +08:00
fawney19 fbe303a3cd fix: 补充 cost.py 中 Provider 未实现模型时的 else 分支处理 2026-01-07 16:13:53 +08:00
fawney19 373845450b refactor: 简化 docker-compose 环境变量配置
使用 env_file 加载 .env 文件,移除冗余的环境变量声明,
仅保留需要组合的变量和容器级别设置
2026-01-07 15:19:55 +08:00
fawney19 084bbc0bef refactor: 将 nginx gzip 压缩配置移至 server 块内部 2026-01-07 15:10:11 +08:00
fawney19 0061fc04b7 feat: 添加访问令牌管理功能并升级至 0.2.4
- 新增 Management Token(访问令牌)功能,支持创建、更新、删除和管理
- 前端添加访问令牌管理页面,支持普通用户和管理员
- 后端实现完整的令牌生命周期管理 API
- 添加数据库迁移脚本创建 management_tokens 表
- Nginx 配置添加 gzip 压缩,优化响应传输
- Dialog 组件添加 persistent 属性,防止意外关闭
- 为管理后台 API 添加详细的中文文档注释
- 简化多处类型注解,统一代码风格
2026-01-07 14:55:07 +08:00
fawney19 f6a6410626 feat: 添加 GitHub 仓库链接到页面头部
- 新增 GithubIcon 组件复用 GitHub 图标
- MainLayout 和 Home 页面头部添加 GitHub 链接按钮
- 重构 Home 页面 header 布局结构
2026-01-06 18:27:28 +08:00
fawney19 835be3d329 refactor: nginx 透传外层代理 IP 头并禁用审计日志页面的审计记录
- Dockerfile.app/local: 使用 map 指令智能处理 X-Real-IP 和 X-Forwarded-For,
  有外层代理头则透传,否则使用 remote_addr
- audit.py: 查看审计日志不再产生审计记录,避免刷新页面时产生大量无意义日志
2026-01-06 17:23:08 +08:00
fawney19 2395093394 refactor: 简化 IP 获取逻辑并将请求体超时配置化
- 移除 TRUSTED_PROXY_COUNT 配置,改为优先使用 X-Real-IP 头
- 添加 REQUEST_BODY_TIMEOUT 环境变量,默认 60 秒
- 统一 get_client_ip 逻辑,优先级:X-Real-IP > X-Forwarded-For > 直连 IP
2026-01-06 16:29:03 +08:00
fawney19 28209e1c2a Merge pull request #72 from fawney19/test-ldap-pr
feat: 添加 LDAP 认证支持
2026-01-06 14:45:31 +08:00
fawney19 00562dd1d4 feat: 添加 LDAP 认证支持
- 新增 LDAP 服务和 API 接口
- 添加 LDAP 配置管理页面
- 登录页面支持 LDAP/本地认证切换
- 数据库迁移支持 LDAP 相关字段
2026-01-06 14:38:42 +08:00
fawney19 0f78d5cbf3 fix: 增强 CLI 处理器的错误信息,包含上游响应详情 2026-01-05 19:44:38 +08:00
fawney19 431c6de8d2 feat: 用户用量页面支持分页、搜索和密钥信息展示
- 用户用量API增加search参数支持密钥名、模型名搜索
- 用户用量API返回api_key信息(id、name、display)
- 用户页面记录表格增加密钥列显示
- 前端统一管理员和用户页面的分页/搜索逻辑
- 后端LIKE查询增加特殊字符转义防止SQL注入
- 添加escape_like_pattern和safe_truncate_escaped工具函数
2026-01-05 19:35:14 +08:00
fawney19 142e15bbcc Merge pull request #69 from AoaoMH/feature/Record-optimization
feat: add usage statistics and records feature with new API routes, f…
2026-01-05 19:31:59 +08:00
AAEE86 31acc5c607 feat(models): sort models by release date within each provider
Models are now sorted by release date in descending order (newest first)
within each provider group. Models without release dates are placed at the
end. When release dates are identical or missing, models fall back to
alphabetical sorting by name.
2026-01-05 18:23:04 +08:00
fawney19 bfa0a26d41 feat: 用户导出支持独立余额Key,新增系统版本接口
- 用户导出/导入支持独立余额 Key (standalone_keys)
- API Key 导出增加 expires_at 字段
- 新增 /api/admin/system/version 接口获取版本信息
- 前端系统设置页面显示当前版本
- 移除导入对话框中多余的 bg-muted 背景样式
2026-01-05 18:18:45 +08:00
AoaoMH 93ab9b6a5e feat: add usage statistics and records feature with new API routes, frontend types, services, and UI components 2026-01-05 17:03:05 +08:00
fawney19 35e29d46bd refactor: 抽取统一计费模块,支持配置驱动的多厂商计费
- 新增 src/services/billing/ 模块,包含计费计算器、模板和使用量映射
- 将 ChatAdapterBase 和 CliAdapterBase 中的计费逻辑重构为调用 billing 模块
- 为每个 adapter 添加 BILLING_TEMPLATE 类属性,指定计费模板
- 支持 Claude/OpenAI/Gemini 三种计费模板,支持阶梯计费和缓存 TTL 定价
- 新增 tests/services/billing/ 单元测试
2026-01-05 16:48:59 +08:00
fawney19 465da6f818 feat: OpenAI 流式响应解析器支持提取 usage 信息
部分 OpenAI 兼容 API(如豆包)会在最后一个 chunk 中发送 usage 信息,
现在可以正确提取 prompt_tokens 和 completion_tokens。
2026-01-05 12:50:05 +08:00
fawney19 e5f12fddd9 feat: 流式预读增强与自适应并发算法优化
流式预读增强:
- 新增预读字节上限(64KB),防止无换行响应导致内存增长
- 预读结束后检测非 SSE 格式的错误响应(HTML 页面、纯 JSON 错误)
- 抽取 check_html_response 和 check_prefetched_response_error 到 utils.py

自适应并发算法优化(边界记忆 + 渐进探测):
- 缩容策略:从乘性减少改为边界 -1,一次 429 即可收敛到真实限制附近
- 扩容策略:普通扩容不超过已知边界,探测性扩容可谨慎突破(每次 +1)
- 仅在并发限制 429 时记录边界,避免 RPM/UNKNOWN 类型覆盖
2026-01-05 12:17:45 +08:00
fawney19 4fa9a1303a feat: 优化首字时间和 streaming 状态的记录时序
改进 streaming 状态更新机制:
- 统一在首次输出时记录 TTFB 并更新 streaming 状态
- 重构 CliMessageHandlerBase 中的状态更新逻辑,消除重复
- 确保 provider/key 信息在 streaming 状态更新时已可用

前端改进:
- 添加 first_byte_time_ms 字段支持
- 管理员接口支持返回 provider/api_key_name 字段
- 优化活跃请求轮询逻辑,更准确地判断是否需要刷新完整数据

数据库与 API:
- UsageService.get_active_requests_status 添加 include_admin_fields 参数
- 管理员接口调用时启用该参数以获取额外信息
2026-01-05 10:31:34 +08:00
fawney19 43f349d415 fix: 确保 CLI handler 的 streaming 状态更新时 provider 信息已设置
在 execute_with_fallback 返回后,显式设置 ctx 的 provider 信息,
与 chat_handler_base.py 的行为保持一致,避免 streaming 状态更新
时 provider 为空的问题。
2026-01-05 09:36:35 +08:00
fawney19 02069954de fix: streaming 状态更新时传递 first_byte_time_ms 2026-01-05 09:29:38 +08:00
fawney19 2e15875fed feat: 端点 API 支持 custom_path 字段
- ProviderEndpointCreate 添加 custom_path 参数
- ProviderEndpointUpdate 添加 custom_path 参数
- ProviderEndpointResponse 返回 custom_path 字段
- 创建端点时传递 custom_path 到数据库模型
2026-01-05 09:22:20 +08:00
fawney19 b34cfb676d fix: streaming 状态更新时传递 provider 相关 ID 信息
在 update_usage_status 方法中增加 provider_id、provider_endpoint_id
和 provider_api_key_id 参数,确保流式请求进入 streaming 状态时
能正确记录这些字段。
2026-01-05 09:12:03 +08:00
fawney19 3064497636 refactor: 改进上游错误消息的提取和传递
- 新增 extract_error_message 工具函数,统一错误消息提取逻辑
- 在 HTTPStatusError 异常上附加 upstream_response 属性,保留原始错误
- 优先使用上游响应内容作为错误消息,而非异常字符串表示
- 移除错误消息的长度限制(500/1000 字符)
- 修复边界条件检查,使用 startswith 匹配 "Unable to read" 前缀
- 简化 result.py 中的条件判断逻辑
2026-01-05 03:18:55 +08:00
fawney19 dec681fea0 fix: 统一时区处理,确保所有 datetime 带时区信息
- token_bucket.py: get_reset_time 和 Redis 后端使用 timezone.utc
- sliding_window.py: get_reset_time 和 retry_after 计算使用 timezone.utc
- provider_strategy.py: dateutil.parser 解析后确保有时区信息
2026-01-05 02:23:24 +08:00
fawney19 523e27ba9a fix: API Key 过期时间使用应用时区而非 UTC
- 后端:parse_expiry_date 使用 APP_TIMEZONE(默认 Asia/Shanghai)
- 前端:移除提示文案中的 "UTC"
2026-01-05 02:18:16 +08:00
fawney19 e7db76e581 refactor: API Key 过期时间改用日期选择器,rate_limit 支持无限制
- 前端:将过期时间设置从"天数输入"改为"日期选择器",更直观
- 后端:新增 expires_at 字段(ISO 日期格式),兼容旧版 expire_days
- rate_limit 字段现在支持 null 表示无限制,移除默认值 100
- 解析逻辑:过期时间设为当天 UTC 23:59:59.999999
2026-01-05 02:16:16 +08:00
fawney19 689339117a refactor: 提取 ModelMultiSelect 组件并支持失效模型检测
- 新增 ModelMultiSelect 组件,支持显示和移除已失效的模型
- 新增 useInvalidModels composable 检测 allowed_models 中的无效引用
- 重构 StandaloneKeyFormDialog 和 UserFormDialog 使用新组件
- 补充 GlobalModel 删除逻辑的设计说明注释
2026-01-05 01:20:58 +08:00
fawney19 b202765be4 perf: 优化流式响应 TTFB,将数据库状态更新移至 yield 后执行
- StreamUsageTracker: 先 yield 首个 chunk 再更新 streaming 状态
- EnhancedStreamUsageTracker: 同步添加 TTFB 记录和状态更新逻辑
- 确保客户端首字节响应不受数据库操作延迟影响
2026-01-05 00:13:23 +08:00
fawney19 3bbf3073df feat: 所有 Provider 失败时透传上游错误信息
- FallbackOrchestrator 在所有候选组合失败后保留最后的错误信息
- 从 httpx.HTTPStatusError 提取上游状态码和响应内容
- ProviderNotAvailableException 携带上游错误信息
- ErrorResponse 在返回错误时透传上游状态码和响应
2026-01-04 23:50:15 +08:00
fawney19 f46aaa2182 debug: 添加 streaming 状态更新时 provider 为空的调试日志
- base_handler: 更新 streaming 状态时检测并记录 provider 为空的情况
- cli_handler_base: 修复预读数据为空时未更新 streaming 状态的问题
- usage service: 检测状态变为 streaming 但 provider 仍为 pending 的异常
2026-01-04 23:16:01 +08:00
fawney19 a2f33a6c35 perf: 拆分热力图为独立接口并添加 Redis 缓存
- 新增独立热力图 API 端点 (/api/admin/usage/heatmap, /api/users/me/usage/heatmap)
- 添加 Redis 缓存层 (5分钟 TTL),减少数据库查询
- 用户角色变更时清除热力图缓存
- 前端并行加载统计数据和热力图,添加加载/错误状态显示
- 修复 cache_decorator 缺少 JSON 解析错误处理的问题
- 更新 docker-compose 启动命令提示
2026-01-04 22:42:58 +08:00
fawney19 b6bd6357ed perf: 优化 GlobalModel 列表查询的 N+1 问题 2026-01-04 20:05:23 +08:00
fawney19 c3a5878b1b feat: 优化用量查询分页和热力图性能
- 用量查询接口添加 limit/offset 分页参数支持
- 热力图统计从实时查询 Usage 表改为读取预计算的 StatsDaily/StatsUserDaily 表
- 修复 avg_response_time_ms 为 0 时被错误跳过的问题
2026-01-04 18:02:47 +08:00
RWDai 3e4309eba3 Enhance LDAP auth config handling 2026-01-04 16:27:02 +08:00
RWDai 414f45aa71 Fix LDAP authentication stability 2026-01-04 13:09:55 +08:00
RWDai ebdc76346f revert: 回滚 _version.py 版本号变更 2026-01-04 11:25:58 +08:00
RWDai 64bfa955f4 feat(ldap): 完善 LDAP 认证功能和安全性
- 添加 LDAP 配置类型定义,移除 any 类型
- 首次配置 LDAP 时强制要求设置绑定密码
- 根据认证类型区分登录标识验证(本地需邮箱,LDAP 允许用户名)
- 添加 LDAP 过滤器转义函数防止注入攻击
- 增加 LDAP 连接超时设置
- 添加账户来源冲突检查,防止 LDAP 覆盖本地账户
- 添加用户名冲突自动重命名机制
2026-01-04 11:18:28 +08:00
RWDai 612992fa1f Merge remote-tracking branch 'origin/master' into feature/ldap-authentication 2026-01-04 10:48:36 +08:00
fawney19 c02ac56da8 chore: 更新 docker-compose 命令为 docker compose
统一使用 Docker Compose V2 的现代写法
2026-01-03 01:39:45 +08:00
RWDai 9bfb295238 feat: add ldap login 2026-01-02 16:17:24 +08:00
1372 changed files with 298237 additions and 42728 deletions
+1
View File
@@ -11,6 +11,7 @@ ENV/
.uv/
*.egg-info/
dist/
!aether-hub/dist/aether-hub
build/
*.egg
+72
View File
@@ -21,6 +21,10 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
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_USERNAME=admin
@@ -32,8 +36,76 @@ ADMIN_PASSWORD=admin123456
# 应用端口(默认 8084)
# APP_PORT=8084
# 生产部署镜像(deploy.sh 会读取)
# APP_IMAGE=ghcr.io/fawney19/aether:latest
# Gunicorn Worker 数量(默认 2)
# Tunnel 请求统一经 Hub 转发,可安全使用多 worker。
# 非 Docker 运行时若使用 ProxyNode tunnel,请确保 aether-hub 可达(默认 ws://127.0.0.1:8085)。
# GUNICORN_WORKERS=2
# Gunicorn Max Requests(默认 4000)
# Worker 处理指定数量请求后自动重启,防止内存泄漏
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
# 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_PREFIX=sk
# 日志级别(默认 INFO,可选:DEBUG, INFO, WARNING, ERROR)
# LOG_LEVEL=INFO
# CORS 配置(允许跨域的源,多个源用逗号分隔)
# 示例: http://localhost:3000,https://example.com
# 默认: * (允许所有源)
# 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 并告警)
# BILLING_REQUIRE_RULE=false
#
# required 维度缺失时是否拒绝请求/标记任务失败(默认 false:cost=0 + 标记 incomplete)
# BILLING_STRICT_MODE=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
+227
View File
@@ -0,0 +1,227 @@
name: Build aether-proxy
on:
push:
tags: ['proxy-v*']
workflow_dispatch:
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
GHCR_IMAGE: fawney19/aether-proxy
DOCKERHUB_IMAGE: fawney19/aether-proxy
jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-amd64
target: x86_64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
- name: linux-arm64
target: aarch64-unknown-linux-gnu
os: ubuntu-latest
use_cross: true
- name: macos-amd64
target: x86_64-apple-darwin
os: macos-latest
use_cross: false
- name: macos-arm64
target: aarch64-apple-darwin
os: macos-latest
use_cross: false
- name: windows-amd64
target: x86_64-pc-windows-msvc
os: windows-latest
use_cross: false
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-proxy -> target
key: ${{ matrix.target }}
- name: Install cross
if: matrix.use_cross
uses: taiki-e/install-action@cross
- name: Build
working-directory: aether-proxy
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 (Unix)
if: runner.os != 'Windows'
shell: bash
run: |
cd aether-proxy/target/${{ matrix.target }}/release
chmod +x aether-proxy
tar czf ../../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy
- name: Package (Windows)
if: runner.os == 'Windows'
shell: bash
run: |
cd aether-proxy/target/${{ matrix.target }}/release
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: aether-proxy-${{ matrix.name }}
path: |
aether-proxy-*.tar.gz
aether-proxy-*.zip
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-proxy-* > SHA256SUMS.txt
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "${{ github.ref_name }}"
generate_release_notes: true
files: |
artifacts/aether-proxy-*
artifacts/SHA256SUMS.txt
fail_on_unmatched_files: true
docker:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v5
- name: Download Linux artifacts
uses: actions/download-artifact@v5
with:
pattern: aether-proxy-linux-*
merge-multiple: true
path: artifacts
- name: Prepare binaries
run: |
mkdir -p aether-proxy/build/linux-amd64 aether-proxy/build/linux-arm64
tar xzf artifacts/aether-proxy-linux-amd64.tar.gz -C aether-proxy/build/linux-amd64
tar xzf artifacts/aether-proxy-linux-arm64.tar.gz -C aether-proxy/build/linux-arm64
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
docker.io/${{ env.DOCKERHUB_IMAGE }}
tags: |
type=match,pattern=proxy-v(.*),group=1
type=match,pattern=proxy-v(\d+\.\d+),group=1
type=sha,prefix=
flavor: |
latest=auto
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./aether-proxy
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
update-readme:
needs: release
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v5
with:
ref: master
- name: Update README download links
env:
TAG: ${{ github.ref_name }}
run: |
VERSION="${TAG#proxy-v}"
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
cd aether-proxy
TABLE="| Platform | Download |\n|----------|----------|\n"
TABLE+="| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](${BASE}/aether-proxy-linux-amd64.tar.gz) |\n"
TABLE+="| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](${BASE}/aether-proxy-linux-arm64.tar.gz) |\n"
TABLE+="| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](${BASE}/aether-proxy-macos-amd64.tar.gz) |\n"
TABLE+="| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](${BASE}/aether-proxy-macos-arm64.tar.gz) |\n"
TABLE+="| Windows x86_64 | [aether-proxy-windows-amd64.zip](${BASE}/aether-proxy-windows-amd64.zip) |"
# Replace content between markers
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
awk -v table="$TABLE" '
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
!skip { print }
' README.md > README.tmp && mv README.tmp README.md
fi
- name: Commit and push
run: |
cd aether-proxy
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git diff --cached --quiet && exit 0
TAG="${GITHUB_REF#refs/tags/}"
git commit -m "chore(proxy): update download links for ${TAG}"
git push
+4 -4
View File
@@ -18,12 +18,12 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '20'
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -41,7 +41,7 @@ jobs:
run: cp frontend/dist/index.html frontend/dist/404.html
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
+190 -26
View File
@@ -15,16 +15,22 @@ env:
REGISTRY: ghcr.io
BASE_IMAGE_NAME: fawney19/aether-base
APP_IMAGE_NAME: fawney19/aether
# Files that affect base image - used for hash calculation
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
GITHUB_REPO: fawney19/Aether
# Base image hash inputs:
# - Dockerfile.base
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
# - frontend/package-lock.json
jobs:
check-base-changes:
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
base_changed: ${{ steps.check.outputs.base_changed }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Log in to Container Registry
uses: docker/login-action@v3
@@ -41,15 +47,45 @@ jobs:
exit 0
fi
# Calculate current hash of base-related files
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
echo "Current base files hash: $CURRENT_HASH"
# Calculate current hash of base-related inputs (dependency-only fingerprint)
PY_FINGERPRINT=$(python3 - <<'PY'
import json
import pathlib
import tomllib
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
project = data.get("project") or {}
build = data.get("build-system") or {}
fingerprint = {
"requires-python": project.get("requires-python"),
"dependencies": sorted(project.get("dependencies") or []),
"build-backend": build.get("build-backend"),
"build-requires": sorted(build.get("requires") or []),
}
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
PY
)
CURRENT_HASH=$(
(
cat Dockerfile.base
printf '%s\n' "$PY_FINGERPRINT"
cat frontend/package-lock.json
) | sha256sum | cut -d' ' -f1
)
echo "Current base hash: $CURRENT_HASH"
# Try to get hash label from remote image config
# Pull the image config and extract labels
REMOTE_HASH=""
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest; then
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
else
echo "WARN: failed to pull remote base image; forcing base rebuild."
echo "base_changed=true" >> $GITHUB_OUTPUT
exit 0
fi
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
@@ -72,7 +108,7 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -87,7 +123,33 @@ jobs:
- name: Calculate base files hash
id: hash
run: |
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
PY_FINGERPRINT=$(python3 - <<'PY'
import json
import pathlib
import tomllib
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
project = data.get("project") or {}
build = data.get("build-system") or {}
fingerprint = {
"requires-python": project.get("requires-python"),
"dependencies": sorted(project.get("dependencies") or []),
"build-backend": build.get("build-backend"),
"build-requires": sorted(build.get("requires") or []),
}
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
PY
)
HASH=$(
(
cat Dockerfile.base
printf '%s\n' "$PY_FINGERPRINT"
cat frontend/package-lock.json
) | sha256sum | cut -d' ' -f1
)
echo "hash=$HASH" >> $GITHUB_OUTPUT
- name: Extract metadata for base image
@@ -102,26 +164,47 @@ jobs:
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
- name: Build and push base image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.base
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=base
cache-to: type=gha,mode=max,scope=base
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:
needs: [check-base-changes, build-base]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
needs: [check-base-changes, build-base, download-hub]
if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped') && needs.download-hub.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -133,31 +216,112 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for app image
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
images: |
${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
docker.io/fawney19/aether
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=pre,enable=${{ contains(github.ref, '-') }}
type=raw,value=fix,enable=${{ contains(github.ref, '-fix') }}
type=sha,prefix=
flavor: |
latest=auto
- name: Extract version from tag
id: version
run: |
# 从 tag 提取版本号,如 v0.2.5 -> 0.2.5
VERSION="${GITHUB_REF#refs/tags/v}"
if [ "$VERSION" = "$GITHUB_REF" ]; then
# 不是 tag 触发,使用 git describe
VERSION=$(git describe --tags --always | sed 's/^v//')
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Update Dockerfile.app to use registry base image
run: |
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
- name: Build and push app image
uses: docker/build-push-action@v5
- name: Generate version file
run: |
# 生成 _version.py 文件
cat > src/_version.py << EOF
# Auto-generated by CI
__version__ = '${{ steps.version.outputs.version }}'
__version_tuple__ = tuple(int(x) for x in '${{ steps.version.outputs.version }}'.split('.') if x.isdigit())
version = __version__
version_tuple = __version_tuple__
EOF
- name: Resolve hub release for build args
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:
context: .
file: ./Dockerfile.app
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
no-cache-filters: builder
cache-from: type=gha,scope=app-amd64
cache-to: type=gha,mode=min,scope=app-amd64
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
+16
View File
@@ -2,9 +2,11 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=python
# AI Assistant Configuration
.codex/
.claude/
.serena/
.gemini*/
.plans
### Python ###
*.db
@@ -202,6 +204,7 @@ logs/
# Git backup
.git.backup/
.worktrees/
# Database backups
backups/
@@ -219,8 +222,21 @@ frontend/public/*-firework.svg
# Debug and experimental files
debug_*.html
extracted_*.ts
test.py
# Deploy script cache
.deps-hash
.code-hash
.migration-hash
.hub-hash
# Hub prebuilt binaries
aether-hub/dist/
# Version file (auto-generated by hatch-vcs)
src/_version.py
# Analysis folder (third-party code for reference)
analysis/
new-api/
/aether-proxy/target/
+1
View File
@@ -0,0 +1 @@
3.13
+249 -92
View File
@@ -1,134 +1,291 @@
# syntax=docker/dockerfile:1
# 运行镜像:从 base 提取产物到精简运行时
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
# 用于 GitHub Actions CI(官方源)
FROM aether-base:latest AS builder
WORKDIR /app
# 复制前端源码并构建
# 复制前端源码并构建(CI 通过 no-cache-filters=builder 确保每次重建)
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
FROM python:3.13-slim
WORKDIR /app
# 运行时依赖(无 gcc/nodejs/npm)
RUN apt-get update && apt-get install -y \
ARG HUB_RELEASE_REPO=fawney19/Aether
ARG HUB_TAG
ARG TARGETARCH
ARG GITHUB_TOKEN
# 运行时依赖(无 gcc/nodejs/npm,使用 BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
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 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# 只复制需要的 Python 可执行文件
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/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 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
COPY gunicorn_conf.py ./
# Nginx 配置模板
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' # 静态资源:长期缓存' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' # 安全:阻止访问源码目录' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:8085/proxy;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_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_send_timeout 86400s;' \
' }' \
'' \
' # 后端 API 路由(白名单)→ 代理到后端' \
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' 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_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 60s;' \
' proxy_send_timeout 3600s;' \
' proxy_read_timeout 3600s;' \
' }' \
'' \
' # API 文档路由 → 代理到后端' \
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' 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)' \
' location / {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'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
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/8084/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'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' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'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
# 入口脚本(启动前执行迁移)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \
GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+263 -78
View File
@@ -1,6 +1,8 @@
# syntax=docker/dockerfile:1
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# 用于本地/国内服务器部署
FROM aether-base:latest AS builder
WORKDIR /app
@@ -10,126 +12,309 @@ COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
FROM python:3.13-slim
WORKDIR /app
# 运行时依赖(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
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 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
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 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# 只复制需要的 Python 可执行文件
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/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 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
RUN chmod -R 755 /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
COPY gunicorn_conf.py ./
# Nginx 配置模板
# 策略:白名单后端路由 → 后端代理,其余全部 → 前端 SPA(index.html)
# 智能处理 IP:有外层代理头就透传,没有就用直连 IP
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
'map $http_x_real_ip $real_ip {' \
' default $http_x_real_ip;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_x_forwarded_for $forwarded_for {' \
' default $http_x_forwarded_for;' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' # gzip 压缩配置(对 base64 图片等非流式响应有效)' \
' gzip on;' \
' gzip_min_length 256;' \
' gzip_comp_level 5;' \
' gzip_vary on;' \
' gzip_proxied any;' \
' gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \
' gzip_disable "msie6";' \
'' \
' # 静态资源:长期缓存' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' # 安全:阻止访问源码目录' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' # WebSocket 隧道端点(aether-proxy tunnel 模式)' \
' location = /api/internal/proxy-tunnel {' \
' proxy_pass http://127.0.0.1:8085/proxy;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_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_send_timeout 86400s;' \
' }' \
'' \
' # 后端 API 路由(白名单)→ 代理到后端' \
' location ~ ^/(api|v1|v1beta|upload|health)(/|$) {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Upgrade $http_upgrade;' \
' proxy_set_header Connection $connection_upgrade;' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' 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_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 60s;' \
' proxy_send_timeout 3600s;' \
' proxy_read_timeout 3600s;' \
' }' \
'' \
' # API 文档路由 → 代理到后端' \
' location ~ ^/(docs|redoc|openapi\\.json)$ {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $real_ip;' \
' proxy_set_header X-Forwarded-For $forwarded_for;' \
' 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)' \
' location / {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'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
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'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' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'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
# 入口脚本(启动前执行迁移)
COPY entrypoint.sh /entrypoint.sh
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \
GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+14 -11
View File
@@ -1,25 +1,28 @@
# syntax=docker/dockerfile:1
# 构建镜像:编译环境 + 预编译的依赖
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
FROM python:3.13-slim
WORKDIR /app
# 构建工具
RUN apt-get update && apt-get install -y \
# 构建工具(使用 BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
npm
# Python 依赖
# Python 依赖(使用 BuildKit 缓存加速)
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
RUN --mount=type=cache,target=/root/.cache/pip \
mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
# 前端依赖(只安装,不构建)
# 前端依赖(只安装,不构建,使用 BuildKit 缓存加速)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci
RUN --mount=type=cache,target=/root/.npm \
cd frontend && npm ci
+15 -12
View File
@@ -1,28 +1,31 @@
# syntax=docker/dockerfile:1
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
FROM python:3.13-slim
WORKDIR /app
# 构建工具(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
# 构建工具(使用清华镜像源 + BuildKit 缓存加速)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y --no-install-recommends \
libpq-dev \
gcc \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
npm
# pip 镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# Python 依赖
# Python 依赖(使用 BuildKit 缓存加速)
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
RUN --mount=type=cache,target=/root/.cache/pip \
mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install .
# 前端依赖(只安装,不构建,使用淘宝镜像源)
# 前端依赖(只安装,不构建,使用淘宝镜像源 + BuildKit 缓存加速)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
RUN --mount=type=cache,target=/root/.npm \
cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
+56 -50
View File
@@ -5,8 +5,8 @@
<h1 align="center">Aether</h1>
<p align="center">
<strong>开源 AI API 网关</strong><br>
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入层
<strong>一站式 AI 基础设施平台</strong><br>
支持 Claude / OpenAI / Gemini 及其 CLI 客户端的统一接入、格式转换、正/反向代理, 致力于成为用户驱动AI服务的底座
</p>
<p align="center">
<a href="#简介">简介</a> •
@@ -22,27 +22,15 @@
Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户管理、智能负载均衡、成本配额控制和健康监控能力。通过统一的 API 入口,可以无缝对接 Claude、OpenAI、Gemini 等主流 AI 服务及其 CLI 工具。
### 页面预览
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/architecture/architecture-dark.svg">
<source media="(prefers-color-scheme: light)" srcset="docs/architecture/architecture-light.svg">
<img src="docs/architecture/architecture-light.svg" width="680" alt="Aether Architecture">
</picture>
</p>
| 首页 | 仪表盘 |
|:---:|:---:|
| ![首页](docs/screenshots/home.png) | ![仪表盘](docs/screenshots/dashboard.png) |
| 健康监控 | 用户管理 |
|:---:|:---:|
| ![健康监控](docs/screenshots/health.png) | ![用户管理](docs/screenshots/users.png) |
| 提供商管理 | 使用记录 |
|:---:|:---:|
| ![提供商管理](docs/screenshots/providers.png) | ![使用记录](docs/screenshots/usage.png) |
| 模型详情 | 关联提供商 |
|:---:|:---:|
| ![模型详情](docs/screenshots/model-detail.png) | ![关联提供商](docs/screenshots/model-providers.png) |
| 链路追踪 | 系统设置 |
|:---:|:---:|
| ![链路追踪](docs/screenshots/tracing.png) | ![系统设置](docs/screenshots/settings.png) |
页面预览: https://fawney19.github.io/Aether/
## 部署
@@ -51,20 +39,17 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
```bash
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd aether
cd Aether
# 2. 配置环境变量
cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署
docker-compose up -d
# 3. 部署 / 更新(自动执行数据库迁移)
docker compose pull && docker compose up -d
# 4. 首次部署时, 初始化数据库
./migrate.sh
# 5. 更新
docker-compose pull && docker-compose up -d && ./migrate.sh
# 4. 升级前备份 (可选)
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
```
### Docker Compose(本地构建镜像)
@@ -72,13 +57,14 @@ docker-compose pull && docker-compose up -d && ./migrate.sh
```bash
# 1. 克隆代码
git clone https://github.com/fawney19/Aether.git
cd aether
cd Aether
# 2. 配置环境变量
cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动构建、启动、迁移)
git pull
./deploy.sh
```
@@ -86,7 +72,7 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
```bash
# 启动依赖
docker-compose -f docker-compose.build.yml up -d postgres redis
docker compose -f docker-compose.build.yml up -d postgres redis
# 后端
uv sync
@@ -96,6 +82,14 @@ uv sync
cd frontend && npm install && npm run dev
```
## Aether Proxy (可选)
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
- Docker Compose 部署或下载预编译二进制直接运行
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
- 详细文档见 [aether-proxy/README.md](aether-proxy/README.md)
## 环境变量
### 必需配置
@@ -117,7 +111,7 @@ cd frontend && npm install && npm run dev
| `APP_PORT` | 8084 | 应用端口 |
| `API_KEY_PREFIX` | sk | API Key 前缀 |
| `LOG_LEVEL` | INFO | 日志级别 (DEBUG/INFO/WARNING/ERROR) |
| `GUNICORN_WORKERS` | 4 | Gunicorn 工作进程数 |
| `GUNICORN_WORKERS` | 2 | Gunicorn 工作进程数 |
| `DB_PORT` | 5432 | PostgreSQL 端口 |
| `REDIS_PORT` | 6379 | Redis 端口 |
@@ -133,30 +127,42 @@ cd frontend && npm install && npm run dev
| Headers | Base + 请求头 |
| Full | Headers + 请求体 |
### Q: 管理员如何给模型配置 1M上下文 / 1H缓存 能力支持?
### Q: 更新出问题如何回滚?
1. **模型管理**: 给模型设置 1M上下文 / 1H缓存 的能力支持, 并配置好价格
2. **提供商管理**: 给端点添加支持该能力的密钥, 并勾选对应的能力标签
**有备份的情况(推荐):**
### Q: 用户如何使用 1H缓存?
```bash
# 1. 停止应用
docker compose stop app
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
# 2. 恢复数据库(先清空再导入)
docker compose exec -T postgres psql -U postgres -c "DROP DATABASE aether; CREATE DATABASE aether;"
gunzip < backup_xxx.sql.gz | docker compose exec -T postgres psql -U postgres -d aether
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
# 3. 拉取旧版本镜像并重启
# 方式一:使用具体版本 tag(如果有发布版本号)
# 将 docker-compose.yml 中 image 从 ghcr.io/fawney19/aether:latest 改为指定版本
# 方式二:使用之前记录的镜像 digest
# 将 image 改为 ghcr.io/fawney19/aether@sha256:xxxxx
docker compose up -d app
```
### Q: 如何配置负载均衡?
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest,方便回滚时使用。
在管理后台 **提供商管理** 中切换调度模式:
**没有备份的情况:**
| 模式 | 说明 | 适用场景 |
|------|------|----------|
| **提供商优先** | 按 Provider 优先级排序, 同优先级内按 Key 优先级排序, 相同优先级哈希分散 | 优先使用特定供应商 |
| **全局 Key 优先** | 忽略 Provider 层级, 所有 Key 按全局优先级统一排序, 相同优先级哈希分散 | 跨 Provider 统一调度, 最大化利用所有 Key |
```bash
# 1. 用当前容器回退数据库迁移(回退 1 步,按需调整数字)
docker compose exec app alembic downgrade -1
### Q: 提供商免费套餐的计费模式会计入成本吗?
# 2. 查看回退后的版本确认正确
docker compose exec app alembic current
> **不会**。免费套餐的计费模式倍率为 0, 产生的记录不计入成本费用。
# 3. 切回旧镜像并重启(同上方式修改 docker-compose.yml 中的 image)
docker compose up -d app
```
> 注意:没有备份的回滚依赖 alembic downgrade,如果迁移涉及不可逆的数据变更(如删除列),可能无法完全恢复数据。因此强烈建议升级前备份。
---
@@ -168,10 +174,10 @@ cd frontend && npm install && npm run dev
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
&nbsp;&nbsp;&nbsp;&nbsp;
<img src="docs/author/qrcode_1770574997172.jpg" width="200" alt="QQ群二维码">
</p>
## Star History
[![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;
}
_ => {}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
# Aether server URL
AETHER_PROXY_AETHER_URL=https://aether.example.com
# Management Token (ae_xxx, must belong to an ADMIN user)
AETHER_PROXY_MANAGEMENT_TOKEN=ae_xxxxx
# Node identification
AETHER_PROXY_NODE_NAME=proxy-01
+3403
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "aether-proxy"
version = "0.2.5"
edition = "2021"
description = "Tunnel proxy for Aether"
[dependencies]
tokio = { version = "1", features = ["full"] }
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-rustls = "0.26"
futures-util = "0.3"
base64 = "0.22"
clap = { version = "4", features = ["derive", "env"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
bytes = "1"
sha2 = "0.10"
hex = "0.4"
anyhow = "1"
arc-swap = "1"
toml = "0.8"
rustls = { version = "0.23", features = ["ring"] }
ratatui = "0.30"
crossterm = "0.28"
url = "2"
sysinfo = "0.32"
libc = "0.2"
flate2 = "1"
tar = "0.4"
socket2 = { version = "0.5", features = ["all"] }
tower-service = "0.3"
webpki-roots = "0.26"
[profile.release]
lto = true
strip = true
codegen-units = 1
+10
View File
@@ -0,0 +1,10 @@
FROM debian:bookworm-slim
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY build/linux-${TARGETARCH}/aether-proxy /usr/local/bin/aether-proxy
ENTRYPOINT ["aether-proxy"]
+154
View File
@@ -0,0 +1,154 @@
# aether-proxy
Aether Tunnel 代理节点,部署在海外 VPS 上,通过 WebSocket 隧道为 Aether 实例中转 API 流量。
Tunnel 模式下代理节点**无需对外监听端口**,仅需出站连接到 Aether 服务器。
## 安装
### Docker Compose 部署
```bash
cp .env.example .env
# 编辑 .env 填入 AETHER_PROXY_AETHER_URL 和 AETHER_PROXY_MANAGEMENT_TOKEN
docker compose up -d
```
### 下载预编译二进制
<!-- DOWNLOAD_TABLE_START -->
| Platform | Download |
|----------|----------|
| 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.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.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.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.5/aether-proxy-windows-amd64.zip) |
<!-- DOWNLOAD_TABLE_END -->
## 快速开始
```bash
# 1. 首次安装配置(TUI 向导,勾选 Install Service 随系统启动服务)
sudo ./aether-proxy setup
# 2. 日常管理 (勾选 Install Service 作为系统服务的情况下)
aether-proxy status # 看状态
aether-proxy logs # 看日志
sudo aether-proxy start # 启动服务
sudo aether-proxy stop # 停止服务
sudo aether-proxy restart # 重启服务
# 3. 重新配置(改完自动重启服务)
sudo aether-proxy setup
# 4. 彻底卸载
sudo aether-proxy uninstall
```
完成向导后, 配置自动保存到 `aether-proxy.toml`,如果启用了 Install Service,将自动注册并启动 systemd 服务。
### 直接运行
如果不需要安装为系统服务,可以直接运行。缺少必填参数时会自动进入 setup 向导:
```bash
./aether-proxy
```
## 配置
配置按以下优先级加载(高优先级覆盖低优先级):
1. CLI 参数
2. 环境变量(`AETHER_PROXY_*`)
3. 配置文件(`aether-proxy.toml`,或通过 `AETHER_PROXY_CONFIG` 指定路径)
### 参数一览
#### 基础配置
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--aether-url` | `AETHER_PROXY_AETHER_URL` | **必填** | Aether 服务器地址 |
| `--management-token` | `AETHER_PROXY_MANAGEMENT_TOKEN` | **必填** | 管理员 Token(`ae_xxx` 格式) |
| `--public-ip` | `AETHER_PROXY_PUBLIC_IP` | 自动检测 | 公网 IP |
| `--node-name` | `AETHER_PROXY_NODE_NAME` | `proxy-01` | 节点名称标识 |
| `--node-region` | `AETHER_PROXY_NODE_REGION` | 自动检测 | 地区标识 |
| `--heartbeat-interval` | `AETHER_PROXY_HEARTBEAT_INTERVAL` | `30` | 心跳间隔(秒) |
| `--allowed-ports` | `AETHER_PROXY_ALLOWED_PORTS` | `80,443,8080,8443` | 允许代理的目标端口 |
#### Tunnel 连接
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--tunnel-connections` | `AETHER_PROXY_TUNNEL_CONNECTIONS` | `3` | 到 Aether 的连接池大小 |
| `--tunnel-max-streams` | `AETHER_PROXY_TUNNEL_MAX_STREAMS` | 自动(硬件估算) | 单连接最大并发 stream 数 |
| `--tunnel-connect-timeout-secs` | `AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT_SECS` | `15` | TCP + TLS 握手超时(秒) |
| `--tunnel-tcp-keepalive-secs` | `AETHER_PROXY_TUNNEL_TCP_KEEPALIVE_SECS` | `30` | TCP keepalive 初始延迟(秒) |
| `--tunnel-tcp-nodelay` | `AETHER_PROXY_TUNNEL_TCP_NODELAY` | `true` | 禁用 Nagle 算法 |
| `--tunnel-ping-interval-secs` | `AETHER_PROXY_TUNNEL_PING_INTERVAL_SECS` | `15` | WebSocket Ping 频率(秒) |
| `--tunnel-stale-timeout-secs` | `AETHER_PROXY_TUNNEL_STALE_TIMEOUT_SECS` | `45` | 无数据断连阈值(秒) |
| `--tunnel-reconnect-base-ms` | `AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS` | `500` | 指数退避基础延迟(毫秒) |
| `--tunnel-reconnect-max-ms` | `AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS` | `30000` | 指数退避上限(毫秒) |
#### 上游 HTTP 请求
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--upstream-connect-timeout-secs` | `AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT_SECS` | `30` | 上游建连超时(秒) |
| `--upstream-pool-max-idle-per-host` | `AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST` | `64` | 每 Host 最大空闲连接数 |
| `--upstream-pool-idle-timeout-secs` | `AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT_SECS` | `300` | 连接池空闲超时(秒) |
| `--upstream-tcp-keepalive-secs` | `AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE_SECS` | `60` | TCP keepalive(秒,0 关闭) |
| `--upstream-tcp-nodelay` | `AETHER_PROXY_UPSTREAM_TCP_NODELAY` | `true` | 启用 TCP_NODELAY |
#### Aether API 客户端
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--aether-request-timeout-secs` | `AETHER_PROXY_AETHER_REQUEST_TIMEOUT_SECS` | `10` | 请求总超时(秒) |
| `--aether-connect-timeout-secs` | `AETHER_PROXY_AETHER_CONNECT_TIMEOUT_SECS` | `10` | 建连超时(秒) |
| `--aether-retry-max-attempts` | `AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS` | `3` | 最大重试次数 |
#### DNS 与安全
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--dns-cache-ttl-secs` | `AETHER_PROXY_DNS_CACHE_TTL_SECS` | `60` | DNS 缓存 TTL(秒) |
| `--dns-cache-capacity` | `AETHER_PROXY_DNS_CACHE_CAPACITY` | `1024` | DNS 缓存容量(条目数) |
#### 日志
| 参数 | 环境变量 | 默认值 | 说明 |
|------|----------|--------|------|
| `--log-level` | `AETHER_PROXY_LOG_LEVEL` | `info` | 日志级别 |
| `--log-json` | `AETHER_PROXY_LOG_JSON` | `false` | JSON 格式日志 |
### 多服务器配置
在 `aether-proxy.toml` 中使用 `[[servers]]` 配置多个 Aether 服务器:
```toml
[[servers]]
aether_url = "https://aether-1.example.com"
management_token = "ae_xxx"
node_name = "jp-proxy-01"
[[servers]]
aether_url = "https://aether-2.example.com"
management_token = "ae_yyy"
node_name = "jp-proxy-02"
```
## 发布新版本
推送 `proxy-v*` 格式的 tag,GitHub Actions 会自动:
- 编译所有平台二进制并发布到 Releases
- 构建 Docker 镜像并推送到 GHCR 和 Docker Hub
- 更新 README 中的下载链接表格
```bash
git tag proxy-v0.2.0
git push origin proxy-v0.2.0
```
+14
View File
@@ -0,0 +1,14 @@
services:
aether-proxy:
image: ghcr.io/fawney19/aether-proxy:latest
container_name: aether-proxy
restart: unless-stopped
env_file:
- .env
environment:
AETHER_PROXY_LOG_JSON: "true"
logging:
driver: json-file
options:
max-size: "50m"
max-file: "3"
+360
View File
@@ -0,0 +1,360 @@
//! Application lifecycle: initialization, task orchestration, and shutdown.
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use arc_swap::ArcSwap;
use tokio::signal;
use tokio::sync::{watch, Mutex};
use tracing::{error, info, warn};
use crate::config::{Config, ServerEntry};
use crate::net;
use crate::registration::client::AetherClient;
use crate::runtime::{self, DynamicConfig};
use crate::state::{AppState, ProxyMetrics, ServerContext};
use crate::upstream_client;
use crate::{hardware, target_filter, tunnel};
/// Run the full application lifecycle after config has been parsed.
pub async fn run(mut config: Config, servers: Vec<ServerEntry>) -> anyhow::Result<()> {
config.validate()?;
init_tracing(&config);
info!(
version = env!("CARGO_PKG_VERSION"),
node_name = %config.node_name,
server_count = servers.len(),
"aether-proxy starting (tunnel mode)"
);
// Resolve public IP (best-effort for region info)
let public_ip = match &config.public_ip {
Some(ip) => ip.clone(),
None => net::detect_public_ip()
.await
.unwrap_or_else(|_| "0.0.0.0".to_string()),
};
// Auto-detect region if not configured
if config.node_region.is_none() {
if let Some(region) = net::detect_region(&public_ip).await {
config.node_region = Some(region);
}
}
// Collect hardware info (once at startup, sent during registration)
let hw_info = hardware::collect();
// Auto-detect tunnel_max_streams from hardware if not explicitly set
if config.tunnel_max_streams.is_none() {
let auto = (hw_info.estimated_max_concurrency / 10).clamp(64, 1024) as u32;
config.tunnel_max_streams = Some(auto);
info!(
tunnel_max_streams = auto,
"auto-detected tunnel_max_streams from hardware"
);
}
info!(
max_concurrency = hw_info.estimated_max_concurrency,
"hardware info collected"
);
let dns_cache = Arc::new(target_filter::DnsCache::new(
Duration::from_secs(config.dns_cache_ttl_secs),
config.dns_cache_capacity,
));
// Build Hyper client for tunnel upstream requests (shared).
// DNS still flows through validated addresses from DnsCache, while the
// custom connector exposes per-request connect/TLS timing when available.
let upstream_client = upstream_client::build_upstream_client(&config, Arc::clone(&dns_cache));
// Register with each Aether server and build per-server contexts.
// Wrapped in Arc<Mutex> so retry_failed_registrations can append later.
let server_contexts: Arc<Mutex<Vec<Arc<ServerContext>>>> = Arc::new(Mutex::new(Vec::new()));
let mut failed_entries: Vec<(String, ServerEntry)> = Vec::new();
for (i, entry) in servers.iter().enumerate() {
let label = if servers.len() == 1 {
"server".to_string()
} else {
format!("server-{}", i)
};
let node_name = entry
.node_name
.clone()
.unwrap_or_else(|| config.node_name.clone());
let client = Arc::new(AetherClient::new(
&config,
&entry.aether_url,
&entry.management_token,
));
match client
.register(&config, &node_name, &public_ip, Some(&hw_info))
.await
{
Ok(node_id) => {
info!(server = %label, node_id = %node_id, url = %entry.aether_url, node_name = %node_name, "registered");
// Initialize dynamic config with per-server node_name (not global),
// so that the heartbeat and reconnect use the correct name.
let mut dynamic = DynamicConfig::from_config(&config);
dynamic.node_name = node_name.clone();
server_contexts.lock().await.push(Arc::new(ServerContext {
server_label: label,
aether_url: entry.aether_url.clone(),
management_token: entry.management_token.clone(),
node_name,
node_id: Arc::new(RwLock::new(node_id)),
aether_client: client,
dynamic: Arc::new(ArcSwap::from_pointee(dynamic)),
active_connections: Arc::new(AtomicU64::new(0)),
metrics: Arc::new(ProxyMetrics::new()),
}));
}
Err(e) => {
warn!(
server = %label,
url = %entry.aether_url,
error = %e,
"registration failed, will retry in background"
);
failed_entries.push((label, entry.clone()));
}
}
}
{
let ctx_count = server_contexts.lock().await.len();
if ctx_count == 0 && failed_entries.is_empty() {
anyhow::bail!("no servers configured");
}
if ctx_count == 0 {
anyhow::bail!(
"no servers registered successfully (all {} failed)",
failed_entries.len()
);
}
}
// Build shared application state
let tunnel_tls_config = Arc::new(crate::tunnel::client::build_tls_config());
let state = Arc::new(AppState {
config: Arc::new(config),
dns_cache,
upstream_client,
tunnel_tls_config,
});
// Shutdown signal channel
let (shutdown_tx, shutdown_rx) = watch::channel(false);
info!(
active_servers = server_contexts.lock().await.len(),
"running in tunnel mode"
);
// Spawn tunnel connections per server (pool_size connections each)
let pool_size = state.config.tunnel_connections.max(1) as usize;
let mut tunnel_handles = Vec::new();
for server in server_contexts.lock().await.iter() {
for conn_idx in 0..pool_size {
let s = Arc::clone(&state);
let srv = Arc::clone(server);
let rx = shutdown_rx.clone();
tunnel_handles.push(tokio::spawn(async move {
tunnel::run(&s, &srv, conn_idx, rx).await;
}));
}
}
// Spawn background retry for failed server registrations
if !failed_entries.is_empty() {
let retry_state = Arc::clone(&state);
let retry_contexts = Arc::clone(&server_contexts);
let retry_public_ip = public_ip.clone();
let retry_hw_info = hw_info.clone();
let retry_shutdown = shutdown_rx.clone();
let retry_pool_size = pool_size;
tokio::spawn(async move {
retry_failed_registrations(
retry_state,
retry_contexts,
failed_entries,
retry_public_ip,
retry_hw_info,
retry_pool_size,
retry_shutdown,
)
.await;
});
}
// Wait for shutdown signal
wait_for_shutdown().await;
info!("shutdown signal received, cleaning up...");
let _ = shutdown_tx.send(true);
// Graceful unregister from all servers (including retry-registered ones)
for server in server_contexts.lock().await.iter() {
let node_id = server.node_id.read().unwrap().clone();
if let Err(e) = server.aether_client.unregister(&node_id).await {
error!(
server = %server.server_label,
error = %e,
"unregister failed during shutdown"
);
}
}
// Wait for all tunnel tasks
for h in tunnel_handles {
let _ = h.await;
}
info!("aether-proxy stopped");
Ok(())
}
/// Retry interval for failed server registrations (5 minutes).
const REGISTRATION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
/// Max registration retry attempts before giving up.
const REGISTRATION_RETRY_MAX: u32 = 12;
/// Background task that retries registration for servers that failed at startup.
async fn retry_failed_registrations(
state: Arc<AppState>,
server_contexts: Arc<Mutex<Vec<Arc<ServerContext>>>>,
failed: Vec<(String, ServerEntry)>,
public_ip: String,
hw_info: crate::hardware::HardwareInfo,
pool_size: usize,
mut shutdown: watch::Receiver<bool>,
) {
for (label, entry) in &failed {
let node_name = entry
.node_name
.clone()
.unwrap_or_else(|| state.config.node_name.clone());
let client = Arc::new(AetherClient::new(
&state.config,
&entry.aether_url,
&entry.management_token,
));
let mut attempt = 0u32;
let node_id = loop {
attempt += 1;
tokio::select! {
_ = tokio::time::sleep(REGISTRATION_RETRY_INTERVAL) => {}
_ = shutdown.changed() => {
info!(server = %label, "shutdown during registration retry");
return;
}
}
match client
.register(&state.config, &node_name, &public_ip, Some(&hw_info))
.await
{
Ok(id) => {
info!(server = %label, node_id = %id, attempt, "registration retry succeeded");
break id;
}
Err(e) => {
warn!(
server = %label,
attempt,
max = REGISTRATION_RETRY_MAX,
error = %e,
"registration retry failed"
);
if attempt >= REGISTRATION_RETRY_MAX {
error!(server = %label, "giving up registration after {} attempts", attempt);
return;
}
}
}
};
// Build server context and spawn tunnels
let mut dynamic = DynamicConfig::from_config(&state.config);
dynamic.node_name = node_name.clone();
let server = Arc::new(ServerContext {
server_label: label.clone(),
aether_url: entry.aether_url.clone(),
management_token: entry.management_token.clone(),
node_name,
node_id: Arc::new(RwLock::new(node_id)),
aether_client: client,
dynamic: Arc::new(ArcSwap::from_pointee(dynamic)),
active_connections: Arc::new(AtomicU64::new(0)),
metrics: Arc::new(ProxyMetrics::new()),
});
// Add to shared list so shutdown can unregister this server
server_contexts.lock().await.push(Arc::clone(&server));
for conn_idx in 0..pool_size {
let s = Arc::clone(&state);
let srv = Arc::clone(&server);
let rx = shutdown.clone();
tokio::spawn(async move {
tunnel::run(&s, &srv, conn_idx, rx).await;
});
}
}
}
fn init_tracing(config: &Config) {
use tracing_subscriber::prelude::*;
use tracing_subscriber::{reload, EnvFilter};
let filter = EnvFilter::try_new(&config.log_level).unwrap_or_else(|_| EnvFilter::new("info"));
let (filter_layer, reload_handle) = reload::Layer::new(filter);
runtime::set_log_reloader(Box::new(move |level: &str| {
if let Ok(new_filter) = EnvFilter::try_new(level) {
let _ = reload_handle.modify(|f| *f = new_filter);
}
}));
if config.log_json {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer().json())
.init();
} else {
tracing_subscriber::registry()
.with(filter_layer)
.with(tracing_subscriber::fmt::layer())
.init();
}
}
async fn wait_for_shutdown() {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
+656
View File
@@ -0,0 +1,656 @@
use std::path::Path;
use clap::Parser;
use serde::{Deserialize, Serialize};
/// Fields that existed in 0.1.x but were removed in 0.2.0.
const LEGACY_ONLY_KEYS: &[&str] = &[
"hmac_key",
"listen_port",
"timestamp_tolerance",
"connect_timeout_secs",
"tls_handshake_timeout_secs",
"enable_tls",
"tls_cert",
"tls_key",
];
/// Fields renamed from 0.1.x `delegate_*` to 0.2.0 `upstream_*`.
const DELEGATE_TO_UPSTREAM: &[(&str, &str)] = &[
(
"delegate_connect_timeout_secs",
"upstream_connect_timeout_secs",
),
(
"delegate_pool_max_idle_per_host",
"upstream_pool_max_idle_per_host",
),
(
"delegate_pool_idle_timeout_secs",
"upstream_pool_idle_timeout_secs",
),
("delegate_tcp_keepalive_secs", "upstream_tcp_keepalive_secs"),
("delegate_tcp_nodelay", "upstream_tcp_nodelay"),
];
/// Aether tunnel proxy.
///
/// Deployed on overseas VPS to relay API traffic for Aether instances
/// behind the GFW. Connects to Aether via WebSocket tunnel, registers
/// with Aether, and relays upstream requests.
#[derive(Parser, Debug, Clone)]
#[command(version, about)]
pub struct Config {
/// Aether server URL (e.g. https://aether.example.com)
#[arg(long, env = "AETHER_PROXY_AETHER_URL")]
pub aether_url: String,
/// Management Token for Aether admin API (ae_xxx)
#[arg(long, env = "AETHER_PROXY_MANAGEMENT_TOKEN")]
pub management_token: String,
/// Public IP address of this node (auto-detected if omitted)
#[arg(long, env = "AETHER_PROXY_PUBLIC_IP")]
pub public_ip: Option<String>,
/// Human-readable node name
#[arg(long, env = "AETHER_PROXY_NODE_NAME", default_value = "proxy-01")]
pub node_name: String,
/// Region label (e.g. ap-northeast-1)
#[arg(long, env = "AETHER_PROXY_NODE_REGION")]
pub node_region: Option<String>,
/// Heartbeat interval in seconds
#[arg(long, env = "AETHER_PROXY_HEARTBEAT_INTERVAL", default_value_t = 30)]
pub heartbeat_interval: u64,
/// Allowed destination ports (default: 80,443,8080,8443)
#[arg(
long,
env = "AETHER_PROXY_ALLOWED_PORTS",
value_delimiter = ',',
default_values_t = vec![80, 443, 8080, 8443]
)]
pub allowed_ports: Vec<u16>,
/// Aether API request timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
default_value_t = 10
)]
pub aether_request_timeout_secs: u64,
/// Aether API connect timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_AETHER_CONNECT_TIMEOUT",
default_value_t = 10
)]
pub aether_connect_timeout_secs: u64,
/// Aether API max idle connections per host
#[arg(
long,
env = "AETHER_PROXY_AETHER_POOL_MAX_IDLE_PER_HOST",
default_value_t = 8
)]
pub aether_pool_max_idle_per_host: usize,
/// Aether API idle timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_AETHER_POOL_IDLE_TIMEOUT",
default_value_t = 90
)]
pub aether_pool_idle_timeout_secs: u64,
/// Aether API TCP keepalive in seconds (0 disables)
#[arg(long, env = "AETHER_PROXY_AETHER_TCP_KEEPALIVE", default_value_t = 60)]
pub aether_tcp_keepalive_secs: u64,
/// Aether API TCP_NODELAY
#[arg(long, env = "AETHER_PROXY_AETHER_TCP_NODELAY", default_value_t = true)]
pub aether_tcp_nodelay: bool,
/// Enable HTTP/2 when talking to Aether API
#[arg(long, env = "AETHER_PROXY_AETHER_HTTP2", default_value_t = true)]
pub aether_http2: bool,
/// Aether API retry attempts (including initial)
#[arg(
long,
env = "AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS",
default_value_t = 3
)]
pub aether_retry_max_attempts: u32,
/// Aether API retry base delay in milliseconds
#[arg(
long,
env = "AETHER_PROXY_AETHER_RETRY_BASE_DELAY_MS",
default_value_t = 200
)]
pub aether_retry_base_delay_ms: u64,
/// Aether API retry max delay in milliseconds
#[arg(
long,
env = "AETHER_PROXY_AETHER_RETRY_MAX_DELAY_MS",
default_value_t = 2000
)]
pub aether_retry_max_delay_ms: u64,
/// Maximum concurrent TCP connections (defaults to hardware estimate)
#[arg(long, env = "AETHER_PROXY_MAX_CONCURRENT_CONNECTIONS")]
pub max_concurrent_connections: Option<u64>,
/// DNS cache TTL in seconds
#[arg(long, env = "AETHER_PROXY_DNS_CACHE_TTL", default_value_t = 60)]
pub dns_cache_ttl_secs: u64,
/// DNS cache capacity (entries)
#[arg(long, env = "AETHER_PROXY_DNS_CACHE_CAPACITY", default_value_t = 1024)]
pub dns_cache_capacity: usize,
/// Upstream HTTP client connect timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT",
default_value_t = 30
)]
pub upstream_connect_timeout_secs: u64,
/// Upstream HTTP client max idle connections per host
#[arg(
long,
env = "AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST",
default_value_t = 64
)]
pub upstream_pool_max_idle_per_host: usize,
/// Upstream HTTP client idle timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT",
default_value_t = 300
)]
pub upstream_pool_idle_timeout_secs: u64,
/// Upstream TCP keepalive in seconds (0 disables)
#[arg(
long,
env = "AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE",
default_value_t = 60
)]
pub upstream_tcp_keepalive_secs: u64,
/// Upstream TCP_NODELAY
#[arg(
long,
env = "AETHER_PROXY_UPSTREAM_TCP_NODELAY",
default_value_t = true
)]
pub upstream_tcp_nodelay: bool,
/// Log level (trace, debug, info, warn, error)
#[arg(long, env = "AETHER_PROXY_LOG_LEVEL", default_value = "info")]
pub log_level: String,
/// Output logs as JSON
#[arg(long, env = "AETHER_PROXY_LOG_JSON", default_value_t = false)]
pub log_json: bool,
/// Tunnel reconnect base delay in milliseconds (used by exponential backoff)
#[arg(
long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
default_value_t = 500
)]
pub tunnel_reconnect_base_ms: u64,
/// Tunnel reconnect max delay in milliseconds (cap for exponential backoff)
#[arg(
long,
env = "AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
default_value_t = 30000
)]
pub tunnel_reconnect_max_ms: u64,
/// WebSocket tunnel ping interval in seconds
#[arg(long, env = "AETHER_PROXY_TUNNEL_PING_INTERVAL", default_value_t = 15)]
pub tunnel_ping_interval_secs: u64,
/// Maximum concurrent streams over tunnel (auto-detected from hardware if omitted)
#[arg(long, env = "AETHER_PROXY_TUNNEL_MAX_STREAMS")]
pub tunnel_max_streams: Option<u32>,
/// WebSocket tunnel TCP connect timeout in seconds
#[arg(
long,
env = "AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT",
default_value_t = 15
)]
pub tunnel_connect_timeout_secs: u64,
/// WebSocket tunnel TCP keepalive in seconds (0 disables)
#[arg(long, env = "AETHER_PROXY_TUNNEL_TCP_KEEPALIVE", default_value_t = 30)]
pub tunnel_tcp_keepalive_secs: u64,
/// WebSocket tunnel TCP_NODELAY
#[arg(long, env = "AETHER_PROXY_TUNNEL_TCP_NODELAY", default_value_t = true)]
pub tunnel_tcp_nodelay: bool,
/// Tunnel connection staleness timeout in seconds (triggers reconnect if no data received)
#[arg(long, env = "AETHER_PROXY_TUNNEL_STALE_TIMEOUT", default_value_t = 45)]
pub tunnel_stale_timeout_secs: u64,
/// Number of parallel WebSocket tunnel connections per server (connection pool)
#[arg(long, env = "AETHER_PROXY_TUNNEL_CONNECTIONS", default_value_t = 3)]
pub tunnel_connections: u32,
}
impl Config {
/// Validate configuration values are within sane ranges.
/// Called after parsing to catch misconfigurations early.
pub fn validate(&self) -> anyhow::Result<()> {
if self.heartbeat_interval == 0 {
anyhow::bail!("heartbeat_interval must be > 0");
}
if self.heartbeat_interval > 3600 {
anyhow::bail!("heartbeat_interval must be <= 3600");
}
if self.allowed_ports.is_empty() {
anyhow::bail!("allowed_ports must not be empty");
}
for &port in &self.allowed_ports {
if port == 0 {
anyhow::bail!("allowed_ports: port 0 is not valid");
}
}
if self.tunnel_connect_timeout_secs == 0 {
anyhow::bail!("tunnel_connect_timeout_secs must be > 0");
}
if self.tunnel_ping_interval_secs == 0 {
anyhow::bail!("tunnel_ping_interval_secs must be > 0");
}
if self.tunnel_stale_timeout_secs <= self.tunnel_ping_interval_secs {
anyhow::bail!(
"tunnel_stale_timeout_secs ({}) must be > tunnel_ping_interval_secs ({})",
self.tunnel_stale_timeout_secs,
self.tunnel_ping_interval_secs
);
}
if self.tunnel_connections == 0 {
anyhow::bail!("tunnel_connections must be > 0");
}
if self.aether_retry_max_attempts == 0 {
anyhow::bail!("aether_retry_max_attempts must be >= 1");
}
if self.upstream_connect_timeout_secs == 0 {
anyhow::bail!("upstream_connect_timeout_secs must be > 0");
}
Ok(())
}
}
/// Per-server connection config (used in multi-server TOML `[[servers]]`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerEntry {
pub aether_url: String,
pub management_token: String,
/// Per-server node name override. Falls back to the global `node_name`.
pub node_name: Option<String>,
}
// ---------------------------------------------------------------------------
// TOML config file support
// ---------------------------------------------------------------------------
/// Serializable config for TOML file persistence.
/// All fields are optional -- only populated values are written.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub management_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub public_ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub heartbeat_interval: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allowed_ports: Option<Vec<u16>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_request_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_connect_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_pool_max_idle_per_host: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_pool_idle_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_tcp_keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_tcp_nodelay: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_http2: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_retry_max_attempts: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_retry_base_delay_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aether_retry_max_delay_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrent_connections: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_cache_ttl_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dns_cache_capacity: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_connect_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_pool_max_idle_per_host: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_pool_idle_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_tcp_keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream_tcp_nodelay: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_json: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_reconnect_base_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_reconnect_max_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_ping_interval_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_max_streams: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_connect_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_tcp_keepalive_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_tcp_nodelay: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_stale_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tunnel_connections: Option<u32>,
/// Multi-server config: each entry connects to a separate Aether instance.
/// When present, top-level aether_url/management_token are ignored for
/// tunnel connections (but still injected as env for clap compatibility).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub servers: Vec<ServerEntry>,
}
impl ConfigFile {
/// Load from a TOML file.
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
Ok(toml::from_str(&content)?)
}
/// Save to a TOML file.
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
let content = toml::to_string_pretty(self)?;
std::fs::write(path, content)?;
Ok(())
}
/// Detect and migrate a 0.1.x config file to 0.2.0 format in-place.
///
/// Returns `true` if migration was performed, `false` if already current.
/// The original file is backed up as `<name>.v1.bak` before rewriting.
pub fn migrate_legacy(path: &Path) -> anyhow::Result<bool> {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return Ok(false),
};
let mut table: toml::map::Map<String, toml::Value> = toml::from_str(&content)?;
// Detect legacy format: presence of any 0.1.x-only key.
let is_legacy = LEGACY_ONLY_KEYS.iter().any(|k| table.contains_key(*k))
|| DELEGATE_TO_UPSTREAM
.iter()
.any(|(old, _)| table.contains_key(*old));
if !is_legacy {
return Ok(false);
}
// 1. Rename delegate_* -> upstream_* (carry over user-customized values)
for &(old, new) in DELEGATE_TO_UPSTREAM {
if let Some(val) = table.remove(old) {
table.entry(new.to_string()).or_insert(val);
}
}
// 2. Build [[servers]] from top-level aether_url + management_token + node_name
if !table.contains_key("servers") {
let aether_url = table.get("aether_url").and_then(|v| v.as_str());
let management_token = table.get("management_token").and_then(|v| v.as_str());
if let (Some(url), Some(token)) = (aether_url, management_token) {
let mut entry = toml::map::Map::new();
entry.insert("aether_url".into(), toml::Value::String(url.to_string()));
entry.insert(
"management_token".into(),
toml::Value::String(token.to_string()),
);
if let Some(name) = table.get("node_name").and_then(|v| v.as_str()) {
entry.insert("node_name".into(), toml::Value::String(name.to_string()));
}
table.insert(
"servers".into(),
toml::Value::Array(vec![toml::Value::Table(entry)]),
);
}
}
// 3. Remove top-level fields that are now in [[servers]] or obsolete
table.remove("aether_url");
table.remove("management_token");
table.remove("node_name");
for &key in LEGACY_ONLY_KEYS {
table.remove(key);
}
// 4. Backup original file (abort migration if backup fails)
let backup_path = path.with_extension("v1.bak");
std::fs::copy(path, &backup_path).map_err(|e| {
anyhow::anyhow!(
"failed to backup config before migration: {} -> {}: {}",
path.display(),
backup_path.display(),
e
)
})?;
// 5. Write migrated config
let new_content = toml::to_string_pretty(&table)?;
std::fs::write(path, &new_content)?;
eprintln!(" Config migrated from 0.1.x to 0.2.0 format.");
eprintln!(" Backup saved: {}", backup_path.display());
Ok(true)
}
/// Resolve the effective server list.
///
/// If `[[servers]]` is present, use it. Otherwise fall back to the
/// top-level `aether_url` + `management_token` as a single server.
pub fn effective_servers(&self) -> Vec<ServerEntry> {
if !self.servers.is_empty() {
return self.servers.clone();
}
match (&self.aether_url, &self.management_token) {
(Some(url), Some(token)) => vec![ServerEntry {
aether_url: url.clone(),
management_token: token.clone(),
node_name: None,
}],
_ => vec![],
}
}
/// Inject values as environment variables so clap picks them up.
///
/// Only sets variables that are **not** already present in the
/// environment, preserving the precedence: CLI > env > config file.
pub fn inject_env(&self) {
self.inject_env_inner(false);
}
/// Inject values as environment variables, **overriding** any existing
/// values. Used after setup to ensure the freshly-saved config takes
/// effect before re-parsing.
pub fn inject_env_override(&self) {
self.inject_env_inner(true);
}
fn inject_env_inner(&self, force: bool) {
macro_rules! set {
($env:expr, $val:expr) => {
if let Some(ref v) = $val {
if force || std::env::var($env).is_err() {
std::env::set_var($env, v.to_string());
}
}
};
}
// When top-level fields are absent, fall back to the first [[servers]]
// entry so that clap's required `aether_url` / `management_token` are
// satisfied even with the new config format.
let first_server = self.servers.first();
let aether_url = self
.aether_url
.as_deref()
.or(first_server.map(|s| s.aether_url.as_str()));
let management_token = self
.management_token
.as_deref()
.or(first_server.map(|s| s.management_token.as_str()));
let node_name = self
.node_name
.as_deref()
.or(first_server.and_then(|s| s.node_name.as_deref()));
set!("AETHER_PROXY_AETHER_URL", aether_url);
set!("AETHER_PROXY_MANAGEMENT_TOKEN", management_token);
set!("AETHER_PROXY_PUBLIC_IP", self.public_ip);
set!("AETHER_PROXY_NODE_NAME", node_name);
set!("AETHER_PROXY_NODE_REGION", self.node_region);
set!("AETHER_PROXY_HEARTBEAT_INTERVAL", self.heartbeat_interval);
set!(
"AETHER_PROXY_AETHER_REQUEST_TIMEOUT",
self.aether_request_timeout_secs
);
set!(
"AETHER_PROXY_AETHER_CONNECT_TIMEOUT",
self.aether_connect_timeout_secs
);
set!(
"AETHER_PROXY_AETHER_POOL_MAX_IDLE_PER_HOST",
self.aether_pool_max_idle_per_host
);
set!(
"AETHER_PROXY_AETHER_POOL_IDLE_TIMEOUT",
self.aether_pool_idle_timeout_secs
);
set!(
"AETHER_PROXY_AETHER_TCP_KEEPALIVE",
self.aether_tcp_keepalive_secs
);
set!("AETHER_PROXY_AETHER_TCP_NODELAY", self.aether_tcp_nodelay);
set!("AETHER_PROXY_AETHER_HTTP2", self.aether_http2);
set!(
"AETHER_PROXY_AETHER_RETRY_MAX_ATTEMPTS",
self.aether_retry_max_attempts
);
set!(
"AETHER_PROXY_AETHER_RETRY_BASE_DELAY_MS",
self.aether_retry_base_delay_ms
);
set!(
"AETHER_PROXY_AETHER_RETRY_MAX_DELAY_MS",
self.aether_retry_max_delay_ms
);
set!(
"AETHER_PROXY_MAX_CONCURRENT_CONNECTIONS",
self.max_concurrent_connections
);
set!("AETHER_PROXY_DNS_CACHE_TTL", self.dns_cache_ttl_secs);
set!("AETHER_PROXY_DNS_CACHE_CAPACITY", self.dns_cache_capacity);
set!(
"AETHER_PROXY_UPSTREAM_CONNECT_TIMEOUT",
self.upstream_connect_timeout_secs
);
set!(
"AETHER_PROXY_UPSTREAM_POOL_MAX_IDLE_PER_HOST",
self.upstream_pool_max_idle_per_host
);
set!(
"AETHER_PROXY_UPSTREAM_POOL_IDLE_TIMEOUT",
self.upstream_pool_idle_timeout_secs
);
set!(
"AETHER_PROXY_UPSTREAM_TCP_KEEPALIVE",
self.upstream_tcp_keepalive_secs
);
set!(
"AETHER_PROXY_UPSTREAM_TCP_NODELAY",
self.upstream_tcp_nodelay
);
set!("AETHER_PROXY_LOG_LEVEL", self.log_level);
set!("AETHER_PROXY_LOG_JSON", self.log_json);
set!(
"AETHER_PROXY_TUNNEL_RECONNECT_BASE_MS",
self.tunnel_reconnect_base_ms
);
set!(
"AETHER_PROXY_TUNNEL_RECONNECT_MAX_MS",
self.tunnel_reconnect_max_ms
);
set!(
"AETHER_PROXY_TUNNEL_PING_INTERVAL",
self.tunnel_ping_interval_secs
);
set!("AETHER_PROXY_TUNNEL_MAX_STREAMS", self.tunnel_max_streams);
set!(
"AETHER_PROXY_TUNNEL_CONNECT_TIMEOUT",
self.tunnel_connect_timeout_secs
);
set!(
"AETHER_PROXY_TUNNEL_TCP_KEEPALIVE",
self.tunnel_tcp_keepalive_secs
);
set!("AETHER_PROXY_TUNNEL_TCP_NODELAY", self.tunnel_tcp_nodelay);
set!(
"AETHER_PROXY_TUNNEL_STALE_TIMEOUT",
self.tunnel_stale_timeout_secs
);
set!("AETHER_PROXY_TUNNEL_CONNECTIONS", self.tunnel_connections);
// allowed_ports needs special handling (comma-separated)
if let Some(ref ports) = self.allowed_ports {
if force || std::env::var("AETHER_PROXY_ALLOWED_PORTS").is_err() {
let s: String = ports
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(",");
std::env::set_var("AETHER_PROXY_ALLOWED_PORTS", s);
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
use serde::Serialize;
use sysinfo::System;
use tracing::info;
/// Hardware information collected at startup.
///
/// The struct is `Serialize`-able so it can be sent directly as the
/// `hardware_info` JSON bag in the registration request. New fields
/// can be added without database schema migrations.
#[derive(Debug, Clone, Serialize)]
pub struct HardwareInfo {
pub cpu_cores: u32,
pub total_memory_mb: u64,
pub os_info: String,
pub fd_limit: u64,
#[serde(skip)]
pub estimated_max_concurrency: u64,
}
/// Collect hardware information and estimate max concurrency.
///
/// Should be called once at startup -- hardware does not change at runtime.
pub fn collect() -> HardwareInfo {
let sys = System::new_all();
let cpu_cores = sys.cpus().len() as u32;
let total_memory_mb = sys.total_memory() / (1024 * 1024);
let os_info = format!(
"{} {}",
System::name().unwrap_or_else(|| "Unknown".into()),
System::os_version().unwrap_or_default(),
)
.trim()
.to_string();
// Estimate max concurrent connections:
// - Each tokio async task uses ~8-16 KB stack + heap buffers
// - OS file descriptor limit is often the real bottleneck
// - Conservative formula: min(fd_limit - 100, ram_mb * 40, cpu_cores * 2000)
let fd_limit = get_fd_limit();
let by_fd = fd_limit.saturating_sub(100);
let by_ram = total_memory_mb.saturating_mul(40);
let by_cpu = (cpu_cores as u64).saturating_mul(2000);
let estimated_max_concurrency = by_fd.min(by_ram).min(by_cpu);
info!(
cpu_cores,
total_memory_mb,
os_info = %os_info,
fd_limit,
estimated_max_concurrency,
"hardware info collected"
);
HardwareInfo {
cpu_cores,
total_memory_mb,
os_info,
fd_limit,
estimated_max_concurrency,
}
}
/// Read the soft file-descriptor limit (RLIMIT_NOFILE).
fn get_fd_limit() -> u64 {
#[cfg(unix)]
{
let mut rlim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let ret = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut rlim) };
if ret == 0 {
return rlim.rlim_cur;
}
}
// Fallback for non-unix or error
1024
}
+168
View File
@@ -0,0 +1,168 @@
mod app;
mod config;
mod hardware;
mod net;
mod registration;
mod runtime;
mod setup;
mod state;
mod target_filter;
mod tunnel;
mod upstream_client;
use std::path::PathBuf;
use clap::{CommandFactory, FromArgMatches, Parser};
use config::Config;
/// Default config file name.
const DEFAULT_CONFIG: &str = "aether-proxy.toml";
/// Build the full clap command: Config args + discoverable subcommands.
///
/// `subcommand_negates_reqs` lets subcommands bypass the required Config
/// flags so that e.g. `aether-proxy setup` doesn't demand `--aether-url`.
fn build_command() -> clap::Command {
Config::command()
.subcommand(
clap::Command::new("setup")
.about("Interactive setup wizard (TUI)")
.arg(
clap::Arg::new("config_path")
.help("Path to config file")
.default_value(DEFAULT_CONFIG),
),
)
.subcommand(clap::Command::new("start").about("Start the systemd service"))
.subcommand(clap::Command::new("status").about("Show service status"))
.subcommand(clap::Command::new("logs").about("Tail service logs"))
.subcommand(clap::Command::new("restart").about("Restart the systemd service"))
.subcommand(clap::Command::new("stop").about("Stop the systemd service"))
.subcommand(clap::Command::new("uninstall").about("Uninstall the systemd service"))
.subcommand(
clap::Command::new("upgrade")
.about("Self-upgrade from GitHub releases")
.arg(clap::Arg::new("version").help("Target version (e.g. 0.2.0)")),
)
.subcommand_negates_reqs(true)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("Failed to install rustls CryptoProvider"))?;
// Load config file as env-var defaults (before clap parsing)
let config_file_path =
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
let config_path = std::path::Path::new(&config_file_path);
if config_path.exists() {
// Migrate legacy 0.1.x config to 0.2.0 format if needed
if let Err(e) = config::ConfigFile::migrate_legacy(config_path) {
eprintln!(" WARNING: config migration failed: {}", e);
}
if let Ok(file_cfg) = config::ConfigFile::load(config_path) {
file_cfg.inject_env();
}
}
// Parse CLI (subcommands + config args in one pass)
match build_command().try_get_matches() {
Ok(matches) => match matches.subcommand() {
Some(("setup", sub_m)) => {
let path = sub_m
.get_one::<String>("config_path")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG));
handle_setup_result(setup::run(path)?).await
}
Some(("start", _)) => setup::service::cmd_start(),
Some(("status", _)) => setup::service::cmd_status(),
Some(("logs", _)) => setup::service::cmd_logs(),
Some(("restart", _)) => setup::service::cmd_restart(),
Some(("stop", _)) => setup::service::cmd_stop(),
Some(("uninstall", _)) => setup::service::cmd_uninstall(),
Some(("upgrade", sub_m)) => {
let version = sub_m.get_one::<String>("version").cloned();
setup::upgrade::cmd_upgrade(version).await
}
Some(_) => unreachable!(),
None => {
// No subcommand — run the proxy with parsed config.
let config = Config::from_arg_matches(&matches)?;
run_proxy(config).await
}
},
Err(e) => {
if e.kind() == clap::error::ErrorKind::MissingRequiredArgument {
eprintln!("Missing required config, launching setup wizard...\n");
handle_setup_result(setup::run(PathBuf::from(&config_file_path))?).await
} else {
e.exit();
}
}
}
}
/// Decide what to do after the setup wizard completes.
async fn handle_setup_result(outcome: setup::SetupOutcome) -> anyhow::Result<()> {
match outcome {
setup::SetupOutcome::ServiceInstalled => Ok(()),
setup::SetupOutcome::ReadyToRun(config_path) => {
// Reload config from the file that setup just wrote, overriding
// any stale env vars from a previous config.
match config::ConfigFile::load(&config_path) {
Ok(file_cfg) => file_cfg.inject_env_override(),
Err(e) => anyhow::bail!("failed to reload config after setup: {}", e),
}
// Parse from env-only (argv may still contain "setup" etc.)
let config = Config::try_parse_from(["aether-proxy"])
.map_err(|e| anyhow::anyhow!("config invalid after setup: {}", e))?;
eprintln!(" Starting proxy...\n");
run_proxy(config).await
}
setup::SetupOutcome::Cancelled => {
eprintln!(" Setup cancelled.");
Ok(())
}
}
}
/// Start the proxy server, checking for systemd conflicts first.
async fn run_proxy(config: Config) -> anyhow::Result<()> {
// Warn if systemd service is already running (would cause port conflict).
// 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() {
eprintln!("Warning: systemd service is already running.");
eprintln!("Use `./aether-proxy stop` to stop it first, or manage via subcommands:");
eprintln!(" ./aether-proxy status / logs / restart / stop");
std::process::exit(1);
}
// Resolve server list: prefer [[servers]] from TOML, fall back to CLI/env single server.
let config_path =
std::env::var("AETHER_PROXY_CONFIG").unwrap_or_else(|_| DEFAULT_CONFIG.to_string());
let servers = if std::path::Path::new(&config_path).exists() {
config::ConfigFile::load(std::path::Path::new(&config_path))
.ok()
.map(|f| f.effective_servers())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
vec![config::ServerEntry {
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
node_name: None,
}]
})
} else {
vec![config::ServerEntry {
aether_url: config.aether_url.clone(),
management_token: config.management_token.clone(),
node_name: None,
}]
};
app::run(config, servers).await
}
+86
View File
@@ -0,0 +1,86 @@
//! Network utility functions (public IP detection, region detection).
//!
//! These are standalone helpers not tied to any specific client or service.
use reqwest::Client;
use tracing::{debug, info};
/// Auto-detect public IP by querying external services.
pub async fn detect_public_ip() -> anyhow::Result<String> {
let endpoints = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
];
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?;
for endpoint in &endpoints {
match client.get(*endpoint).send().await {
Ok(resp) if resp.status().is_success() => {
let ip = resp.text().await?.trim().to_string();
if !ip.is_empty() {
info!(ip = %ip, source = %endpoint, "detected public IP");
return Ok(ip);
}
}
Ok(resp) => {
debug!(endpoint = %endpoint, status = %resp.status(), "IP detection failed");
}
Err(e) => {
debug!(endpoint = %endpoint, error = %e, "IP detection failed");
}
}
}
anyhow::bail!("failed to detect public IP from any source; use --public-ip")
}
/// Auto-detect geographic region from a public IP address.
///
/// Uses multiple providers with HTTPS preferred. Falls back to ip-api.com
/// over plain HTTP (their free tier doesn't support HTTPS).
/// This is best-effort and non-sensitive -- region detection should never
/// block startup.
pub async fn detect_region(ip: &str) -> Option<String> {
// Try HTTPS provider first
let https_url = format!("https://ipinfo.io/{}/country", ip);
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
// Try ipinfo.io (HTTPS, returns plain text country code)
if let Ok(resp) = client.get(&https_url).send().await {
if resp.status().is_success() {
if let Ok(text) = resp.text().await {
let code = text.trim();
if !code.is_empty() && code.len() <= 3 {
info!(region = %code, ip = %ip, source = "ipinfo.io", "detected region");
return Some(code.to_string());
}
}
}
}
// Fallback: ip-api.com (HTTP only on free tier, non-sensitive data)
let http_url = format!("http://ip-api.com/json/{}?fields=countryCode", ip);
match client.get(&http_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: serde_json::Value = resp.json().await.ok()?;
let code = body.get("countryCode")?.as_str()?;
if code.is_empty() {
return None;
}
info!(region = %code, ip = %ip, source = "ip-api.com", "detected region");
Some(code.to_string())
}
_ => {
debug!(ip = %ip, "region detection failed");
None
}
}
}
+257
View File
@@ -0,0 +1,257 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use tracing::{debug, error, info};
use crate::config::Config;
use crate::hardware::HardwareInfo;
#[derive(Debug, Serialize)]
struct RegisterRequest {
name: String,
ip: String,
port: u16,
#[serde(skip_serializing_if = "Option::is_none")]
region: Option<String>,
heartbeat_interval: u64,
#[serde(skip_serializing_if = "Option::is_none")]
hardware_info: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
estimated_max_concurrency: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
proxy_metadata: Option<serde_json::Value>,
tunnel_mode: bool,
}
#[derive(Debug, Deserialize)]
pub struct RegisterResponse {
pub node_id: String,
}
/// Remote configuration pushed by the Aether management backend.
#[derive(Debug, Clone, Deserialize)]
pub struct RemoteConfig {
pub node_name: Option<String>,
pub allowed_ports: Option<Vec<u16>>,
pub log_level: Option<String>,
pub heartbeat_interval: Option<u64>,
}
#[derive(Debug, Serialize)]
struct UnregisterRequest {
node_id: String,
}
/// Aether API client for proxy node lifecycle management.
pub struct AetherClient {
http: Client,
base_url: String,
token: String,
retry_max_attempts: u32,
retry_base_delay: Duration,
retry_max_delay: Duration,
}
impl AetherClient {
pub fn new(config: &Config, aether_url: &str, management_token: &str) -> Self {
let mut builder = Client::builder()
.timeout(Duration::from_secs(config.aether_request_timeout_secs))
.connect_timeout(Duration::from_secs(config.aether_connect_timeout_secs))
.pool_max_idle_per_host(config.aether_pool_max_idle_per_host)
.pool_idle_timeout(Duration::from_secs(config.aether_pool_idle_timeout_secs))
.tcp_nodelay(config.aether_tcp_nodelay);
if config.aether_tcp_keepalive_secs > 0 {
builder =
builder.tcp_keepalive(Some(Duration::from_secs(config.aether_tcp_keepalive_secs)));
} else {
builder = builder.tcp_keepalive(None);
}
if config.aether_http2 {
builder = builder.http2_adaptive_window(true);
}
let http = builder.build().expect("failed to create HTTP client");
let retry_base_delay = Duration::from_millis(config.aether_retry_base_delay_ms);
let retry_max_delay =
Duration::from_millis(config.aether_retry_max_delay_ms).max(retry_base_delay);
Self {
http,
base_url: aether_url.trim_end_matches('/').to_string(),
token: management_token.to_string(),
retry_max_attempts: config.aether_retry_max_attempts.max(1),
retry_base_delay,
retry_max_delay,
}
}
/// Register this node with Aether (idempotent upsert by ip:port).
///
/// Returns the stable node_id assigned by Aether.
pub async fn register(
&self,
config: &Config,
node_name: &str,
public_ip: &str,
hw: Option<&HardwareInfo>,
) -> anyhow::Result<String> {
let url = format!("{}/api/admin/proxy-nodes/register", self.base_url);
let body = RegisterRequest {
name: node_name.to_string(),
ip: public_ip.to_string(),
port: 0,
region: config.node_region.clone(),
heartbeat_interval: config.heartbeat_interval,
hardware_info: hw.and_then(|h| serde_json::to_value(h).ok()),
estimated_max_concurrency: hw.map(|h| h.estimated_max_concurrency),
proxy_metadata: Some(serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
})),
tunnel_mode: true,
};
info!(
url = %url,
name = %body.name,
ip = %body.ip,
"registering with Aether"
);
let resp = self
.send_with_retry(
|| {
self.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
},
"register",
)
.await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("register failed (HTTP {}): {}", status, text);
}
let data: RegisterResponse = resp.json().await?;
info!(node_id = %data.node_id, "registered successfully");
Ok(data.node_id)
}
/// Unregister this node from Aether (graceful shutdown).
pub async fn unregister(&self, node_id: &str) -> anyhow::Result<()> {
let url = format!("{}/api/admin/proxy-nodes/unregister", self.base_url);
let body = UnregisterRequest {
node_id: node_id.to_string(),
};
info!(node_id = %node_id, "unregistering from Aether");
let resp = self
.send_with_retry(
|| {
self.http
.post(&url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&body)
},
"unregister",
)
.await;
match resp {
Ok(r) if r.status().is_success() => {
info!(node_id = %node_id, "unregistered successfully");
Ok(())
}
Ok(r) => {
let text = r.text().await.unwrap_or_default();
error!(body = %text, "unregister failed");
anyhow::bail!("unregister failed: {}", text);
}
Err(e) => {
// Best-effort during shutdown
error!(error = %e, "unregister request failed");
anyhow::bail!("unregister request failed: {}", e);
}
}
}
async fn send_with_retry<F>(
&self,
mut make_req: F,
label: &str,
) -> Result<reqwest::Response, reqwest::Error>
where
F: FnMut() -> reqwest::RequestBuilder,
{
let mut attempt: u32 = 0;
let mut delay = self.retry_base_delay;
loop {
attempt = attempt.saturating_add(1);
let resp = make_req().send().await;
match resp {
Ok(resp) => {
if should_retry_status(resp.status()) && attempt < self.retry_max_attempts {
let sleep_for = jitter_delay(delay);
debug!(
attempt,
status = %resp.status(),
sleep_ms = sleep_for.as_millis(),
label,
"Aether request retrying"
);
sleep(sleep_for).await;
let next_delay = delay.checked_mul(2).unwrap_or(self.retry_max_delay);
delay = std::cmp::min(next_delay, self.retry_max_delay);
continue;
}
return Ok(resp);
}
Err(e) => {
if attempt < self.retry_max_attempts {
let sleep_for = jitter_delay(delay);
debug!(
attempt,
error = %e,
sleep_ms = sleep_for.as_millis(),
label,
"Aether request retrying"
);
sleep(sleep_for).await;
let next_delay = delay.checked_mul(2).unwrap_or(self.retry_max_delay);
delay = std::cmp::min(next_delay, self.retry_max_delay);
continue;
}
return Err(e);
}
}
}
}
}
fn should_retry_status(status: StatusCode) -> bool {
status.is_server_error()
|| status == StatusCode::TOO_MANY_REQUESTS
|| status == StatusCode::REQUEST_TIMEOUT
}
fn jitter_delay(base: Duration) -> Duration {
if base.is_zero() {
return base;
}
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let jitter_ms = nanos % 100;
base + Duration::from_millis(jitter_ms)
}
+1
View File
@@ -0,0 +1 @@
pub mod client;
+121
View File
@@ -0,0 +1,121 @@
//! Runtime-mutable configuration that can be updated remotely via heartbeat.
//!
//! Fields in [`DynamicConfig`] are initially populated from the static
//! [`Config`](crate::config::Config) and may be overridden by the Aether
//! management backend through the heartbeat response.
use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use arc_swap::ArcSwap;
use tracing::info;
use crate::config::Config;
/// Configuration that can be changed at runtime without restart.
#[derive(Debug, Clone)]
pub struct DynamicConfig {
pub node_name: String,
pub allowed_ports: Arc<HashSet<u16>>,
pub log_level: String,
pub heartbeat_interval: u64,
/// Monotonically increasing version from the backend.
/// `0` means no remote config has ever been applied.
pub config_version: u64,
}
impl DynamicConfig {
/// Initialize from static config (startup defaults).
pub fn from_config(config: &Config) -> Self {
Self {
node_name: config.node_name.clone(),
allowed_ports: Arc::new(config.allowed_ports.iter().copied().collect()),
log_level: config.log_level.clone(),
heartbeat_interval: config.heartbeat_interval,
config_version: 0,
}
}
}
/// Shared dynamic config handle (lock-free reads via ArcSwap).
pub type SharedDynamicConfig = Arc<ArcSwap<DynamicConfig>>;
// -- Log-level hot-reload -----
/// Global log-level reloader function, set during tracing init.
type LogReloader = Box<dyn Fn(&str) + Send + Sync>;
static LOG_RELOADER: OnceLock<LogReloader> = OnceLock::new();
/// Register the log-level reload function (called once from `init_tracing`).
pub fn set_log_reloader(f: LogReloader) {
let _ = LOG_RELOADER.set(f);
}
/// Apply a remote config update to the dynamic config.
///
/// Uses copy-on-write: loads the current snapshot, clones it, applies changes,
/// and stores the new Arc. Reads are always lock-free.
///
/// Returns `true` if the config was actually changed.
pub fn apply_remote_config(
dynamic: &SharedDynamicConfig,
remote: &crate::registration::client::RemoteConfig,
version: u64,
) -> bool {
let current = dynamic.load();
if version <= current.config_version {
return false;
}
let mut new_cfg = (**current).clone();
let mut changed = Vec::new();
if let Some(ref name) = remote.node_name {
if *name != new_cfg.node_name {
changed.push(format!("node_name -> {}", name));
new_cfg.node_name = name.clone();
}
}
if let Some(ref ports) = remote.allowed_ports {
let new_set: HashSet<u16> = ports.iter().copied().collect();
if new_set != *new_cfg.allowed_ports {
changed.push(format!("allowed_ports -> {:?}", ports));
new_cfg.allowed_ports = Arc::new(new_set);
}
}
if let Some(interval) = remote.heartbeat_interval {
if interval != new_cfg.heartbeat_interval {
changed.push(format!("heartbeat_interval -> {}s", interval));
new_cfg.heartbeat_interval = interval;
}
}
if let Some(ref level) = remote.log_level {
if *level != new_cfg.log_level {
changed.push(format!("log_level -> {}", level));
new_cfg.log_level = level.clone();
// Hot-reload tracing filter
if let Some(reloader) = LOG_RELOADER.get() {
reloader(level);
}
}
}
let has_changes = !changed.is_empty();
if has_changes {
new_cfg.config_version = version;
info!(
version,
changes = %changed.join(", "),
"remote config applied"
);
dynamic.store(Arc::new(new_cfg));
}
has_changes
}
+66
View File
@@ -0,0 +1,66 @@
//! Safe DNS resolver for reqwest that reuses validated addresses from DnsCache.
//!
//! This resolver ensures reqwest connects only to addresses that have been
//! previously validated by `target_filter::validate_target()`, eliminating
//! the TOCTTOU gap where DNS rebinding could redirect traffic to private IPs.
use std::net::SocketAddr;
use std::sync::Arc;
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
use crate::target_filter::{self, DnsCache};
/// A DNS resolver that serves validated public addresses from the shared DnsCache.
///
/// When reqwest needs to resolve a hostname, this resolver returns addresses
/// from the cache (populated by `validate_target()` during request validation).
/// If the hostname is not in cache (shouldn't happen in normal flow), it
/// performs a fresh resolution with private-IP filtering.
pub struct SafeDnsResolver {
dns_cache: Arc<DnsCache>,
}
impl SafeDnsResolver {
pub fn new(dns_cache: Arc<DnsCache>) -> Self {
Self { dns_cache }
}
}
impl Resolve for SafeDnsResolver {
fn resolve(&self, name: Name) -> Resolving {
let dns_cache = Arc::clone(&self.dns_cache);
Box::pin(async move {
let host = name.as_str();
// Try cache first (should be populated by validate_target).
// reqwest resolves by hostname only (no port), so use host-only lookup.
if let Some(addrs) = dns_cache.get_by_host(host).await {
let socket_addrs: Vec<SocketAddr> = (*addrs).clone();
return Ok(Box::new(socket_addrs.into_iter()) as Addrs);
}
// Fallback: resolve with private-IP filtering (defensive).
// This path should rarely be hit since validate_target() runs first.
// We don't know the real port here (reqwest Resolve only gives hostname),
// so resolve directly without caching to avoid polluting the cache with
// an incorrect port-based key.
let addr_str = format!("{}:0", host);
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { Box::new(e) })?
.filter(|addr| !target_filter::is_private_ip(&addr.ip()))
.collect();
if resolved.is_empty() {
return Err(Box::new(std::io::Error::other(format!(
"all resolved addresses for {} are private/reserved",
host
)))
as Box<dyn std::error::Error + Send + Sync>);
}
Ok(Box::new(resolved.into_iter()) as Addrs)
})
}
}
+5
View File
@@ -0,0 +1,5 @@
pub(crate) mod service;
mod tui;
pub(crate) mod upgrade;
pub use self::tui::{run, SetupOutcome};
+256
View File
@@ -0,0 +1,256 @@
//! Systemd service installation for aether-proxy.
//!
//! Called from the setup TUI when the user enables "Install Service".
//! The unit file points to the binary and config at their current
//! absolute paths -- no files are copied.
use std::path::Path;
use std::process::Command;
const UNIT_PATH: &str = "/etc/systemd/system/aether-proxy.service";
const SERVICE_NAME: &str = "aether-proxy";
/// Whether systemd service installation is possible (systemd present + root).
pub fn is_available() -> bool {
is_systemd_available() && is_root()
}
/// Install aether-proxy as a systemd service. Must be run as root.
pub fn install_service(config_path: &Path) -> anyhow::Result<()> {
if !is_systemd_available() {
anyhow::bail!("systemd not available");
}
if !is_root() {
anyhow::bail!("root required, use: sudo ./aether-proxy setup");
}
let exe_path = std::env::current_exe()?.canonicalize()?;
let exe_str = exe_path
.to_str()
.ok_or_else(|| anyhow::anyhow!("binary path contains invalid UTF-8"))?;
let config_abs = std::fs::canonicalize(config_path)?;
let config_str = config_abs
.to_str()
.ok_or_else(|| anyhow::anyhow!("config path contains invalid UTF-8"))?;
let working_dir = config_abs
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_str()
.unwrap_or("/");
// Stop existing service if running (ignore errors)
if Path::new(UNIT_PATH).exists() {
eprintln!(" Stopping existing service...");
let _ = Command::new("systemctl")
.args(["stop", SERVICE_NAME])
.status();
}
// Write unit file
eprintln!(" Generating systemd unit file...");
eprintln!(" Binary: {}", exe_str);
eprintln!(" Config: {}", config_str);
eprintln!(" WorkDir: {}", working_dir);
let unit_content = format!(
"[Unit]\n\
Description=Aether Proxy\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
WorkingDirectory={working_dir}\n\
Environment=AETHER_PROXY_CONFIG={config_str}\n\
ExecStart={exe_str}\n\
Restart=on-failure\n\
RestartSec=5\n\
LimitNOFILE=65535\n\
UMask=0077\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
);
std::fs::write(UNIT_PATH, &unit_content)?;
// Reload and enable
eprintln!(" Enabling and starting service...");
run_cmd("systemctl", &["daemon-reload"])?;
run_cmd("systemctl", &["enable", "--now", SERVICE_NAME])?;
// Verify
eprintln!();
let output = Command::new("systemctl")
.args(["is-active", SERVICE_NAME])
.output()?;
let state = String::from_utf8_lossy(&output.stdout).trim().to_string();
if state == "active" {
eprintln!(" Service started successfully!");
} else {
eprintln!(" Service state: {} (check logs)", state);
}
eprintln!();
eprintln!(" Commands:");
eprintln!(" ./aether-proxy status # service status");
eprintln!(" ./aether-proxy logs # tail logs");
eprintln!(" sudo ./aether-proxy restart # restart");
eprintln!(" sudo ./aether-proxy stop # stop");
eprintln!(" sudo ./aether-proxy uninstall # remove service");
eprintln!();
Ok(())
}
fn is_systemd_available() -> bool {
Command::new("systemctl")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) fn is_root() -> bool {
#[cfg(unix)]
{
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
{
false
}
}
/// Whether a systemd unit file is currently installed.
pub fn is_installed() -> bool {
Path::new(UNIT_PATH).exists()
}
/// Remove the systemd service (called from setup TUI when Install Service is toggled off).
pub fn uninstall_service() -> anyhow::Result<()> {
if !Path::new(UNIT_PATH).exists() {
return Ok(());
}
eprintln!(" Stopping and removing existing service...");
let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
std::fs::remove_file(UNIT_PATH)?;
eprintln!(" Removed {}", UNIT_PATH);
run_cmd("systemctl", &["daemon-reload"])?;
eprintln!(" Service uninstalled.");
eprintln!();
Ok(())
}
/// Check if the systemd service is currently active.
pub fn is_service_active() -> bool {
std::path::Path::new(UNIT_PATH).exists()
&& Command::new("systemctl")
.args(["is-active", "--quiet", SERVICE_NAME])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
// ── CLI subcommands (systemd wrappers) ──────────────────────────────────────
fn ensure_service_installed() -> anyhow::Result<()> {
if !std::path::Path::new(UNIT_PATH).exists() {
anyhow::bail!("service not installed, run `sudo ./aether-proxy setup` first");
}
Ok(())
}
fn ensure_root_and_service() -> anyhow::Result<()> {
ensure_service_installed()?;
if !is_root() {
anyhow::bail!("root required, use: sudo ./aether-proxy <command>");
}
Ok(())
}
/// `aether-proxy status` -- show service status.
pub fn cmd_status() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("systemctl")
.args(["status", SERVICE_NAME])
.status()?;
// systemctl status returns non-zero when inactive; that's fine
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy logs` -- tail service logs.
pub fn cmd_logs() -> anyhow::Result<()> {
ensure_service_installed()?;
let status = Command::new("journalctl")
.args(["-u", SERVICE_NAME, "-f", "--no-pager", "-n", "100"])
.status()?;
std::process::exit(status.code().unwrap_or(1));
}
/// `aether-proxy start` -- start the service.
pub fn cmd_start() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["start", SERVICE_NAME])?;
eprintln!(" Service started.");
Ok(())
}
/// `aether-proxy restart` -- restart the service.
pub fn cmd_restart() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["restart", SERVICE_NAME])?;
eprintln!(" Service restarted.");
Ok(())
}
/// `aether-proxy stop` -- stop the service.
pub fn cmd_stop() -> anyhow::Result<()> {
ensure_root_and_service()?;
run_cmd("systemctl", &["stop", SERVICE_NAME])?;
eprintln!(" Service stopped.");
Ok(())
}
/// `aether-proxy uninstall` -- disable and remove the systemd service.
pub fn cmd_uninstall() -> anyhow::Result<()> {
ensure_root_and_service()?;
eprintln!(" Stopping and disabling service...");
let _ = Command::new("systemctl")
.args(["disable", "--now", SERVICE_NAME])
.status();
if std::path::Path::new(UNIT_PATH).exists() {
std::fs::remove_file(UNIT_PATH)?;
eprintln!(" Removed {}", UNIT_PATH);
}
run_cmd("systemctl", &["daemon-reload"])?;
eprintln!(" Service uninstalled.");
eprintln!();
eprintln!(" Config file and TLS certs are preserved. Remove manually if needed.");
Ok(())
}
pub(crate) fn run_cmd(program: &str, args: &[&str]) -> anyhow::Result<()> {
let display = format!("{} {}", program, args.join(" "));
eprintln!(" > {}", display);
let status = Command::new(program).args(args).status()?;
if !status.success() {
anyhow::bail!("command failed: {}", display);
}
Ok(())
}
+868
View File
@@ -0,0 +1,868 @@
//! Interactive TUI for configuring aether-proxy.
//!
//! Launched via `aether-proxy setup [path]`. Presents a full-screen form
//! backed by ratatui where the user can navigate fields, edit values, and
//! save to a TOML config file. Supports multi-server configuration via
//! a tabbed interface.
use std::io;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use ratatui::Terminal;
use crate::config::{ConfigFile, ServerEntry};
/// Outcome of the setup wizard, returned to the caller.
pub enum SetupOutcome {
/// Config saved; systemd service installed and started.
ServiceInstalled,
/// Config saved; no service -- caller should start the proxy directly.
ReadyToRun(PathBuf),
/// User quit without saving.
Cancelled,
}
/// Column width reserved for the field label (chars).
const LABEL_WIDTH: usize = 22;
// -- Field types --------------------------------------------------------------
#[derive(Clone, Copy, PartialEq)]
enum FieldKind {
Text,
Secret,
Bool,
LogLevel,
}
struct Field {
label: &'static str,
key: &'static str,
value: String,
kind: FieldKind,
required: bool,
help: &'static str,
}
// -- Server tab ---------------------------------------------------------------
/// A single server tab's editable fields.
struct ServerTab {
fields: Vec<Field>,
}
impl ServerTab {
fn new() -> Self {
Self {
fields: vec![
Field {
label: "Aether URL",
key: "aether_url",
value: String::new(),
kind: FieldKind::Text,
required: true,
help: "Aether URL (e.g. https://aether.example.com)",
},
Field {
label: "Management Token",
key: "management_token",
value: String::new(),
kind: FieldKind::Secret,
required: true,
help: "Aether Management Token (ae_xxx)",
},
Field {
label: "Node Name",
key: "node_name",
value: "proxy-01".into(),
kind: FieldKind::Text,
required: true,
help: "Node name for identification in Aether dashboard",
},
],
}
}
fn from_entry(entry: &ServerEntry) -> Self {
let mut tab = Self::new();
tab.fields[0].value = entry.aether_url.clone();
tab.fields[1].value = entry.management_token.clone();
if let Some(ref name) = entry.node_name {
tab.fields[2].value = name.clone();
}
tab
}
}
// -- App state ----------------------------------------------------------------
#[derive(PartialEq)]
enum Mode {
Normal,
Editing,
}
struct App {
server_tabs: Vec<ServerTab>,
active_tab: usize,
global_fields: Vec<Field>,
selected: usize,
mode: Mode,
edit_buffer: String,
edit_cursor: usize,
config_path: PathBuf,
modified: bool,
message: Option<(String, Instant, bool)>,
scroll_offset: usize,
saved_once: bool,
pending_quit: bool,
confirm_delete: bool,
}
impl App {
fn new(config_path: PathBuf) -> Self {
Self {
server_tabs: vec![ServerTab::new()],
active_tab: 0,
global_fields: vec![
Field {
label: "Log Level",
key: "log_level",
value: "info".into(),
kind: FieldKind::LogLevel,
required: true,
help: "Log level -- Enter to cycle: trace / debug / info / warn / error",
},
Field {
label: "Log JSON",
key: "log_json",
value: "false".into(),
kind: FieldKind::Bool,
required: true,
help: "Output logs as JSON -- Enter to toggle",
},
Field {
label: "Install Service",
key: "install_service",
value: if super::service::is_available() {
"true"
} else {
"false"
}
.into(),
kind: FieldKind::Bool,
required: true,
help: "Install as systemd service (requires root) -- Enter to toggle",
},
],
selected: 0,
mode: Mode::Normal,
edit_buffer: String::new(),
edit_cursor: 0,
config_path,
modified: false,
message: None,
scroll_offset: 0,
saved_once: false,
pending_quit: false,
confirm_delete: false,
}
}
// -- Field accessors (unified index across server + global) ---------------
fn server_field_count(&self) -> usize {
self.server_tabs[self.active_tab].fields.len()
}
fn total_field_count(&self) -> usize {
self.server_field_count() + self.global_fields.len()
}
fn selected_field(&self) -> &Field {
let sc = self.server_field_count();
if self.selected < sc {
&self.server_tabs[self.active_tab].fields[self.selected]
} else {
&self.global_fields[self.selected - sc]
}
}
fn selected_field_mut(&mut self) -> &mut Field {
let sc = self.server_field_count();
if self.selected < sc {
&mut self.server_tabs[self.active_tab].fields[self.selected]
} else {
&mut self.global_fields[self.selected - sc]
}
}
fn clamp_selection(&mut self) {
let max = self.total_field_count();
if self.selected >= max {
self.selected = max.saturating_sub(1);
}
self.scroll_offset = 0;
self.confirm_delete = false;
}
// -- Config <-> fields -----------------------------------------------------
fn load_from_file(&mut self) {
if let Ok(cfg) = ConfigFile::load(&self.config_path) {
self.apply_config(&cfg);
}
}
fn apply_config(&mut self, cfg: &ConfigFile) {
// Global fields
for field in &mut self.global_fields {
let val: Option<String> = match field.key {
"log_level" => cfg.log_level.clone(),
"log_json" => cfg.log_json.map(|v| v.to_string()),
_ => None,
};
if let Some(v) = val {
field.value = v;
}
}
// Server tabs
let servers = cfg.effective_servers();
if servers.is_empty() {
let mut tab = ServerTab::new();
// Single-server fallback: use top-level node_name
if let Some(ref name) = cfg.node_name {
tab.fields[2].value = name.clone();
}
self.server_tabs = vec![tab];
} else {
self.server_tabs = servers.iter().map(ServerTab::from_entry).collect();
// For single-server mode, node_name might be in top-level only
if self.server_tabs.len() == 1 && self.server_tabs[0].fields[2].value.is_empty() {
if let Some(ref name) = cfg.node_name {
self.server_tabs[0].fields[2].value = name.clone();
}
}
}
self.active_tab = 0;
self.selected = 0;
self.scroll_offset = 0;
}
fn to_config(&self) -> ConfigFile {
let get_global = |key: &str| -> Option<String> {
self.global_fields
.iter()
.find(|f| f.key == key)
.map(|f| f.value.clone())
.filter(|v| !v.is_empty())
};
let get_tab = |tab: &ServerTab, key: &str| -> Option<String> {
tab.fields
.iter()
.find(|f| f.key == key)
.map(|f| f.value.clone())
.filter(|v| !v.is_empty())
};
let mut cfg = ConfigFile {
log_level: get_global("log_level"),
log_json: get_global("log_json").and_then(|v| v.parse().ok()),
..ConfigFile::default()
};
// Always write [[servers]] format; old top-level fields are read-only compat
cfg.servers = self
.server_tabs
.iter()
.map(|tab| ServerEntry {
aether_url: get_tab(tab, "aether_url").unwrap_or_default(),
management_token: get_tab(tab, "management_token").unwrap_or_default(),
node_name: get_tab(tab, "node_name"),
})
.collect();
cfg
}
fn save(&mut self) -> anyhow::Result<()> {
let cfg = self.to_config();
cfg.save(&self.config_path)?;
// Restrict config file permissions to owner-only (contains management token).
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(&self.config_path, std::fs::Permissions::from_mode(0o600));
}
self.modified = false;
self.saved_once = true;
self.message = Some((
format!("saved to {}", self.config_path.display()),
Instant::now(),
false,
));
Ok(())
}
// -- Scrolling ---------------------------------------------------------------
fn ensure_visible(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
// Account for separator line between server and global fields
let display_row = if self.selected >= self.server_field_count() {
self.selected + 1
} else {
self.selected
};
if display_row < self.scroll_offset {
self.scroll_offset = display_row;
} else if display_row >= self.scroll_offset + visible_rows {
self.scroll_offset = display_row - visible_rows + 1;
}
}
// -- Key handling -------------------------------------------------------------
/// Returns `true` when the app should exit.
fn handle_key(&mut self, key: KeyEvent) -> bool {
// Expire old messages (but keep quit-confirmation messages alive)
if let Some((_, when, _)) = &self.message {
if !self.pending_quit && !self.confirm_delete && when.elapsed() > Duration::from_secs(4)
{
self.message = None;
}
}
match self.mode {
Mode::Normal => self.handle_normal(key),
Mode::Editing => {
self.handle_edit(key);
false
}
}
}
fn handle_normal(&mut self, key: KeyEvent) -> bool {
// -- Quit handling (with unsaved-changes confirmation) -----------------
let is_quit_key = matches!(key.code, KeyCode::Char('q') | KeyCode::Esc);
if is_quit_key {
if !self.modified || self.pending_quit {
return true;
}
self.pending_quit = true;
self.confirm_delete = false;
self.message = Some((
"unsaved changes! q again to discard, ^S to save".into(),
Instant::now(),
true,
));
return false;
}
// Any other key cancels pending quit / pending delete
if self.pending_quit {
self.pending_quit = false;
self.message = None;
}
if self.confirm_delete && !matches!(key.code, KeyCode::Delete | KeyCode::Char('x')) {
self.confirm_delete = false;
self.message = None;
}
match key.code {
KeyCode::Char('s')
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER) =>
{
if let Err(e) = self.save() {
self.message = Some((format!("error: {}", e), Instant::now(), true));
}
}
KeyCode::Up | KeyCode::Char('k') => {
self.selected = self.selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected + 1 < self.total_field_count() {
self.selected += 1;
}
}
KeyCode::Home => self.selected = 0,
KeyCode::End => self.selected = self.total_field_count() - 1,
KeyCode::Enter | KeyCode::Char(' ') => {
let kind = self.selected_field().kind;
let key_str = self.selected_field().key;
let value = self.selected_field().value.clone();
match kind {
FieldKind::Bool => {
let toggled = if value == "true" { "false" } else { "true" };
if key_str == "install_service"
&& toggled == "true"
&& !super::service::is_available()
{
self.message = Some((
"requires root with systemd, use: sudo aether-proxy setup".into(),
Instant::now(),
true,
));
} else {
self.selected_field_mut().value = toggled.into();
self.modified = true;
}
}
FieldKind::LogLevel => {
const LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
let idx = LEVELS.iter().position(|l| *l == value).unwrap_or(2);
self.selected_field_mut().value = LEVELS[(idx + 1) % LEVELS.len()].into();
self.modified = true;
}
_ => {
self.edit_buffer = value;
self.edit_cursor = self.edit_buffer.chars().count();
self.mode = Mode::Editing;
}
}
}
// -- Tab navigation --
KeyCode::Tab => {
if self.server_tabs.len() > 1 {
self.active_tab = (self.active_tab + 1) % self.server_tabs.len();
self.clamp_selection();
}
}
KeyCode::BackTab => {
if self.server_tabs.len() > 1 {
self.active_tab = if self.active_tab == 0 {
self.server_tabs.len() - 1
} else {
self.active_tab - 1
};
self.clamp_selection();
}
}
KeyCode::Char(c @ '1'..='9') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
let idx = (c as usize) - ('1' as usize);
if idx < self.server_tabs.len() && idx != self.active_tab {
self.active_tab = idx;
self.clamp_selection();
}
}
// -- Add / remove server --
KeyCode::Char('+') | KeyCode::Char('a') => {
self.server_tabs.push(ServerTab::new());
self.active_tab = self.server_tabs.len() - 1;
self.selected = 0;
self.scroll_offset = 0;
self.modified = true;
self.message = Some((
format!("added server {}", self.server_tabs.len()),
Instant::now(),
false,
));
}
KeyCode::Delete | KeyCode::Char('x') => {
if self.server_tabs.len() <= 1 {
self.message =
Some(("cannot remove the last server".into(), Instant::now(), true));
} else if self.confirm_delete {
let removed = self.active_tab + 1;
self.server_tabs.remove(self.active_tab);
self.active_tab = self.active_tab.min(self.server_tabs.len() - 1);
self.clamp_selection();
self.modified = true;
self.message =
Some((format!("server {} removed", removed), Instant::now(), false));
} else {
self.confirm_delete = true;
self.message = Some((
"press Delete/x again to remove this server".into(),
Instant::now(),
true,
));
}
}
_ => {}
}
false
}
fn handle_edit(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.mode = Mode::Normal;
}
KeyCode::Enter => {
if self.validate_edit() {
self.selected_field_mut().value = self.edit_buffer.clone();
self.modified = true;
self.mode = Mode::Normal;
} else {
self.message = Some(("invalid format".into(), Instant::now(), true));
}
}
KeyCode::Backspace => {
if self.edit_cursor > 0 {
self.edit_cursor -= 1;
let byte = self.char_byte_pos(self.edit_cursor);
self.edit_buffer.remove(byte);
}
}
KeyCode::Delete => {
if self.edit_cursor < self.edit_buffer.chars().count() {
let byte = self.char_byte_pos(self.edit_cursor);
self.edit_buffer.remove(byte);
}
}
KeyCode::Left => {
self.edit_cursor = self.edit_cursor.saturating_sub(1);
}
KeyCode::Right => {
let len = self.edit_buffer.chars().count();
if self.edit_cursor < len {
self.edit_cursor += 1;
}
}
KeyCode::Home => self.edit_cursor = 0,
KeyCode::End => self.edit_cursor = self.edit_buffer.chars().count(),
KeyCode::Char(c) => {
let byte = self.char_byte_pos(self.edit_cursor);
self.edit_buffer.insert(byte, c);
self.edit_cursor += 1;
}
_ => {}
}
}
fn validate_edit(&self) -> bool {
true
}
/// Byte offset of the char at `char_idx`.
fn char_byte_pos(&self, char_idx: usize) -> usize {
self.edit_buffer
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.edit_buffer.len())
}
}
// -- Rendering ----------------------------------------------------------------
fn ui(f: &mut Frame, app: &mut App) {
let area = f.area();
let title = if app.modified {
" Aether Proxy Setup [*] "
} else {
" Aether Proxy Setup "
};
let outer = Block::default()
.borders(Borders::ALL)
.title(title)
.title_alignment(ratatui::layout::Alignment::Center)
.border_style(Style::default().fg(Color::Cyan));
let inner = outer.inner(area);
f.render_widget(outer, area);
// Split: fields | tab bar | footer
let chunks = Layout::vertical([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(4),
])
.split(inner);
render_fields(f, app, chunks[0]);
render_tab_bar(f, app, chunks[1]);
render_footer(f, app, chunks[2]);
}
fn render_fields(f: &mut Frame, app: &mut App, area: Rect) {
let visible = area.height as usize;
app.ensure_visible(visible);
let server_count = app.server_field_count();
let mut lines: Vec<Line> = Vec::new();
// display_row tracks the actual row index (including separator)
let mut display_row: usize = 0;
// Server fields
for i in 0..server_count {
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
lines.push(build_field_line(app, i, display_row));
}
display_row += 1;
}
// Separator line
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
lines.push(Line::from(Span::styled(
" ----------------------------------------",
Style::default().fg(Color::DarkGray),
)));
}
display_row += 1;
// Global fields
for i in 0..app.global_fields.len() {
let field_idx = server_count + i;
if display_row >= app.scroll_offset && display_row < app.scroll_offset + visible {
lines.push(build_field_line(app, field_idx, display_row));
}
display_row += 1;
}
let paragraph = Paragraph::new(lines);
f.render_widget(paragraph, area);
// Cursor position while editing
if app.mode == Mode::Editing {
let sel_display_row = if app.selected >= server_count {
app.selected + 1
} else {
app.selected
};
let row_in_view = sel_display_row.saturating_sub(app.scroll_offset);
let prefix: u16 = 3 + LABEL_WIDTH as u16 + 2;
let cx = area.x + prefix + app.edit_cursor as u16;
let cy = area.y + row_in_view as u16;
if cx < area.x + area.width && cy < area.y + area.height {
f.set_cursor_position((cx, cy));
}
}
}
fn build_field_line(app: &App, field_idx: usize, _display_row: usize) -> Line<'static> {
let sc = app.server_field_count();
let field = if field_idx < sc {
&app.server_tabs[app.active_tab].fields[field_idx]
} else {
&app.global_fields[field_idx - sc]
};
let selected = field_idx == app.selected;
let indicator = if selected { " > " } else { " " };
let label_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let padded_label = format!("{:<width$}", field.label, width = LABEL_WIDTH);
let (value_text, value_style) = if app.mode == Mode::Editing && selected {
(app.edit_buffer.clone(), Style::default().fg(Color::Yellow))
} else {
field_display(field)
};
Line::from(vec![
Span::styled(indicator.to_string(), label_style),
Span::styled(padded_label, label_style),
Span::raw(" "),
Span::styled(value_text, value_style),
])
}
/// Returns (display_text, style) for a field in normal mode.
fn field_display(field: &Field) -> (String, Style) {
if field.value.is_empty() {
let text = if field.required {
"(required)".into()
} else {
"-".into()
};
let color = if field.required {
Color::Red
} else {
Color::DarkGray
};
return (text, Style::default().fg(color));
}
match field.kind {
FieldKind::Secret => (
"*".repeat(field.value.len().min(20)),
Style::default().fg(Color::White),
),
FieldKind::Bool => {
if field.value == "true" {
("[x] on".into(), Style::default().fg(Color::Green))
} else {
("[ ] off".into(), Style::default().fg(Color::DarkGray))
}
}
FieldKind::LogLevel => {
let color = match field.value.as_str() {
"trace" => Color::Magenta,
"debug" => Color::Blue,
"info" => Color::Green,
"warn" => Color::Yellow,
"error" => Color::Red,
_ => Color::White,
};
(field.value.clone(), Style::default().fg(color))
}
_ => (field.value.clone(), Style::default().fg(Color::White)),
}
}
fn render_tab_bar(f: &mut Frame, app: &App, area: Rect) {
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::raw(" "));
for (i, tab) in app.server_tabs.iter().enumerate() {
let num = i + 1;
let name = tab
.fields
.iter()
.find(|f| f.key == "node_name")
.filter(|f| !f.value.is_empty())
.map(|f| f.value.clone())
.unwrap_or_else(|| format!("Server {}", num));
let label = format!(" {} {} ", num, name);
if i == app.active_tab {
spans.push(Span::styled(
label,
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(label, Style::default().fg(Color::DarkGray)));
}
spans.push(Span::raw(" "));
}
spans.push(Span::styled(" + Add ", Style::default().fg(Color::Green)));
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_footer(f: &mut Frame, app: &App, area: Rect) {
let help = app.selected_field().help;
let keybindings = if app.mode == Mode::Editing {
"Enter confirm Esc cancel"
} else if app.server_tabs.len() > 1 {
"j/k select Enter edit Tab switch + add x remove ^S save q quit"
} else {
"j/k select Enter edit + add server ^S save q quit"
};
let mut status_spans: Vec<Span> = vec![Span::styled(
format!(" {}", keybindings),
Style::default().fg(Color::DarkGray),
)];
if let Some((msg, _, is_err)) = &app.message {
let color = if *is_err { Color::Red } else { Color::Green };
status_spans.push(Span::raw(" "));
status_spans.push(Span::styled(msg.clone(), Style::default().fg(color)));
}
let footer_text = vec![
Line::raw(""),
Line::from(Span::styled(
format!(" {}", help),
Style::default().fg(Color::DarkGray),
)),
Line::from(status_spans),
];
let footer = Paragraph::new(footer_text).block(
Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)),
);
f.render_widget(footer, area);
}
// -- Entry point --------------------------------------------------------------
pub fn run(config_path: PathBuf) -> anyhow::Result<SetupOutcome> {
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(config_path.clone());
app.load_from_file();
let result = event_loop(&mut terminal, &mut app);
terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result?;
// -- Post-TUI: decide outcome ---------------------------------------------
if !app.saved_once {
return Ok(SetupOutcome::Cancelled);
}
eprintln!();
eprintln!(" Config saved to {}", config_path.display());
eprintln!();
let wants_service = app
.global_fields
.iter()
.find(|f| f.key == "install_service")
.map(|f| f.value == "true")
.unwrap_or(false);
if wants_service {
match super::service::install_service(&config_path) {
Ok(()) => return Ok(SetupOutcome::ServiceInstalled),
Err(e) => {
eprintln!(" Service install failed: {}", e);
eprintln!(" Starting proxy directly instead.\n");
}
}
} else if super::service::is_installed() {
if let Err(e) = super::service::uninstall_service() {
eprintln!(" Service uninstall failed: {}", e);
eprintln!();
}
}
Ok(SetupOutcome::ReadyToRun(config_path))
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> anyhow::Result<()> {
loop {
terminal.draw(|f| ui(f, app))?;
if event::poll(Duration::from_millis(200))? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press && app.handle_key(key) {
break;
}
}
}
}
Ok(())
}
+411
View File
@@ -0,0 +1,411 @@
//! Self-upgrade for aether-proxy.
//!
//! Downloads a release from GitHub, verifies SHA256 checksum, and atomically
//! replaces the running binary. Restarts the systemd service if active.
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_REPO: &str = "fawney19/Aether";
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
// ── GitHub API types ─────────────────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct GithubRelease {
tag_name: String,
name: String,
}
// ── Platform detection ───────────────────────────────────────────────────────
fn detect_platform() -> &'static str {
if cfg!(target_os = "linux") && cfg!(target_arch = "x86_64") {
"linux-amd64"
} else if cfg!(target_os = "linux") && cfg!(target_arch = "aarch64") {
"linux-arm64"
} else if cfg!(target_os = "macos") && cfg!(target_arch = "x86_64") {
"macos-amd64"
} else if cfg!(target_os = "macos") && cfg!(target_arch = "aarch64") {
"macos-arm64"
} else if cfg!(target_os = "windows") && cfg!(target_arch = "x86_64") {
"windows-amd64"
} else {
// All supported targets are covered above; this is unreachable for
// any platform we actually build for.
panic!("unsupported platform: compile-time target not in the supported matrix")
}
}
// ── GitHub HTTP client ───────────────────────────────────────────────────────
fn build_github_client() -> anyhow::Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(token) = std::env::var("GITHUB_TOKEN") {
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
}
headers.insert(
reqwest::header::ACCEPT,
reqwest::header::HeaderValue::from_static("application/vnd.github+json"),
);
Ok(reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.user_agent(format!("aether-proxy/{}", CURRENT_VERSION))
.default_headers(headers)
.build()?)
}
// ── Release fetching ─────────────────────────────────────────────────────────
async fn fetch_release(
client: &reqwest::Client,
version: Option<&str>,
) -> anyhow::Result<GithubRelease> {
match version {
Some(ver) => {
// Accept both "proxy-v0.2.0" and bare "0.2.0"
let tag = if ver.starts_with("proxy-v") {
ver.to_string()
} else {
format!("proxy-v{}", ver)
};
let url = format!(
"{}/repos/{}/releases/tags/{}",
GITHUB_API_BASE, GITHUB_REPO, tag
);
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("release '{}' not found (HTTP {}): {}", tag, status, body);
}
Ok(resp.json().await?)
}
None => {
// List releases and find the latest proxy-v* tag
let url = format!(
"{}/repos/{}/releases?per_page=20",
GITHUB_API_BASE, GITHUB_REPO
);
let resp = client.get(&url).send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("failed to list releases (HTTP {}): {}", status, body);
}
let releases: Vec<GithubRelease> = resp.json().await?;
releases
.into_iter()
.find(|r| r.tag_name.starts_with("proxy-v"))
.ok_or_else(|| anyhow::anyhow!("no proxy-v* release found"))
}
}
}
// ── Download via GitHub release direct links ─────────────────────────────────
/// Download a release asset via the public direct download URL:
/// `https://github.com/{repo}/releases/download/{tag}/{filename}`
async fn download_release_file(
client: &reqwest::Client,
tag: &str,
filename: &str,
) -> anyhow::Result<Vec<u8>> {
let url = format!(
"https://github.com/{}/releases/download/{}/{}",
GITHUB_REPO, tag, filename
);
let resp = client
.get(&url)
.header(reqwest::header::ACCEPT, "application/octet-stream")
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!(
"download failed for '{}' (HTTP {})",
filename,
resp.status(),
);
}
Ok(resp.bytes().await?.to_vec())
}
fn parse_checksum(sums_text: &str, filename: &str) -> anyhow::Result<String> {
for line in sums_text.lines() {
// Format: "<hash> <filename>" (GNU coreutils convention)
let mut parts = line.split_ascii_whitespace();
let (Some(hash), Some(name)) = (parts.next(), parts.next()) else {
continue;
};
if name == filename || name.ends_with(filename) {
return Ok(hash.to_lowercase());
}
}
anyhow::bail!("checksum for '{}' not found in SHA256SUMS.txt", filename);
}
async fn download_and_verify(
client: &reqwest::Client,
tag: &str,
platform: &str,
dest: &Path,
) -> anyhow::Result<()> {
let archive_name = format!("aether-proxy-{}.tar.gz", platform);
eprintln!(" Downloading {}...", archive_name);
let (archive_bytes, checksum_bytes) = tokio::try_join!(
download_release_file(client, tag, &archive_name),
download_release_file(client, tag, "SHA256SUMS.txt"),
)?;
let checksum_text = String::from_utf8(checksum_bytes)?;
eprintln!(
" Downloaded {} ({} bytes)",
archive_name,
archive_bytes.len()
);
// Verify SHA256
let expected_hash = parse_checksum(&checksum_text, &archive_name)?;
let mut hasher = Sha256::new();
hasher.update(&archive_bytes);
let actual_hash = hex::encode(hasher.finalize());
if actual_hash != expected_hash {
anyhow::bail!(
"SHA256 mismatch for {}:\n expected: {}\n actual: {}",
archive_name,
expected_hash,
actual_hash
);
}
eprintln!(" SHA256 verified: {}", &actual_hash[..16]);
extract_binary(&archive_bytes, dest)?;
Ok(())
}
// ── Archive extraction ───────────────────────────────────────────────────────
fn extract_binary(archive_bytes: &[u8], dest: &Path) -> anyhow::Result<()> {
use flate2::read::GzDecoder;
use tar::Archive;
// Guard against decompression bombs
const MAX_BINARY_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
let decoder = GzDecoder::new(archive_bytes);
let mut archive = Archive::new(decoder);
let binary_name = if cfg!(target_os = "windows") {
"aether-proxy.exe"
} else {
"aether-proxy"
};
for entry in archive.entries()? {
let mut entry = entry?;
// Only accept regular files -- reject symlinks to prevent write-through attacks
if entry.header().entry_type() != tar::EntryType::Regular {
continue;
}
let path = entry.path()?;
if path.file_name().and_then(|n| n.to_str()) == Some(binary_name) {
let size = entry.header().size()?;
if size > MAX_BINARY_SIZE {
anyhow::bail!(
"binary too large ({} bytes, max {} bytes)",
size,
MAX_BINARY_SIZE
);
}
let mut file = std::fs::File::create(dest)?;
std::io::copy(&mut entry, &mut file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dest, std::fs::Permissions::from_mode(0o755))?;
}
return Ok(());
}
}
anyhow::bail!("'{}' not found in archive", binary_name);
}
// ── Atomic binary replacement ────────────────────────────────────────────────
fn atomic_replace(new_binary: &Path) -> anyhow::Result<PathBuf> {
let current_exe = std::env::current_exe()?.canonicalize()?;
let backup_path = current_exe.with_extension("bak");
// Remove stale backup
let _ = std::fs::remove_file(&backup_path);
// current -> .bak
std::fs::rename(&current_exe, &backup_path).map_err(|e| {
anyhow::anyhow!(
"failed to backup current binary '{}' -> '{}': {}",
current_exe.display(),
backup_path.display(),
e
)
})?;
// new -> current
if let Err(e) = std::fs::rename(new_binary, &current_exe) {
eprintln!(" ERROR: failed to place new binary, rolling back...");
let _ = std::fs::rename(&backup_path, &current_exe);
anyhow::bail!(
"failed to install new binary '{}' -> '{}': {}",
new_binary.display(),
current_exe.display(),
e
);
}
eprintln!(" Binary replaced: {}", current_exe.display());
Ok(backup_path)
}
// ── Public entry point ───────────────────────────────────────────────────────
#[derive(Clone, Copy)]
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
let current_exe = std::env::current_exe()?.canonicalize()?;
let exe_dir = current_exe
.parent()
.ok_or_else(|| anyhow::anyhow!("cannot determine binary directory"))?;
let temp_path = exe_dir.join(".aether-proxy.upgrade.tmp");
if require_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");
match std::fs::File::create(&test_path) {
Ok(_) => {
let _ = std::fs::remove_file(&test_path);
}
Err(_) => {
anyhow::bail!(
"no write access to {}. Use: sudo aether-proxy upgrade",
exe_dir.display()
);
}
}
}
let platform = detect_platform();
eprintln!(" Platform: {}", platform);
eprintln!(" Current version: {}", CURRENT_VERSION);
let client = build_github_client()?;
let release = fetch_release(&client, version).await?;
let target_tag = &release.tag_name;
let target_semver = target_tag.strip_prefix("proxy-v").unwrap_or(target_tag);
eprintln!(" Target version: {} ({})", target_tag, release.name);
if target_semver == CURRENT_VERSION {
eprintln!(
" Already running version {}, nothing to do.",
CURRENT_VERSION
);
return Ok(());
}
eprintln!();
eprintln!(" Upgrading: {} -> {}", CURRENT_VERSION, target_semver);
eprintln!();
if let Err(e) = download_and_verify(&client, target_tag, platform, &temp_path).await {
let _ = std::fs::remove_file(&temp_path);
return Err(e);
}
let backup_path = match atomic_replace(&temp_path) {
Ok(backup) => backup,
Err(e) => {
let _ = std::fs::remove_file(&temp_path);
return Err(e);
}
};
match restart_mode {
RestartMode::BestEffort => {
// Restart systemd service if running.
// Use best-effort: binary is already replaced, so a restart failure should
// not abort the whole upgrade -- the user can restart manually.
if super::service::is_service_active() {
if super::service::is_root() {
eprintln!(" Restarting systemd service...");
match super::service::run_cmd("systemctl", &["restart", "aether-proxy"]) {
Ok(()) => eprintln!(" Service restarted."),
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.");
}
}
RestartMode::Required => {
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!(" Upgrade complete!");
eprintln!(
" Backup kept at: {} (will be cleaned up on next upgrade)",
backup_path.display()
);
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
}
+77
View File
@@ -0,0 +1,77 @@
//! Shared application state passed to all subsystems.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use crate::config::Config;
use crate::registration::client::AetherClient;
use crate::runtime::SharedDynamicConfig;
use crate::target_filter::DnsCache;
use crate::upstream_client::UpstreamClient;
/// Central application state shared across all servers/tunnels.
pub struct AppState {
pub config: Arc<Config>,
/// DNS cache for upstream target resolution (shared).
pub dns_cache: Arc<DnsCache>,
/// Hyper client for tunnel upstream requests with validated DNS and connection timing.
pub upstream_client: UpstreamClient,
/// Shared TLS config for tunnel WebSocket connections (avoids re-parsing root CAs on each reconnect).
pub tunnel_tls_config: Arc<rustls::ClientConfig>,
}
/// Per-server state: one instance per Aether server connection.
pub struct ServerContext {
/// Human-readable label for logging (e.g. "server-0").
pub server_label: String,
/// Aether server URL for this connection.
pub aether_url: String,
/// Management token for this server.
pub management_token: String,
/// Resolved node name at registration time (per-server override or global fallback).
/// After startup, the active node_name is read from `dynamic` (may be updated remotely).
#[allow(dead_code)]
pub node_name: String,
/// Node ID assigned by this Aether server.
pub node_id: Arc<RwLock<String>>,
/// API client for this server.
pub aether_client: Arc<AetherClient>,
/// Dynamic config from this server's heartbeat ACKs.
pub dynamic: SharedDynamicConfig,
/// Per-server active connection count.
pub active_connections: Arc<AtomicU64>,
/// Per-server request/latency metrics.
pub metrics: Arc<ProxyMetrics>,
}
/// Aggregate metrics for reporting to Aether.
pub struct ProxyMetrics {
pub total_requests: AtomicU64,
/// Cumulative connection-establishment latency in nanoseconds
/// (DNS + TCP/TLS + TTFB, excludes response body streaming).
pub total_latency_ns: AtomicU64,
pub failed_requests: AtomicU64,
pub dns_failures: AtomicU64,
pub stream_errors: AtomicU64,
}
impl ProxyMetrics {
pub fn new() -> Self {
Self {
total_requests: AtomicU64::new(0),
total_latency_ns: AtomicU64::new(0),
failed_requests: AtomicU64::new(0),
dns_failures: AtomicU64::new(0),
stream_errors: AtomicU64::new(0),
}
}
/// Record a completed request with its connection-establishment latency
/// (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_latency_ns.fetch_add(nanos, Ordering::Release);
}
}
+381
View File
@@ -0,0 +1,381 @@
use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
/// Check if an IP address belongs to a private/reserved network.
pub fn is_private_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => is_private_ipv4(v4),
IpAddr::V6(v6) => is_private_ipv6(v6),
}
}
fn is_private_ipv4(ip: &Ipv4Addr) -> bool {
let octets = ip.octets();
// 10.0.0.0/8
if octets[0] == 10 {
return true;
}
// 172.16.0.0/12
if octets[0] == 172 && (16..=31).contains(&octets[1]) {
return true;
}
// 192.168.0.0/16
if octets[0] == 192 && octets[1] == 168 {
return true;
}
// 127.0.0.0/8
if octets[0] == 127 {
return true;
}
// 169.254.0.0/16 (link-local)
if octets[0] == 169 && octets[1] == 254 {
return true;
}
// 0.0.0.0/8
if octets[0] == 0 {
return true;
}
// 100.64.0.0/10 (CGNAT / shared address space)
if octets[0] == 100 && (64..=127).contains(&octets[1]) {
return true;
}
// 192.0.0.0/24 (IETF protocol assignments)
if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 {
return true;
}
// 198.18.0.0/15 (benchmark testing)
if octets[0] == 198 && (18..=19).contains(&octets[1]) {
return true;
}
// 240.0.0.0/4 (reserved for future use)
if octets[0] >= 240 {
return true;
}
false
}
fn is_private_ipv6(ip: &Ipv6Addr) -> bool {
// ::1 loopback
if ip.is_loopback() {
return true;
}
// :: unspecified
if ip.is_unspecified() {
return true;
}
let segments = ip.segments();
// fc00::/7 (ULA) - first byte is 0xfc or 0xfd
if segments[0] & 0xfe00 == 0xfc00 {
return true;
}
// fe80::/10 (link-local)
if segments[0] & 0xffc0 == 0xfe80 {
return true;
}
// IPv4-mapped IPv6 (::ffff:x.x.x.x) - check the embedded IPv4
if let Some(v4) = ip.to_ipv4_mapped() {
return is_private_ipv4(&v4);
}
false
}
#[derive(Debug)]
pub enum FilterError {
PrivateIp(IpAddr),
PortNotAllowed(u16),
DnsResolutionFailed(String),
NoPublicAddrs(String),
}
impl std::fmt::Display for FilterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PrivateIp(ip) => write!(f, "target IP {} is in private/reserved range", ip),
Self::PortNotAllowed(port) => write!(f, "port {} not in allowed list", port),
Self::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for {}", host),
Self::NoPublicAddrs(host) => {
write!(
f,
"all resolved addresses for {} are private/reserved",
host
)
}
}
}
}
struct DnsCacheEntry {
addrs: Arc<Vec<SocketAddr>>,
expires_at: Instant,
inserted_at: Instant,
}
/// Lightweight DNS cache with TTL + capacity bounds.
/// Stores all public resolved addresses per host (used by SafeDnsResolver
/// to ensure reqwest connects to the same validated addresses).
pub struct DnsCache {
ttl: Duration,
capacity: usize,
entries: RwLock<HashMap<String, DnsCacheEntry>>,
}
impl DnsCache {
pub fn new(ttl: Duration, capacity: usize) -> Self {
Self {
ttl,
capacity,
entries: RwLock::new(HashMap::new()),
}
}
/// Look up cached public addresses for a host (any port).
///
/// Used by `SafeDnsResolver` which only knows the hostname — returns the
/// first unexpired entry whose key starts with `host:`.
pub async fn get_by_host(&self, host: &str) -> Option<Arc<Vec<SocketAddr>>> {
if self.capacity == 0 || self.ttl.is_zero() {
return None;
}
let prefix = format!("{}:", host.to_ascii_lowercase());
let now = Instant::now();
let entries = self.entries.read().await;
for (key, entry) in entries.iter() {
if key.starts_with(&prefix) && entry.expires_at > now {
return Some(Arc::clone(&entry.addrs));
}
}
None
}
/// Look up cached public addresses for a host + port.
pub async fn get(&self, host: &str, port: u16) -> Option<Arc<Vec<SocketAddr>>> {
if self.capacity == 0 || self.ttl.is_zero() {
return None;
}
let key = Self::key(host, port);
let now = Instant::now();
// Fast path: read lock for cache hit
{
let entries = self.entries.read().await;
match entries.get(&key) {
Some(entry) if entry.expires_at > now => return Some(Arc::clone(&entry.addrs)),
None => return None,
Some(_) => {} // expired, fall through to evict
}
}
// Slow path: write lock to remove expired entry
let mut entries = self.entries.write().await;
entries.remove(&key);
None
}
/// Insert resolved public addresses into cache.
pub async fn insert(&self, host: &str, port: u16, addrs: Arc<Vec<SocketAddr>>) {
if self.capacity == 0 || self.ttl.is_zero() || addrs.is_empty() {
return;
}
let key = Self::key(host, port);
let now = Instant::now();
let mut entries = self.entries.write().await;
entries.retain(|_, entry| entry.expires_at > now);
while entries.len() >= self.capacity {
let oldest_key = entries
.iter()
.min_by_key(|(_, entry)| entry.inserted_at)
.map(|(key, _)| key.clone());
if let Some(key) = oldest_key {
entries.remove(&key);
} else {
break;
}
}
entries.insert(
key,
DnsCacheEntry {
addrs,
expires_at: now + self.ttl,
inserted_at: now,
},
);
}
fn key(host: &str, port: u16) -> String {
format!("{}:{}", host.to_ascii_lowercase(), port)
}
}
/// Resolve a hostname to public (non-private) socket addresses.
///
/// Results are cached in `dns_cache`. Private/reserved IPs are filtered out.
/// Returns an error if no public addresses remain after filtering.
pub async fn resolve_public_addrs(
host: &str,
port: u16,
dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> {
// Cache hit
if let Some(addrs) = dns_cache.get(host, port).await {
return Ok((*addrs).clone());
}
// Async DNS resolution
let addr_str = format!("{}:{}", host, port);
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(&addr_str)
.await
.map_err(|_| FilterError::DnsResolutionFailed(host.to_string()))?
.collect();
if resolved.is_empty() {
return Err(FilterError::DnsResolutionFailed(host.to_string()));
}
// Filter out private/reserved addresses
let public: Vec<SocketAddr> = resolved
.into_iter()
.filter(|addr| !is_private_ip(&addr.ip()))
.collect();
if public.is_empty() {
return Err(FilterError::NoPublicAddrs(host.to_string()));
}
// Cache the validated public addresses
let arc_addrs = Arc::new(public);
dns_cache.insert(host, port, Arc::clone(&arc_addrs)).await;
Ok((*arc_addrs).clone())
}
/// Validate that the target host:port is allowed.
///
/// Performs port whitelist check, private IP filtering, and DNS resolution
/// with caching. The resolved addresses are stored in the shared DnsCache
/// so that the SafeDnsResolver can reuse them, eliminating the TOCTTOU gap.
pub async fn validate_target(
host: &str,
port: u16,
allowed_ports: &HashSet<u16>,
dns_cache: &DnsCache,
) -> Result<Vec<SocketAddr>, FilterError> {
// Port whitelist check
if !allowed_ports.contains(&port) {
return Err(FilterError::PortNotAllowed(port));
}
// Try parsing as IP directly (no DNS needed)
if let Ok(ip) = host.parse::<IpAddr>() {
if is_private_ip(&ip) {
return Err(FilterError::PrivateIp(ip));
}
return Ok(vec![SocketAddr::new(ip, port)]);
}
// Resolve and validate DNS (populates cache for SafeDnsResolver)
resolve_public_addrs(host, port, dns_cache).await
}
#[cfg(test)]
mod tests {
use super::*;
fn ports() -> HashSet<u16> {
[80, 443, 8080, 8443].into_iter().collect()
}
fn cache() -> DnsCache {
DnsCache::new(Duration::from_secs(60), 128)
}
#[test]
fn test_private_ipv4() {
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))));
// CGNAT
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1))));
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(
100, 127, 255, 254
))));
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(
100, 63, 255, 254
))));
// Benchmark testing
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1))));
// Reserved
assert!(is_private_ip(&IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1))));
// Public
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))));
assert!(!is_private_ip(&IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))));
}
#[test]
fn test_private_ipv6() {
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
// fc00::1 (ULA)
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
0xfc00, 0, 0, 0, 0, 0, 0, 1
))));
// fe80::1 (link-local)
assert!(is_private_ip(&IpAddr::V6(Ipv6Addr::new(
0xfe80, 0, 0, 0, 0, 0, 0, 1
))));
}
#[tokio::test]
async fn test_port_not_allowed() {
let cache = cache();
let result = validate_target("8.8.8.8", 22, &ports(), &cache).await;
assert!(matches!(result, Err(FilterError::PortNotAllowed(22))));
}
#[tokio::test]
async fn test_private_ip_blocked() {
let cache = cache();
let result = validate_target("127.0.0.1", 80, &ports(), &cache).await;
assert!(matches!(result, Err(FilterError::PrivateIp(_))));
}
#[tokio::test]
async fn test_public_ip_allowed() {
let cache = cache();
let result = validate_target("8.8.8.8", 443, &ports(), &cache).await;
assert!(result.is_ok());
let addrs = result.unwrap();
assert_eq!(addrs.len(), 1);
assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)));
}
#[tokio::test]
async fn test_cache_stores_multiple_addrs() {
let cache = cache();
let addrs = vec![
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443),
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 0, 0, 1)), 443),
];
cache
.insert("example.com", 443, Arc::new(addrs.clone()))
.await;
let cached = cache.get("example.com", 443).await.unwrap();
assert_eq!(*cached, addrs);
}
#[tokio::test]
async fn test_cache_key_case_insensitive() {
let cache = cache();
let addrs = vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 443)];
cache
.insert("Example.COM", 443, Arc::new(addrs.clone()))
.await;
let cached = cache.get("example.com", 443).await.unwrap();
assert_eq!(*cached, addrs);
}
}
+238
View File
@@ -0,0 +1,238 @@
//! WebSocket tunnel client: connect, authenticate, and run the tunnel.
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::sync::watch;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tracing::{debug, info, warn};
use crate::state::{AppState, ServerContext};
use super::{dispatcher, heartbeat, writer};
/// Outcome of a tunnel session.
pub enum TunnelOutcome {
/// Graceful shutdown requested by the local process.
Shutdown,
/// Remote side disconnected or connection lost — should reconnect.
Disconnected,
}
/// Connect to Aether's WebSocket tunnel endpoint and run until disconnected.
///
/// `conn_idx` identifies which connection in the pool this is (0-based).
/// Only connection 0 sends heartbeats to avoid resetting shared metrics.
pub async fn connect_and_run(
state: &Arc<AppState>,
server: &Arc<ServerContext>,
conn_idx: usize,
shutdown: &mut watch::Receiver<bool>,
) -> Result<TunnelOutcome, anyhow::Error> {
let ws_url = build_tunnel_url(server);
info!(url = %ws_url, conn = conn_idx, "connecting tunnel");
// Build WebSocket request with auth headers
let mut request = ws_url.clone().into_client_request()?;
let headers = request.headers_mut();
headers.insert(
"Authorization",
http::HeaderValue::from_str(&format!("Bearer {}", server.management_token))?,
);
let node_id = server.node_id.read().unwrap().clone();
headers.insert("X-Node-Id", http::HeaderValue::from_str(&node_id)?);
// Use dynamic node_name (may be updated by remote config) instead of
// the static server.node_name, so that remote name changes take effect
// on the next reconnect.
let dynamic_node_name = server.dynamic.load().node_name.clone();
headers.insert(
"X-Node-Name",
http::HeaderValue::from_str(&dynamic_node_name)?,
);
// Advertise per-connection max concurrent streams so the backend can
// respect the proxy's capacity limit (backward-compatible: old backends
// ignore this header).
let max_streams = state.config.tunnel_max_streams.unwrap_or(128);
headers.insert("X-Tunnel-Max-Streams", http::HeaderValue::from(max_streams));
// Parse host:port from URL
let uri: http::Uri = ws_url.parse()?;
let host = uri
.host()
.ok_or_else(|| anyhow::anyhow!("missing host in tunnel URL"))?;
let is_tls = uri.scheme_str() == Some("wss");
let port = uri.port_u16().unwrap_or(if is_tls { 443 } else { 80 });
// TCP connect with timeout
let connect_timeout = Duration::from_secs(state.config.tunnel_connect_timeout_secs);
let tcp_stream = tokio::time::timeout(connect_timeout, TcpStream::connect((host, port)))
.await
.map_err(|_| {
anyhow::anyhow!(
"tunnel TCP connect timeout ({}s)",
connect_timeout.as_secs()
)
})??;
// Configure TCP parameters via socket2
configure_tcp_socket(&tcp_stream, state);
// WebSocket upgrade (with TLS if wss://)
let connector = if is_tls {
Some(tokio_tungstenite::Connector::Rustls(Arc::clone(
&state.tunnel_tls_config,
)))
} else {
None
};
// Match Python-side _MAX_FRAME_SIZE (64 MiB) to prevent tungstenite's
// default 16 MiB limit from rejecting large AI API payloads (multi-image
// base64 requests can exceed 16 MiB).
let ws_config = WebSocketConfig {
max_frame_size: Some(64 << 20),
max_message_size: Some(64 << 20),
..Default::default()
};
let handshake_timeout = Duration::from_secs(state.config.tunnel_connect_timeout_secs);
let (ws_stream, _response) = tokio::time::timeout(
handshake_timeout,
tokio_tungstenite::client_async_tls_with_config(
request,
tcp_stream,
Some(ws_config),
connector,
),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"tunnel WebSocket handshake timeout ({}s)",
handshake_timeout.as_secs()
)
})??;
info!(
conn = conn_idx,
tcp_keepalive_secs = state.config.tunnel_tcp_keepalive_secs,
tcp_nodelay = state.config.tunnel_tcp_nodelay,
connect_timeout_secs = state.config.tunnel_connect_timeout_secs,
stale_timeout_secs = state.config.tunnel_stale_timeout_secs,
"tunnel connected"
);
// NOTE: reconnect_attempts reset is handled by the caller (mod.rs)
// based on how long the connection stayed alive.
// Split into read/write halves
let (ws_sink, ws_read) = futures_util::StreamExt::split(ws_stream);
// Spawn writer task (with WebSocket ping keepalive)
let ping_interval = Duration::from_secs(state.config.tunnel_ping_interval_secs);
let (frame_tx, mut writer_handle) = writer::spawn_writer(ws_sink, ping_interval);
// Spawn heartbeat task (only for primary connection to avoid
// resetting shared atomic metrics via swap(0))
let hb_handle = if conn_idx == 0 {
heartbeat::spawn(
Arc::clone(&state.config),
Arc::clone(server),
frame_tx.clone(),
shutdown.clone(),
)
} else {
heartbeat::spawn_noop()
};
// Run dispatcher (blocks until disconnect or shutdown).
// Also watch for writer exit — if the write half dies (e.g. the peer
// closed the connection) but the read half stays open, dispatcher would
// block forever on `ws_stream.next()`. Monitoring `writer_handle`
// ensures we detect this and trigger a reconnect promptly.
let state_clone = Arc::clone(state);
let server_clone = Arc::clone(server);
let outcome = tokio::select! {
result = dispatcher::run(state_clone, server_clone, ws_read, frame_tx.clone(), hb_handle) => {
match result {
Ok(()) => TunnelOutcome::Disconnected,
Err(e) => return Err(e),
}
}
writer_result = &mut writer_handle => {
match writer_result {
Ok(()) => warn!("writer task exited normally, triggering reconnect"),
Err(e) => {
if e.is_panic() {
tracing::error!(error = %e, "writer task panicked, triggering reconnect");
} else {
warn!(error = %e, "writer task cancelled, triggering reconnect");
}
}
}
TunnelOutcome::Disconnected
}
_ = shutdown.changed() => {
debug!("shutdown during tunnel dispatch");
TunnelOutcome::Shutdown
}
};
// Drop our sender; the writer will exit once all stream handler clones
// are also dropped (i.e. after they finish their in-flight work).
drop(frame_tx);
// Wait for the writer task to finish with a generous timeout — the
// dispatcher already waits up to 30s for stream handlers, so 35s here
// covers that plus a small margin.
// Skip if the writer already exited (the select branch that fired).
if !writer_handle.is_finished() {
let _ = tokio::time::timeout(Duration::from_secs(35), writer_handle).await;
}
info!("tunnel disconnected");
Ok(outcome)
}
/// Configure TCP keepalive and NODELAY on an established socket.
fn configure_tcp_socket(stream: &TcpStream, state: &Arc<AppState>) {
let sock_ref = socket2::SockRef::from(stream);
if state.config.tunnel_tcp_keepalive_secs > 0 {
let keepalive = socket2::TcpKeepalive::new()
.with_time(Duration::from_secs(state.config.tunnel_tcp_keepalive_secs))
.with_interval(Duration::from_secs(5));
#[cfg(not(target_os = "windows"))]
let keepalive = keepalive.with_retries(3);
if let Err(e) = sock_ref.set_tcp_keepalive(&keepalive) {
warn!(error = %e, "failed to set TCP keepalive on tunnel socket");
}
}
if state.config.tunnel_tcp_nodelay {
if let Err(e) = sock_ref.set_nodelay(true) {
warn!(error = %e, "failed to set TCP_NODELAY on tunnel socket");
}
}
}
/// Build rustls ClientConfig with system root certificates.
pub fn build_tls_config() -> rustls::ClientConfig {
let root_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth()
}
fn build_tunnel_url(server: &ServerContext) -> String {
let base = server.aether_url.trim_end_matches('/');
let ws_base = if base.starts_with("https://") {
base.replacen("https://", "wss://", 1)
} else if base.starts_with("http://") {
base.replacen("http://", "ws://", 1)
} else {
format!("wss://{}", base)
};
format!("{}/api/internal/proxy-tunnel", ws_base)
}
+249
View File
@@ -0,0 +1,249 @@
//! Frame dispatcher: reads incoming WebSocket frames and routes them.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use futures_util::StreamExt;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, error, info, warn};
use crate::state::{AppState, ServerContext};
use super::heartbeat::HeartbeatHandle;
use super::protocol::{decompress_if_gzip, Frame, MsgType, RequestMeta};
use super::stream_handler;
use super::writer::FrameSender;
/// Run the dispatcher loop, reading from the WebSocket stream.
pub async fn run<S>(
state: Arc<AppState>,
server: Arc<ServerContext>,
mut ws_stream: S,
frame_tx: FrameSender,
heartbeat: HeartbeatHandle,
) -> Result<(), anyhow::Error>
where
S: StreamExt<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
+ Unpin
+ Send
+ 'static,
{
// Active streams: stream_id -> body sender
let mut streams: HashMap<u32, mpsc::Sender<Frame>> = HashMap::new();
// Track spawned stream handlers so we can wait for them on shutdown
let mut handler_handles: Vec<JoinHandle<()>> = Vec::new();
let max_streams = state.config.tunnel_max_streams.unwrap_or(128) as usize;
let mut frames_since_cleanup: u32 = 0;
let stale_timeout = Duration::from_secs(state.config.tunnel_stale_timeout_secs);
// Track last time we received any data to detect stale connections
let mut last_data_at = tokio::time::Instant::now();
let read_err = loop {
let msg_result = tokio::select! {
msg = ws_stream.next() => {
match msg {
Some(r) => r,
None => break None,
}
}
_ = tokio::time::sleep_until(last_data_at + stale_timeout) => {
warn!(
stale_secs = stale_timeout.as_secs(),
"tunnel connection stale, no data received"
);
break None;
}
};
let msg = match msg_result {
Ok(m) => m,
Err(e) => {
error!(error = %e, "WebSocket read error");
break Some(e);
}
};
// Any successfully received message proves the connection is alive
last_data_at = tokio::time::Instant::now();
let data = match msg {
Message::Binary(data) => Bytes::from(data),
Message::Ping(_) => continue,
Message::Pong(_) => continue,
Message::Close(_) => {
info!("received WebSocket close");
break None;
}
_ => continue,
};
let frame = match Frame::decode(data) {
Ok(f) => f,
Err(e) => {
warn!(error = %e, "failed to decode frame");
continue;
}
};
match frame.msg_type {
MsgType::RequestHeaders => {
// Decompress if the frame is gzip-compressed, then parse metadata
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,
Err(e) => {
warn!(stream_id = frame.stream_id, error = %e, "invalid request metadata");
// Use try_send to avoid blocking the read loop
if frame_tx
.try_send(Frame::new(
frame.stream_id,
MsgType::StreamError,
0,
Bytes::from(format!("invalid request metadata: {e}")),
))
.is_err()
{
warn!(
stream_id = frame.stream_id,
"writer channel full, StreamError dropped"
);
}
continue;
}
};
if streams.len() >= max_streams {
warn!(
stream_id = frame.stream_id,
"max concurrent streams reached"
);
if frame_tx
.try_send(Frame::new(
frame.stream_id,
MsgType::StreamError,
0,
Bytes::from("max concurrent streams reached"),
))
.is_err()
{
warn!(
stream_id = frame.stream_id,
"writer channel full, StreamError dropped"
);
}
continue;
}
// Create body channel and spawn handler
let (body_tx, body_rx) = mpsc::channel::<Frame>(64);
streams.insert(frame.stream_id, body_tx);
let state_clone = Arc::clone(&state);
let server_clone = Arc::clone(&server);
let tx_clone = frame_tx.clone();
let sid = frame.stream_id;
let handle = tokio::spawn(async move {
stream_handler::handle_stream(
state_clone,
server_clone,
sid,
meta,
body_rx,
tx_clone,
)
.await;
});
handler_handles.push(handle);
debug!(stream_id = frame.stream_id, "new stream started");
}
MsgType::RequestBody => {
if let Some(tx) = streams.get(&frame.stream_id) {
let is_end = frame.is_end_stream();
let sid = frame.stream_id;
let _ = tx.send(frame).await;
if is_end {
streams.remove(&sid);
}
}
}
MsgType::StreamEnd | MsgType::StreamError => {
// Client-side cancellation or end
if let Some(tx) = streams.remove(&frame.stream_id) {
let _ = tx.send(frame).await;
}
}
MsgType::Ping => {
// Use try_send to avoid blocking the read loop when writer is congested
if frame_tx
.try_send(Frame::control(MsgType::Pong, frame.payload))
.is_err()
{
warn!("writer channel full, Pong dropped");
}
}
MsgType::HeartbeatAck => {
heartbeat.on_ack(frame.payload).await;
}
MsgType::GoAway => {
info!("received GOAWAY");
break None;
}
_ => {
debug!(msg_type = ?frame.msg_type, "ignoring unexpected frame type");
}
}
// Periodically clean up finished handles to avoid unbounded growth.
// Trigger every 64 frames OR when the count exceeds max_streams.
frames_since_cleanup += 1;
if frames_since_cleanup >= 64 || handler_handles.len() > max_streams {
handler_handles.retain(|h| !h.is_finished());
frames_since_cleanup = 0;
}
};
// Drop body senders so stream handlers waiting on body_rx will unblock
streams.clear();
// Wait for active stream handlers to finish so their frame_tx clones
// are dropped before the writer closes the sink.
drain_handlers(handler_handles).await;
match read_err {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
/// Wait for all active stream handlers to finish (with a timeout).
async fn drain_handlers(handles: Vec<JoinHandle<()>>) {
if handles.is_empty() {
return;
}
let count = handles.len();
debug!(count, "waiting for active stream handlers to finish");
let _ = tokio::time::timeout(Duration::from_secs(30), async {
for h in handles {
let _ = h.await;
}
})
.await;
}
+340
View File
@@ -0,0 +1,340 @@
//! Tunnel heartbeat: sends metrics over the tunnel, processes ACKs.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use bytes::Bytes;
use tokio::sync::watch;
use tracing::{debug, info, warn};
use crate::config::Config;
use crate::registration::client::RemoteConfig;
use crate::runtime;
use crate::state::ServerContext;
use super::protocol::{Frame, MsgType};
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.
#[derive(Clone)]
pub struct HeartbeatHandle {
ack_tx: tokio::sync::mpsc::Sender<Bytes>,
}
impl HeartbeatHandle {
pub async fn on_ack(&self, payload: Bytes) {
let _ = self.ack_tx.send(payload).await;
}
}
/// Create a no-op heartbeat handle that silently discards ACKs.
/// Used for non-primary tunnel connections (conn_idx > 0) to avoid
/// resetting shared atomic metrics via `swap(0)`.
pub fn spawn_noop() -> HeartbeatHandle {
let (ack_tx, _) = tokio::sync::mpsc::channel::<Bytes>(1);
// receiver is immediately dropped; on_ack() calls will silently fail
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.
pub fn spawn(
_config: Arc<Config>,
server: Arc<ServerContext>,
frame_tx: FrameSender,
mut shutdown: watch::Receiver<bool>,
) -> HeartbeatHandle {
let (ack_tx, mut ack_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
tokio::spawn(async move {
// Read initial interval from dynamic config (may be updated by remote config).
let initial_interval = Duration::from_secs(server.dynamic.load().heartbeat_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.
tokio::time::sleep(current_interval).await;
loop {
tokio::select! {
_ = tokio::time::sleep(current_interval) => {
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);
if frame_tx.send(frame).await.is_err() {
if let Some((_, snap)) = pending.take() {
restore_snapshot(&server, snap);
}
break; // Writer closed
}
debug!("sent heartbeat data");
// Re-read interval from dynamic config (remote config may have
// updated it since the last heartbeat).
let new_interval = Duration::from_secs(
server.dynamic.load().heartbeat_interval
);
if new_interval != current_interval {
debug!(
old_secs = current_interval.as_secs(),
new_secs = new_interval.as_secs(),
"heartbeat interval updated from dynamic config"
);
current_interval = new_interval;
}
}
Some(ack_payload) = ack_rx.recv() => {
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() => {
debug!("heartbeat task shutting down");
if let Some((_, snap)) = pending.take() {
restore_snapshot(&server, snap);
}
break;
}
}
}
});
HeartbeatHandle { ack_tx }
}
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 avg_latency_ms = if snapshot.requests > 0 {
Some(snapshot.latency_ns as f64 / snapshot.requests as f64 / 1_000_000.0)
} else {
None
};
let payload = serde_json::json!({
"node_id": node_id,
"heartbeat_session_id": heartbeat_session_id,
"heartbeat_id": heartbeat_id,
"active_connections": server.active_connections.load(Ordering::Acquire),
"total_requests": snapshot.requests,
"avg_latency_ms": avg_latency_ms,
"failed_requests": snapshot.failed,
"dns_failures": snapshot.dns_failures,
"stream_errors": snapshot.stream_errors,
"proxy_metadata": {
"version": CURRENT_VERSION,
},
});
Bytes::from(serde_json::to_vec(&payload).unwrap_or_default())
}
fn handle_ack(server: &ServerContext, payload: &[u8]) -> AckDecision {
if payload.is_empty() {
return AckDecision::Accept {
heartbeat_id: None,
upgrade_to: None,
};
}
#[derive(serde::Deserialize)]
struct AckPayload {
#[serde(default)]
remote_config: Option<RemoteConfig>,
#[serde(default)]
config_version: u64,
#[serde(default)]
heartbeat_id: Option<u64>,
#[serde(default)]
upgrade_to: Option<String>,
}
match serde_json::from_slice::<AckPayload>(payload) {
Ok(ack) => {
if let Some(ref rc) = ack.remote_config {
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) => {
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);
}
}
});
}
+236
View File
@@ -0,0 +1,236 @@
pub mod client;
pub mod dispatcher;
pub mod heartbeat;
pub mod protocol;
pub mod stream_handler;
pub mod writer;
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::sync::watch;
use tracing::{error, info};
use crate::state::{AppState, ServerContext};
/// If a tunnel stays connected at least this long, treat the next disconnect
/// as a non-failure and reset reconnect backoff.
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).
///
/// `conn_idx` identifies which connection in the pool this is (0-based).
/// Only connection 0 sends heartbeats to avoid resetting shared metrics.
pub async fn run(
state: &Arc<AppState>,
server: &Arc<ServerContext>,
conn_idx: usize,
mut shutdown: watch::Receiver<bool>,
) {
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 {
let started_at = Instant::now();
match client::connect_and_run(state, server, conn_idx, &mut shutdown).await {
Ok(client::TunnelOutcome::Shutdown) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel shut down gracefully");
return;
}
Ok(client::TunnelOutcome::Disconnected) => {
info!(server = %server.server_label, conn = conn_idx, "tunnel disconnected, reconnecting");
}
Err(e) => {
error!(server = %server.server_label, conn = conn_idx, error = %e, "tunnel connection error, reconnecting");
}
}
if *shutdown.borrow() {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested, not reconnecting");
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::time::sleep(reconnect_delay) => {}
_ = shutdown.changed() => {
info!(server = %server.server_label, conn = conn_idx, "shutdown requested during reconnect wait");
return;
}
}
}
}
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));
}
}
+260
View File
@@ -0,0 +1,260 @@
//! Binary frame protocol for WebSocket tunnel multiplexing.
//!
//! Frame layout (10-byte header + variable payload):
//! ```text
//! | stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
//! ```
use bytes::{Buf, BufMut, Bytes, BytesMut};
pub const HEADER_SIZE: usize = 10;
/// Frame flags.
pub mod flags {
pub const END_STREAM: u8 = 0x01;
pub const GZIP_COMPRESSED: u8 = 0x02;
}
/// Message types for the tunnel protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MsgType {
RequestHeaders = 0x01,
RequestBody = 0x02,
ResponseHeaders = 0x03,
ResponseBody = 0x04,
StreamEnd = 0x05,
StreamError = 0x06,
Ping = 0x10,
Pong = 0x11,
GoAway = 0x12,
HeartbeatData = 0x13,
HeartbeatAck = 0x14,
}
impl MsgType {
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0x01 => Some(Self::RequestHeaders),
0x02 => Some(Self::RequestBody),
0x03 => Some(Self::ResponseHeaders),
0x04 => Some(Self::ResponseBody),
0x05 => Some(Self::StreamEnd),
0x06 => Some(Self::StreamError),
0x10 => Some(Self::Ping),
0x11 => Some(Self::Pong),
0x12 => Some(Self::GoAway),
0x13 => Some(Self::HeartbeatData),
0x14 => Some(Self::HeartbeatAck),
_ => None,
}
}
}
/// A single multiplexed frame.
#[derive(Debug, Clone)]
pub struct Frame {
pub stream_id: u32,
pub msg_type: MsgType,
pub flags: u8,
pub payload: Bytes,
}
impl Frame {
pub fn new(stream_id: u32, msg_type: MsgType, flags: u8, payload: impl Into<Bytes>) -> Self {
Self {
stream_id,
msg_type,
flags,
payload: payload.into(),
}
}
/// Control frame (stream_id = 0).
pub fn control(msg_type: MsgType, payload: impl Into<Bytes>) -> Self {
Self::new(0, msg_type, 0, payload)
}
pub fn is_end_stream(&self) -> bool {
self.flags & flags::END_STREAM != 0
}
pub fn is_gzip(&self) -> bool {
self.flags & flags::GZIP_COMPRESSED != 0
}
/// Encode into a binary buffer.
pub fn encode(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(HEADER_SIZE + self.payload.len());
buf.put_u32(self.stream_id);
buf.put_u8(self.msg_type as u8);
buf.put_u8(self.flags);
buf.put_u32(self.payload.len() as u32);
buf.put(self.payload.clone());
buf.freeze()
}
/// Decode from a binary buffer.
pub fn decode(mut data: Bytes) -> Result<Self, ProtocolError> {
if data.len() < HEADER_SIZE {
return Err(ProtocolError::TooShort {
expected: HEADER_SIZE,
actual: data.len(),
});
}
let stream_id = data.get_u32();
let msg_type_raw = data.get_u8();
let frame_flags = data.get_u8();
let payload_len = data.get_u32() as usize;
if data.remaining() < payload_len {
return Err(ProtocolError::Incomplete {
expected: HEADER_SIZE + payload_len,
actual: HEADER_SIZE + data.remaining(),
});
}
let msg_type =
MsgType::from_u8(msg_type_raw).ok_or(ProtocolError::UnknownMsgType(msg_type_raw))?;
let payload = data.split_to(payload_len);
Ok(Self {
stream_id,
msg_type,
flags: frame_flags,
payload,
})
}
}
/// Protocol errors.
#[derive(Debug, thiserror::Error)]
pub enum ProtocolError {
#[error("frame too short: expected {expected} bytes, got {actual}")]
TooShort { expected: usize, actual: usize },
#[error("frame incomplete: expected {expected} bytes, got {actual}")]
Incomplete { expected: usize, actual: usize },
#[error("unknown message type: 0x{0:02x}")]
UnknownMsgType(u8),
}
/// JSON payload for REQUEST_HEADERS frames.
#[derive(Debug, 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)
}
}
}
/// JSON payload for RESPONSE_HEADERS frames.
#[derive(Debug, serde::Serialize)]
pub struct ResponseMeta {
pub status: u16,
/// Header list preserving duplicates (e.g. multiple Set-Cookie).
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);
}
}
+506
View File
@@ -0,0 +1,506 @@
//! Per-stream request handler.
//!
//! Receives request frames, executes the upstream HTTP request,
//! 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::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use futures_util::stream;
use futures_util::StreamExt;
use http_body_util::BodyExt;
use hyper::body::Frame as BodyFrame;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::state::{AppState, ServerContext};
use crate::target_filter;
use crate::upstream_client;
use super::protocol::{
compress_payload, decompress_if_gzip, flags, Frame as TunnelFrame, MsgType, RequestMeta,
ResponseMeta,
};
use super::writer::FrameSender;
/// Maximum response body chunk size per frame (32 KB).
const MAX_CHUNK_SIZE: usize = 32 * 1024;
/// Timeout for sending a single frame to the writer channel.
/// If the writer is congested (TCP backpressure), we abandon the stream
/// rather than blocking indefinitely and exhausting the stream pool.
const FRAME_SEND_TIMEOUT: Duration = Duration::from_secs(30);
/// Minimum allowed upstream request timeout (seconds).
const MIN_TIMEOUT_SECS: u64 = 5;
/// Maximum allowed upstream request timeout (seconds).
const MAX_TIMEOUT_SECS: u64 = 300;
/// 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] = &[
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
/// Handle a single stream: receive body, execute upstream, send response.
pub async fn handle_stream(
state: Arc<AppState>,
server: Arc<ServerContext>,
stream_id: u32,
meta: RequestMeta,
body_rx: mpsc::Receiver<TunnelFrame>,
frame_tx: FrameSender,
) {
server.active_connections.fetch_add(1, Ordering::Release);
let connect_elapsed =
handle_stream_inner(&state, &server, stream_id, meta, body_rx, &frame_tx).await;
server.active_connections.fetch_sub(1, Ordering::Release);
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.
async fn send_frame(tx: &FrameSender, frame: TunnelFrame) -> bool {
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
Ok(Ok(())) => true,
Ok(Err(_)) => {
// Channel closed (writer exited)
false
}
Err(_) => {
// Timeout — writer is congested
warn!("frame send timeout (writer congested), abandoning stream");
false
}
}
}
/// 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(
state: &AppState,
server: &ServerContext,
stream_id: u32,
meta: RequestMeta,
body_rx: mpsc::Receiver<TunnelFrame>,
frame_tx: &FrameSender,
) -> Option<Duration> {
// Validate target
let target_url = match url::Url::parse(&meta.url) {
Ok(u) => u,
Err(e) => {
send_error(frame_tx, stream_id, &format!("invalid URL: {e}")).await;
return None;
}
};
// Only allow http/https schemes (block file://, data://, etc.)
match target_url.scheme() {
"http" | "https" => {}
other => {
send_error(
frame_tx,
stream_id,
&format!("unsupported URL scheme: {other}"),
)
.await;
return None;
}
}
let host = match target_url.host_str() {
Some(h) => h.to_string(),
None => {
send_error(frame_tx, stream_id, "missing host in URL").await;
return None;
}
};
let port = target_url.port_or_known_default().unwrap_or(443);
// DNS + target validation (populates dns_cache for SafeDnsResolver)
let connect_start = Instant::now();
{
let allowed_ports = Arc::clone(&server.dynamic.load().allowed_ports);
if let Err(e) =
target_filter::validate_target(&host, port, &allowed_ports, &state.dns_cache).await
{
server.metrics.dns_failures.fetch_add(1, Ordering::Release);
send_error(frame_tx, stream_id, &format!("target blocked: {e}")).await;
return None;
}
}
let dns_ms = connect_start.elapsed().as_millis() as u64;
// Execute upstream request
let client = &state.upstream_client;
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: hyper::Method = meta.method.parse().unwrap_or(hyper::Method::GET);
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 {
let k_lower = k.to_ascii_lowercase();
if BLOCKED_HEADERS.contains(&k_lower.as_str()) {
continue;
}
if let (Ok(name), Ok(value)) = (
hyper::header::HeaderName::from_bytes(k.as_bytes()),
hyper::header::HeaderValue::from_str(v),
) {
headers.insert(name, value);
}
}
let mut captured_connection = upstream_client::capture_connection(&mut request);
let connection_start = Instant::now();
let connection_capture = tokio::spawn(async move {
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 response = match tokio::time::timeout(timeout, client.request(request)).await {
Ok(Ok(response)) => response,
Ok(Err(e)) => {
connection_capture.abort();
server
.metrics
.failed_requests
.fetch_add(1, Ordering::Release);
let msg = if e.is_connect() {
format!("upstream connect error: {e}")
} else {
format!("upstream error: {e}")
};
send_error(frame_tx, stream_id, &msg).await;
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
let status = response.status().as_u16();
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);
for (k, v) in response.headers() {
if let Ok(vs) = v.to_str() {
resp_headers.push((k.as_str().to_string(), vs.to_string()));
}
}
let timing = serde_json::json!({
"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,
"upstream_ms": ttfb_ms,
"response_wait_ms": request_timing.response_wait_ms,
"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",
});
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
let resp_meta = ResponseMeta {
status,
headers: resp_headers,
};
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(
frame_tx,
TunnelFrame::new(
stream_id,
MsgType::ResponseHeaders,
meta_flags,
meta_payload,
),
)
.await
{
return Some(connect_elapsed);
}
// Stream response body — relay upstream bytes through the tunnel.
// 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 {
match chunk_result {
Ok(chunk) => {
if chunk.len() <= MAX_CHUNK_SIZE {
let (payload, extra_flags) = compress_payload(chunk);
if !send_frame(
frame_tx,
TunnelFrame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
)
.await
{
return Some(connect_elapsed);
}
} else {
// Split oversized chunks, compress each slice
let mut offset = 0;
while offset < chunk.len() {
let end = (offset + MAX_CHUNK_SIZE).min(chunk.len());
let slice = chunk.slice(offset..end);
let (payload, extra_flags) = compress_payload(slice);
if !send_frame(
frame_tx,
TunnelFrame::new(
stream_id,
MsgType::ResponseBody,
extra_flags,
payload,
),
)
.await
{
return Some(connect_elapsed);
}
offset = end;
}
}
}
Err(e) => {
server.metrics.stream_errors.fetch_add(1, Ordering::Release);
warn!(stream_id, error = %e, "upstream body read error");
send_error(frame_tx, stream_id, &format!("body read error: {e}")).await;
return Some(connect_elapsed);
}
}
}
// Send STREAM_END
let _ = send_frame(
frame_tx,
TunnelFrame::new(
stream_id,
MsgType::StreamEnd,
flags::END_STREAM,
Bytes::new(),
),
)
.await;
debug!(stream_id, status, "stream completed");
Some(connect_elapsed)
}
async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
// Error frames use best-effort delivery — don't block if writer is congested
let _ = send_frame(
tx,
TunnelFrame::new(
stream_id,
MsgType::StreamError,
0,
Bytes::from(msg.to_string()),
),
)
.await;
}
fn build_streaming_request_body(
body_rx: mpsc::Receiver<TunnelFrame>,
body_size: Arc<AtomicUsize>,
) -> upstream_client::UpstreamRequestBody {
let body_stream = stream::unfold(
(body_rx, body_size, false),
|(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);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Dedicated WebSocket writer task.
//!
//! All frame writes go through an mpsc channel to a single writer task,
//! avoiding contention on the WebSocket sink. The writer also sends
//! periodic WebSocket Ping frames to keep the connection alive through
//! intermediary proxies (Nginx, Cloudflare, etc.).
use std::time::Duration;
use futures_util::SinkExt;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::Message;
use tracing::{debug, error, trace};
use super::protocol::Frame;
/// Sender half — cloned by stream handlers and heartbeat.
pub type FrameSender = mpsc::Sender<Frame>;
/// Spawn the writer task. Returns the sender and a JoinHandle for cleanup.
///
/// `ping_interval` controls WebSocket-level Ping frequency (typically 15s).
/// This keeps the connection alive through intermediary proxies/load-balancers.
pub fn spawn_writer<S>(mut sink: S, ping_interval: Duration) -> (FrameSender, JoinHandle<()>)
where
S: SinkExt<Message, Error = tokio_tungstenite::tungstenite::Error> + Unpin + Send + 'static,
{
let (tx, mut rx) = mpsc::channel::<Frame>(256);
let handle = tokio::spawn(async move {
let mut ping_ticker = tokio::time::interval(ping_interval);
ping_ticker.tick().await; // skip first immediate tick
loop {
tokio::select! {
frame = rx.recv() => {
match frame {
Some(frame) => {
let data = frame.encode();
if let Err(e) = sink.send(Message::Binary(data.into())).await {
error!(error = %e, "failed to write frame to WebSocket");
break;
}
}
None => break, // all senders dropped
}
}
_ = ping_ticker.tick() => {
if let Err(e) = sink.send(Message::Ping(vec![])).await {
error!(error = %e, "failed to send WebSocket ping");
break;
}
trace!("sent WebSocket ping");
}
}
}
debug!("writer task exiting");
let _ = sink.close().await;
});
(tx, handle)
}
+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);
}
}
+32 -12
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 sys
from logging.config import fileConfig
from pathlib import Path
from sqlalchemy import engine_from_config, pool, text
from alembic import context
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
@@ -30,7 +32,7 @@ from src.models.database import Base
config = context.config
# 从环境变量获取数据库 URL
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker-compose 保持一致)
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_password = os.getenv("DB_PASSWORD", "")
@@ -48,6 +50,11 @@ if config.config_file_name is not None:
# 目标元数据(包含所有表定义)
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:
"""
@@ -83,15 +90,28 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
try:
# 使用会话级 advisory lock(非事务级),避免干扰 Alembic 的事务管理。
# pg_advisory_lock 在会话结束时自动释放,不受 COMMIT/ROLLBACK 影响。
if connection.dialect.name == "postgresql":
connection.execute(
text("SELECT pg_advisory_lock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
connection.commit()
with context.begin_transaction():
context.run_migrations()
context.configure(
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
# 根据模式选择运行方式
@@ -0,0 +1,161 @@
"""add ldap authentication support
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2026-01-01 14:00:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'c3d4e5f6g7h8'
down_revision = 'b2c3d4e5f6g7'
branch_labels = None
depends_on = None
def _type_exists(conn, type_name: str) -> bool:
"""检查 PostgreSQL 类型是否存在"""
result = conn.execute(
text("SELECT 1 FROM pg_type WHERE typname = :name"),
{"name": type_name}
)
return result.scalar() is not None
def _column_exists(conn, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = conn.execute(
text("""
SELECT 1 FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""),
{"table": table_name, "column": column_name}
)
return result.scalar() is not None
def _index_exists(conn, index_name: str) -> bool:
"""检查索引是否存在"""
result = conn.execute(
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": index_name}
)
return result.scalar() is not None
def _table_exists(conn, table_name: str) -> bool:
"""检查表是否存在"""
result = conn.execute(
text("""
SELECT 1 FROM information_schema.tables
WHERE table_name = :name AND table_schema = 'public'
"""),
{"name": table_name}
)
return result.scalar() is not None
def upgrade() -> None:
"""添加 LDAP 认证支持
1. 创建 authsource 枚举类型
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
3. 创建 ldap_configs 表
"""
conn = op.get_bind()
# 1. 创建 authsource 枚举类型(幂等)
if not _type_exists(conn, 'authsource'):
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
# 2. 在 users 表添加字段(幂等)
if not _column_exists(conn, 'users', 'auth_source'):
op.add_column('users', sa.Column(
'auth_source',
sa.Enum('local', 'ldap', name='authsource', create_type=False),
nullable=False,
server_default='local'
))
if not _column_exists(conn, 'users', 'ldap_dn'):
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
if not _column_exists(conn, 'users', 'ldap_username'):
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
# 创建索引(幂等)
if not _index_exists(conn, 'ix_users_ldap_dn'):
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
if not _index_exists(conn, 'ix_users_ldap_username'):
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
# 3. 创建 ldap_configs 表(幂等)
if not _table_exists(conn, 'ldap_configs'):
op.create_table(
'ldap_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('server_url', sa.String(length=255), nullable=False),
sa.Column('bind_dn', sa.String(length=255), nullable=False),
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
sa.Column('base_dn', sa.String(length=255), nullable=False),
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
"""回滚 LDAP 认证支持
警告:回滚前请确保:
1. 已备份数据库
2. 没有 LDAP 用户需要保留
"""
conn = op.get_bind()
# 检查是否存在 LDAP 用户,防止数据丢失
if _column_exists(conn, 'users', 'auth_source'):
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
ldap_user_count = result.scalar()
if ldap_user_count and ldap_user_count > 0:
raise RuntimeError(
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
)
# 1. 删除 ldap_configs 表(幂等)
if _table_exists(conn, 'ldap_configs'):
op.drop_table('ldap_configs')
# 2. 删除 users 表的 LDAP 相关字段(幂等)
if _index_exists(conn, 'ix_users_ldap_username'):
op.drop_index('ix_users_ldap_username', table_name='users')
if _index_exists(conn, 'ix_users_ldap_dn'):
op.drop_index('ix_users_ldap_dn', table_name='users')
if _column_exists(conn, 'users', 'ldap_username'):
op.drop_column('users', 'ldap_username')
if _column_exists(conn, 'users', 'ldap_dn'):
op.drop_column('users', 'ldap_dn')
if _column_exists(conn, 'users', 'auth_source'):
op.drop_column('users', 'auth_source')
# 3. 删除 authsource 枚举类型(幂等)
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
if _type_exists(conn, 'authsource'):
conn.execute(text("DROP TYPE authsource"))
@@ -0,0 +1,131 @@
"""add_management_tokens_table
Revision ID: ad55f1d008b7
Revises: c3d4e5f6g7h8
Create Date: 2026-01-06 15:24:10.660394+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'ad55f1d008b7'
down_revision = 'c3d4e5f6g7h8'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
indexes = inspector.get_indexes(table_name)
return any(idx["name"] == index_name for idx in indexes)
except Exception:
return False
def constraint_exists(table_name: str, constraint_name: str) -> bool:
"""检查约束是否存在"""
conn = op.get_bind()
inspector = inspect(conn)
try:
constraints = inspector.get_unique_constraints(table_name)
if any(c["name"] == constraint_name for c in constraints):
return True
# 也检查 check 约束
check_constraints = inspector.get_check_constraints(table_name)
if any(c["name"] == constraint_name for c in check_constraints):
return True
return False
except Exception:
return False
def upgrade() -> None:
"""应用迁移:创建 management_tokens 表"""
# 幂等性检查
if table_exists("management_tokens"):
# 表已存在,检查是否需要添加约束
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
return
op.create_table('management_tokens',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('user_id', sa.String(length=36), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('token_prefix', sa.String(length=12), nullable=True),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('allowed_ips', sa.JSON(), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
# 添加用户名称唯一约束
op.create_unique_constraint(
"uq_management_tokens_user_name",
"management_tokens",
["user_id", "name"],
)
# 添加 IP 白名单非空检查约束
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
op.create_check_constraint(
"check_allowed_ips_not_empty",
"management_tokens",
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
)
def downgrade() -> None:
"""回滚迁移:删除 management_tokens 表"""
# 幂等性检查
if not table_exists("management_tokens"):
return
# 删除约束
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
# 删除索引
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_user_id"):
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
if index_exists("management_tokens", "idx_management_tokens_is_active"):
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
# 删除表
op.drop_table('management_tokens')
@@ -0,0 +1,73 @@
"""cleanup ambiguous database fields
Revision ID: 02a45b66b7c4
Revises: ad55f1d008b7
Create Date: 2026-01-07 11:20:12.684426+00:00
变更内容:
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = '02a45b66b7c4'
down_revision = 'ad55f1d008b7'
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""
1. users.allowed_endpoints -> allowed_api_formats(重命名)
2. api_keys.allowed_endpoints 删除
3. providers.rate_limit 删除(与 rpm_limit 重复)
4. usage.provider -> provider_name(重命名)
"""
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
if _column_exists('users', 'allowed_endpoints'):
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
# 2. api_keys 表:删除 allowed_endpoints 字段
if _column_exists('api_keys', 'allowed_endpoints'):
op.drop_column('api_keys', 'allowed_endpoints')
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
if _column_exists('providers', 'rate_limit'):
op.drop_column('providers', 'rate_limit')
# 4. usage 表:重命名 provider 为 provider_name
if _column_exists('usage', 'provider'):
op.alter_column('usage', 'provider', new_column_name='provider_name')
def downgrade() -> None:
"""回滚:恢复原字段"""
# 4. usage 表:将 provider_name 改回 provider
if _column_exists('usage', 'provider_name'):
op.alter_column('usage', 'provider_name', new_column_name='provider')
# 3. providers 表:恢复 rate_limit 字段
if not _column_exists('providers', 'rate_limit'):
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
# 2. api_keys 表:恢复 allowed_endpoints 字段
if not _column_exists('api_keys', 'allowed_endpoints'):
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
if _column_exists('users', 'allowed_api_formats'):
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
@@ -0,0 +1,604 @@
"""consolidated schema updates
Revision ID: m4n5o6p7q8r9
Revises: 02a45b66b7c4
Create Date: 2026-01-10 20:00:00.000000
This migration consolidates all schema changes from 2026-01-08 to 2026-01-10:
1. provider_api_keys: Key 直接关联 Provider (provider_id, api_formats)
2. provider_api_keys: 添加 rate_multipliers JSON 字段(按格式费率)
3. models: global_model_id 改为可空(支持独立 ProviderModel)
4. providers: 添加 timeout, max_retries, proxy(从 endpoint 迁移)
5. providers: display_name 重命名为 name,删除原 name
6. provider_api_keys: max_concurrent -> rpm_limit(并发改 RPM)
7. provider_api_keys: 健康度改为按格式存储(health_by_format, circuit_breaker_by_format)
8. provider_endpoints: 删除废弃的 rate_limit 列
9. usage: 添加 client_response_headers 字段
10. provider_api_keys: 删除 endpoint_id(Key 不再与 Endpoint 绑定)
11. provider_endpoints: 删除废弃的 max_concurrent 列
12. providers: 删除废弃的 rpm_limit, rpm_used, rpm_reset_at 列
"""
import logging
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import ProgrammingError
from alembic import op
# 配置日志
alembic_logger = logging.getLogger("alembic.runtime.migration")
revision = "m4n5o6p7q8r9"
down_revision = "02a45b66b7c4"
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""Check if a column exists in the table (bypasses inspector cache)"""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :col"
),
{"table": table_name, "col": column_name},
)
return result.scalar() is not None
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
"""Check if a constraint exists (bypasses inspector cache)"""
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM information_schema.table_constraints "
"WHERE table_name = :table AND constraint_name = :name"
),
{"table": table_name, "name": constraint_name},
)
return result.scalar() is not None
def _index_exists(table_name: str, index_name: str) -> bool:
"""Check if an index exists (bypasses inspector cache)"""
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": index_name},
)
return result.scalar() is not None
def upgrade() -> None:
"""Apply all consolidated schema changes"""
bind = op.get_bind()
# ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ==========
if not _column_exists("provider_api_keys", "provider_id"):
conn = op.get_bind()
conn.execute(sa.text("SAVEPOINT sp_add_provider_id"))
try:
op.add_column(
"provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)
)
conn.execute(sa.text("RELEASE SAVEPOINT sp_add_provider_id"))
except ProgrammingError as exc:
if getattr(getattr(exc, "orig", None), "pgcode", None) == "42701":
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
alembic_logger.warning("provider_api_keys.provider_id already exists; skipping add")
else:
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
raise
# 数据迁移:从 endpoint 获取 provider_id(如果 endpoint_id 仍存在)
if _column_exists("provider_api_keys", "endpoint_id"):
op.execute("""
UPDATE provider_api_keys k
SET provider_id = e.provider_id
FROM provider_endpoints e
WHERE k.endpoint_id = e.id AND k.provider_id IS NULL
""")
# 检查无法关联的孤儿 Key
result = bind.execute(
sa.text("SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL")
)
orphan_count = result.scalar() or 0
if orphan_count > 0:
# 使用 logger 记录更明显的告警
alembic_logger.warning("=" * 60)
alembic_logger.warning(
f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key"
)
alembic_logger.warning("=" * 60)
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...")
# 先备份孤儿数据到临时表,避免数据丢失
op.execute("""
CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS
SELECT *, NOW() as backup_at
FROM provider_api_keys
WHERE provider_id IS NULL
""")
# 记录备份的 Key ID
orphan_ids = bind.execute(
sa.text("SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL")
).fetchall()
alembic_logger.info("备份的孤儿 Key 列表:")
for key_id, key_name in orphan_ids:
alembic_logger.info(f" - Key: {key_name} (ID: {key_id})")
# 删除孤儿数据
op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL")
alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key")
# 提供恢复指南
alembic_logger.warning("-" * 60)
alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key:")
alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;")
alembic_logger.warning(" 2. 确定正确的 provider_id")
alembic_logger.warning(" 3. 执行恢复:")
alembic_logger.warning(" INSERT INTO provider_api_keys (...)")
alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;")
alembic_logger.warning("-" * 60)
# 设置 NOT NULL 并创建外键
op.alter_column("provider_api_keys", "provider_id", nullable=False)
if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
op.create_foreign_key(
"fk_provider_api_keys_provider",
"provider_api_keys",
"providers",
["provider_id"],
["id"],
ondelete="CASCADE",
)
if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"])
if not _column_exists("provider_api_keys", "api_formats"):
op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True))
# 数据迁移:从 endpoint 获取 api_format
op.execute("""
UPDATE provider_api_keys k
SET api_formats = json_build_array(e.api_format)
FROM provider_endpoints e
WHERE k.endpoint_id = e.id AND k.api_formats IS NULL
""")
op.alter_column("provider_api_keys", "api_formats", nullable=False, server_default="[]")
# 修改 endpoint_id 为可空,外键改为 SET NULL
if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
op.drop_constraint(
"provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey"
)
op.alter_column("provider_api_keys", "endpoint_id", nullable=True)
# 不再重建外键,因为后面会删除这个字段
# ========== 2. provider_api_keys: 添加 rate_multipliers ==========
if not _column_exists("provider_api_keys", "rate_multipliers"):
op.add_column(
"provider_api_keys",
sa.Column("rate_multipliers", postgresql.JSON(astext_type=sa.Text()), nullable=True),
)
# 数据迁移:将 rate_multiplier 按 api_formats 转换
op.execute("""
UPDATE provider_api_keys
SET rate_multipliers = (
SELECT jsonb_object_agg(elem, rate_multiplier)
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
)
WHERE api_formats IS NOT NULL
AND api_formats::text != '[]'
AND api_formats::text != 'null'
AND rate_multipliers IS NULL
""")
# ========== 3. models: global_model_id 改为可空 ==========
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
# ========== 4. providers: 添加 timeout, max_retries, proxy ==========
if not _column_exists("providers", "timeout"):
op.add_column(
"providers",
sa.Column("timeout", sa.Integer(), nullable=True, comment="请求超时(秒)"),
)
if not _column_exists("providers", "max_retries"):
op.add_column(
"providers",
sa.Column("max_retries", sa.Integer(), nullable=True, comment="最大重试次数"),
)
if not _column_exists("providers", "proxy"):
op.add_column(
"providers",
sa.Column("proxy", postgresql.JSONB(), nullable=True, comment="代理配置"),
)
# 从端点迁移数据到 provider(动态构建 SQL,仅引用存在的列)
ep_has_timeout = _column_exists("provider_endpoints", "timeout")
ep_has_max_retries = _column_exists("provider_endpoints", "max_retries")
ep_has_proxy = _column_exists("provider_endpoints", "proxy")
set_clauses = []
if _column_exists("providers", "timeout"):
if ep_has_timeout:
set_clauses.append("""
timeout = COALESCE(
p.timeout,
(SELECT MAX(e.timeout) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.timeout IS NOT NULL),
300
)""")
else:
set_clauses.append("timeout = COALESCE(p.timeout, 300)")
if _column_exists("providers", "max_retries"):
if ep_has_max_retries:
set_clauses.append("""
max_retries = COALESCE(
p.max_retries,
(SELECT MAX(e.max_retries) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.max_retries IS NOT NULL),
2
)""")
else:
set_clauses.append("max_retries = COALESCE(p.max_retries, 2)")
if _column_exists("providers", "proxy") and ep_has_proxy:
set_clauses.append("""
proxy = COALESCE(
p.proxy,
(SELECT e.proxy FROM provider_endpoints e WHERE e.provider_id = p.id AND e.proxy IS NOT NULL ORDER BY e.created_at LIMIT 1)
)""")
if set_clauses:
where_parts = []
if _column_exists("providers", "timeout"):
where_parts.append("p.timeout IS NULL")
if _column_exists("providers", "max_retries"):
where_parts.append("p.max_retries IS NULL")
where_clause = " OR ".join(where_parts) if where_parts else "TRUE"
sql = "UPDATE providers p SET " + ", ".join(set_clauses) + " WHERE " + where_clause
op.execute(sql)
# ========== 5. providers: display_name -> name ==========
# 注意:这里假设 display_name 已经被重命名为 name
# 如果 display_name 仍然存在,则需要执行重命名
if _column_exists("providers", "display_name"):
# 删除旧的 name 索引
if _index_exists("providers", "ix_providers_name"):
op.drop_index("ix_providers_name", table_name="providers")
# 如果存在旧的 name 列,先删除
if _column_exists("providers", "name"):
op.drop_column("providers", "name")
# 重命名 display_name 为 name
op.alter_column("providers", "display_name", new_column_name="name")
# 创建新索引
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
# ========== 6. provider_api_keys: max_concurrent -> rpm_limit ==========
if _column_exists("provider_api_keys", "max_concurrent"):
op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit")
if _column_exists("provider_api_keys", "learned_max_concurrent"):
op.alter_column(
"provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit"
)
if _column_exists("provider_api_keys", "last_concurrent_peak"):
op.alter_column(
"provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak"
)
# 删除废弃字段
for col in ["rate_limit", "daily_limit", "monthly_limit"]:
if _column_exists("provider_api_keys", col):
op.drop_column("provider_api_keys", col)
# ========== 7. provider_api_keys: 健康度改为按格式存储 ==========
if not _column_exists("provider_api_keys", "health_by_format"):
op.add_column(
"provider_api_keys",
sa.Column(
"health_by_format",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
comment="按API格式存储的健康度数据",
),
)
if not _column_exists("provider_api_keys", "circuit_breaker_by_format"):
op.add_column(
"provider_api_keys",
sa.Column(
"circuit_breaker_by_format",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
comment="按API格式存储的熔断器状态",
),
)
# 数据迁移:如果存在旧字段,迁移数据到新结构
if _column_exists("provider_api_keys", "health_score"):
op.execute("""
UPDATE provider_api_keys
SET health_by_format = (
SELECT jsonb_object_agg(
elem,
jsonb_build_object(
'health_score', COALESCE(health_score, 1.0),
'consecutive_failures', COALESCE(consecutive_failures, 0),
'last_failure_at', last_failure_at,
'request_results_window', COALESCE(request_results_window::jsonb, '[]'::jsonb)
)
)
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
)
WHERE api_formats IS NOT NULL
AND api_formats::text != '[]'
AND health_by_format IS NULL
""")
# Circuit Breaker 迁移策略:
# 不复制旧的 circuit_breaker_open 状态到所有 format,而是全部重置为 closed
# 原因:旧的单一 circuit breaker 状态可能因某一个 format 失败而打开,
# 如果复制到所有 format,会导致其他正常工作的 format 被错误标记为不可用
if _column_exists("provider_api_keys", "circuit_breaker_open"):
op.execute("""
UPDATE provider_api_keys
SET circuit_breaker_by_format = (
SELECT jsonb_object_agg(
elem,
jsonb_build_object(
'open', false,
'open_at', NULL,
'next_probe_at', NULL,
'half_open_until', NULL,
'half_open_successes', 0,
'half_open_failures', 0
)
)
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
)
WHERE api_formats IS NOT NULL
AND api_formats::text != '[]'
AND circuit_breaker_by_format IS NULL
""")
# 设置默认空对象
op.execute("""
UPDATE provider_api_keys
SET health_by_format = '{}'::jsonb
WHERE health_by_format IS NULL
""")
op.execute("""
UPDATE provider_api_keys
SET circuit_breaker_by_format = '{}'::jsonb
WHERE circuit_breaker_by_format IS NULL
""")
# 创建 GIN 索引
if not _index_exists("provider_api_keys", "ix_provider_api_keys_health_by_format"):
op.create_index(
"ix_provider_api_keys_health_by_format",
"provider_api_keys",
["health_by_format"],
postgresql_using="gin",
)
if not _index_exists("provider_api_keys", "ix_provider_api_keys_circuit_breaker_by_format"):
op.create_index(
"ix_provider_api_keys_circuit_breaker_by_format",
"provider_api_keys",
["circuit_breaker_by_format"],
postgresql_using="gin",
)
# 删除旧字段
old_health_columns = [
"health_score",
"consecutive_failures",
"last_failure_at",
"request_results_window",
"circuit_breaker_open",
"circuit_breaker_open_at",
"next_probe_at",
"half_open_until",
"half_open_successes",
"half_open_failures",
]
for col in old_health_columns:
if _column_exists("provider_api_keys", col):
op.drop_column("provider_api_keys", col)
# ========== 8. provider_endpoints: 删除废弃的 rate_limit 列 ==========
if _column_exists("provider_endpoints", "rate_limit"):
op.drop_column("provider_endpoints", "rate_limit")
# ========== 9. usage: 添加 client_response_headers ==========
if not _column_exists("usage", "client_response_headers"):
op.add_column(
"usage",
sa.Column("client_response_headers", sa.JSON(), nullable=True),
)
# ========== 10. provider_api_keys: 删除 endpoint_id ==========
# Key 不再与 Endpoint 绑定,通过 provider_id + api_formats 关联
if _column_exists("provider_api_keys", "endpoint_id"):
# 查找 endpoint_id 上的外键并删除(用 savepoint 保护,避免事务中止)
conn = op.get_bind()
fk_rows = conn.execute(
sa.text(
"SELECT con.conname FROM pg_constraint con "
"JOIN pg_attribute att ON att.attnum = ANY(con.conkey) "
" AND att.attrelid = con.conrelid "
"WHERE con.conrelid = 'provider_api_keys'::regclass "
" AND con.contype = 'f' AND att.attname = 'endpoint_id'"
)
).fetchall()
for (fk_name,) in fk_rows:
conn.execute(sa.text(f"SAVEPOINT sp_drop_fk_{fk_name}"))
try:
op.drop_constraint(fk_name, "provider_api_keys", type_="foreignkey")
conn.execute(sa.text(f"RELEASE SAVEPOINT sp_drop_fk_{fk_name}"))
except Exception:
conn.execute(sa.text(f"ROLLBACK TO SAVEPOINT sp_drop_fk_{fk_name}"))
op.drop_column("provider_api_keys", "endpoint_id")
# ========== 11. provider_endpoints: 删除废弃的 max_concurrent 列 ==========
if _column_exists("provider_endpoints", "max_concurrent"):
op.drop_column("provider_endpoints", "max_concurrent")
# ========== 12. providers: 删除废弃的 RPM 相关字段 ==========
if _column_exists("providers", "rpm_limit"):
op.drop_column("providers", "rpm_limit")
if _column_exists("providers", "rpm_used"):
op.drop_column("providers", "rpm_used")
if _column_exists("providers", "rpm_reset_at"):
op.drop_column("providers", "rpm_reset_at")
alembic_logger.info("[OK] Consolidated migration completed successfully")
def downgrade() -> None:
"""
Downgrade is complex due to data migrations.
For safety, this only removes new columns without restoring old structure.
Manual intervention may be required for full rollback.
"""
bind = op.get_bind()
# 12. 恢复 providers RPM 相关字段
if not _column_exists("providers", "rpm_limit"):
op.add_column("providers", sa.Column("rpm_limit", sa.Integer(), nullable=True))
if not _column_exists("providers", "rpm_used"):
op.add_column(
"providers",
sa.Column("rpm_used", sa.Integer(), server_default="0", nullable=True),
)
if not _column_exists("providers", "rpm_reset_at"):
op.add_column(
"providers",
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
)
# 11. 恢复 provider_endpoints.max_concurrent
if not _column_exists("provider_endpoints", "max_concurrent"):
op.add_column(
"provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)
)
# 10. 恢复 endpoint_id
if not _column_exists("provider_api_keys", "endpoint_id"):
op.add_column("provider_api_keys", sa.Column("endpoint_id", sa.String(36), nullable=True))
# 9. 删除 client_response_headers
if _column_exists("usage", "client_response_headers"):
op.drop_column("usage", "client_response_headers")
# 8. 恢复 provider_endpoints.rate_limit(如果需要)
if not _column_exists("provider_endpoints", "rate_limit"):
op.add_column("provider_endpoints", sa.Column("rate_limit", sa.Integer(), nullable=True))
# 7. 删除健康度 JSON 字段
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_health_by_format"))
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_circuit_breaker_by_format"))
if _column_exists("provider_api_keys", "health_by_format"):
op.drop_column("provider_api_keys", "health_by_format")
if _column_exists("provider_api_keys", "circuit_breaker_by_format"):
op.drop_column("provider_api_keys", "circuit_breaker_by_format")
# 6. rpm_limit -> max_concurrent(简化版:仅重命名)
if _column_exists("provider_api_keys", "rpm_limit"):
op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent")
if _column_exists("provider_api_keys", "learned_rpm_limit"):
op.alter_column(
"provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent"
)
if _column_exists("provider_api_keys", "last_rpm_peak"):
op.alter_column(
"provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak"
)
# 恢复已删除的字段
if not _column_exists("provider_api_keys", "rate_limit"):
op.add_column("provider_api_keys", sa.Column("rate_limit", sa.Integer(), nullable=True))
if not _column_exists("provider_api_keys", "daily_limit"):
op.add_column("provider_api_keys", sa.Column("daily_limit", sa.Integer(), nullable=True))
if not _column_exists("provider_api_keys", "monthly_limit"):
op.add_column("provider_api_keys", sa.Column("monthly_limit", sa.Integer(), nullable=True))
# 5. name -> display_name (需要先删除索引)
if _column_exists("providers", "name") and not _column_exists("providers", "display_name"):
if _index_exists("providers", "ix_providers_name"):
op.drop_index("ix_providers_name", table_name="providers")
op.alter_column("providers", "name", new_column_name="display_name")
if not _column_exists("providers", "name"):
op.add_column("providers", sa.Column("name", sa.String(100), nullable=True))
op.execute("""
UPDATE providers
SET name = LOWER(REPLACE(REPLACE(display_name, ' ', '_'), '-', '_'))
""")
op.alter_column("providers", "name", nullable=False)
if not _index_exists("providers", "ix_providers_name"):
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
# 4. 删除 providers 的 timeout, max_retries, proxy
if _column_exists("providers", "proxy"):
op.drop_column("providers", "proxy")
if _column_exists("providers", "max_retries"):
op.drop_column("providers", "max_retries")
if _column_exists("providers", "timeout"):
op.drop_column("providers", "timeout")
# 3. models: global_model_id 改回 NOT NULL
result = bind.execute(sa.text("SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"))
orphan_model_count = result.scalar() or 0
if orphan_model_count > 0:
alembic_logger.warning(
f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除"
)
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型")
op.alter_column("models", "global_model_id", nullable=False)
# 2. 删除 rate_multipliers
if _column_exists("provider_api_keys", "rate_multipliers"):
op.drop_column("provider_api_keys", "rate_multipliers")
# 1. 删除 provider_id 和 api_formats
if _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
op.drop_index("idx_provider_api_keys_provider_id", table_name="provider_api_keys")
if _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
op.drop_constraint("fk_provider_api_keys_provider", "provider_api_keys", type_="foreignkey")
if _column_exists("provider_api_keys", "api_formats"):
op.drop_column("provider_api_keys", "api_formats")
if _column_exists("provider_api_keys", "provider_id"):
op.drop_column("provider_api_keys", "provider_id")
# 恢复 endpoint_id 外键(简化版:仅创建外键,不强制 NOT NULL)
if _column_exists("provider_api_keys", "endpoint_id"):
if not _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
op.create_foreign_key(
"provider_api_keys_endpoint_id_fkey",
"provider_api_keys",
"provider_endpoints",
["endpoint_id"],
["id"],
ondelete="SET NULL",
)
alembic_logger.info("[OK] Downgrade completed (simplified version)")
@@ -0,0 +1,95 @@
"""add auto_fetch_models and locked_models to provider_api_keys
Revision ID: e4ebe3233b40
Revises: m4n5o6p7q8r9
Create Date: 2026-01-13 17:59:53.119479+00:00
为 provider_api_keys 表添加自动获取模型相关字段:
1. auto_fetch_models: 是否启用自动获取模型
2. last_models_fetch_at: 最后获取时间
3. last_models_fetch_error: 最后获取错误信息
4. locked_models: 被锁定的模型列表(刷新时不会被删除)
注意: downgrade 操作会永久删除 auto_fetch_models 配置和 locked_models 数据
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
def _index_exists(index_name: str) -> bool:
"""Check if an index exists"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes("provider_api_keys")
return any(idx["name"] == index_name for idx in indexes)
# revision identifiers, used by Alembic.
revision = 'e4ebe3233b40'
down_revision = 'm4n5o6p7q8r9'
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""Check if a column exists in the table"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""添加自动获取模型相关字段"""
if not _column_exists("provider_api_keys", "auto_fetch_models"):
op.add_column(
"provider_api_keys",
sa.Column("auto_fetch_models", sa.Boolean(), nullable=False, server_default="false"),
)
if not _column_exists("provider_api_keys", "last_models_fetch_at"):
op.add_column(
"provider_api_keys",
sa.Column("last_models_fetch_at", sa.DateTime(timezone=True), nullable=True),
)
if not _column_exists("provider_api_keys", "last_models_fetch_error"):
op.add_column(
"provider_api_keys",
sa.Column("last_models_fetch_error", sa.Text(), nullable=True),
)
if not _column_exists("provider_api_keys", "locked_models"):
op.add_column(
"provider_api_keys",
sa.Column("locked_models", sa.JSON(), nullable=True),
)
# 添加复合索引以优化调度器查询
if not _index_exists("ix_provider_api_keys_auto_fetch_active"):
op.create_index(
"ix_provider_api_keys_auto_fetch_active",
"provider_api_keys",
["auto_fetch_models", "is_active"],
postgresql_where=sa.text("auto_fetch_models = true AND is_active = true"),
)
def downgrade() -> None:
"""移除自动获取模型相关字段"""
# 先删除索引
if _index_exists("ix_provider_api_keys_auto_fetch_active"):
op.drop_index("ix_provider_api_keys_auto_fetch_active", table_name="provider_api_keys")
if _column_exists("provider_api_keys", "locked_models"):
op.drop_column("provider_api_keys", "locked_models")
if _column_exists("provider_api_keys", "last_models_fetch_error"):
op.drop_column("provider_api_keys", "last_models_fetch_error")
if _column_exists("provider_api_keys", "last_models_fetch_at"):
op.drop_column("provider_api_keys", "last_models_fetch_at")
if _column_exists("provider_api_keys", "auto_fetch_models"):
op.drop_column("provider_api_keys", "auto_fetch_models")
@@ -0,0 +1,104 @@
"""add header_rules to provider_endpoints and is_locked to api_keys
Revision ID: 6d579000e511
Revises: e4ebe3233b40
Create Date: 2026-01-15 23:00:00.000000+00:00
变更:
1. provider_endpoints 表: 添加 header_rules 字段,迁移 headers 数据
2. api_keys 表: 添加 is_locked 字段(管理员锁定标志)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = '6d579000e511'
down_revision = 'e4ebe3233b40'
branch_labels = None
depends_on = None
def _column_exists(connection, table: str, column: str) -> bool:
"""检查列是否存在"""
result = connection.execute(
sa.text("""
SELECT 1 FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""),
{"table": table, "column": column}
)
return result.fetchone() is not None
def upgrade() -> None:
"""添加 header_rules 字段并迁移现有 headers 数据;添加 is_locked 字段"""
connection = op.get_bind()
# ========== provider_endpoints.header_rules ==========
# 1. 添加 header_rules 列(幂等)
if not _column_exists(connection, 'provider_endpoints', 'header_rules'):
op.add_column('provider_endpoints', sa.Column('header_rules', JSON, nullable=True))
# 2. 批量迁移:headers -> header_rules
# 使用纯 SQL 将 {"k1":"v1", "k2":"v2"} 转换为 [{"action":"set","key":"k1","value":"v1"}, ...]
if _column_exists(connection, 'provider_endpoints', 'headers'):
connection.execute(
sa.text("""
UPDATE provider_endpoints
SET header_rules = (
SELECT jsonb_agg(
jsonb_build_object('action', 'set', 'key', key, 'value', value)
)
FROM jsonb_each_text(headers::jsonb)
)
WHERE headers IS NOT NULL
AND headers::text != '{}'
AND jsonb_typeof(headers::jsonb) = 'object'
AND header_rules IS NULL
""")
)
# 3. 删除旧列
op.drop_column('provider_endpoints', 'headers')
# ========== api_keys.is_locked ==========
if not _column_exists(connection, 'api_keys', 'is_locked'):
op.add_column(
'api_keys',
sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')
)
def downgrade() -> None:
"""移除 header_rules 字段,恢复 headers 字段;移除 is_locked 字段"""
connection = op.get_bind()
# ========== api_keys.is_locked ==========
if _column_exists(connection, 'api_keys', 'is_locked'):
op.drop_column('api_keys', 'is_locked')
# ========== provider_endpoints.header_rules ==========
# 1. 添加 headers 列(幂等)
if not _column_exists(connection, 'provider_endpoints', 'headers'):
op.add_column('provider_endpoints', sa.Column('headers', JSON, nullable=True))
# 2. 批量迁移:header_rules -> headers(仅提取 set 操作)
if _column_exists(connection, 'provider_endpoints', 'header_rules'):
connection.execute(
sa.text("""
UPDATE provider_endpoints
SET headers = (
SELECT jsonb_object_agg(rule->>'key', rule->>'value')
FROM jsonb_array_elements(header_rules::jsonb) AS rule
WHERE rule->>'action' = 'set'
AND rule->>'key' IS NOT NULL
)
WHERE header_rules IS NOT NULL
AND jsonb_typeof(header_rules::jsonb) = 'array'
AND jsonb_array_length(header_rules::jsonb) > 0
""")
)
# 3. 删除 header_rules 列
op.drop_column('provider_endpoints', 'header_rules')
@@ -0,0 +1,127 @@
"""add global_priority_by_format and remove deprecated fields
Revision ID: ddd59cdf0349
Revises: 6d579000e511
Create Date: 2026-01-16 12:00:00.000000+00:00
变更:
1. provider_api_keys 表: 添加 global_priority_by_format 字段(按 API 格式的全局优先级)
2. 迁移现有 global_priority 数据到新字段
3. 删除已废弃的 global_priority 字段
4. 删除已废弃的 rate_multiplier 字段(已被 rate_multipliers 替代)
5. 删除已废弃的 providers.timeout 字段(由环境变量控制)
6. 删除已废弃的 provider_endpoints.timeout 字段(由环境变量控制)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = 'ddd59cdf0349'
down_revision = '6d579000e511'
branch_labels = None
depends_on = None
def _column_exists(connection, table: str, column: str) -> bool:
"""检查列是否存在"""
result = connection.execute(
sa.text("""
SELECT 1 FROM information_schema.columns
WHERE table_name = :table AND column_name = :column
"""),
{"table": table, "column": column}
)
return result.fetchone() is not None
def upgrade():
connection = op.get_bind()
# 1. 添加 global_priority_by_format 字段
if not _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
op.add_column(
'provider_api_keys',
sa.Column('global_priority_by_format', JSON, nullable=True)
)
# 2. 迁移现有 global_priority 数据到新字段
# 对于有 global_priority 的 Key,将其值应用到所有支持的 api_formats
if _column_exists(connection, 'provider_api_keys', 'global_priority'):
# 将 JSON 数组转换为 text[] 后使用 unnest
connection.execute(sa.text("""
UPDATE provider_api_keys
SET global_priority_by_format = (
SELECT jsonb_object_agg(format, global_priority)
FROM jsonb_array_elements_text(api_formats::jsonb) AS format
)
WHERE global_priority IS NOT NULL
AND api_formats IS NOT NULL
AND jsonb_array_length(api_formats::jsonb) > 0
AND global_priority_by_format IS NULL
"""))
# 3. 删除 global_priority 字段
op.drop_column('provider_api_keys', 'global_priority')
# 4. 删除 rate_multiplier 字段(已被 rate_multipliers 替代)
if _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
op.drop_column('provider_api_keys', 'rate_multiplier')
# 5. 删除 providers.timeout 字段(由环境变量控制)
if _column_exists(connection, 'providers', 'timeout'):
op.drop_column('providers', 'timeout')
# 6. 删除 provider_endpoints.timeout 字段(由环境变量控制)
if _column_exists(connection, 'provider_endpoints', 'timeout'):
op.drop_column('provider_endpoints', 'timeout')
def downgrade():
connection = op.get_bind()
# 1. 恢复 rate_multiplier 字段
if not _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
op.add_column(
'provider_api_keys',
sa.Column('rate_multiplier', sa.Float, nullable=False, server_default='1.0')
)
# 2. 恢复 global_priority 字段并迁移数据
if not _column_exists(connection, 'provider_api_keys', 'global_priority'):
op.add_column(
'provider_api_keys',
sa.Column('global_priority', sa.Integer, nullable=True)
)
# 从 global_priority_by_format 迁移数据(取第一个格式的优先级值)
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
connection.execute(sa.text("""
UPDATE provider_api_keys
SET global_priority = (
SELECT (value::text)::integer
FROM jsonb_each(global_priority_by_format::jsonb)
LIMIT 1
)
WHERE global_priority_by_format IS NOT NULL
AND jsonb_typeof(global_priority_by_format::jsonb) = 'object'
AND global_priority IS NULL
"""))
# 3. 删除 global_priority_by_format 字段
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
op.drop_column('provider_api_keys', 'global_priority_by_format')
# 4. 恢复 providers.timeout 字段
if not _column_exists(connection, 'providers', 'timeout'):
op.add_column(
'providers',
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
)
# 5. 恢复 provider_endpoints.timeout 字段
if not _column_exists(connection, 'provider_endpoints', 'timeout'):
op.add_column(
'provider_endpoints',
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
)
@@ -0,0 +1,223 @@
"""make users email/password nullable add email_verified and oauth tables
Revision ID: 33e347f97c0c
Revises: ddd59cdf0349
Create Date: 2026-01-18 11:18:15.940559+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "33e347f97c0c"
down_revision = "ddd59cdf0349"
branch_labels = None
depends_on = None
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_is_nullable(table_name: str, column_name: str) -> bool:
"""检查列是否允许 NULL"""
bind = op.get_bind()
inspector = inspect(bind)
for col in inspector.get_columns(table_name):
if col["name"] == column_name:
return col["nullable"]
return False
def enum_value_exists(enum_name: str, value: str) -> bool:
"""检查 PostgreSQL ENUM 是否包含指定值"""
bind = op.get_bind()
if bind.dialect.name != "postgresql":
return True # 非 PostgreSQL 跳过检查
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_enum WHERE enumlabel = :value "
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = :enum_name)"
),
{"value": value, "enum_name": enum_name},
).first()
return result is not None
def upgrade() -> None:
"""应用迁移:升级到新版本"""
bind = op.get_bind()
# ========== Part 1: users 表修改 ==========
# 1) 新增 email_verified
if not column_exists("users", "email_verified"):
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=True))
# 历史数据回填:已有邮箱的用户默认视为已验证
op.execute(sa.text("UPDATE users SET email_verified = true WHERE email IS NOT NULL"))
op.execute(sa.text("UPDATE users SET email_verified = false WHERE email IS NULL"))
# 收紧约束
op.alter_column("users", "email_verified", existing_type=sa.Boolean(), nullable=False)
# 2) email 放宽为可空
if not column_is_nullable("users", "email"):
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=True,
)
# 3) password_hash 放宽为可空
if not column_is_nullable("users", "password_hash"):
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(length=255),
nullable=True,
)
# ========== Part 2: OAuth 相关 ==========
# 4) 扩展 authsource enum
if bind.dialect.name == "postgresql" and not enum_value_exists("authsource", "oauth"):
ctx = op.get_context()
with ctx.autocommit_block():
op.execute("ALTER TYPE authsource ADD VALUE IF NOT EXISTS 'oauth'")
# 5) OAuth provider 配置表
if not table_exists("oauth_providers"):
op.create_table(
"oauth_providers",
sa.Column("provider_type", sa.String(length=50), primary_key=True),
sa.Column("display_name", sa.String(length=100), nullable=False),
sa.Column("client_id", sa.String(length=255), nullable=False),
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
sa.Column("authorization_url_override", sa.String(length=500), nullable=True),
sa.Column("token_url_override", sa.String(length=500), nullable=True),
sa.Column("userinfo_url_override", sa.String(length=500), nullable=True),
sa.Column("scopes", sa.JSON(), nullable=True),
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
sa.Column("frontend_callback_url", sa.String(length=500), nullable=False),
sa.Column("attribute_mapping", sa.JSON(), nullable=True),
sa.Column("extra_config", sa.JSON(), nullable=True),
sa.Column(
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
# 6) 用户 OAuth 绑定关系表
if not table_exists("user_oauth_links"):
op.create_table(
"user_oauth_links",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"user_id",
sa.String(length=36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"provider_type",
sa.String(length=50),
sa.ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
nullable=False,
),
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
sa.Column("provider_username", sa.String(length=255), nullable=True),
sa.Column("provider_email", sa.String(length=255), nullable=True),
sa.Column("extra_data", sa.JSON(), nullable=True),
sa.Column(
"linked_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint(
"provider_type", "provider_user_id", name="uq_oauth_provider_user"
),
sa.UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
)
op.create_index("ix_user_oauth_links_user_id", "user_oauth_links", ["user_id"])
op.create_index(
"ix_user_oauth_links_provider_type", "user_oauth_links", ["provider_type"]
)
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
bind = op.get_bind()
# ========== Part 2: OAuth 相关(先删除,因为有外键依赖) ==========
if table_exists("user_oauth_links"):
op.drop_index("ix_user_oauth_links_provider_type", table_name="user_oauth_links")
op.drop_index("ix_user_oauth_links_user_id", table_name="user_oauth_links")
op.drop_table("user_oauth_links")
if table_exists("oauth_providers"):
op.drop_table("oauth_providers")
# 注意:Postgres 不支持从 ENUM 删除值,authsource 不回退
# ========== Part 1: users 表修改 ==========
# 降级前检查:避免把包含 NULL 的列强制改回 NOT NULL
has_null_email = bind.execute(
sa.text("SELECT 1 FROM users WHERE email IS NULL LIMIT 1")
).first()
if has_null_email:
raise RuntimeError("Cannot downgrade: users.email contains NULL values")
has_null_password = bind.execute(
sa.text("SELECT 1 FROM users WHERE password_hash IS NULL LIMIT 1")
).first()
if has_null_password:
raise RuntimeError("Cannot downgrade: users.password_hash contains NULL values")
# 恢复 NOT NULL 约束
if column_is_nullable("users", "email"):
op.alter_column(
"users",
"email",
existing_type=sa.String(length=255),
nullable=False,
)
if column_is_nullable("users", "password_hash"):
op.alter_column(
"users",
"password_hash",
existing_type=sa.String(length=255),
nullable=False,
)
if column_exists("users", "email_verified"):
op.drop_column("users", "email_verified")
@@ -0,0 +1,65 @@
"""add_stats_daily_provider_table
Revision ID: c868729753ad
Revises: 33e347f97c0c
Create Date: 2026-01-19 05:19:49.634662+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'c868729753ad'
down_revision = '33e347f97c0c'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
return index_name in indexes
def upgrade() -> None:
"""应用迁移:升级到新版本"""
if not table_exists('stats_daily_provider'):
op.create_table(
'stats_daily_provider',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
sa.Column('provider_name', sa.String(length=100), nullable=False),
sa.Column('total_requests', sa.Integer(), nullable=False),
sa.Column('input_tokens', sa.BigInteger(), nullable=False),
sa.Column('output_tokens', sa.BigInteger(), nullable=False),
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False),
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False),
sa.Column('total_cost', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('date', 'provider_name', name='uq_stats_daily_provider')
)
op.create_index('idx_stats_daily_provider_date', 'stats_daily_provider', ['date'], unique=False)
op.create_index('idx_stats_daily_provider_date_provider', 'stats_daily_provider', ['date', 'provider_name'], unique=False)
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
if table_exists('stats_daily_provider'):
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date_provider'):
op.drop_index('idx_stats_daily_provider_date_provider', table_name='stats_daily_provider')
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date'):
op.drop_index('idx_stats_daily_provider_date', table_name='stats_daily_provider')
op.drop_table('stats_daily_provider')
@@ -0,0 +1,51 @@
"""add_format_acceptance_config_to_provider_endpoints
Revision ID: 4b4c7b0df1a2
Revises: c868729753ad
Create Date: 2026-01-21 18:45:00+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "4b4c7b0df1a2"
down_revision = "c868729753ad"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not table_exists("provider_endpoints"):
return
if column_exists("provider_endpoints", "format_acceptance_config"):
return
op.add_column(
"provider_endpoints",
sa.Column("format_acceptance_config", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if not table_exists("provider_endpoints"):
return
if not column_exists("provider_endpoints", "format_acceptance_config"):
return
op.drop_column("provider_endpoints", "format_acceptance_config")
@@ -0,0 +1,114 @@
"""add_format_conversion_tracking_and_model_filter_patterns_and_provider_timeout
Revision ID: f7c8d9e0a1b2
Revises: 4b4c7b0df1a2
Create Date: 2026-01-27 10:00:00+00:00
Changes:
1. usage 表: 添加 endpoint_api_format 和 has_format_conversion 字段
2. provider_api_keys 表: 添加 model_include_patterns 和 model_exclude_patterns 字段
- 支持通配符规则自动过滤从上游获取的模型列表
- 包含规则和排除规则(支持 * 和 ? 通配符)
3. providers 表: 添加 stream_first_byte_timeout 和 request_timeout 字段
- 允许每个提供商单独配置超时时间
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "f7c8d9e0a1b2"
down_revision = "4b4c7b0df1a2"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# === usage 表: 格式转换追踪 ===
if table_exists("usage"):
# 添加 endpoint_api_format 字段(端点原生 API 格式)
if not column_exists("usage", "endpoint_api_format"):
op.add_column(
"usage",
sa.Column("endpoint_api_format", sa.String(50), nullable=True),
)
# 添加 has_format_conversion 字段(是否发生了格式转换)
if not column_exists("usage", "has_format_conversion"):
op.add_column(
"usage",
sa.Column("has_format_conversion", sa.Boolean(), nullable=True, server_default="false"),
)
# === provider_api_keys 表: 模型过滤规则 ===
if table_exists("provider_api_keys"):
# 添加 model_include_patterns 字段(包含规则,支持 * 和 ? 通配符)
if not column_exists("provider_api_keys", "model_include_patterns"):
op.add_column(
"provider_api_keys",
sa.Column("model_include_patterns", sa.JSON(), nullable=True),
)
# 添加 model_exclude_patterns 字段(排除规则,支持 * 和 ? 通配符)
if not column_exists("provider_api_keys", "model_exclude_patterns"):
op.add_column(
"provider_api_keys",
sa.Column("model_exclude_patterns", sa.JSON(), nullable=True),
)
# === providers 表: 超时配置 ===
if table_exists("providers"):
# 添加 stream_first_byte_timeout 字段(流式请求首字节超时)
if not column_exists("providers", "stream_first_byte_timeout"):
op.add_column(
"providers",
sa.Column("stream_first_byte_timeout", sa.Float(), nullable=True),
)
# 添加 request_timeout 字段(非流式请求整体超时)
if not column_exists("providers", "request_timeout"):
op.add_column(
"providers",
sa.Column("request_timeout", sa.Float(), nullable=True),
)
def downgrade() -> None:
# === providers 表: 移除超时配置 ===
if table_exists("providers"):
if column_exists("providers", "request_timeout"):
op.drop_column("providers", "request_timeout")
if column_exists("providers", "stream_first_byte_timeout"):
op.drop_column("providers", "stream_first_byte_timeout")
# === provider_api_keys 表: 移除模型过滤规则 ===
if table_exists("provider_api_keys"):
if column_exists("provider_api_keys", "model_exclude_patterns"):
op.drop_column("provider_api_keys", "model_exclude_patterns")
if column_exists("provider_api_keys", "model_include_patterns"):
op.drop_column("provider_api_keys", "model_include_patterns")
# === usage 表: 移除格式转换追踪 ===
if table_exists("usage"):
if column_exists("usage", "has_format_conversion"):
op.drop_column("usage", "has_format_conversion")
if column_exists("usage", "endpoint_api_format"):
op.drop_column("usage", "endpoint_api_format")
@@ -0,0 +1,58 @@
"""add_keep_priority_on_conversion_to_providers
Revision ID: 364680d1bc99
Revises: f7c8d9e0a1b2
Create Date: 2026-01-28 12:00:00+00:00
Changes:
1. providers 表: 添加 keep_priority_on_conversion 字段
- 格式转换时是否保持提供商原优先级
- 默认 False:需要格式转换时,候选会被降级到不需要转换的候选之后
- 设为 True:即使需要格式转换,也保持原优先级排名
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "364680d1bc99"
down_revision = "f7c8d9e0a1b2"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# === providers 表: 添加格式转换优先级保持配置 ===
if table_exists("providers"):
if not column_exists("providers", "keep_priority_on_conversion"):
op.add_column(
"providers",
sa.Column(
"keep_priority_on_conversion",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
# === providers 表: 移除格式转换优先级保持配置 ===
if table_exists("providers"):
if column_exists("providers", "keep_priority_on_conversion"):
op.drop_column("providers", "keep_priority_on_conversion")
@@ -0,0 +1,51 @@
"""Add auth_type and auth_config fields to provider_api_keys table
Revision ID: 7f6f8065f517
Revises: 364680d1bc99
Create Date: 2026-01-30 10:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = "7f6f8065f517"
down_revision: Union[str, None] = "364680d1bc99"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否已存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# 添加 auth_type 字段,默认值为 "api_key"
if not column_exists("provider_api_keys", "auth_type"):
op.add_column(
"provider_api_keys",
sa.Column("auth_type", sa.String(20), nullable=False, server_default="api_key"),
)
# 添加 auth_config 字段(Text,存储加密后的认证配置)
if not column_exists("provider_api_keys", "auth_config"):
op.add_column(
"provider_api_keys",
sa.Column("auth_config", sa.Text, nullable=True),
)
def downgrade() -> None:
if column_exists("provider_api_keys", "auth_config"):
op.drop_column("provider_api_keys", "auth_config")
if column_exists("provider_api_keys", "auth_type"):
op.drop_column("provider_api_keys", "auth_type")
@@ -0,0 +1,112 @@
"""Add video_tasks table
Revision ID: b6f1a2c5d8e9
Revises: 7f6f8065f517
Create Date: 2026-01-30 18:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b6f1a2c5d8e9"
down_revision: Union[str, None] = "7f6f8065f517"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
if table_exists("video_tasks"):
return
op.create_table(
"video_tasks",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("external_task_id", sa.String(200), nullable=True, index=False),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
sa.Column("api_key_id", sa.String(36), sa.ForeignKey("api_keys.id"), nullable=True),
sa.Column("provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True),
sa.Column(
"endpoint_id", sa.String(36), sa.ForeignKey("provider_endpoints.id"), nullable=True
),
sa.Column("key_id", sa.String(36), sa.ForeignKey("provider_api_keys.id"), nullable=True),
sa.Column("client_api_format", sa.String(50), nullable=False),
sa.Column("provider_api_format", sa.String(50), nullable=False),
sa.Column("format_converted", sa.Boolean(), server_default=sa.false()),
sa.Column("model", sa.String(100), nullable=False),
sa.Column("prompt", sa.Text(), nullable=False),
sa.Column("original_request_body", sa.JSON(), nullable=True),
sa.Column("converted_request_body", sa.JSON(), nullable=True),
sa.Column("duration_seconds", sa.Integer(), server_default=sa.text("4")),
sa.Column("resolution", sa.String(20), server_default=sa.text("'720p'")),
sa.Column("aspect_ratio", sa.String(10), server_default=sa.text("'16:9'")),
sa.Column("size", sa.String(20), nullable=True),
sa.Column("status", sa.String(20), server_default=sa.text("'pending'")),
sa.Column("progress_percent", sa.Integer(), server_default=sa.text("0")),
sa.Column("progress_message", sa.String(500), nullable=True),
sa.Column("video_url", sa.String(2000), nullable=True),
sa.Column("video_urls", sa.JSON(), nullable=True),
sa.Column("thumbnail_url", sa.String(2000), nullable=True),
sa.Column("video_size_bytes", sa.BigInteger(), nullable=True),
sa.Column("video_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("stored_video_path", sa.String(500), nullable=True),
sa.Column("storage_provider", sa.String(50), nullable=True),
sa.Column("error_code", sa.String(50), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("retry_count", sa.Integer(), server_default=sa.text("0")),
sa.Column("max_retries", sa.Integer(), server_default=sa.text("3")),
sa.Column("poll_interval_seconds", sa.Integer(), server_default=sa.text("10")),
sa.Column("next_poll_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("poll_count", sa.Integer(), server_default=sa.text("0")),
sa.Column("max_poll_count", sa.Integer(), server_default=sa.text("360")),
sa.Column(
"remixed_from_task_id",
sa.String(36),
sa.ForeignKey("video_tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
),
)
op.create_index("idx_video_tasks_user_status", "video_tasks", ["user_id", "status"])
op.create_index("idx_video_tasks_next_poll", "video_tasks", ["next_poll_at"])
op.create_index("idx_video_tasks_external_id", "video_tasks", ["external_task_id"])
# 唯一约束:同一用户不能有重复的 external_task_id
op.create_unique_constraint(
"uq_video_tasks_user_external_id",
"video_tasks",
["user_id", "external_task_id"],
)
def downgrade() -> None:
if not table_exists("video_tasks"):
return
op.drop_constraint("uq_video_tasks_user_external_id", "video_tasks", type_="unique")
op.drop_index("idx_video_tasks_external_id", table_name="video_tasks")
op.drop_index("idx_video_tasks_next_poll", table_name="video_tasks")
op.drop_index("idx_video_tasks_user_status", table_name="video_tasks")
op.drop_table("video_tasks")
@@ -0,0 +1,180 @@
"""Add billing system tables and video_tasks.request_metadata
Revision ID: c8d2e4f6a1b3
Revises: b6f1a2c5d8e9
Create Date: 2026-01-31 12:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c8d2e4f6a1b3"
down_revision: Union[str, None] = "b6f1a2c5d8e9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def upgrade() -> None:
# ==================== video_tasks.request_metadata ====================
if not column_exists("video_tasks", "request_metadata"):
op.add_column(
"video_tasks",
sa.Column("request_metadata", sa.JSON(), nullable=True),
)
# ==================== billing_rules ====================
if not table_exists("billing_rules"):
op.create_table(
"billing_rules",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"global_model_id",
sa.String(36),
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column(
"model_id",
sa.String(36),
sa.ForeignKey("models.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("task_type", sa.String(20), nullable=False, server_default="chat"),
sa.Column("expression", sa.Text(), nullable=False),
sa.Column("variables", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column(
"dimension_mappings", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"(global_model_id IS NOT NULL AND model_id IS NULL) OR "
"(global_model_id IS NULL AND model_id IS NOT NULL)",
name="chk_billing_rules_model_ref",
),
)
# Partial unique indexes for enabled rules
if table_exists("billing_rules"):
if not index_exists("billing_rules", "uq_billing_rules_global_model_task"):
op.create_index(
"uq_billing_rules_global_model_task",
"billing_rules",
["global_model_id", "task_type"],
unique=True,
postgresql_where=sa.text("is_enabled = TRUE AND global_model_id IS NOT NULL"),
)
if not index_exists("billing_rules", "uq_billing_rules_model_task"):
op.create_index(
"uq_billing_rules_model_task",
"billing_rules",
["model_id", "task_type"],
unique=True,
postgresql_where=sa.text("is_enabled = TRUE AND model_id IS NOT NULL"),
)
# ==================== dimension_collectors ====================
if not table_exists("dimension_collectors"):
op.create_table(
"dimension_collectors",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("api_format", sa.String(50), nullable=False),
sa.Column("task_type", sa.String(20), nullable=False),
sa.Column("dimension_name", sa.String(100), nullable=False),
sa.Column("source_type", sa.String(20), nullable=False),
sa.Column("source_path", sa.String(200), nullable=True),
sa.Column("value_type", sa.String(20), nullable=False, server_default="float"),
sa.Column("transform_expression", sa.Text(), nullable=True),
sa.Column("default_value", sa.String(100), nullable=True),
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint(
"(source_type = 'computed' AND source_path IS NULL AND transform_expression IS NOT NULL) OR "
"(source_type != 'computed' AND source_path IS NOT NULL)",
name="chk_dimension_collectors_source_config",
),
)
if table_exists("dimension_collectors"):
if not index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
op.create_index(
"uq_dimension_collectors_enabled",
"dimension_collectors",
["api_format", "task_type", "dimension_name", "priority"],
unique=True,
postgresql_where=sa.text("is_enabled = TRUE"),
)
def downgrade() -> None:
# Drop in reverse order
if table_exists("dimension_collectors"):
if index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
op.drop_index("uq_dimension_collectors_enabled", table_name="dimension_collectors")
op.drop_table("dimension_collectors")
if table_exists("billing_rules"):
if index_exists("billing_rules", "uq_billing_rules_model_task"):
op.drop_index("uq_billing_rules_model_task", table_name="billing_rules")
if index_exists("billing_rules", "uq_billing_rules_global_model_task"):
op.drop_index("uq_billing_rules_global_model_task", table_name="billing_rules")
op.drop_table("billing_rules")
if column_exists("video_tasks", "request_metadata"):
op.drop_column("video_tasks", "request_metadata")
@@ -0,0 +1,462 @@
"""Add api_family/endpoint_kind and migrate api_format to endpoint signature keys
Revision ID: cf40e6a5c5b1
Revises: c8d2e4f6a1b3
Create Date: 2026-01-31 15:30:00.000000
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Sequence, Union
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy import inspect, text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "cf40e6a5c5b1"
down_revision: Union[str, None] = "c8d2e4f6a1b3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _json_loads(val):
if val is None:
return None
if isinstance(val, (dict, list)):
return val
if isinstance(val, str):
try:
return json.loads(val)
except Exception:
return None
return None
def _json_dumps(val):
"""将 dict/list 转为 JSON 字符串,None 保持 None"""
if val is None:
return None
if isinstance(val, str):
return val
return json.dumps(val)
def _normalize_signature(value: str | None) -> str | None:
"""
Normalize legacy api_format / signature-ish strings to canonical signature key.
- canonical: `<family>:<kind>` (lowercase)
- legacy examples: "OPENAI", "OPENAI_CLI", "GEMINI_VIDEO"
"""
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
if ":" in raw:
fam, kind = raw.split(":", 1)
fam = fam.strip().lower()
kind = kind.strip().lower()
if not fam or not kind:
return None
return f"{fam}:{kind}"
upper = raw.upper()
if upper.startswith("CLAUDE"):
fam = "claude"
elif upper.startswith("OPENAI"):
fam = "openai"
elif upper.startswith("GEMINI"):
fam = "gemini"
else:
return None
kind = "chat"
if upper.endswith("_CLI"):
kind = "cli"
elif upper.endswith("_VIDEO"):
kind = "video"
return f"{fam}:{kind}"
def _normalize_signature_list(values) -> list[str] | None:
if values is None:
return None
if isinstance(values, str):
values = _json_loads(values)
if not isinstance(values, list):
return None
out: list[str] = []
seen: set[str] = set()
for v in values:
sig = _normalize_signature(str(v) if v is not None else None)
if not sig:
continue
if sig in seen:
continue
seen.add(sig)
out.append(sig)
return out
def _normalize_signature_dict(values) -> dict | None:
if values is None:
return None
if isinstance(values, str):
values = _json_loads(values)
if not isinstance(values, dict):
return None
out: dict = {}
for k, v in values.items():
sig = _normalize_signature(str(k) if k is not None else None)
if not sig:
continue
out[sig] = v
return out
def _add_video_variants(formats: list[str]) -> list[str]:
"""
保持原有格式,不自动补齐 video 变体。
"""
return formats
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(idx.get("name") == index_name for idx in indexes)
def _migrate_format_acceptance_config(cfg) -> dict | None:
cfg_obj = _json_loads(cfg)
if not isinstance(cfg_obj, dict):
return cfg_obj if cfg_obj is None else None
for key in ("accept_formats", "reject_formats"):
raw = cfg_obj.get(key)
if not isinstance(raw, list):
continue
normalized = _normalize_signature_list(raw) or []
cfg_obj[key] = normalized
return cfg_obj
def migrate_provider_endpoints(connection) -> None:
"""
- 将 provider_endpoints.api_format 统一迁移为 signature key(小写)
- 填充/校准 api_family / endpoint_kind
- 迁移 format_acceptance_config 中的 accept/reject formats
"""
rows = connection.execute(text("""
SELECT
id,
api_format,
api_family,
endpoint_kind,
format_acceptance_config
FROM provider_endpoints
""")).fetchall()
for row in rows:
sig = _normalize_signature(row.api_format)
if not sig:
continue
fam, kind = sig.split(":", 1)
cfg = _migrate_format_acceptance_config(row.format_acceptance_config)
connection.execute(
text("""
UPDATE provider_endpoints
SET
api_format = :api_format,
api_family = :api_family,
endpoint_kind = :endpoint_kind,
format_acceptance_config = CAST(:format_acceptance_config AS json)
WHERE id = :id
"""),
{
"id": row.id,
"api_format": sig,
"api_family": fam,
"endpoint_kind": kind,
"format_acceptance_config": _json_dumps(cfg),
},
)
def create_video_endpoints(connection) -> None:
"""
不再自动创建 video endpoint,保持原有配置。
"""
pass
def migrate_provider_api_keys(connection) -> None:
"""
迁移 provider_api_keys:
- api_formats -> signature keys(并补齐 video 变体)
- dict 字段 key -> signature keys(rate_multipliers/global_priority/health/circuit_breaker)
- rate_multipliers/global_priority_by_format 复制 chat -> video(如 openai:chat -> openai:video)
"""
rows = connection.execute(text("""
SELECT
id,
api_formats,
rate_multipliers,
global_priority_by_format,
health_by_format,
circuit_breaker_by_format
FROM provider_api_keys
""")).fetchall()
for row in rows:
api_formats = _normalize_signature_list(row.api_formats)
if api_formats is not None:
api_formats = _add_video_variants(api_formats)
rate_multipliers = _normalize_signature_dict(row.rate_multipliers)
global_priority_by_format = _normalize_signature_dict(row.global_priority_by_format)
health_by_format = _normalize_signature_dict(row.health_by_format)
circuit_breaker_by_format = _normalize_signature_dict(row.circuit_breaker_by_format)
connection.execute(
text("""
UPDATE provider_api_keys
SET
api_formats = CAST(:api_formats AS json),
rate_multipliers = CAST(:rate_multipliers AS json),
global_priority_by_format = CAST(:global_priority_by_format AS json),
health_by_format = CAST(:health_by_format AS json),
circuit_breaker_by_format = CAST(:circuit_breaker_by_format AS json)
WHERE id = :id
"""),
{
"id": row.id,
"api_formats": _json_dumps(api_formats),
"rate_multipliers": _json_dumps(rate_multipliers),
"global_priority_by_format": _json_dumps(global_priority_by_format),
"health_by_format": _json_dumps(health_by_format),
"circuit_breaker_by_format": _json_dumps(circuit_breaker_by_format),
},
)
def migrate_allowed_api_formats(connection, *, table_name: str) -> None:
"""迁移 users/api_keys.allowed_api_formats 为 signature keys(并补齐 video 变体)。"""
if not table_exists(table_name):
return
rows = connection.execute(text(f"""
SELECT id, allowed_api_formats
FROM {table_name}
""")).fetchall()
for row in rows:
allowed = _normalize_signature_list(row.allowed_api_formats)
if allowed is None:
continue
allowed = _add_video_variants(allowed)
connection.execute(
text(f"""
UPDATE {table_name}
SET allowed_api_formats = CAST(:allowed_api_formats AS json)
WHERE id = :id
"""),
{"id": row.id, "allowed_api_formats": _json_dumps(allowed)},
)
def migrate_video_tasks(connection) -> None:
"""
video_tasks.*_api_format 迁移为 signature keys。
注意:video_tasks 表天然是 video 任务,因此将 openai/gemini 的 kind 强制归一为 video,
以兼容历史上复用 chat 格式存储的旧记录。
"""
if not table_exists("video_tasks"):
return
rows = connection.execute(text("""
SELECT id, client_api_format, provider_api_format
FROM video_tasks
""")).fetchall()
for row in rows:
client_sig = _normalize_signature(row.client_api_format) or ""
provider_sig = _normalize_signature(row.provider_api_format) or ""
def _force_video(sig: str) -> str:
if not sig or ":" not in sig:
return sig
fam, _kind = sig.split(":", 1)
fam = fam.strip().lower()
if fam in ("openai", "gemini"):
return f"{fam}:video"
return sig
client_sig = _force_video(client_sig)
provider_sig = _force_video(provider_sig)
if not client_sig or not provider_sig:
continue
connection.execute(
text("""
UPDATE video_tasks
SET client_api_format = :client_api_format,
provider_api_format = :provider_api_format
WHERE id = :id
"""),
{
"id": row.id,
"client_api_format": client_sig,
"provider_api_format": provider_sig,
},
)
def migrate_model_provider_mappings(connection) -> None:
"""迁移 models.provider_model_mappings[*].api_formats 为 signature keys。"""
if not table_exists("models"):
return
rows = connection.execute(text("""
SELECT id, provider_model_mappings
FROM models
WHERE provider_model_mappings IS NOT NULL
""")).fetchall()
for row in rows:
mappings = _json_loads(row.provider_model_mappings)
if not isinstance(mappings, list):
continue
changed = False
new_mappings: list = []
for item in mappings:
if not isinstance(item, dict):
new_mappings.append(item)
continue
raw_formats = item.get("api_formats")
if isinstance(raw_formats, list):
normalized = _normalize_signature_list(raw_formats) or []
# 内容比较(而非引用比较),避免已迁移数据被无意义地重复 UPDATE
if set(normalized) != set(raw_formats):
changed = True
item = dict(item)
item["api_formats"] = normalized
new_mappings.append(item)
if not changed:
continue
connection.execute(
text("""
UPDATE models
SET provider_model_mappings = CAST(:provider_model_mappings AS json)
WHERE id = :id
"""),
{"id": row.id, "provider_model_mappings": _json_dumps(new_mappings)},
)
def migrate_dimension_collectors(connection) -> None:
"""迁移 dimension_collectors.api_format 为 signature keys(如果存在历史数据)。"""
if not table_exists("dimension_collectors"):
return
rows = connection.execute(text("""
SELECT id, api_format
FROM dimension_collectors
WHERE api_format IS NOT NULL
""")).fetchall()
for row in rows:
sig = _normalize_signature(row.api_format)
if not sig:
continue
connection.execute(
text("""
UPDATE dimension_collectors
SET api_format = :api_format
WHERE id = :id
"""),
{"id": row.id, "api_format": sig},
)
def upgrade() -> None:
if not table_exists("provider_endpoints"):
return
# ==================== provider_endpoints.api_family / endpoint_kind ====================
if not column_exists("provider_endpoints", "api_family"):
op.add_column("provider_endpoints", sa.Column("api_family", sa.String(50), nullable=True))
if not column_exists("provider_endpoints", "endpoint_kind"):
op.add_column(
"provider_endpoints", sa.Column("endpoint_kind", sa.String(50), nullable=True)
)
# ==================== idx_provider_family_kind ====================
if not index_exists("provider_endpoints", "idx_provider_family_kind"):
op.create_index(
"idx_provider_family_kind",
"provider_endpoints",
["provider_id", "api_family", "endpoint_kind"],
)
# ==================== data migrations (idempotent) ====================
conn = op.get_bind()
migrate_provider_endpoints(conn)
create_video_endpoints(conn)
if table_exists("provider_api_keys"):
migrate_provider_api_keys(conn)
migrate_allowed_api_formats(conn, table_name="users")
migrate_allowed_api_formats(conn, table_name="api_keys")
migrate_video_tasks(conn)
migrate_model_provider_mappings(conn)
migrate_dimension_collectors(conn)
def downgrade() -> None:
# Drop index/columns only; data changes are intentionally kept (safe rollback strategy).
if table_exists("provider_endpoints"):
if index_exists("provider_endpoints", "idx_provider_family_kind"):
op.drop_index("idx_provider_family_kind", table_name="provider_endpoints")
if column_exists("provider_endpoints", "endpoint_kind"):
op.drop_column("provider_endpoints", "endpoint_kind")
if column_exists("provider_endpoints", "api_family"):
op.drop_column("provider_endpoints", "api_family")
@@ -0,0 +1,329 @@
"""Add usage billing, video_tasks fields, gemini_file_mappings, provider format conversion, and indexes
Revision ID: a2f1b3c4d5e6
Revises: cf40e6a5c5b1
Create Date: 2026-02-01 12:00:00+00:00
Changes:
1. usage 表:
- 添加 billing_status (pending/settled/void),用于表示结算状态
- 添加 finalized_at,用于记录结算完成时间
- 添加 (provider_name, created_at) 和 (model, created_at) 索引
2. video_tasks 表:
- 添加 request_id(全局唯一),用于与 Usage/RequestCandidate 建立稳定关联
- 添加 short_id (Gemini-style short ID)
3. gemini_file_mappings 表:
- 创建新表用于文件映射
- 添加 source_hash 字段用于关联相同源文件
4. providers 表:
- 添加 enable_format_conversion 开关字段
5. request_candidates 表:
- 添加 created_at 索引
"""
from __future__ import annotations
import secrets
import string
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect, text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a2f1b3c4d5e6"
down_revision: Union[str, None] = "cf40e6a5c5b1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
inspector.clear_cache()
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
# Clear cached schema info to get fresh data
inspector.clear_cache()
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
inspector.clear_cache()
indexes = inspector.get_indexes(table_name)
return any(idx.get("name") == index_name for idx in indexes)
def unique_constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
inspector.clear_cache()
constraints = inspector.get_unique_constraints(table_name)
return any(c.get("name") == constraint_name for c in constraints)
def generate_short_id(length: int = 12) -> str:
"""Generate a Gemini-style short ID (lowercase letters + digits)"""
alphabet = string.ascii_lowercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def upgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name
# =========================================================================
# 1. usage 表: billing_status + finalized_at + 索引
# =========================================================================
if table_exists("usage"):
if not column_exists("usage", "billing_status"):
op.add_column(
"usage",
sa.Column(
"billing_status",
sa.String(20),
nullable=False,
server_default="settled",
),
)
if not column_exists("usage", "finalized_at"):
op.add_column(
"usage",
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
)
if not index_exists("usage", "idx_usage_billing_status"):
op.create_index("idx_usage_billing_status", "usage", ["billing_status"])
# (provider_name, created_at) — provider list / dashboard queries
if (
column_exists("usage", "provider_name")
and column_exists("usage", "created_at")
and not index_exists("usage", "idx_usage_provider_created")
):
op.create_index("idx_usage_provider_created", "usage", ["provider_name", "created_at"])
# (model, created_at) — model analytics / recent requests queries
if (
column_exists("usage", "model")
and column_exists("usage", "created_at")
and not index_exists("usage", "idx_usage_model_created")
):
op.create_index("idx_usage_model_created", "usage", ["model", "created_at"])
# =========================================================================
# 2. video_tasks 表: request_id + short_id
# =========================================================================
if table_exists("video_tasks"):
# --- request_id ---
if not column_exists("video_tasks", "request_id"):
op.add_column(
"video_tasks",
sa.Column("request_id", sa.String(100), nullable=True),
)
# 回填 request_id
if dialect == "postgresql":
op.execute("""
UPDATE video_tasks
SET request_id = COALESCE(request_metadata->>'request_id', id)
WHERE request_id IS NULL
""")
elif dialect == "sqlite":
op.execute("""
UPDATE video_tasks
SET request_id = COALESCE(json_extract(request_metadata, '$.request_id'), id)
WHERE request_id IS NULL
""")
else:
op.execute("""
UPDATE video_tasks
SET request_id = id
WHERE request_id IS NULL
""")
if dialect == "postgresql":
op.alter_column("video_tasks", "request_id", nullable=False)
if not index_exists("video_tasks", "idx_video_tasks_request_id"):
op.create_index("idx_video_tasks_request_id", "video_tasks", ["request_id"])
if not unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
op.create_unique_constraint(
"uq_video_tasks_request_id",
"video_tasks",
["request_id"],
)
# --- short_id ---
if not column_exists("video_tasks", "short_id"):
op.add_column(
"video_tasks",
sa.Column("short_id", sa.String(16), nullable=True),
)
# Populate existing rows with unique short_ids
result = bind.execute(text("SELECT id FROM video_tasks WHERE short_id IS NULL"))
for row in result:
short_id = generate_short_id()
bind.execute(
text("UPDATE video_tasks SET short_id = :short_id WHERE id = :id"),
{"short_id": short_id, "id": row[0]},
)
op.alter_column("video_tasks", "short_id", nullable=False)
op.create_index("ix_video_tasks_short_id", "video_tasks", ["short_id"], unique=True)
# =========================================================================
# 3. gemini_file_mappings 表
# =========================================================================
if not table_exists("gemini_file_mappings"):
op.create_table(
"gemini_file_mappings",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("file_name", sa.String(255), nullable=False, unique=True),
sa.Column(
"key_id",
sa.String(36),
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.String(36),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
),
sa.Column("display_name", sa.String(255), nullable=True),
sa.Column("mime_type", sa.String(100), nullable=True),
sa.Column("source_hash", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_gemini_file_mappings_id", "gemini_file_mappings", ["id"])
op.create_index(
"ix_gemini_file_mappings_file_name", "gemini_file_mappings", ["file_name"], unique=True
)
op.create_index("ix_gemini_file_mappings_key_id", "gemini_file_mappings", ["key_id"])
op.create_index("ix_gemini_file_mappings_user_id", "gemini_file_mappings", ["user_id"])
op.create_index("idx_gemini_file_mappings_expires", "gemini_file_mappings", ["expires_at"])
op.create_index(
"idx_gemini_file_mappings_source_hash", "gemini_file_mappings", ["source_hash"]
)
else:
# 表已存在,只添加 source_hash
if not column_exists("gemini_file_mappings", "source_hash"):
op.add_column(
"gemini_file_mappings",
sa.Column("source_hash", sa.String(64), nullable=True),
)
op.create_index(
"idx_gemini_file_mappings_source_hash",
"gemini_file_mappings",
["source_hash"],
)
# =========================================================================
# 4. providers 表: enable_format_conversion
# =========================================================================
if table_exists("providers") and not column_exists("providers", "enable_format_conversion"):
op.add_column(
"providers",
sa.Column(
"enable_format_conversion",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# =========================================================================
# 5. request_candidates 表: created_at 索引
# =========================================================================
if table_exists("request_candidates"):
if not index_exists("request_candidates", "idx_request_candidates_created_at"):
op.create_index(
"idx_request_candidates_created_at",
"request_candidates",
["created_at"],
unique=False,
)
def downgrade() -> None:
bind = op.get_bind()
dialect = bind.dialect.name
# =========================================================================
# 5. request_candidates 表回滚
# =========================================================================
if table_exists("request_candidates"):
if index_exists("request_candidates", "idx_request_candidates_created_at"):
op.drop_index("idx_request_candidates_created_at", table_name="request_candidates")
# =========================================================================
# 4. providers 表回滚
# =========================================================================
if table_exists("providers") and column_exists("providers", "enable_format_conversion"):
op.drop_column("providers", "enable_format_conversion")
# =========================================================================
# 3. gemini_file_mappings 表回滚
# =========================================================================
if table_exists("gemini_file_mappings"):
op.drop_index("idx_gemini_file_mappings_source_hash", table_name="gemini_file_mappings")
op.drop_index("idx_gemini_file_mappings_expires", table_name="gemini_file_mappings")
op.drop_index("ix_gemini_file_mappings_user_id", table_name="gemini_file_mappings")
op.drop_index("ix_gemini_file_mappings_key_id", table_name="gemini_file_mappings")
op.drop_index("ix_gemini_file_mappings_file_name", table_name="gemini_file_mappings")
op.drop_index("ix_gemini_file_mappings_id", table_name="gemini_file_mappings")
op.drop_table("gemini_file_mappings")
# =========================================================================
# 2. video_tasks 表回滚
# =========================================================================
if table_exists("video_tasks"):
# short_id
if column_exists("video_tasks", "short_id"):
if index_exists("video_tasks", "ix_video_tasks_short_id"):
op.drop_index("ix_video_tasks_short_id", table_name="video_tasks")
op.drop_column("video_tasks", "short_id")
# request_id
if column_exists("video_tasks", "request_id"):
if dialect == "postgresql":
if unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
op.drop_constraint("uq_video_tasks_request_id", "video_tasks", type_="unique")
if index_exists("video_tasks", "idx_video_tasks_request_id"):
op.drop_index("idx_video_tasks_request_id", table_name="video_tasks")
op.drop_column("video_tasks", "request_id")
# =========================================================================
# 1. usage 表回滚
# =========================================================================
if table_exists("usage"):
if index_exists("usage", "idx_usage_model_created"):
op.drop_index("idx_usage_model_created", table_name="usage")
if index_exists("usage", "idx_usage_provider_created"):
op.drop_index("idx_usage_provider_created", table_name="usage")
if index_exists("usage", "idx_usage_billing_status"):
op.drop_index("idx_usage_billing_status", table_name="usage")
if column_exists("usage", "finalized_at"):
op.drop_column("usage", "finalized_at")
if column_exists("usage", "billing_status"):
op.drop_column("usage", "billing_status")
@@ -0,0 +1,60 @@
"""Add video_duration_seconds to video_tasks and body_rules to provider_endpoints
Revision ID: b3c4d5e6f7a8
Revises: a2f1b3c4d5e6
Create Date: 2026-02-03 15:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b3c4d5e6f7a8"
down_revision: Union[str, None] = "a2f1b3c4d5e6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# 1. Add video_duration_seconds to video_tasks
if not _column_exists("video_tasks", "video_duration_seconds"):
op.add_column(
"video_tasks",
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
)
# 2. Add body_rules to provider_endpoints
# 请求体规则支持三种操作:
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
if not _column_exists("provider_endpoints", "body_rules"):
op.add_column(
"provider_endpoints",
sa.Column("body_rules", sa.JSON(), nullable=True),
)
def downgrade() -> None:
# Remove body_rules from provider_endpoints
if _column_exists("provider_endpoints", "body_rules"):
op.drop_column("provider_endpoints", "body_rules")
# Remove video_duration_seconds from video_tasks
if _column_exists("video_tasks", "video_duration_seconds"):
op.drop_column("video_tasks", "video_duration_seconds")
@@ -0,0 +1,347 @@
"""add_stats_hourly_and_daily_complete_flag
Revision ID: c4e8f9a1b2c3
Revises: b3c4d5e6f7a8
Create Date: 2026-02-04 12:00:00.000000
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c4e8f9a1b2c3"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = [idx["name"] for idx in inspector.get_indexes(table_name)]
return index_name in indexes
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
# Use information_schema for more reliable detection (inspector can have caching issues)
result = bind.execute(
sa.text(
"SELECT EXISTS ("
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :table AND column_name = :column"
")"
),
{"table": table_name, "column": column_name},
)
return bool(result.scalar())
def upgrade() -> None:
if _table_exists("stats_daily"):
if not _column_exists("stats_daily", "is_complete"):
op.add_column(
"stats_daily",
sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.execute("UPDATE stats_daily SET is_complete = true")
if not _column_exists("stats_daily", "aggregated_at"):
op.add_column(
"stats_daily",
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
)
if not _table_exists("stats_hourly"):
op.create_table(
"stats_hourly",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("success_requests", sa.Integer(), nullable=False),
sa.Column("error_requests", sa.Integer(), nullable=False),
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False),
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False),
sa.Column("total_cost", sa.Float(), nullable=False),
sa.Column("actual_total_cost", sa.Float(), nullable=False),
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
sa.Column("is_complete", sa.Boolean(), nullable=False),
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hour_utc", name="uq_stats_hourly_hour"),
)
op.create_index("idx_stats_hourly_hour", "stats_hourly", ["hour_utc"], unique=False)
if not _table_exists("stats_hourly_user"):
op.create_table(
"stats_hourly_user",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("success_requests", sa.Integer(), nullable=False),
sa.Column("error_requests", sa.Integer(), nullable=False),
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
sa.Column("total_cost", sa.Float(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hour_utc", "user_id", name="uq_stats_hourly_user"),
)
op.create_index(
"idx_stats_hourly_user_hour", "stats_hourly_user", ["hour_utc"], unique=False
)
op.create_index(
"idx_stats_hourly_user_user_hour",
"stats_hourly_user",
["user_id", "hour_utc"],
unique=False,
)
if not _table_exists("stats_hourly_model"):
op.create_table(
"stats_hourly_model",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("model", sa.String(length=100), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
sa.Column("total_cost", sa.Float(), nullable=False),
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hour_utc", "model", name="uq_stats_hourly_model"),
)
op.create_index(
"idx_stats_hourly_model_hour", "stats_hourly_model", ["hour_utc"], unique=False
)
op.create_index(
"idx_stats_hourly_model_model_hour",
"stats_hourly_model",
["model", "hour_utc"],
unique=False,
)
if not _table_exists("stats_hourly_provider"):
op.create_table(
"stats_hourly_provider",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
sa.Column("provider_name", sa.String(length=100), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False),
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
sa.Column("total_cost", sa.Float(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
)
op.create_index(
"idx_stats_hourly_provider_hour",
"stats_hourly_provider",
["hour_utc"],
unique=False,
)
if not _table_exists("stats_daily_api_key"):
op.create_table(
"stats_daily_api_key",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column(
"api_key_id",
sa.String(length=36),
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("success_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_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("total_cost", sa.Float(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
)
if _table_exists("stats_daily_api_key"):
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
op.create_index("idx_stats_daily_api_key_date", "stats_daily_api_key", ["date"])
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
op.create_index(
"idx_stats_daily_api_key_key_date",
"stats_daily_api_key",
["api_key_id", "date"],
)
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
op.create_index(
"idx_stats_daily_api_key_date_requests",
"stats_daily_api_key",
["date", "total_requests"],
)
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
op.create_index(
"idx_stats_daily_api_key_date_cost",
"stats_daily_api_key",
["date", "total_cost"],
)
if _table_exists("usage"):
if not _column_exists("usage", "error_category"):
op.add_column(
"usage",
sa.Column("error_category", sa.String(length=50), nullable=True),
)
op.create_index("idx_usage_error_category", "usage", ["error_category"], unique=False)
if _table_exists("stats_daily"):
for name in (
"p50_response_time_ms",
"p90_response_time_ms",
"p99_response_time_ms",
"p50_first_byte_time_ms",
"p90_first_byte_time_ms",
"p99_first_byte_time_ms",
):
if not _column_exists("stats_daily", name):
op.add_column("stats_daily", sa.Column(name, sa.Integer(), nullable=True))
if not _table_exists("stats_daily_error"):
op.create_table(
"stats_daily_error",
sa.Column("id", sa.String(length=36), primary_key=True),
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
sa.Column("error_category", sa.String(length=50), nullable=False),
sa.Column("provider_name", sa.String(length=100), nullable=True),
sa.Column("model", sa.String(length=100), nullable=True),
sa.Column("count", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("CURRENT_TIMESTAMP"),
),
sa.UniqueConstraint(
"date",
"error_category",
"provider_name",
"model",
name="uq_stats_daily_error",
),
)
if _table_exists("stats_daily_error"):
if not _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
op.create_index("idx_stats_daily_error_date", "stats_daily_error", ["date"])
if not _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
op.create_index(
"idx_stats_daily_error_category",
"stats_daily_error",
["date", "error_category"],
)
def downgrade() -> None:
if _table_exists("stats_daily_error"):
if _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
op.drop_index("idx_stats_daily_error_category", table_name="stats_daily_error")
if _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
op.drop_index("idx_stats_daily_error_date", table_name="stats_daily_error")
op.drop_table("stats_daily_error")
if _table_exists("stats_daily"):
for name in (
"p50_response_time_ms",
"p90_response_time_ms",
"p99_response_time_ms",
"p50_first_byte_time_ms",
"p90_first_byte_time_ms",
"p99_first_byte_time_ms",
):
if _column_exists("stats_daily", name):
op.drop_column("stats_daily", name)
if _table_exists("usage") and _column_exists("usage", "error_category"):
if _index_exists("usage", "idx_usage_error_category"):
op.drop_index("idx_usage_error_category", table_name="usage")
op.drop_column("usage", "error_category")
if _table_exists("stats_daily_api_key"):
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
op.drop_index("idx_stats_daily_api_key_date_cost", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
op.drop_index("idx_stats_daily_api_key_date_requests", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
op.drop_index("idx_stats_daily_api_key_key_date", table_name="stats_daily_api_key")
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
op.drop_index("idx_stats_daily_api_key_date", table_name="stats_daily_api_key")
op.drop_table("stats_daily_api_key")
if _table_exists("stats_hourly_provider"):
if _index_exists("stats_hourly_provider", "idx_stats_hourly_provider_hour"):
op.drop_index("idx_stats_hourly_provider_hour", table_name="stats_hourly_provider")
op.drop_table("stats_hourly_provider")
if _table_exists("stats_hourly_model"):
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_model_hour"):
op.drop_index("idx_stats_hourly_model_model_hour", table_name="stats_hourly_model")
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_hour"):
op.drop_index("idx_stats_hourly_model_hour", table_name="stats_hourly_model")
op.drop_table("stats_hourly_model")
if _table_exists("stats_hourly_user"):
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_user_hour"):
op.drop_index("idx_stats_hourly_user_user_hour", table_name="stats_hourly_user")
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_hour"):
op.drop_index("idx_stats_hourly_user_hour", table_name="stats_hourly_user")
op.drop_table("stats_hourly_user")
if _table_exists("stats_hourly"):
if _index_exists("stats_hourly", "idx_stats_hourly_hour"):
op.drop_index("idx_stats_hourly_hour", table_name="stats_hourly")
op.drop_table("stats_hourly")
if _table_exists("stats_daily"):
if _column_exists("stats_daily", "aggregated_at"):
op.drop_column("stats_daily", "aggregated_at")
if _column_exists("stats_daily", "is_complete"):
op.drop_column("stats_daily", "is_complete")
@@ -0,0 +1,205 @@
"""Add provider_type, upstream_metadata, oauth_invalid fields and expand string columns to TEXT
- Add providers.provider_type (String(20), server_default="custom")
- Add provider_api_keys.upstream_metadata (JSON, nullable)
- Add provider_api_keys.oauth_invalid_at (DateTime, nullable) - OAuth Token 失效时间
- Add provider_api_keys.oauth_invalid_reason (String(255), nullable) - OAuth Token 失效原因
- Expand multiple VARCHAR columns to TEXT for long values (OAuth tokens, LDAP DN, URLs, etc.)
Revision ID: b5c6d7e8f9a0
Revises: c4e8f9a1b2c3
Create Date: 2026-02-04 15:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b5c6d7e8f9a0"
down_revision: Union[str, None] = "c4e8f9a1b2c3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# 需要扩展为 TEXT 的列(表名, 列名, 原始类型长度)
COLUMNS_TO_EXPAND = [
("provider_api_keys", "api_key", 500), # OAuth tokens can be very long
(
"provider_api_keys",
"auth_config",
None,
), # 确保 auth_config 是 TEXT 类型(可能从 JSON 迁移过来)
("ldap_configs", "bind_dn", 255), # LDAP DN can be deeply nested
("ldap_configs", "base_dn", 255), # LDAP DN can be deeply nested
("ldap_configs", "user_search_filter", 500), # Complex LDAP filters
("oauth_providers", "client_id", 255), # Some OAuth providers use JWT client_id
]
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否已存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def is_sqlite() -> bool:
"""检查是否为 SQLite 数据库"""
bind = op.get_bind()
return bind.dialect.name == "sqlite"
def get_column_type(table_name: str, column_name: str) -> str | None:
"""获取列的数据类型"""
bind = op.get_bind()
inspector = inspect(bind)
for col in inspector.get_columns(table_name):
if col["name"] == column_name:
return str(col["type"]).upper()
return None
def expand_column_to_text(table_name: str, column_name: str, original_length: int | None) -> None:
"""将 VARCHAR 列扩展为 TEXT(兼容 SQLite)"""
if not table_exists(table_name):
return
if not column_exists(table_name, column_name):
return
# 检查当前列类型,如果已经是 TEXT 则跳过
col_type = get_column_type(table_name, column_name)
if col_type and "TEXT" in col_type:
return
# 如果是 JSON 类型(可能是历史遗留),先将 JSON 数据转为文本表示再变更类型
is_json_col = col_type and "JSON" in col_type
if is_json_col and not is_sqlite():
# PostgreSQL: 先用 CAST 把 JSON 值转为 TEXT,保留数据
op.execute(
sa.text(
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
f"TYPE TEXT USING {column_name}::TEXT"
)
)
return
if is_sqlite():
# SQLite 不支持直接 ALTER COLUMN,需要用 batch 模式
# batch 模式会自动处理 JSON->TEXT 的数据迁移
with op.batch_alter_table(table_name) as batch_op:
batch_op.alter_column(
column_name,
type_=sa.Text(),
existing_type=sa.String(original_length) if original_length else sa.Text(),
)
else:
op.alter_column(
table_name,
column_name,
type_=sa.Text(),
existing_type=sa.String(original_length) if original_length else sa.Text(),
existing_nullable=True,
)
def shrink_column_to_varchar(
table_name: str, column_name: str, target_length: int, nullable: bool = False
) -> None:
"""将 TEXT 列缩小为 VARCHAR(兼容 SQLite)
WARNING: 如果数据超过 target_length 会失败
"""
if not table_exists(table_name):
return
if not column_exists(table_name, column_name):
return
if is_sqlite():
with op.batch_alter_table(table_name) as batch_op:
batch_op.alter_column(
column_name,
type_=sa.String(target_length),
existing_type=sa.Text(),
existing_nullable=nullable,
)
else:
op.alter_column(
table_name,
column_name,
type_=sa.String(target_length),
existing_type=sa.Text(),
existing_nullable=nullable,
)
def upgrade() -> None:
# Add providers.provider_type
if not column_exists("providers", "provider_type"):
op.add_column(
"providers",
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
)
# Add provider_api_keys.upstream_metadata
if not column_exists("provider_api_keys", "upstream_metadata"):
op.add_column(
"provider_api_keys",
sa.Column("upstream_metadata", sa.JSON(), nullable=True),
)
# Add provider_api_keys.oauth_invalid_at
if not column_exists("provider_api_keys", "oauth_invalid_at"):
op.add_column(
"provider_api_keys",
sa.Column("oauth_invalid_at", sa.DateTime(timezone=True), nullable=True),
)
# Add provider_api_keys.oauth_invalid_reason
if not column_exists("provider_api_keys", "oauth_invalid_reason"):
op.add_column(
"provider_api_keys",
sa.Column("oauth_invalid_reason", sa.String(255), nullable=True),
)
# Expand VARCHAR columns to TEXT
for table_name, column_name, original_length in COLUMNS_TO_EXPAND:
expand_column_to_text(table_name, column_name, original_length)
def downgrade() -> None:
# Shrink TEXT columns back to VARCHAR
# WARNING: Downgrade may fail if any values exceed original length
for table_name, column_name, original_length in reversed(COLUMNS_TO_EXPAND):
# 跳过没有原始长度的列(如 auth_config,由其他迁移创建)
if original_length is None:
continue
shrink_column_to_varchar(table_name, column_name, original_length)
# Drop provider_api_keys.oauth_invalid_reason
if column_exists("provider_api_keys", "oauth_invalid_reason"):
op.drop_column("provider_api_keys", "oauth_invalid_reason")
# Drop provider_api_keys.oauth_invalid_at
if column_exists("provider_api_keys", "oauth_invalid_at"):
op.drop_column("provider_api_keys", "oauth_invalid_at")
# Drop provider_api_keys.upstream_metadata
if column_exists("provider_api_keys", "upstream_metadata"):
op.drop_column("provider_api_keys", "upstream_metadata")
# Drop providers.provider_type
if column_exists("providers", "provider_type"):
op.drop_column("providers", "provider_type")
@@ -0,0 +1,254 @@
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table (with manual fields)
Revision ID: e1b2c3d4f5a6
Revises: b5c6d7e8f9a0
Create Date: 2026-02-06 23:45:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect, text
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e1b2c3d4f5a6"
down_revision: str | None = "b5c6d7e8f9a0"
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()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
conn = op.get_bind()
# =========================================================================
# Part 1: Antigravity endpoint signature migration (gemini:cli -> gemini:chat)
# =========================================================================
# --- provider_endpoints ---
# Update only when there is no conflicting gemini:chat endpoint for the same provider
# (provider_endpoints has a unique constraint on (provider_id, api_format)).
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_format = 'gemini:chat',
api_family = 'gemini',
endpoint_kind = 'chat'
WHERE pe.api_format = 'gemini:cli'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND NOT EXISTS (
SELECT 1 FROM provider_endpoints pe2
WHERE pe2.provider_id = pe.provider_id
AND pe2.api_format = 'gemini:chat'
)
"""))
# Best-effort normalization for already-existing Antigravity gemini:chat endpoints.
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_family = 'gemini',
endpoint_kind = 'chat'
WHERE pe.api_format = 'gemini:chat'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
"""))
# --- provider_api_keys.api_formats (JSON array) ---
# Replace "gemini:cli" with "gemini:chat" in the JSON array for Antigravity keys.
# Uses text-level replace on the serialized JSON -- safe because the value is a
# simple string with no special characters that could cause ambiguous replacements.
conn.execute(text("""
UPDATE provider_api_keys pak
SET api_formats = replace(pak.api_formats::text, '"gemini:cli"', '"gemini:chat"')::json
WHERE pak.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND pak.api_formats IS NOT NULL
AND pak.api_formats::text LIKE '%"gemini:cli"%'
"""))
# =========================================================================
# Part 2: Create proxy_nodes table with manual proxy fields (idempotent)
# =========================================================================
# Create ENUM type (idempotent)
op.execute(
"DO $$ BEGIN "
"CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline'); "
"EXCEPTION WHEN duplicate_object THEN NULL; "
"END $$"
)
if table_exists("proxy_nodes"):
# Table already exists — ensure manual proxy columns are present
inspector = inspect(conn)
existing_columns = {c["name"] for c in inspector.get_columns("proxy_nodes")}
# ip 列扩容:手动节点的 ip 存储 "socks5://hostname" 形式,45 字符可能不够
ip_col = next((c for c in inspector.get_columns("proxy_nodes") if c["name"] == "ip"), None)
if ip_col and hasattr(ip_col["type"], "length") and (ip_col["type"].length or 0) < 512:
op.alter_column("proxy_nodes", "ip", type_=sa.String(512), existing_nullable=False)
manual_columns = [
("is_manual", sa.Boolean(), False, sa.text("false"), "是否为手动添加的代理节点"),
("proxy_url", sa.String(500), True, None, "手动节点的完整代理 URL"),
("proxy_username", sa.String(255), True, None, "手动节点的代理用户名"),
("proxy_password", sa.String(500), True, None, "手动节点的代理密码"),
]
for col_name, col_type, nullable, default, comment in manual_columns:
if col_name not in existing_columns:
op.add_column(
"proxy_nodes",
sa.Column(
col_name,
col_type, # type: ignore[arg-type]
nullable=nullable,
server_default=default,
comment=comment,
),
)
return
op.create_table(
"proxy_nodes",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("ip", sa.String(512), nullable=False),
sa.Column("port", sa.Integer(), nullable=False),
sa.Column("region", sa.String(100), nullable=True),
sa.Column(
"status",
postgresql.ENUM(
"online",
"unhealthy",
"offline",
name="proxynodestatus",
create_type=False,
),
nullable=False,
server_default=sa.text("'online'"),
),
sa.Column(
"registered_by",
sa.String(36),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("heartbeat_interval", sa.Integer(), nullable=False, server_default=sa.text("30")),
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
# --- Manual proxy node fields ---
sa.Column(
"is_manual",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
comment="是否为手动添加的代理节点",
),
sa.Column(
"proxy_url",
sa.String(500),
nullable=True,
comment="手动节点的完整代理 URL",
),
sa.Column(
"proxy_username",
sa.String(255),
nullable=True,
comment="手动节点的代理用户名",
),
sa.Column(
"proxy_password",
sa.String(500),
nullable=True,
comment="手动节点的代理密码",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
),
sa.UniqueConstraint("ip", "port", name="uq_proxy_node_ip_port"),
)
def downgrade() -> None:
conn = op.get_bind()
# =========================================================================
# Part 2 rollback: Drop proxy_nodes table (and manual columns if present)
# =========================================================================
if table_exists("proxy_nodes"):
op.drop_table("proxy_nodes")
# Best-effort: drop type (only used by proxy_nodes)
op.execute("DROP TYPE IF EXISTS proxynodestatus")
# =========================================================================
# Part 1 rollback: Revert Antigravity endpoint signature (gemini:chat -> gemini:cli)
# =========================================================================
# --- provider_endpoints ---
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_format = 'gemini:cli',
api_family = 'gemini',
endpoint_kind = 'cli'
WHERE pe.api_format = 'gemini:chat'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND NOT EXISTS (
SELECT 1 FROM provider_endpoints pe2
WHERE pe2.provider_id = pe.provider_id
AND pe2.api_format = 'gemini:cli'
)
"""))
# Best-effort normalization for already-existing Antigravity gemini:cli endpoints.
conn.execute(text("""
UPDATE provider_endpoints pe
SET
api_family = 'gemini',
endpoint_kind = 'cli'
WHERE pe.api_format = 'gemini:cli'
AND pe.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
"""))
# --- provider_api_keys.api_formats (JSON array) ---
conn.execute(text("""
UPDATE provider_api_keys pak
SET api_formats = replace(pak.api_formats::text, '"gemini:chat"', '"gemini:cli"')::json
WHERE pak.provider_id IN (
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
)
AND pak.api_formats IS NOT NULL
AND pak.api_formats::text LIKE '%"gemini:chat"%'
"""))
@@ -0,0 +1,61 @@
"""Add remote_config and config_version to proxy_nodes
Revision ID: 3aff3ffc4a0e
Revises: e1b2c3d4f5a6
Create Date: 2026-02-07 15: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 = "3aff3ffc4a0e"
down_revision: str | None = "e1b2c3d4f5a6"
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("proxy_nodes", "remote_config"):
op.add_column(
"proxy_nodes",
sa.Column(
"remote_config",
sa.JSON(),
nullable=True,
comment="管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)",
),
)
if not column_exists("proxy_nodes", "config_version"):
op.add_column(
"proxy_nodes",
sa.Column(
"config_version",
sa.Integer(),
nullable=False,
server_default="0",
comment="远程配置版本号,每次更新 +1",
),
)
def downgrade() -> None:
if column_exists("proxy_nodes", "config_version"):
op.drop_column("proxy_nodes", "config_version")
if column_exists("proxy_nodes", "remote_config"):
op.drop_column("proxy_nodes", "remote_config")
@@ -0,0 +1,61 @@
"""Add tls_enabled and tls_cert_fingerprint to proxy_nodes
Revision ID: 4b5c6d7e8f9a
Revises: 3aff3ffc4a0e
Create Date: 2026-02-07 18: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 = "4b5c6d7e8f9a"
down_revision: str | None = "3aff3ffc4a0e"
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("proxy_nodes", "tls_enabled"):
op.add_column(
"proxy_nodes",
sa.Column(
"tls_enabled",
sa.Boolean(),
nullable=False,
server_default="false",
comment="是否启用 TLS 加密",
),
)
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
op.add_column(
"proxy_nodes",
sa.Column(
"tls_cert_fingerprint",
sa.String(128),
nullable=True,
comment="TLS 证书 SHA-256 指纹(hex)",
),
)
def downgrade() -> None:
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
if column_exists("proxy_nodes", "tls_enabled"):
op.drop_column("proxy_nodes", "tls_enabled")
@@ -0,0 +1,60 @@
"""Add hardware_info and estimated_max_concurrency to proxy_nodes
Revision ID: 5c6d7e8f9a0b
Revises: 4b5c6d7e8f9a
Create Date: 2026-02-08 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 = "5c6d7e8f9a0b"
down_revision: str | None = "4b5c6d7e8f9a"
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("proxy_nodes", "hardware_info"):
op.add_column(
"proxy_nodes",
sa.Column(
"hardware_info",
sa.JSON(),
nullable=True,
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
),
)
if not column_exists("proxy_nodes", "estimated_max_concurrency"):
op.add_column(
"proxy_nodes",
sa.Column(
"estimated_max_concurrency",
sa.Integer(),
nullable=True,
comment="基于硬件估算的最大并发连接数",
),
)
def downgrade() -> None:
if column_exists("proxy_nodes", "estimated_max_concurrency"):
op.drop_column("proxy_nodes", "estimated_max_concurrency")
if column_exists("proxy_nodes", "hardware_info"):
op.drop_column("proxy_nodes", "hardware_info")
@@ -0,0 +1,47 @@
"""Add proxy column to provider_api_keys for per-key proxy configuration
Revision ID: 6d7e8f9a0b1c
Revises: 5c6d7e8f9a0b
Create Date: 2026-02-08 15: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 = "6d7e8f9a0b1c"
down_revision: str | None = "5c6d7e8f9a0b"
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", "proxy"):
op.add_column(
"provider_api_keys",
sa.Column(
"proxy",
sa.JSON(),
nullable=True,
comment="Key 级别代理配置(覆盖 Provider 级别代理),如 {node_id, enabled}",
),
)
def downgrade() -> None:
if column_exists("provider_api_keys", "proxy"):
op.drop_column("provider_api_keys", "proxy")
@@ -0,0 +1,46 @@
"""Add provider_request_body and client_response_body columns to usage table
Revision ID: 7e8f9a0b1c2d
Revises: 6d7e8f9a0b1c
Create Date: 2026-02-20 18:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy import text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7e8f9a0b1c2d"
down_revision: str | None = "6d7e8f9a0b1c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# Use PostgreSQL native IF NOT EXISTS to avoid duplicate-column races
# 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"))
conn.execute(
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body_compressed BYTEA")
)
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body JSON"))
conn.execute(
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body_compressed BYTEA")
)
def downgrade() -> None:
conn = op.get_bind()
for col in (
"client_response_body_compressed",
"client_response_body",
"provider_request_body_compressed",
"provider_request_body",
):
conn.execute(text(f"ALTER TABLE usage DROP COLUMN IF EXISTS {col}"))
@@ -0,0 +1,91 @@
"""Add api_family and endpoint_kind columns to usage table
Revision ID: 8f9a0b1c2d3e
Revises: 7e8f9a0b1c2d
Create Date: 2026-02-21 15:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect, text
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8f9a0b1c2d3e"
down_revision: str | None = "7e8f9a0b1c2d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Usage 表新增列
NEW_COLUMNS = [
("api_family", sa.String(50)),
("endpoint_kind", sa.String(50)),
("provider_api_family", sa.String(50)),
("provider_endpoint_kind", sa.String(50)),
]
# 新增索引
NEW_INDEXES = [
("idx_usage_api_family", "usage", ["api_family"]),
("idx_usage_endpoint_kind", "usage", ["endpoint_kind"]),
("idx_usage_family_kind", "usage", ["api_family", "endpoint_kind"]),
]
def upgrade() -> None:
conn = op.get_bind()
# 使用 PostgreSQL 原生 IF NOT EXISTS,比 inspect 更可靠(避免同一事务内缓存问题)
col_definitions = {
"api_family": "VARCHAR(50)",
"endpoint_kind": "VARCHAR(50)",
"provider_api_family": "VARCHAR(50)",
"provider_endpoint_kind": "VARCHAR(50)",
}
for col_name, col_type_sql in col_definitions.items():
conn.execute(text(f"ALTER TABLE usage ADD COLUMN IF NOT EXISTS {col_name} {col_type_sql}"))
# 数据迁移:从 api_format 解析 api_family + endpoint_kind
conn.execute(text("""
UPDATE usage SET
api_family = lower(split_part(api_format, ':', 1)),
endpoint_kind = lower(split_part(api_format, ':', 2))
WHERE api_format IS NOT NULL
AND api_format LIKE '%%:%%'
AND api_family IS NULL
"""))
conn.execute(text("""
UPDATE usage SET
provider_api_family = lower(split_part(endpoint_api_format, ':', 1)),
provider_endpoint_kind = lower(split_part(endpoint_api_format, ':', 2))
WHERE endpoint_api_format IS NOT NULL
AND endpoint_api_format LIKE '%%:%%'
AND provider_api_family IS NULL
"""))
# 创建索引
inspector = inspect(conn)
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
for idx_name, table, columns in NEW_INDEXES:
if idx_name not in existing_indexes:
op.create_index(idx_name, table, columns)
def downgrade() -> None:
conn = op.get_bind()
inspector = inspect(conn)
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
for idx_name, _, _ in reversed(NEW_INDEXES):
if idx_name in existing_indexes:
op.drop_index(idx_name, table_name="usage")
existing_columns = {col["name"] for col in inspector.get_columns("usage")}
for col_name, _ in reversed(NEW_COLUMNS):
if col_name in existing_columns:
op.drop_column("usage", col_name)
@@ -0,0 +1,106 @@
"""Add tunnel mode fields and remove IP forwarding fields
Revision ID: 9a0b1c2d3e4f
Revises: 8f9a0b1c2d3e
Create Date: 2026-02-24 17: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: str = "9a0b1c2d3e4f"
down_revision: str | None = "8f9a0b1c2d3e"
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:
# 添加 tunnel 模式字段
if not column_exists("proxy_nodes", "tunnel_mode"):
op.add_column(
"proxy_nodes",
sa.Column(
"tunnel_mode",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
comment="是否使用 WebSocket 隧道模式",
),
)
if not column_exists("proxy_nodes", "tunnel_connected"):
op.add_column(
"proxy_nodes",
sa.Column(
"tunnel_connected",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
comment="隧道是否已连接",
),
)
if not column_exists("proxy_nodes", "tunnel_connected_at"):
op.add_column(
"proxy_nodes",
sa.Column(
"tunnel_connected_at",
sa.DateTime(timezone=True),
nullable=True,
comment="隧道最近一次建立时间",
),
)
# tunnel 模式节点不需要 port,将其置零
op.execute("UPDATE proxy_nodes SET port = 0 WHERE tunnel_mode = true")
# 移除旧的 IP 转发字段
if column_exists("proxy_nodes", "tls_enabled"):
op.drop_column("proxy_nodes", "tls_enabled")
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
def downgrade() -> None:
# 恢复 IP 转发字段
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
op.add_column(
"proxy_nodes",
sa.Column(
"tls_cert_fingerprint",
sa.String(128),
nullable=True,
comment="TLS 证书 SHA-256 指纹(hex)",
),
)
if not column_exists("proxy_nodes", "tls_enabled"):
op.add_column(
"proxy_nodes",
sa.Column(
"tls_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
comment="是否启用 TLS 加密",
),
)
# 移除 tunnel 模式字段
if column_exists("proxy_nodes", "tunnel_connected_at"):
op.drop_column("proxy_nodes", "tunnel_connected_at")
if column_exists("proxy_nodes", "tunnel_connected"):
op.drop_column("proxy_nodes", "tunnel_connected")
if column_exists("proxy_nodes", "tunnel_mode"):
op.drop_column("proxy_nodes", "tunnel_mode")
@@ -0,0 +1,224 @@
"""Add cache_creation columns, clean up capability settings, add user_model_usage_counts,
enforce global_model_id NOT NULL
1. Add cache_creation_input_tokens_5m and cache_creation_input_tokens_1h to usage table.
2. Clean up cache_1h/context_1m/gemini_files from user-configurable settings
(now auto-detected via REQUEST_PARAM mode).
3. Create user_model_usage_counts table for per-user per-model atomic usage counters.
4. Enforce models.global_model_id NOT NULL (delete orphan models without global model).
Revision ID: b2c3d4e5f6a7
Revises: 9a0b1c2d3e4f
Create Date: 2026-02-28 14:00:00.000000
"""
from __future__ import annotations
import json
import uuid
from collections.abc import Sequence
from datetime import datetime, timezone
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "b2c3d4e5f6a7"
down_revision: str | None = "9a0b1c2d3e4f"
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()
insp = inspect(bind)
columns = [c["name"] for c in insp.get_columns(table_name)]
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:
# --- 1. Add cache_creation columns ---
if not column_exists("usage", "cache_creation_input_tokens_5m"):
op.add_column(
"usage",
sa.Column(
"cache_creation_input_tokens_5m",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
comment="5min TTL cache creation input tokens",
),
)
if not column_exists("usage", "cache_creation_input_tokens_1h"):
op.add_column(
"usage",
sa.Column(
"cache_creation_input_tokens_1h",
sa.Integer(),
nullable=False,
server_default=sa.text("0"),
comment="1h TTL cache creation input tokens",
),
)
# --- 2. Clean up stale capability settings (pure Python, DB-agnostic) ---
stale_keys = {"cache_1h", "context_1m", "gemini_files"}
conn = op.get_bind()
# ApiKey.force_capabilities: dict-like JSON, remove stale keys
rows = conn.execute(
sa.text("SELECT id, force_capabilities FROM api_keys WHERE force_capabilities IS NOT NULL")
).fetchall()
for row in rows:
raw = row[1]
if raw is None:
continue
data = raw if isinstance(raw, dict) else json.loads(raw)
cleaned = {k: v for k, v in data.items() if k not in stale_keys}
new_val = json.dumps(cleaned) if cleaned else None
conn.execute(
sa.text("UPDATE api_keys SET force_capabilities = :val WHERE id = :id"),
{"val": new_val, "id": row[0]},
)
# User.model_capability_settings: nested dict {model_key: {cap: val}}, remove stale keys
rows = conn.execute(
sa.text(
"SELECT id, model_capability_settings FROM users"
" WHERE model_capability_settings IS NOT NULL"
)
).fetchall()
for row in rows:
raw = row[1]
if raw is None:
continue
data = raw if isinstance(raw, dict) else json.loads(raw)
cleaned = {}
for model_key, caps in data.items():
cap_cleaned = {k: v for k, v in caps.items() if k not in stale_keys}
if cap_cleaned:
cleaned[model_key] = cap_cleaned
new_val = json.dumps(cleaned) if cleaned else None
conn.execute(
sa.text("UPDATE users SET model_capability_settings = :val WHERE id = :id"),
{"val": new_val, "id": row[0]},
)
# GlobalModel.supported_capabilities: JSON array, remove stale entries
rows = conn.execute(
sa.text(
"SELECT id, supported_capabilities FROM global_models"
" WHERE supported_capabilities IS NOT NULL"
)
).fetchall()
for row in rows:
raw = row[1]
if raw is None:
continue
data = raw if isinstance(raw, list) else json.loads(raw)
cleaned = [c for c in data if c not in stale_keys]
new_val = json.dumps(cleaned) if cleaned else None
conn.execute(
sa.text("UPDATE global_models SET supported_capabilities = :val WHERE id = :id"),
{"val": new_val, "id": row[0]},
)
# --- 3. Create user_model_usage_counts table ---
if not table_exists("user_model_usage_counts"):
op.create_table(
"user_model_usage_counts",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column(
"user_id",
sa.String(36),
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(
"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.UniqueConstraint("user_id", "model", name="uq_user_model_usage_count"),
)
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 (truncate first for idempotency)
conn.execute(sa.text("DELETE FROM user_model_usage_counts"))
rows = conn.execute(
sa.text(
"SELECT user_id, model, COUNT(*) AS cnt FROM usage"
" WHERE user_id IS NOT NULL GROUP BY user_id, model"
)
).fetchall()
now = datetime.now(timezone.utc)
for row in rows:
conn.execute(
sa.text(
"INSERT INTO user_model_usage_counts"
" (id, user_id, model, usage_count, created_at, updated_at)"
" VALUES (:id, :user_id, :model, :cnt, :now, :now)"
),
{
"id": str(uuid.uuid4()),
"user_id": row[0],
"model": row[1],
"cnt": row[2],
"now": now,
},
)
# --- 4. Enforce models.global_model_id NOT NULL ---
conn = op.get_bind()
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:
# Revert models.global_model_id to nullable
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
if table_exists("user_model_usage_counts"):
if index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
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
if column_exists("usage", "cache_creation_input_tokens_1h"):
op.drop_column("usage", "cache_creation_input_tokens_1h")
if column_exists("usage", "cache_creation_input_tokens_5m"):
op.drop_column("usage", "cache_creation_input_tokens_5m")
# capability settings cleanup is not reversible
@@ -0,0 +1,153 @@
"""proxy_node_metrics_and_events
Revision ID: 48afe197cc15
Revises: b2c3d4e5f6a7
Create Date: 2026-02-28 04:33:11.201185+00:00
"""
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision = "48afe197cc15"
down_revision = "b2c3d4e5f6a7"
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
columns = [c["name"] for c in insp.get_columns(table_name)]
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 _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:
# 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: 新增错误指标字段
if not _column_exists("proxy_nodes", "failed_requests"):
op.add_column(
"proxy_nodes",
sa.Column(
"failed_requests",
sa.BigInteger(),
nullable=False,
server_default="0",
comment="累计失败请求数",
),
)
if not _column_exists("proxy_nodes", "dns_failures"):
op.add_column(
"proxy_nodes",
sa.Column(
"dns_failures",
sa.BigInteger(),
nullable=False,
server_default="0",
comment="累计 DNS 失败数",
),
)
if not _column_exists("proxy_nodes", "stream_errors"):
op.add_column(
"proxy_nodes",
sa.Column(
"stream_errors",
sa.BigInteger(),
nullable=False,
server_default="0",
comment="累计流错误数",
),
)
# proxy_node_events: 连接事件表
if not _table_exists("proxy_node_events"):
op.create_table(
"proxy_node_events",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("node_id", sa.String(length=36), nullable=False),
sa.Column(
"event_type",
sa.String(length=20),
nullable=False,
comment="事件类型: connected, disconnected, error",
),
sa.Column(
"detail",
sa.String(length=500),
nullable=True,
comment="事件详情(如断开原因)",
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["node_id"], ["proxy_nodes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_proxy_node_events_node_created",
"proxy_node_events",
["node_id", "created_at"],
)
op.create_index(
op.f("ix_proxy_node_events_node_id"),
"proxy_node_events",
["node_id"],
)
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"):
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_table("proxy_node_events")
if _column_exists("proxy_nodes", "stream_errors"):
op.drop_column("proxy_nodes", "stream_errors")
if _column_exists("proxy_nodes", "dns_failures"):
op.drop_column("proxy_nodes", "dns_failures")
if _column_exists("proxy_nodes", "failed_requests"):
op.drop_column("proxy_nodes", "failed_requests")
@@ -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",
)

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