Compare commits

..
160 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
AAEE86 258be3b640 fix: 处理客户端断开连接情况,以防止出现虚假的系统错误报告
捕获 starlette.requests.ClientDisconnect 异常,避免客户端主动断开连接时被当作系统未知错误(500)处理。
- 在读取请求体阶段捕获 ClientDisconnect,返回 499 状态码
- 在 adapter.handle 阶段捕获 ClientDisconnect,记录审计日志并返回 499
- 日志级别从 ERROR 降为 WARNING,减少误报告警噪音
2026-03-09 10:38:14 +08:00
658 changed files with 69560 additions and 27960 deletions
+42
View File
@@ -49,6 +49,40 @@ ADMIN_PASSWORD=admin123456
# 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
@@ -60,6 +94,14 @@ ADMIN_PASSWORD=admin123456
# 默认: * (允许所有源)
# 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
+3 -84
View File
@@ -7,12 +7,6 @@ on:
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
GHCR_IMAGE: fawney19/aether-hub
DOCKERHUB_IMAGE: fawney19/aether-hub
jobs:
build:
@@ -30,7 +24,7 @@ jobs:
use_cross: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -65,7 +59,7 @@ jobs:
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: aether-hub-${{ matrix.name }}
path: aether-hub-*.tar.gz
@@ -77,7 +71,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
merge-multiple: true
path: artifacts
@@ -95,78 +89,3 @@ jobs:
artifacts/aether-hub-*
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@v4
- name: Download Linux artifacts
uses: actions/download-artifact@v4
with:
pattern: aether-hub-linux-*
merge-multiple: true
path: artifacts
- name: Prepare binaries
run: |
mkdir -p aether-hub/build/linux-amd64 aether-hub/build/linux-arm64
tar xzf artifacts/aether-hub-linux-amd64.tar.gz -C aether-hub/build/linux-amd64
tar xzf artifacts/aether-hub-linux-arm64.tar.gz -C aether-hub/build/linux-arm64
- name: Generate CI Dockerfile
run: |
cat > aether-hub/Dockerfile.ci << 'EOF'
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-hub /usr/local/bin/aether-hub
EXPOSE 8085
ENTRYPOINT ["/usr/local/bin/aether-hub"]
CMD ["--bind", "0.0.0.0:8085"]
EOF
- 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=hub-v(.*),group=1
type=match,pattern=hub-v(\d+\.\d+),group=1
type=sha,prefix=
flavor: |
latest=auto
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./aether-hub
file: ./aether-hub/Dockerfile.ci
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
+7 -7
View File
@@ -44,7 +44,7 @@ jobs:
use_cross: false
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
@@ -87,7 +87,7 @@ jobs:
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: aether-proxy-${{ matrix.name }}
path: |
@@ -101,7 +101,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
merge-multiple: true
path: artifacts
@@ -125,10 +125,10 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Download Linux artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: aether-proxy-linux-*
merge-multiple: true
@@ -174,7 +174,7 @@ jobs:
latest=auto
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: ./aether-proxy
push: true
@@ -187,7 +187,7 @@ jobs:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: master
+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
+6 -6
View File
@@ -30,7 +30,7 @@ jobs:
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
@@ -108,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
@@ -164,7 +164,7 @@ 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
@@ -204,7 +204,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
@@ -271,7 +271,7 @@ jobs:
- name: Build and push app image (amd64)
id: build-amd64
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
@@ -287,7 +287,7 @@ jobs:
- name: Build and push app image (arm64)
id: build-arm64
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.app
+2
View File
@@ -2,6 +2,7 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=python
# AI Assistant Configuration
.codex/
.claude/
.serena/
.gemini*/
@@ -203,6 +204,7 @@ logs/
# Git backup
.git.backup/
.worktrees/
# Database backups
backups/
+65 -4
View File
@@ -25,7 +25,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
nginx \
supervisor \
libpq5 \
curl
curl \
libjemalloc2
RUN set -eux; \
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
[ -n "$jemalloc_path" ]; \
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
# 只复制需要的 Python 可执行文件
@@ -85,6 +90,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -124,6 +134,25 @@ RUN printf '%s\n' \
' 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;' \
' }' \
@@ -136,12 +165,22 @@ RUN printf '%s\n' \
' 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 Connection "";' \
' 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 头,防止泄露给上游 AI 提供商' \
' # 剥离 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 "";' \
@@ -150,6 +189,7 @@ RUN printf '%s\n' \
' 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;' \
@@ -169,6 +209,25 @@ RUN printf '%s\n' \
' 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)' \
@@ -199,7 +258,7 @@ RUN printf '%s\n' \
'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' \
'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' \
@@ -220,6 +279,8 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \
GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000
+89 -16
View File
@@ -20,6 +20,11 @@ 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 \
@@ -29,7 +34,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
nginx \
supervisor \
libpq5 \
curl
curl \
libjemalloc2
RUN set -eux; \
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
[ -n "$jemalloc_path" ]; \
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
@@ -39,9 +49,20 @@ 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 下载)
# 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}"; \
@@ -54,18 +75,14 @@ RUN set -eux; \
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"; \
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; \
@@ -95,6 +112,11 @@ RUN printf '%s\n' \
' "" $remote_addr;' \
'}' \
'' \
'map $http_upgrade $connection_upgrade {' \
' default upgrade;' \
' "" "";' \
'}' \
'' \
'server {' \
' listen 80;' \
' server_name _;' \
@@ -134,6 +156,25 @@ RUN printf '%s\n' \
' 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;' \
' }' \
@@ -146,12 +187,22 @@ RUN printf '%s\n' \
' 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 Connection "";' \
' 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 头,防止泄露给上游 AI 提供商' \
' # 剥离 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 "";' \
@@ -160,6 +211,7 @@ RUN printf '%s\n' \
' 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;' \
@@ -179,6 +231,25 @@ RUN printf '%s\n' \
' 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)' \
@@ -210,7 +281,7 @@ RUN printf '%s\n' \
'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' \
'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' \
@@ -234,6 +305,8 @@ ENV PYTHONUNBUFFERED=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
PORT=8084 \
GUNICORN_WORKERS=2 \
MAX_REQUESTS=4000
+2 -1
View File
@@ -34,7 +34,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
## 部署
### Docker Compose(推荐:本地构建镜像)
### Docker Compose(推荐:预构建镜像)
```bash
# 1. 克隆代码
@@ -64,6 +64,7 @@ cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署 / 更新(自动构建、启动、迁移)
git pull
./deploy.sh
```
+794 -11
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "aether-hub"
version = "0.1.4"
version = "0.2.0"
edition = "2021"
description = "Tunnel Hub for Aether - frame router between workers and proxies"
@@ -16,6 +16,10 @@ 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
+5
View File
@@ -28,6 +28,11 @@ cd /path/to/Aether
- `--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` 固定版本)。
+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()
))
}
}
}
+637 -477
View File
File diff suppressed because it is too large Load Diff
+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")))
}
+44 -36
View File
@@ -1,20 +1,23 @@
mod control_plane;
mod hub;
mod local_relay;
mod protocol;
mod proxy_conn;
mod worker_conn;
use std::sync::Arc;
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;
use axum::routing::{get, post};
use axum::Router;
use clap::Parser;
use tracing::{info, warn};
use crate::hub::HubRouter;
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")]
@@ -23,14 +26,10 @@ struct Args {
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
bind: String,
/// Proxy-side idle timeout in seconds
#[arg(long, default_value_t = 90, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
/// 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,
/// Worker-side idle timeout in seconds
#[arg(long, default_value_t = 60, env = "TUNNEL_HUB_WORKER_IDLE_TIMEOUT")]
worker_idle_timeout: u64,
/// Ping interval in seconds (for both sides)
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
ping_interval: u64,
@@ -38,15 +37,29 @@ struct Args {
/// 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)]
struct AppState {
hub: Arc<HubRouter>,
proxy_idle_timeout: Duration,
worker_idle_timeout: Duration,
ping_interval: Duration,
max_streams: usize,
pub struct AppState {
pub hub: std::sync::Arc<HubRouter>,
pub proxy_conn_cfg: ConnConfig,
pub max_streams: usize,
}
#[tokio::main]
@@ -61,12 +74,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let hub = HubRouter::new();
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_idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
worker_idle_timeout: Duration::from_secs(args.worker_idle_timeout),
ping_interval: Duration::from_secs(args.ping_interval),
proxy_conn_cfg: ConnConfig {
ping_interval,
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
outbound_queue_capacity,
},
max_streams: args.max_streams,
};
@@ -74,13 +91,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/health", get(health))
.route("/stats", get(stats))
.route("/proxy", get(ws_proxy))
.route("/worker", get(ws_worker))
.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).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
@@ -139,21 +160,8 @@ async fn ws_proxy(
node_id,
node_name,
max_streams,
state.ping_interval,
state.proxy_idle_timeout,
state.proxy_conn_cfg,
)
})
.into_response()
}
async fn ws_worker(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.max_frame_size(64 * 1024 * 1024)
.on_upgrade(move |socket| {
worker_conn::handle_worker_connection(
socket,
state.hub,
state.ping_interval,
state.worker_idle_timeout,
)
})
}
+79 -135
View File
@@ -22,8 +22,6 @@ pub const PONG: u8 = 0x11;
pub const GOAWAY: u8 = 0x12;
pub const HEARTBEAT_DATA: u8 = 0x13;
pub const HEARTBEAT_ACK: u8 = 0x14;
pub const NODE_STATUS: u8 = 0x15;
// Flags
pub const FLAG_END_STREAM: u8 = 0x01;
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
@@ -50,162 +48,89 @@ impl FrameHeader {
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
})
}
}
/// Check if this is a stream-terminating frame
#[inline]
pub fn is_stream_terminal(&self) -> bool {
self.msg_type == STREAM_END || self.msg_type == STREAM_ERROR
#[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)]
pub struct RequestHeadersExtracted {
pub node_id: String,
pub rebuilt_frame: Vec<u8>,
#[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> {
let payload = msg.as_bytes();
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&stream_id.to_be_bytes());
buf.push(STREAM_ERROR);
buf.push(0); // flags
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
buf
}
/// Encode a NODE_STATUS frame (stream_id=0, Hub-generated)
pub fn encode_node_status(node_id: &str, connected: bool, conn_count: usize) -> Vec<u8> {
let payload = serde_json::json!({
"node_id": node_id,
"connected": connected,
"conn_count": conn_count,
});
let payload_bytes = payload.to_string().into_bytes();
let mut buf = Vec::with_capacity(HEADER_SIZE + payload_bytes.len());
buf.extend_from_slice(&0u32.to_be_bytes()); // stream_id = 0
buf.push(NODE_STATUS);
buf.push(0); // flags
buf.extend_from_slice(&(payload_bytes.len() as u32).to_be_bytes());
buf.extend_from_slice(&payload_bytes);
buf
encode_frame(stream_id, STREAM_ERROR, 0, msg.as_bytes())
}
/// Encode a PING frame (stream_id=0)
pub fn encode_ping() -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE);
buf.extend_from_slice(&0u32.to_be_bytes());
buf.push(PING);
buf.push(0);
buf.extend_from_slice(&0u32.to_be_bytes());
buf
encode_frame(0, PING, 0, &[])
}
/// Encode a PONG frame (stream_id=0, echo payload)
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
buf.extend_from_slice(&0u32.to_be_bytes());
buf.push(PONG);
buf.push(0);
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
buf
encode_frame(0, PONG, 0, payload)
}
/// Encode a GOAWAY frame (stream_id=0)
pub fn encode_goaway() -> Vec<u8> {
let mut buf = Vec::with_capacity(HEADER_SIZE);
buf.extend_from_slice(&0u32.to_be_bytes());
buf.push(GOAWAY);
buf.push(0);
buf.extend_from_slice(&0u32.to_be_bytes());
buf
}
/// Rewrite the stream_id in raw frame bytes (first 4 bytes) -- near zero-copy
#[inline]
pub fn rewrite_stream_id(data: &mut [u8], new_stream_id: u32) {
let bytes = new_stream_id.to_be_bytes();
data[0] = bytes[0];
data[1] = bytes[1];
data[2] = bytes[2];
data[3] = bytes[3];
}
/// Get the payload portion of a raw frame (after the 10-byte header)
#[inline]
pub fn frame_payload(data: &[u8]) -> &[u8] {
if data.len() > HEADER_SIZE {
&data[HEADER_SIZE..]
} else {
&[]
}
}
/// Parse REQUEST_HEADERS payload, extract `node_id`, strip it from JSON,
/// and rebuild a new REQUEST_HEADERS frame with `new_stream_id`.
///
/// If the source frame is gzip-compressed, this function will decode it first,
/// then try to re-encode with gzip (only keeps compression when payload shrinks).
pub fn rebuild_request_headers_without_node_id(
data: &[u8],
new_stream_id: u32,
) -> Result<RequestHeadersExtracted, String> {
let header = FrameHeader::parse(data).ok_or_else(|| "invalid frame header".to_string())?;
if header.msg_type != REQUEST_HEADERS {
return Err("frame is not REQUEST_HEADERS".to_string());
}
let payload = frame_payload_by_header(data, &header)
.ok_or_else(|| "incomplete REQUEST_HEADERS payload".to_string())?;
let decoded_payload = 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 REQUEST_HEADERS: {e}"))?;
decoded
} else {
payload.to_vec()
};
let mut meta: serde_json::Value = serde_json::from_slice(&decoded_payload)
.map_err(|e| format!("invalid REQUEST_HEADERS JSON: {e}"))?;
let obj = meta
.as_object_mut()
.ok_or_else(|| "REQUEST_HEADERS payload must be a JSON object".to_string())?;
let node_id = obj
.remove("node_id")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| "missing node_id in REQUEST_HEADERS".to_string())?;
let stripped_payload = serde_json::to_vec(&meta)
.map_err(|e| format!("failed to encode REQUEST_HEADERS payload: {e}"))?;
let (final_payload, flags) =
maybe_recompress_payload(&stripped_payload, header.flags & FLAG_GZIP_COMPRESSED != 0)
.map_err(|e| format!("failed to recompress REQUEST_HEADERS payload: {e}"))?;
let mut rebuilt = Vec::with_capacity(HEADER_SIZE + final_payload.len());
rebuilt.extend_from_slice(&new_stream_id.to_be_bytes());
rebuilt.push(REQUEST_HEADERS);
rebuilt.push(flags);
rebuilt.extend_from_slice(&(final_payload.len() as u32).to_be_bytes());
rebuilt.extend_from_slice(&final_payload);
Ok(RequestHeadersExtracted {
node_id,
rebuilt_frame: rebuilt,
})
encode_frame(0, GOAWAY, 0, &[])
}
#[inline]
fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
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 {
@@ -214,6 +139,25 @@ fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&
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,
+51 -44
View File
@@ -7,10 +7,10 @@ use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info, warn};
use crate::hub::{HubRouter, ProxyConn};
use crate::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
use crate::protocol;
/// Maximum single frame size: 64 MB
@@ -22,69 +22,71 @@ pub async fn handle_proxy_connection(
node_id: String,
node_name: String,
max_streams: usize,
ping_interval: Duration,
idle_timeout: Duration,
cfg: ConnConfig,
) {
let conn_id = hub.alloc_conn_id();
let (mut ws_tx, ws_rx) = ws.split();
// Create channel for outbound messages
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
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());
// Spawn writer task: drains channel -> WebSocket
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if ws_tx.send(msg).await.is_err() {
break;
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;
});
// Spawn ping task
let ping_tx = conn.tx.clone();
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 ping_tx.send(Message::Binary(ping.into())).is_err() {
if !matches!(
ping_conn.send(Message::Binary(ping.into())),
SendStatus::Queued
) {
break;
}
}
});
// Spawn reader task
let reader_hub = hub.clone();
let reader_node_id = node_id.clone();
let reader_tx = conn.tx.clone();
let reader_conn = conn.clone();
let reader = tokio::spawn(async move {
run_proxy_reader(
ws_rx,
reader_hub,
conn_id,
reader_node_id,
reader_tx,
idle_timeout,
)
.await;
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
});
// Wait for reader to end, then cleanup.
let _ = reader.await;
ping_task.abort();
conn.request_close();
hub.unregister_proxy(conn_id, &node_id);
// conn still holds an Arc<ProxyConn> with a channel sender clone.
// Drop it so the writer can drain and exit.
drop(conn);
tokio::time::sleep(Duration::from_millis(100)).await;
writer.abort();
@@ -94,20 +96,24 @@ pub async fn handle_proxy_connection(
async fn run_proxy_reader(
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
hub: Arc<HubRouter>,
conn_id: u64,
node_id: String,
tx: mpsc::UnboundedSender<Message>,
conn: Arc<ProxyConn>,
idle_timeout: Duration,
) {
let idle_enabled = !idle_timeout.is_zero();
let mut oversized_count = 0u32;
loop {
let msg = tokio::select! {
msg = ws_rx.next() => msg,
_ = tokio::time::sleep(idle_timeout) => {
warn!(conn_id = conn_id, node_id = %node_id, "proxy idle timeout");
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
break;
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 {
@@ -116,12 +122,13 @@ async fn run_proxy_reader(
if data.len() > MAX_FRAME_SIZE {
oversized_count += 1;
warn!(
conn_id = conn_id,
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");
warn!(conn_id = conn.id, "too many oversized frames, closing");
conn.request_close();
break;
}
continue;
@@ -129,21 +136,21 @@ async fn run_proxy_reader(
oversized_count = 0;
if data.len() < protocol::HEADER_SIZE {
debug!(conn_id = conn_id, "frame too small, skipping");
debug!(conn_id = conn.id, "frame too small, skipping");
continue;
}
hub.handle_proxy_frame(conn_id, &mut data);
hub.handle_proxy_frame(conn.id, &mut data).await;
}
Some(Ok(Message::Close(_))) | None => {
info!(conn_id = conn_id, node_id = %node_id, "proxy WebSocket closed");
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");
warn!(conn_id = conn.id, error = %e, "proxy WebSocket error");
break;
}
_ => {} // Ignore text/ping/pong at WS level
_ => {}
}
}
}
-136
View File
@@ -1,136 +0,0 @@
/// Worker-side WebSocket connection handler
///
/// Handles the lifecycle of a single Gunicorn worker connection:
/// accept -> read loop (route frames via Hub) -> cleanup
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use tracing::{debug, info, warn};
use crate::hub::{HubRouter, WorkerConn};
use crate::protocol;
pub async fn handle_worker_connection(
ws: WebSocket,
hub: Arc<HubRouter>,
ping_interval: Duration,
idle_timeout: Duration,
) {
let conn_id = hub.alloc_conn_id();
let (mut ws_tx, ws_rx) = ws.split();
// Create channel for outbound messages
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let conn = Arc::new(WorkerConn::new(conn_id, tx));
hub.register_worker(conn.clone());
// Spawn writer task
let writer = tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if ws_tx.send(msg).await.is_err() {
break;
}
}
let _ = ws_tx.close().await;
});
// Spawn ping task
let ping_tx = conn.tx.clone();
let ping_task = tokio::spawn(async move {
loop {
tokio::time::sleep(ping_interval).await;
let ping = protocol::encode_ping();
if ping_tx.send(Message::Binary(ping.into())).is_err() {
break;
}
}
});
// Spawn reader task
let reader_hub = hub.clone();
let reader_tx = conn.tx.clone();
let reader = tokio::spawn(async move {
run_worker_reader(
ws_rx,
reader_hub,
conn_id,
conn.clone(),
reader_tx,
idle_timeout,
)
.await;
});
// Wait for reader to end, then cleanup.
let _ = reader.await;
ping_task.abort();
// Unregister first so all channel senders are dropped (reader_tx dropped
// when reader completes, ping_tx dropped by abort, conn.tx dropped when
// the last Arc<WorkerConn> is removed from hub). This lets the writer
// drain buffered messages (e.g. GOAWAY) before we force-abort it.
hub.unregister_worker(conn_id);
tokio::time::sleep(Duration::from_millis(100)).await;
writer.abort();
let _ = writer.await;
}
async fn run_worker_reader(
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
hub: Arc<HubRouter>,
conn_id: u64,
conn: Arc<WorkerConn>,
tx: mpsc::UnboundedSender<Message>,
idle_timeout: Duration,
) {
loop {
let msg = tokio::select! {
msg = ws_rx.next() => msg,
_ = tokio::time::sleep(idle_timeout) => {
warn!(worker_id = conn_id, "worker idle timeout");
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
break;
}
};
match msg {
Some(Ok(Message::Binary(data))) => {
let mut data = data.to_vec();
if data.len() < protocol::HEADER_SIZE {
debug!(worker_id = conn_id, "frame too small, skipping");
continue;
}
let header = match protocol::FrameHeader::parse(&data) {
Some(h) => h,
None => continue,
};
// HEARTBEAT_ACK from worker -> route back to proxy
if header.msg_type == protocol::HEARTBEAT_ACK {
hub.handle_worker_heartbeat_ack(&mut data);
continue;
}
// Regular frames: route via hub
if let Some(err_msg) = hub.handle_worker_frame(conn_id, &mut data) {
// Send STREAM_ERROR back to worker
let err_frame = protocol::encode_stream_error(header.stream_id, &err_msg);
let _ = conn.send(Message::Binary(err_frame.into()));
}
}
Some(Ok(Message::Close(_))) | None => {
info!(worker_id = conn_id, "worker WebSocket closed");
break;
}
Some(Err(e)) => {
warn!(worker_id = conn_id, error = %e, "worker WebSocket error");
break;
}
_ => {} // Ignore text/ping/pong at WS level
}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aether-proxy"
version = "0.2.3"
version = "0.2.4"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "aether-proxy"
version = "0.2.4"
version = "0.2.5"
edition = "2021"
description = "Tunnel proxy for Aether"
+5 -5
View File
@@ -19,11 +19,11 @@ 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.4/aether-proxy-linux-amd64.tar.gz) |
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/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.4/aether-proxy-macos-amd64.tar.gz) |
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-macos-arm64.tar.gz) |
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-windows-amd64.zip) |
| 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 -->
## 快速开始
+3 -1
View File
@@ -182,7 +182,9 @@ where
MsgType::StreamEnd | MsgType::StreamError => {
// Client-side cancellation or end
streams.remove(&frame.stream_id);
if let Some(tx) = streams.remove(&frame.stream_id) {
let _ = tx.send(frame).await;
}
}
MsgType::Ping => {
+158 -67
View File
@@ -3,22 +3,27 @@
//! 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::{self, UpstreamRequestBody};
use crate::upstream_client;
use super::protocol::{
compress_payload, decompress_if_gzip, flags, Frame, MsgType, RequestMeta, ResponseMeta,
compress_payload, decompress_if_gzip, flags, Frame as TunnelFrame, MsgType, RequestMeta,
ResponseMeta,
};
use super::writer::FrameSender;
@@ -64,13 +69,13 @@ pub async fn handle_stream(
server: Arc<ServerContext>,
stream_id: u32,
meta: RequestMeta,
mut body_rx: mpsc::Receiver<Frame>,
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, &mut body_rx, &frame_tx).await;
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 {
@@ -79,7 +84,7 @@ pub async fn handle_stream(
}
/// Send a frame to the writer with a timeout. Returns false if send failed.
async fn send_frame(tx: &FrameSender, frame: Frame) -> bool {
async fn send_frame(tx: &FrameSender, frame: TunnelFrame) -> bool {
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
Ok(Ok(())) => true,
Ok(Err(_)) => {
@@ -102,62 +107,9 @@ async fn handle_stream_inner(
server: &ServerContext,
stream_id: u32,
meta: RequestMeta,
body_rx: &mut mpsc::Receiver<Frame>,
body_rx: mpsc::Receiver<TunnelFrame>,
frame_tx: &FrameSender,
) -> Option<Duration> {
// Collect request body
let mut body_parts: Vec<Bytes> = Vec::new();
let mut body_done = false;
// Drain body frames
while !body_done {
match body_rx.recv().await {
Some(frame) => {
if frame.msg_type == MsgType::RequestBody {
let payload = match decompress_if_gzip(&frame) {
Ok(d) => d,
Err(e) => {
send_error(
frame_tx,
stream_id,
&format!("gzip decompress failed: {e}"),
)
.await;
return None;
}
};
if !payload.is_empty() {
body_parts.push(payload);
}
if frame.is_end_stream() {
body_done = true;
}
} else if frame.msg_type == MsgType::StreamEnd
|| frame.msg_type == MsgType::StreamError
{
body_done = true;
if frame.msg_type == MsgType::StreamError {
return None; // Client cancelled
}
}
}
None => return None, // Channel closed
}
}
let body: Bytes = if body_parts.is_empty() {
Bytes::new()
} else if body_parts.len() == 1 {
body_parts.into_iter().next().unwrap()
} else {
let total: usize = body_parts.iter().map(|b| b.len()).sum();
let mut combined = Vec::with_capacity(total);
for part in &body_parts {
combined.extend_from_slice(part);
}
Bytes::from(combined)
};
// Validate target
let target_url = match url::Url::parse(&meta.url) {
Ok(u) => u,
@@ -207,12 +159,14 @@ async fn handle_stream_inner(
// 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(UpstreamRequestBody::new(body.clone()))
.body(request_body)
{
Ok(request) => request,
Err(e) => {
@@ -240,7 +194,6 @@ async fn handle_stream_inner(
}
}
let body_size = body.len();
let mut captured_connection = upstream_client::capture_connection(&mut request);
let connection_start = Instant::now();
let connection_capture = tokio::spawn(async move {
@@ -313,7 +266,7 @@ async fn handle_stream_inner(
"upstream_processing_ms": request_timing.response_wait_ms,
"timing_source": "instrumented_connector",
"total_ms": connect_elapsed.as_millis() as u64,
"body_size": body_size,
"body_size": request_body_size.load(Ordering::Relaxed),
"mode": "tunnel",
});
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
@@ -325,7 +278,7 @@ async fn handle_stream_inner(
let (meta_payload, meta_flags) = compress_payload(meta_json);
if !send_frame(
frame_tx,
Frame::new(
TunnelFrame::new(
stream_id,
MsgType::ResponseHeaders,
meta_flags,
@@ -350,7 +303,7 @@ async fn handle_stream_inner(
let (payload, extra_flags) = compress_payload(chunk);
if !send_frame(
frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
TunnelFrame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
)
.await
{
@@ -365,7 +318,12 @@ async fn handle_stream_inner(
let (payload, extra_flags) = compress_payload(slice);
if !send_frame(
frame_tx,
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
TunnelFrame::new(
stream_id,
MsgType::ResponseBody,
extra_flags,
payload,
),
)
.await
{
@@ -387,7 +345,7 @@ async fn handle_stream_inner(
// Send STREAM_END
let _ = send_frame(
frame_tx,
Frame::new(
TunnelFrame::new(
stream_id,
MsgType::StreamEnd,
flags::END_STREAM,
@@ -404,7 +362,7 @@ 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,
Frame::new(
TunnelFrame::new(
stream_id,
MsgType::StreamError,
0,
@@ -413,3 +371,136 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
)
.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);
}
}
+12 -2
View File
@@ -7,7 +7,10 @@ use std::task::{Context, Poll};
use std::time::Duration;
use bytes::Bytes;
use http_body_util::Full;
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;
@@ -30,9 +33,16 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
type PlainStream = TokioIo<TcpStream>;
type TlsStream = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
pub type UpstreamRequestBody = Full<Bytes>;
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,
-2
View File
@@ -14,8 +14,6 @@ from alembic import context
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# 添加 alembic 目录,让迁移文件可以 `from helpers import ...`
sys.path.insert(0, os.path.dirname(__file__))
# 加载 .env 文件(本地开发时需要)
try:
-171
View File
@@ -1,171 +0,0 @@
"""Shared idempotent helpers for Alembic migrations.
All metadata lookups are batched: one query loads an entire table's column info
or all FK delete rules, then results are cached for the migration's lifetime.
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable
import sqlalchemy as sa
from alembic import op
# ---------------------------------------------------------------------------
# Batch metadata cache
# ---------------------------------------------------------------------------
class _SchemaCache:
"""Lazy, per-migration cache for information_schema lookups.
Call ``load_columns(tables)`` / ``load_fk_rules(tables)`` once at the top
of ``upgrade()`` or ``downgrade()`` to prime the cache. Subsequent
``column_exists`` / ``column_type`` / ``fk_ondelete`` calls are pure
dict lookups -- zero extra DB round-trips.
"""
def __init__(self) -> None:
# {table_name: {column_name: data_type}}
self._columns: dict[str, dict[str, str]] = {}
# {(table_name, constraint_name): delete_rule}
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
# -- loaders (one query per call) --------------------------------------
def load_columns(self, tables: list[str]) -> None:
"""Fetch column names + data types for *tables* in one query."""
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)"
),
{"tables": need},
).fetchall()
# Initialise even empty tables so we don't re-query
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:
"""Fetch FK delete rules for *tables* in one query."""
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 "
"WHERE tc.table_name = ANY(:tables)"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
# -- lookups (pure dict, zero DB) --------------------------------------
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 fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def invalidate_columns(self, table: str) -> None:
"""Force re-load on next load_columns() for *table* (after ADD/DROP COLUMN)."""
self._columns.pop(table, None)
def new_cache() -> _SchemaCache:
"""Create a fresh schema cache for a single migration run."""
return _SchemaCache()
# ---------------------------------------------------------------------------
# Idempotent DDL helpers
# ---------------------------------------------------------------------------
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:
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current:
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 index_exists(index_name: str) -> bool:
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
# ---------------------------------------------------------------------------
# Batch ALTER TYPE helper
# ---------------------------------------------------------------------------
def batch_alter_type(
cache: _SchemaCache,
columns: list[tuple[str, str, bool, str | None]],
cast_suffix: str,
type_fn: Callable[[str], str],
) -> None:
"""Group columns by table and issue ONE ``ALTER TABLE`` per table.
Skips columns that don't exist. Each tuple is
``(table_name, column_name, nullable, server_default)``.
"""
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():
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)))
@@ -53,7 +53,23 @@ def _index_exists(table_name: str, index_name: str) -> bool:
def _to_decimal(value: object | None) -> Decimal:
if value is None:
return Decimal("0")
return Decimal(str(value)).quantize(_MONEY_QUANT, rounding=ROUND_HALF_UP)
d = Decimal(str(value))
if d.is_nan() or d.is_infinite():
return Decimal("0")
return d.quantize(_MONEY_QUANT, rounding=ROUND_HALF_UP)
# NUMERIC(20, 8) -> 整数部分最多 12 位 -> 绝对值上限
_NUMERIC_20_8_CAP = Decimal("999999999999.99999999")
def _clamp(value: Decimal) -> Decimal:
"""Clamp a Decimal to fit NUMERIC(20, 8) range."""
if value > _NUMERIC_20_8_CAP:
return _NUMERIC_20_8_CAP
if value < -_NUMERIC_20_8_CAP:
return -_NUMERIC_20_8_CAP
return value
def _insert_wallet(
@@ -191,6 +207,11 @@ def _backfill_wallets(conn: sa.Connection) -> None:
total_adjusted = Decimal("0") if is_unlimited else max(quota, Decimal("0"))
total_consumed = max(total if total > 0 else used, Decimal("0"))
recharge_balance = _clamp(recharge_balance)
gift_balance = _clamp(gift_balance)
total_adjusted = _clamp(total_adjusted)
total_consumed = _clamp(total_consumed)
total_balance = recharge_balance + gift_balance
wallet_id = _insert_wallet(
conn,
@@ -208,7 +229,7 @@ def _backfill_wallets(conn: sa.Connection) -> None:
_insert_wallet_migration_tx(
conn,
wallet_id=wallet_id,
balance=total_balance,
balance=_clamp(total_balance),
recharge_balance=recharge_balance,
gift_balance=gift_balance,
created_at=row["created_at"],
@@ -235,7 +256,9 @@ def _backfill_wallets(conn: sa.Connection) -> None:
current_balance = _to_decimal(row["current_balance_usd"])
used_balance = _to_decimal(row["balance_used_usd"])
recharge_balance = current_balance - used_balance
recharge_balance = _clamp(current_balance - used_balance)
total_recharged = _clamp(max(current_balance, Decimal("0")))
total_consumed = _clamp(max(used_balance, Decimal("0")))
wallet_id = _insert_wallet(
conn,
@@ -244,8 +267,8 @@ def _backfill_wallets(conn: sa.Connection) -> None:
balance=recharge_balance,
gift_balance=Decimal("0"),
limit_mode="finite",
total_recharged=max(current_balance, Decimal("0")),
total_consumed=max(used_balance, Decimal("0")),
total_recharged=total_recharged,
total_consumed=total_consumed,
total_adjusted=Decimal("0"),
created_at=row["created_at"],
updated_at=row["updated_at"],
@@ -6,8 +6,9 @@ Create Date: 2026-03-08 03:48:49.622091+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from helpers import new_cache, replace_fk_if_needed
from alembic import op
@@ -20,8 +21,108 @@ 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 = new_cache()
c = _SchemaCache()
c.load_columns(_TABLES)
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
@@ -37,7 +138,7 @@ def upgrade() -> None:
)
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"stats_user_daily_user_id_fkey",
"stats_user_daily",
@@ -59,7 +160,7 @@ def upgrade() -> None:
)
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
@@ -82,7 +183,7 @@ def upgrade() -> None:
def downgrade() -> None:
c = new_cache()
c = _SchemaCache()
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
@@ -97,7 +198,7 @@ def downgrade() -> None:
op.drop_column("usage", "username")
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"stats_daily_api_key_api_key_id_fkey",
"stats_daily_api_key",
@@ -111,7 +212,7 @@ def downgrade() -> None:
)
# --- StatsUserDaily: SET NULL -> CASCADE ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"stats_user_daily_user_id_fkey",
"stats_user_daily",
@@ -6,8 +6,9 @@ Create Date: 2026-03-08 12:15:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from helpers import new_cache, replace_fk_if_needed
from alembic import op
@@ -20,8 +21,108 @@ 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 = new_cache()
c = _SchemaCache()
c.load_columns(_TABLES)
c.load_fk_rules(_TABLES)
@@ -43,7 +144,7 @@ def upgrade() -> None:
)
# --- request_candidates: CASCADE -> SET NULL ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"request_candidates_user_id_fkey",
"request_candidates",
@@ -52,7 +153,7 @@ def upgrade() -> None:
["id"],
"SET NULL",
)
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"request_candidates_api_key_id_fkey",
"request_candidates",
@@ -81,7 +182,7 @@ def upgrade() -> None:
# --- 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(
_replace_fk_if_needed(
c,
"video_tasks_user_id_fkey",
"video_tasks",
@@ -90,7 +191,7 @@ def upgrade() -> None:
["id"],
"SET NULL",
)
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"video_tasks_api_key_id_fkey",
"video_tasks",
@@ -102,12 +203,12 @@ def upgrade() -> None:
def downgrade() -> None:
c = new_cache()
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(
_replace_fk_if_needed(
c,
"video_tasks_api_key_id_fkey",
"video_tasks",
@@ -116,7 +217,7 @@ def downgrade() -> None:
["id"],
"NO ACTION",
)
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"video_tasks_user_id_fkey",
"video_tasks",
@@ -132,7 +233,7 @@ def downgrade() -> None:
op.drop_column("video_tasks", "username")
# --- request_candidates: SET NULL -> CASCADE ---
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"request_candidates_api_key_id_fkey",
"request_candidates",
@@ -141,7 +242,7 @@ def downgrade() -> None:
["id"],
"CASCADE",
)
replace_fk_if_needed(
_replace_fk_if_needed(
c,
"request_candidates_user_id_fkey",
"request_candidates",
@@ -6,8 +6,15 @@ 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
from helpers import batch_alter_type, index_exists, new_cache
# revision identifiers, used by Alembic.
revision = "2053ab8ed764"
@@ -79,13 +86,106 @@ _COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
_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 = new_cache()
c = _SchemaCache()
c.load_columns(_ALL_TABLES)
# -- 1. cost fields: Float -> Numeric (batched per table)
@@ -94,10 +194,10 @@ def upgrade() -> None:
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)
_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"):
if not _index_exists("idx_provider_api_keys_provider_active"):
op.create_index(
"idx_provider_api_keys_provider_active",
"provider_api_keys",
@@ -107,14 +207,14 @@ def upgrade() -> None:
def downgrade() -> None:
# -- 2. drop composite index
if index_exists("idx_provider_api_keys_provider_active"):
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 = new_cache()
c = _SchemaCache()
c.load_columns(_ALL_TABLES)
cols_to_revert = [
@@ -122,7 +222,7 @@ def downgrade() -> None:
for t, col, n, d in _COST_COLUMNS
if c.column_exists(t, col) and c.is_numeric(t, col)
]
batch_alter_type(
_batch_alter_type(
c,
cols_to_revert,
cast_suffix="double precision",
@@ -6,7 +6,11 @@ Create Date: 2026-03-09 01:00:00.000000+00:00
"""
from helpers import new_cache, replace_fk_if_needed
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d7649c1f8e21"
@@ -18,10 +22,87 @@ _TABLE = "video_tasks"
_FK_NAME = "video_tasks_key_id_fkey"
# ---------------------------------------------------------------------------
# Inline helpers
# ---------------------------------------------------------------------------
class _SchemaCache:
def __init__(self) -> None:
self._fk_rules: dict[tuple[str, str], str] = {}
self._fk_loaded_tables: set[str] = set()
def load_fk_rules(self, tables: list[str]) -> None:
need = [t for t in tables if t not in self._fk_loaded_tables]
if not need:
return
bind = op.get_bind()
rows = bind.execute(
sa.text(
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
"FROM information_schema.referential_constraints rc "
"JOIN information_schema.table_constraints tc "
" ON rc.constraint_name = tc.constraint_name "
" AND rc.constraint_schema = tc.constraint_schema "
"WHERE tc.table_name = ANY(:tables) "
" AND tc.table_schema = current_schema()"
),
{"tables": need},
).fetchall()
for table, name, rule in rows:
self._fk_rules[(table, name)] = rule
self._fk_loaded_tables.update(need)
def fk_ondelete(self, table: str, constraint: str) -> str | None:
return self._fk_rules.get((table, constraint))
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def _replace_fk_if_needed(
cache: _SchemaCache,
constraint_name: str,
table_name: str,
ref_table: str,
local_cols: list[str],
remote_cols: list[str],
desired_ondelete: str,
) -> None:
current = cache.fk_ondelete(table_name, constraint_name)
if current and current.upper() == desired_ondelete.upper():
return
if current or _fk_exists(constraint_name, table_name):
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
op.create_foreign_key(
constraint_name,
table_name,
ref_table,
local_cols,
remote_cols,
ondelete=desired_ondelete,
)
# ---------------------------------------------------------------------------
def upgrade() -> None:
c = new_cache()
c = _SchemaCache()
c.load_fk_rules([_TABLE])
replace_fk_if_needed(
_replace_fk_if_needed(
c,
_FK_NAME,
_TABLE,
@@ -33,9 +114,9 @@ def upgrade() -> None:
def downgrade() -> None:
c = new_cache()
c = _SchemaCache()
c.load_fk_rules([_TABLE])
replace_fk_if_needed(
_replace_fk_if_needed(
c,
_FK_NAME,
_TABLE,
@@ -0,0 +1,42 @@
"""Strip request_results_window from health_by_format JSON.
This data is now maintained in process memory only, no longer persisted to DB.
Revision ID: a3f1b7c9d2e4
Revises: d7649c1f8e21
Create Date: 2026-03-10 12:00:00.000000+00:00
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "a3f1b7c9d2e4"
down_revision = "d7649c1f8e21"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
UPDATE provider_api_keys
SET health_by_format = (
SELECT jsonb_object_agg(
fmt_key,
fmt_value - 'request_results_window'
)
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
)
WHERE health_by_format IS NOT NULL
AND health_by_format != '{}'::jsonb
AND EXISTS (
SELECT 1
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
WHERE fmt_value ? 'request_results_window'
)
""")
def downgrade() -> None:
# No-op: window data is rebuilt from scratch on process start
pass
@@ -0,0 +1,159 @@
"""tighten usage billing state machine
Revision ID: 9e4f1a2b3c4d
Revises: a3f1b7c9d2e4
Create Date: 2026-03-11 19:00:00.000000+00:00
This migration does two things:
1. Change new `usage.billing_status` default from `settled` to `pending`.
2. Repair only the clearly-safe inconsistent historical rows for production:
- failed/cancelled zero-cost rows that were marked settled are converted to void
- terminal rows missing finalized_at are backfilled from created_at
Ambiguous positive-cost settled rows are intentionally left untouched for manual audit.
All data updates are batched (10000 rows per iteration) to avoid long-held locks
and excessive WAL generation on large usage tables.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9e4f1a2b3c4d"
down_revision: str | None = "a3f1b7c9d2e4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BATCH_SIZE = 10000
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [col["name"] for col in insp.get_columns(table_name)]
def upgrade() -> None:
if not _table_exists("usage"):
return
if _column_exists("usage", "billing_status"):
op.alter_column(
"usage",
"billing_status",
existing_type=sa.String(length=20),
server_default="pending",
existing_nullable=False,
)
required_columns = {
"billing_status",
"status",
"total_cost_usd",
"request_cost_usd",
"actual_total_cost_usd",
"actual_request_cost_usd",
"wallet_balance_after",
"finalized_at",
"created_at",
}
if not required_columns.issubset(
{col for col in required_columns if _column_exists("usage", col)}
):
return
conn = op.get_bind()
# Step 1: billing_status IS NULL -> 'pending' (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET billing_status = 'pending'
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
# Step 2: failed/cancelled zero-cost settled -> void (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status = 'settled'
AND status IN ('failed', 'cancelled')
AND COALESCE(total_cost_usd, 0) = 0
AND wallet_balance_after IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET billing_status = 'void',
finalized_at = COALESCE(usage.finalized_at, usage.created_at),
total_cost_usd = 0,
request_cost_usd = 0,
actual_total_cost_usd = 0,
actual_request_cost_usd = 0
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
# Step 3: backfill finalized_at for terminal rows (batched)
while True:
result = conn.execute(
sa.text("""
WITH batch AS (
SELECT id FROM usage
WHERE billing_status IN ('settled', 'void')
AND finalized_at IS NULL
LIMIT :batch_size
FOR UPDATE SKIP LOCKED
)
UPDATE usage
SET finalized_at = COALESCE(usage.finalized_at, usage.created_at)
FROM batch WHERE usage.id = batch.id
"""),
{"batch_size": BATCH_SIZE},
)
if result.rowcount < BATCH_SIZE:
break
def downgrade() -> None:
if not _table_exists("usage") or not _column_exists("usage", "billing_status"):
return
op.alter_column(
"usage",
"billing_status",
existing_type=sa.String(length=20),
server_default="settled",
existing_nullable=False,
)
@@ -0,0 +1,113 @@
"""add wallet daily usage ledgers
Revision ID: d4e5f6a7b8c9
Revises: 9e4f1a2b3c4d
Create Date: 2026-03-11 21:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d4e5f6a7b8c9"
down_revision: str | None = "9e4f1a2b3c4d"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [col["name"] for col in insp.get_columns(table_name)]
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
def upgrade() -> None:
if not _table_exists("wallet_daily_usage_ledgers"):
op.create_table(
"wallet_daily_usage_ledgers",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("wallet_id", sa.String(length=36), nullable=False),
sa.Column("billing_date", sa.Date(), nullable=False),
sa.Column("billing_timezone", sa.String(length=64), nullable=False),
sa.Column("total_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"),
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
sa.Column("first_finalized_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_finalized_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"wallet_id",
"billing_date",
"billing_timezone",
name="uq_wallet_daily_usage_ledgers_wallet_date_tz",
),
)
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
op.create_index(
"idx_wallet_daily_usage_wallet_date",
"wallet_daily_usage_ledgers",
["wallet_id", "billing_date"],
)
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
op.create_index(
"idx_wallet_daily_usage_date",
"wallet_daily_usage_ledgers",
["billing_date"],
)
if (
_table_exists("usage")
and all(
_column_exists("usage", col) for col in ["billing_status", "finalized_at", "wallet_id"]
)
and not _index_exists("usage", "idx_usage_billing_finalized_wallet")
):
op.create_index(
"idx_usage_billing_finalized_wallet",
"usage",
["billing_status", "finalized_at", "wallet_id"],
)
def downgrade() -> None:
if _table_exists("usage") and _index_exists("usage", "idx_usage_billing_finalized_wallet"):
op.drop_index("idx_usage_billing_finalized_wallet", table_name="usage")
if _table_exists("wallet_daily_usage_ledgers"):
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
op.drop_index("idx_wallet_daily_usage_date", table_name="wallet_daily_usage_ledgers")
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
op.drop_index(
"idx_wallet_daily_usage_wallet_date",
table_name="wallet_daily_usage_ledgers",
)
op.drop_table("wallet_daily_usage_ledgers")
@@ -0,0 +1,59 @@
"""add provider_api_keys usage total columns
Revision ID: 9b7c6d5e4f3a
Revises: d4e5f6a7b8c9
Create Date: 2026-03-11 22:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9b7c6d5e4f3a"
down_revision: str | None = "d4e5f6a7b8c9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("provider_api_keys", "total_tokens"):
op.add_column(
"provider_api_keys",
sa.Column("total_tokens", sa.BigInteger(), nullable=False, server_default="0"),
)
if column_exists("provider_api_keys", "total_tokens"):
op.alter_column("provider_api_keys", "total_tokens", server_default=None)
if not column_exists("provider_api_keys", "total_cost_usd"):
op.add_column(
"provider_api_keys",
sa.Column(
"total_cost_usd",
sa.Numeric(20, 8),
nullable=False,
server_default="0.0",
),
)
if column_exists("provider_api_keys", "total_cost_usd"):
op.alter_column("provider_api_keys", "total_cost_usd", server_default=None)
def downgrade() -> None:
if column_exists("provider_api_keys", "total_cost_usd"):
op.drop_column("provider_api_keys", "total_cost_usd")
if column_exists("provider_api_keys", "total_tokens"):
op.drop_column("provider_api_keys", "total_tokens")
@@ -0,0 +1,116 @@
"""cleanup stale provider references after provider deletion
Revision ID: c1d2e3f4a5b6
Revises: 9b7c6d5e4f3a
Create Date: 2026-03-11 23:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "c1d2e3f4a5b6"
down_revision = "9b7c6d5e4f3a"
branch_labels = None
depends_on = None
_users = sa.table(
"users",
sa.column("id", sa.String(36)),
sa.column("allowed_providers", sa.JSON()),
)
_api_keys = sa.table(
"api_keys",
sa.column("id", sa.String(36)),
sa.column("allowed_providers", sa.JSON()),
)
_user_preferences = sa.table(
"user_preferences",
sa.column("id", sa.String(36)),
sa.column("default_provider_id", sa.String(36)),
)
_video_tasks = sa.table(
"video_tasks",
sa.column("id", sa.String(36)),
sa.column("provider_id", sa.String(36)),
sa.column("endpoint_id", sa.String(36)),
)
_providers = sa.table("providers", sa.column("id", sa.String(36)))
_provider_endpoints = sa.table("provider_endpoints", sa.column("id", sa.String(36)))
def _load_valid_ids(conn: sa.Connection, table: sa.Table) -> set[str]:
return {str(row[0]) for row in conn.execute(sa.select(table.c.id)).fetchall() if row[0]}
def _cleanup_allowed_providers(
conn: sa.Connection,
table: sa.Table,
valid_provider_ids: set[str],
) -> None:
rows = conn.execute(
sa.select(table.c.id, table.c.allowed_providers).where(
table.c.allowed_providers.isnot(None)
)
).fetchall()
for row_id, allowed_providers in rows:
if not isinstance(allowed_providers, list):
continue
filtered = [
provider_id for provider_id in allowed_providers if provider_id in valid_provider_ids
]
if filtered == allowed_providers:
continue
conn.execute(table.update().where(table.c.id == row_id).values(allowed_providers=filtered))
def _nullify_missing_fk(
conn: sa.Connection,
table: sa.Table,
id_column: sa.ColumnElement[str],
fk_column: sa.ColumnElement[str],
valid_ids: set[str],
) -> None:
rows = conn.execute(sa.select(id_column, fk_column).where(fk_column.isnot(None))).fetchall()
invalid_row_ids = [row_id for row_id, fk_value in rows if fk_value not in valid_ids]
if not invalid_row_ids:
return
conn.execute(table.update().where(id_column.in_(invalid_row_ids)).values({fk_column.key: None}))
def upgrade() -> None:
conn = op.get_bind()
valid_provider_ids = _load_valid_ids(conn, _providers)
valid_endpoint_ids = _load_valid_ids(conn, _provider_endpoints)
_cleanup_allowed_providers(conn, _users, valid_provider_ids)
_cleanup_allowed_providers(conn, _api_keys, valid_provider_ids)
_nullify_missing_fk(
conn,
_user_preferences,
_user_preferences.c.id,
_user_preferences.c.default_provider_id,
valid_provider_ids,
)
_nullify_missing_fk(
conn,
_video_tasks,
_video_tasks.c.id,
_video_tasks.c.provider_id,
valid_provider_ids,
)
_nullify_missing_fk(
conn,
_video_tasks,
_video_tasks.c.id,
_video_tasks.c.endpoint_id,
valid_endpoint_ids,
)
def downgrade() -> None:
pass
@@ -0,0 +1,64 @@
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
Revision ID: b7c8d9e0f1a2
Revises: c1d2e3f4a5b6
Create Date: 2026-03-12 19:15:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "b7c8d9e0f1a2"
down_revision = "c1d2e3f4a5b6"
branch_labels = None
depends_on = None
def _fk_exists(constraint_name: str, table_name: str) -> bool:
bind = op.get_bind()
result = bind.execute(
sa.text(
"SELECT 1 FROM pg_constraint c "
"JOIN pg_class r ON c.conrelid = r.oid "
"JOIN pg_namespace n ON r.relnamespace = n.oid "
"WHERE c.conname = :name AND r.relname = :table "
" AND n.nspname = current_schema() AND c.contype = 'f'"
),
{"name": constraint_name, "table": table_name},
)
return result.scalar() is not None
def upgrade() -> None:
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.drop_constraint(
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
)
def downgrade() -> None:
bind = op.get_bind()
bind.execute(
sa.text(
"UPDATE request_candidates rc "
"SET key_id = NULL "
"WHERE key_id IS NOT NULL "
" AND NOT EXISTS ("
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
" )"
)
)
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
op.create_foreign_key(
"request_candidates_key_id_fkey",
"request_candidates",
"provider_api_keys",
["key_id"],
["id"],
ondelete="CASCADE",
)
@@ -0,0 +1,54 @@
"""add user rate_limit and backfill normal api key limits
Revision ID: b7e8f9a0c1d2
Revises: b7c8d9e0f1a2
Create Date: 2026-03-13 12:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b7e8f9a0c1d2"
down_revision: str | None = "b7c8d9e0f1a2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("users", "rate_limit"):
op.add_column("users", sa.Column("rate_limit", sa.Integer(), nullable=True))
# 普通 Key 新语义不再允许 NULL;存量 NULL 统一回填为 0(不限制)。
op.execute(sa.text("""
UPDATE api_keys
SET rate_limit = 0
WHERE is_standalone = FALSE
AND rate_limit IS NULL
"""))
def downgrade() -> None:
# 恢复普通 Key 的 rate_limit 为 NULL(与 upgrade 中回填 0 对应)
op.execute(sa.text("""
UPDATE api_keys
SET rate_limit = NULL
WHERE is_standalone = FALSE
AND rate_limit = 0
"""))
if column_exists("users", "rate_limit"):
op.drop_column("users", "rate_limit")
@@ -0,0 +1,87 @@
"""add user sessions table for device-level auth
Revision ID: f6e7d8c9b0a1
Revises: b7e8f9a0c1d2
Create Date: 2026-03-15 12:00:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f6e7d8c9b0a1"
down_revision = "b7e8f9a0c1d2"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if "user_sessions" in inspector.get_table_names():
return
op.create_table(
"user_sessions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.String(length=36), nullable=False),
sa.Column("client_device_id", sa.String(length=128), nullable=False),
sa.Column("device_label", sa.String(length=120), nullable=True),
sa.Column("device_type", sa.String(length=20), nullable=False, server_default="unknown"),
sa.Column("browser_name", sa.String(length=50), nullable=True),
sa.Column("browser_version", sa.String(length=50), nullable=True),
sa.Column("os_name", sa.String(length=50), nullable=True),
sa.Column("os_version", sa.String(length=50), nullable=True),
sa.Column("device_model", sa.String(length=100), nullable=True),
sa.Column("ip_address", sa.String(length=45), nullable=True),
sa.Column("user_agent", sa.String(length=1000), nullable=True),
sa.Column("client_hints", sa.JSON(), nullable=True),
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
sa.Column("prev_refresh_token_hash", sa.String(length=64), nullable=True),
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoke_reason", sa.String(length=100), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"], unique=False)
op.create_index(
"ix_user_sessions_client_device_id",
"user_sessions",
["client_device_id"],
unique=False,
)
op.create_index(
"idx_user_sessions_user_active",
"user_sessions",
["user_id", "revoked_at", "expires_at"],
unique=False,
)
op.create_index(
"idx_user_sessions_user_device",
"user_sessions",
["user_id", "client_device_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index("idx_user_sessions_user_device", table_name="user_sessions")
op.drop_index("idx_user_sessions_user_active", table_name="user_sessions")
op.drop_index("ix_user_sessions_client_device_id", table_name="user_sessions")
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
op.drop_table("user_sessions")
@@ -0,0 +1,41 @@
"""add status_snapshot column to provider_api_keys
Revision ID: c9d8e7f6a5b4
Revises: f6e7d8c9b0a1
Create Date: 2026-03-20 12:00:00.000000
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c9d8e7f6a5b4"
down_revision: str | None = "f6e7d8c9b0a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists("provider_api_keys", "status_snapshot"):
op.add_column(
"provider_api_keys",
sa.Column("status_snapshot", sa.JSON(), nullable=True),
)
def downgrade() -> None:
if column_exists("provider_api_keys", "status_snapshot"):
op.drop_column("provider_api_keys", "status_snapshot")
@@ -0,0 +1,317 @@
"""usage token semantics v2
Revision ID: c3d4e5f6a7b8
Revises: c9d8e7f6a5b4
Create Date: 2026-03-24 14:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: str | None = "c9d8e7f6a5b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
BACKFILL_BATCH_SIZE = 2000
# 最大批次数,防止因数据异常导致死循环(2000 * 500000 = 10亿行上限)
_MAX_BATCHES = 500000
# 使用子查询中间层展开 input_output_total_tokens 的计算,
# 确保 total_tokens 引用的是本次 SET 后的新值而非旧值。
_UPGRADE_BACKFILL_SQL = sa.text(
"""
UPDATE usage
SET
input_output_total_tokens = src.new_iot,
input_context_tokens = src.new_ict,
total_tokens = src.new_total,
cache_creation_cost_usd_5m = src.new_cc5m,
cache_creation_cost_usd_1h = src.new_cc1h,
actual_cache_creation_cost_usd_5m = src.new_acc5m,
actual_cache_creation_cost_usd_1h = src.new_acc1h,
actual_cache_cost_usd = src.new_accu,
cache_creation_price_per_1m_5m = src.new_cp5m,
cache_creation_price_per_1m_1h = src.new_cp1h,
cache_cost_usd = src.new_ccu
FROM (
SELECT
id,
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
AS new_iot,
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
AS new_ict,
/* total_tokens 引用本行计算出的 new_iot,避免依赖 SET 顺序 */
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
AS new_total,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_cc5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_cc1h,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_acc5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END AS new_acc1h,
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END AS new_cp5m,
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END AS new_cp1h,
COALESCE(cache_creation_cost_usd, 0)
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
FROM usage
WHERE id IN (
SELECT id FROM usage
WHERE
input_output_total_tokens IS DISTINCT FROM
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
OR input_context_tokens IS DISTINCT FROM
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
OR total_tokens IS DISTINCT FROM (
COALESCE(input_output_total_tokens,
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
+ COALESCE(cache_creation_input_tokens, 0)
+ COALESCE(cache_read_input_tokens, 0)
)
OR cache_creation_cost_usd_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR cache_creation_cost_usd_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_creation_cost_usd_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_creation_cost_usd_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens, 0) > 0
THEN COALESCE(actual_cache_creation_cost_usd, 0)
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
ELSE 0
END
)
OR actual_cache_cost_usd IS DISTINCT FROM (
COALESCE(actual_cache_creation_cost_usd, 0)
+ COALESCE(actual_cache_read_cost_usd, 0)
)
OR cache_creation_price_per_1m_5m IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END
)
OR cache_creation_price_per_1m_1h IS DISTINCT FROM (
CASE
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
THEN cache_creation_price_per_1m
ELSE NULL
END
)
OR cache_cost_usd IS DISTINCT FROM (
COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
)
ORDER BY id
LIMIT :batch_size
)
) AS src
WHERE usage.id = src.id
"""
)
_DOWNGRADE_BACKFILL_SQL = sa.text(
"""
UPDATE usage
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
WHERE id IN (
SELECT id
FROM usage
WHERE total_tokens IS DISTINCT FROM
COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
ORDER BY id
LIMIT :batch_size
)
"""
)
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [c["name"] for c in inspector.get_columns(table_name)]
return column_name in columns
def run_backfill_in_batches(sql: sa.TextClause, batch_size: int = BACKFILL_BATCH_SIZE) -> None:
context = op.get_context()
for _ in range(_MAX_BATCHES):
# Commit the preceding schema transaction before each batch so PostgreSQL
# does not keep ALTER TABLE locks for the entire data backfill.
with context.autocommit_block():
rowcount = op.get_bind().execute(sql, {"batch_size": batch_size}).rowcount
if rowcount == 0:
break
else:
raise RuntimeError(
f"Backfill did not converge after {_MAX_BATCHES} batches "
f"(batch_size={batch_size}). Possible infinite loop due to data anomaly."
)
def upgrade() -> None:
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
with op.batch_alter_table("usage") as batch_op:
batch_op.alter_column(
"total_tokens",
new_column_name="input_output_total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
with op.batch_alter_table("usage") as batch_op:
if not column_exists("usage", "input_context_tokens"):
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "total_tokens"):
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "actual_cache_cost_usd"):
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
run_backfill_in_batches(_UPGRADE_BACKFILL_SQL)
def downgrade() -> None:
run_backfill_in_batches(_DOWNGRADE_BACKFILL_SQL)
with op.batch_alter_table("usage") as batch_op:
if column_exists("usage", "cache_creation_price_per_1m_1h"):
batch_op.drop_column("cache_creation_price_per_1m_1h")
if column_exists("usage", "cache_creation_price_per_1m_5m"):
batch_op.drop_column("cache_creation_price_per_1m_5m")
if column_exists("usage", "actual_cache_cost_usd"):
batch_op.drop_column("actual_cache_cost_usd")
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
if column_exists("usage", "cache_creation_cost_usd_1h"):
batch_op.drop_column("cache_creation_cost_usd_1h")
if column_exists("usage", "cache_creation_cost_usd_5m"):
batch_op.drop_column("cache_creation_cost_usd_5m")
if column_exists("usage", "input_context_tokens"):
batch_op.drop_column("input_context_tokens")
if column_exists("usage", "total_tokens"):
batch_op.drop_column("total_tokens")
if column_exists("usage", "input_output_total_tokens"):
batch_op.alter_column(
"input_output_total_tokens",
new_column_name="total_tokens",
existing_type=sa.Integer(),
existing_nullable=True,
)
+35 -24
View File
@@ -5,6 +5,7 @@
# 部署/更新: ./deploy.sh (自动检测所有变化)
# 指定 Hub 版本: ./deploy.sh --hub-tag hub-v0.1.0
# 更新 Hub: ./deploy.sh --update-hub
# GitHub 镜像: ./deploy.sh --mirror https://ghfast.top
# 强制重建: ./deploy.sh --rebuild-base
# 强制全部重建: ./deploy.sh --force
@@ -44,6 +45,7 @@ Usage: ./deploy.sh [options]
Options:
--hub-tag <hub-vX.Y.Z> 指定 Hub Release tag(例如 hub-v0.1.0)
--update-hub 强制刷新 Hub 版本标记(下次构建会重新下载)
--mirror <url> GitHub 下载镜像(例如 https://ghfast.top)
--rebuild-base, -r 仅重建 base 镜像
--force, -f 强制重建全部(hub/base/app)并重启
-h, --help 显示帮助
@@ -54,6 +56,7 @@ FORCE_REBUILD_ALL=false
REBUILD_BASE_ONLY=false
FORCE_UPDATE_HUB=false
HUB_TAG="${HUB_TAG:-}"
GITHUB_MIRROR="${GITHUB_MIRROR:-}"
RESOLVED_HUB_TAG=""
while [ $# -gt 0 ]; do
@@ -70,6 +73,14 @@ while [ $# -gt 0 ]; do
FORCE_UPDATE_HUB=true
shift
;;
--mirror)
if [ $# -lt 2 ]; then
echo "ERROR: --mirror needs a URL, e.g. https://ghfast.top"
exit 1
fi
GITHUB_MIRROR="$2"
shift 2
;;
--rebuild-base|-r)
REBUILD_BASE_ONLY=true
shift
@@ -97,27 +108,26 @@ if [ -n "$HUB_TAG" ]; then
esac
fi
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
# - [build-system] requires / build-backend
# - [project] requires-python / dependencies
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
# 提取 pyproject.toml 中会影响运行时依赖安装的字段指纹(纯 shell,无需 Python)
# 用 sed 提取 dependencies / requires 数组块和单值字段,排序后输出稳定文本
pyproject_deps_fingerprint() {
python3 - <<'PY'
import json, pathlib, 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
local file="pyproject.toml"
# 提取 "key = [..." 多行数组块(从 key 行到 ] 行)
extract_array() {
sed -n "/^$1[[:space:]]*=[[:space:]]*\[/,/\]/p" "$file" | grep '"' | sed 's/.*"\(.*\)".*/\1/' | sort
}
# 提取 "key = "value"" 单行值
extract_value() {
grep -m1 "^$1[[:space:]]*=" "$file" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/'
}
{
echo "requires-python=$(extract_value requires-python)"
echo "build-backend=$(extract_value build-backend)"
echo "dependencies:"
extract_array dependencies
echo "build-requires:"
extract_array requires
}
}
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
@@ -286,10 +296,15 @@ build_app() {
if [ -n "${GITHUB_TOKEN:-}" ]; then
token_args=(--build-arg "GITHUB_TOKEN=${GITHUB_TOKEN}")
fi
local mirror_args=()
if [ -n "${GITHUB_MIRROR:-}" ]; then
mirror_args=(--build-arg "GITHUB_MIRROR=${GITHUB_MIRROR}")
fi
docker build --pull=false \
--build-arg HUB_RELEASE_REPO="$GITHUB_REPO" \
--build-arg HUB_TAG="$RESOLVED_HUB_TAG" \
"${token_args[@]}" \
"${mirror_args[@]}" \
-f Dockerfile.app.local \
-t aether-app:latest .
save_code_hash
@@ -372,10 +387,6 @@ if [ "$FORCE_UPDATE_HUB" = true ]; then
exit 0
fi
# 拉取最新代码
echo ">>> Pulling latest code..."
git pull
# 标记是否需要重启
NEED_RESTART=false
BASE_REBUILT=false
+11 -5
View File
@@ -11,10 +11,16 @@ set +a
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
# 启动 uvicorn(热重载模式)
echo "🚀 启动本地开发服务器..."
echo "📍 后端地址: http://localhost:8084"
echo "📊 数据库: ${DATABASE_URL}"
# 开发环境连接池低配(节省内存)
export DB_POOL_SIZE=${DB_POOL_SIZE:-5}
export DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-5}
export HTTP_MAX_CONNECTIONS=${HTTP_MAX_CONNECTIONS:-20}
export HTTP_KEEPALIVE_CONNECTIONS=${HTTP_KEEPALIVE_CONNECTIONS:-5}
# 启动 uvicorn(热重载模式,只监视 src 目录)
echo "=> 启动本地开发服务器..."
echo "=> 后端地址: http://localhost:8084"
echo "=> 数据库: ${DATABASE_URL}"
echo ""
uv run uvicorn src.main:app --reload --port 8084
uv run uvicorn src.main:app --reload --reload-dir src --port 8084
+20
View File
@@ -1,6 +1,26 @@
#!/bin/bash
set -e
# Wait for PostgreSQL to be ready
MAX_ATTEMPTS=30
ATTEMPT=0
until python -c "
from sqlalchemy import create_engine, text
import os
engine = create_engine(os.environ['DATABASE_URL'])
with engine.connect() as conn:
conn.execute(text('SELECT 1'))
" 2>/dev/null; do
ATTEMPT=$((ATTEMPT + 1))
if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
echo "Database not ready after $MAX_ATTEMPTS attempts, exiting."
exit 1
fi
echo "Waiting for database... (attempt $ATTEMPT/$MAX_ATTEMPTS)"
sleep 2
done
echo "Database is ready."
echo "Running database migrations..."
alembic upgrade head
+7
View File
@@ -24,6 +24,7 @@
"marked": "^16.0.0",
"otpauth": "^9.5.0",
"pinia": "^3.0.3",
"pinyin-pro": "^3.28.0",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",
@@ -4718,6 +4719,12 @@
}
}
},
"node_modules/pinyin-pro": {
"version": "3.28.0",
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.0.tgz",
"integrity": "sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==",
"license": "MIT"
},
"node_modules/pirates": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+1
View File
@@ -32,6 +32,7 @@
"marked": "^16.0.0",
"otpauth": "^9.5.0",
"pinia": "^3.0.3",
"pinyin-pro": "^3.28.0",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.3.1",
"three": "^0.180.0",
+66 -2
View File
@@ -5,12 +5,14 @@
</template>
<script setup lang="ts">
import { onMounted, onErrorCaptured } from 'vue'
import { onMounted, onErrorCaptured, onUnmounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ToastContainer from '@/components/ToastContainer.vue'
import ConfirmContainer from '@/components/ConfirmContainer.vue'
import apiClient from '@/api/client'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import router from '@/router'
import { hasAuthIdentityChanged } from '@/utils/authToken'
import { log } from '@/utils/logger'
const authStore = useAuthStore()
@@ -86,7 +88,59 @@ if (typeof window !== 'undefined') {
})
}
async function syncExternalAuthState(nextToken: string | null): Promise<void> {
const previousToken = authStore.token
const previousUser = authStore.user
? {
id: authStore.user.id,
role: authStore.user.role,
}
: null
authStore.syncToken()
if (!nextToken) {
if (previousToken || previousUser) {
authStore.applyExternalLogout()
await router.replace('/')
}
return
}
const identityChanged = hasAuthIdentityChanged(previousToken, nextToken, previousUser)
if (!identityChanged && previousUser) {
return
}
const user = await authStore.fetchCurrentUser()
if (!user) {
return
}
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
await router.replace('/dashboard')
}
}
function handleAuthStorageChange(event: StorageEvent): void {
if (event.key !== 'access_token') {
return
}
syncExternalAuthState(event.newValue).catch((err) => log.error('syncExternalAuthState failed', err))
}
function handleLocalAuthStateChange(event: Event): void {
const authEvent = event as CustomEvent<{ token: string | null }>
syncExternalAuthState(authEvent.detail?.token ?? apiClient.getToken()).catch((err) => log.error('syncExternalAuthState failed', err))
}
onMounted(async () => {
if (typeof window !== 'undefined') {
window.addEventListener('storage', handleAuthStorageChange)
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleLocalAuthStateChange as (event: Event) => void)
}
// 延迟检查认证状态,让页面先加载
setTimeout(async () => {
try {
@@ -97,4 +151,14 @@ onMounted(async () => {
}
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
})
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('storage', handleAuthStorageChange)
window.removeEventListener(
AUTH_STATE_CHANGE_EVENT,
handleLocalAuthStateChange as (event: Event) => void,
)
}
})
</script>
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
describe('apiClient auth state change event', () => {
beforeEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
afterEach(() => {
localStorage.clear()
apiClient.clearAuth()
})
it('dispatches a same-tab auth change event when clearing auth', () => {
const handler = vi.fn()
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
apiClient.setToken('access-token')
apiClient.clearAuth()
expect(localStorage.getItem('access_token')).toBeNull()
expect(handler).toHaveBeenCalledTimes(1)
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
expect(event.detail).toEqual({ token: null })
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
})
})
+4 -312
View File
@@ -86,6 +86,7 @@ export interface UserExport {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
model_capability_settings?: Record<string, Record<string, boolean>>
unlimited?: boolean
wallet?: BillingSummary | null
@@ -102,7 +103,7 @@ export interface UserApiKeyExport {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制
rate_limit?: number | null // legacy/null 兼容;1.3+ standalone null = 跟随系统默认
concurrent_limit?: number | null
force_capabilities?: Record<string, boolean>
is_active: boolean
@@ -349,7 +350,7 @@ export interface AdminApiKey {
total_requests?: number
total_tokens?: number
total_cost_usd?: number
rate_limit?: number | null // null = 无限制
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
allowed_providers?: string[] | null // 允许的提供商列表
allowed_api_formats?: string[] | null // 允许的 API 格式列表
allowed_models?: string[] | null // 允许的模型列表
@@ -365,7 +366,7 @@ export interface CreateStandaloneApiKeyRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
initial_balance_usd: number | null // 初始余额,null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
@@ -379,102 +380,6 @@ export interface AdminApiKeysResponse {
skip: number
}
export interface LeaderboardItem {
rank: number
id: string
name: string
value: number
requests: number
tokens: number
cost: number
}
export interface LeaderboardResponse {
items: LeaderboardItem[]
total: number
metric: string
start_date?: string | null
end_date?: string | null
}
export interface CostForecastResponse {
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
slope: number
intercept: number
start_date: string
end_date: string
}
export interface CostSavingsResponse {
cache_read_tokens: number
cache_read_cost: number
cache_creation_cost: number
estimated_full_cost: number
cache_savings: number
}
export interface QuotaUsageProvider {
id: string
name: string
quota_usd: number
used_usd: number
remaining_usd: number
usage_percent: number
quota_expires_at?: string | null
estimated_exhaust_at?: string | null
}
export interface QuotaUsageResponse {
providers: QuotaUsageProvider[]
}
export interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
export interface ErrorDistributionItem {
category: string
count: number
}
export interface ErrorTrendItem {
date: string
total: number
categories: Record<string, number>
}
export interface ErrorDistributionResponse {
distribution: ErrorDistributionItem[]
trend: ErrorTrendItem[]
}
export interface ComparisonMetric {
total_requests: number
total_tokens: number
total_cost: number
actual_total_cost: number
avg_response_time_ms: number
error_requests: number
}
export interface ComparisonResponse {
current: ComparisonMetric
comparison: ComparisonMetric
change_percent: Record<string, number | null>
current_start: string
current_end: string
comparison_start: string
comparison_end: string
}
export interface ApiKeyToggleResponse {
id: string // UUID
is_active: boolean
@@ -752,197 +657,6 @@ export const adminApi = {
return response.data
},
// Stats / Leaderboards
async getLeaderboardUsers(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
params
})
return response.data
},
20 * 1000
)
},
async getLeaderboardApiKeys(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
include_inactive?: boolean
exclude_admin?: boolean
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
params
})
return response.data
},
20 * 1000
)
},
async getLeaderboardModels(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
metric?: 'requests' | 'tokens' | 'cost'
order?: 'asc' | 'desc'
limit?: number
offset?: number
provider_name?: string
model?: string
}): Promise<LeaderboardResponse> {
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
params
})
return response.data
},
20 * 1000
)
},
async getCostForecast(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
days?: number
forecast_days?: number
}): Promise<CostForecastResponse> {
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
params
})
return response.data
},
30 * 1000
)
},
async getCostSavings(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
provider_name?: string
model?: string
}): Promise<CostSavingsResponse> {
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
params
})
return response.data
},
30 * 1000
)
},
async getQuotaUsage(): Promise<QuotaUsageResponse> {
return cachedRequest(
'admin:stats:providers:quota-usage',
async () => {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
return response.data
},
30 * 1000
)
},
async getPercentiles(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<PercentileItem[]> {
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
params
})
return response.data
},
20 * 1000
)
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> {
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
params
})
return response.data
},
20 * 1000
)
},
async getComparison(params: {
current_start: string
current_end: string
comparison_type?: 'period' | 'year'
timezone?: string
tz_offset_minutes?: number
}): Promise<ComparisonResponse> {
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
params
})
return response.data
},
// 数据清空
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
@@ -951,26 +665,4 @@ export const adminApi = {
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
purgeStats: () => purge<{ message: string }>('stats'),
async getTimeSeries(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
}): Promise<Array<Record<string, unknown>>> {
const cacheKey = buildCacheKey('admin:stats:time-series', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
return response.data
},
20 * 1000
)
},
}
+396
View File
@@ -0,0 +1,396 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
export interface AnalyticsScope {
kind: AnalyticsScopeKind
user_id?: string | null
api_key_id?: string | null
}
export interface AnalyticsTimeRange {
start_date?: string
end_date?: string
preset?: string
granularity?: AnalyticsGranularity
timezone?: string | null
tz_offset_minutes?: number
}
export interface AnalyticsFilters {
user_ids?: string[]
provider_names?: string[]
models?: string[]
target_models?: string[]
api_key_ids?: string[]
api_formats?: string[]
request_types?: string[]
statuses?: string[]
error_categories?: string[]
is_stream?: boolean | null
has_format_conversion?: boolean | null
}
export interface AnalyticsBaseRequest {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
filters?: AnalyticsFilters
}
export interface AnalyticsSummary {
requests_total: number
requests_success: number
requests_error: number
requests_stream: number
success_rate: number
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
cache_hit_rate: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
format_conversion_count: number
models_used_count: number
}
export interface AnalyticsCompositionSegment {
key: string
value: number
percentage: number
}
export interface AnalyticsOverviewResponse {
query_context: {
scope: AnalyticsScope
time_range: AnalyticsTimeRange
}
summary: AnalyticsSummary
composition: {
token_segments: AnalyticsCompositionSegment[]
cost_segments: AnalyticsCompositionSegment[]
}
}
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
bucket_start: string
bucket_end: string
}
export interface AnalyticsTimeseriesResponse {
buckets: AnalyticsTimeseriesBucket[]
}
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
dimension: AnalyticsBreakdownDimension
metric?: AnalyticsBreakdownMetric
limit?: number
}
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
key: string
label: string
share_of_total_cost: number
share_of_total_tokens: number
}
export interface AnalyticsBreakdownResponse {
dimension: AnalyticsBreakdownDimension
metric: AnalyticsBreakdownMetric
rows: AnalyticsBreakdownRow[]
}
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
search?: {
text?: string | null
request_id?: string | null
}
pagination?: {
limit?: number
offset?: number
}
}
export interface AnalyticsRecord {
id: string
request_id: string
created_at: string | null
user_id: string | null
username: string | null
api_key_id: string | null
api_key_name: string | null
provider_api_key_name: string | null
provider_name: string | null
model: string
target_model: string | null
api_format: string | null
request_type: string | null
status: string
billing_status: string
is_stream: boolean
has_format_conversion: boolean | null
has_fallback?: boolean
has_retry?: boolean
status_code: number | null
error_message: string | null
error_category: string | null
response_time_ms: number | null
first_byte_time_ms: number | null
input_tokens: number
output_tokens: number
input_output_total_tokens: number
cache_creation_input_tokens: number
cache_creation_input_tokens_5m: number
cache_creation_input_tokens_1h: number
cache_read_input_tokens: number
input_context_tokens: number
total_tokens: number
input_cost_usd: number
output_cost_usd: number
cache_creation_cost_usd: number
cache_creation_cost_usd_5m: number
cache_creation_cost_usd_1h: number
cache_read_cost_usd: number
cache_cost_usd: number
request_cost_usd: number
total_cost_usd: number
actual_total_cost_usd: number
actual_cache_cost_usd: number
rate_multiplier: number
}
export interface AnalyticsRecordsResponse {
total: number
limit: number
offset: number
records: AnalyticsRecord[]
}
export interface AnalyticsFilterOption {
value: string
label: string
}
export interface AnalyticsFilterOptionsResponse {
providers: AnalyticsFilterOption[]
models: AnalyticsFilterOption[]
target_models: AnalyticsFilterOption[]
api_formats: AnalyticsFilterOption[]
request_types: AnalyticsFilterOption[]
error_categories: AnalyticsFilterOption[]
statuses: AnalyticsFilterOption[]
users?: AnalyticsFilterOption[]
api_keys?: AnalyticsFilterOption[]
}
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
limit?: number
}
export interface AnalyticsLeaderboardItem {
rank: number
id: string
label: string
requests_total: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd: number
metric_value: number
}
export interface AnalyticsLeaderboardResponse {
entity: AnalyticsLeaderboardEntity
metric: AnalyticsLeaderboardMetric
items: AnalyticsLeaderboardItem[]
}
export interface AnalyticsPercentilePoint {
date: string
p50_response_time_ms: number | null
p90_response_time_ms: number | null
p99_response_time_ms: number | null
p50_first_byte_time_ms: number | null
p90_first_byte_time_ms: number | null
p99_first_byte_time_ms: number | null
}
export interface AnalyticsErrorCategory {
category: string
label: string
count: number
}
export interface AnalyticsErrorTrendItem {
date: string
total: number
}
export interface AnalyticsProviderHealthItem {
provider_name: string
requests_total: number
success_rate: number
error_rate: number
avg_response_time_ms: number
avg_first_byte_time_ms: number
}
export interface AnalyticsPerformanceResponse {
latency: {
response_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
first_byte_time_ms: {
avg: number
p50: number | null
p90: number | null
p99: number | null
}
}
percentiles: AnalyticsPercentilePoint[]
errors: {
total: number
rate: number
categories: AnalyticsErrorCategory[]
trend: AnalyticsErrorTrendItem[]
}
provider_health: AnalyticsProviderHealthItem[]
}
export interface AnalyticsActiveRequest {
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost?: number
total_cost_usd?: number
actual_cost?: number | null
actual_total_cost_usd?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
provider_name?: string | null
api_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}
export interface AnalyticsActiveRequestsResponse {
requests: AnalyticsActiveRequest[]
}
export const analyticsApi = {
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
return response.data
},
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
return response.data
},
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
return response.data
},
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
return response.data
},
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
return response.data
},
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
return response.data
},
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
return response.data
},
async getHeatmap(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
}): Promise<ActivityHeatmap> {
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
return response.data
},
60000,
)
},
async getActiveRequests(payload: {
scope: AnalyticsScope
ids?: string[]
}): Promise<AnalyticsActiveRequestsResponse> {
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
...payload,
ids: payload.ids ?? [],
})
return response.data
},
async analyzeCacheAffinityTTL(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
return response.data
},
async analyzeCacheAffinityHit(payload: {
scope: AnalyticsScope
user_id?: string | null
api_key_id?: string | null
hours?: number
}): Promise<Record<string, unknown>> {
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
return response.data
},
}
+202
View File
@@ -33,6 +33,203 @@ export interface AuditFilters {
offset?: number
}
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
export interface MonitoringCpuMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
load_percent: number | null
core_count: number
message?: string | null
}
export interface MonitoringMemoryMetric {
status: MonitoringMetricStatus
label: string
used_percent: number | null
used_bytes: number | null
available_bytes: number | null
total_bytes: number | null
message?: string | null
}
export interface MonitoringServiceMetric {
status: MonitoringMetricStatus
label: string
latency_ms: number | null
memory_status?: MonitoringMetricStatus
memory_label?: string | null
used_memory_bytes?: number | null
peak_memory_bytes?: number | null
maxmemory_bytes?: number | null
memory_ceiling_bytes?: number | null
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
available_memory_bytes?: number | null
memory_percent?: number | null
message?: string | null
}
export interface MonitoringPostgresMetric {
status: MonitoringMetricStatus
label: string
usage_percent: number | null
pool_usage_percent?: number | null
checked_out: number
pool_size: number
overflow: number
max_capacity: number
pool_timeout: number
server_connections?: number | null
server_max_connections?: number | null
server_usage_percent?: number | null
storage_status?: MonitoringMetricStatus
storage_label?: string | null
storage_total_bytes?: number | null
storage_free_bytes?: number | null
storage_free_percent?: number | null
database_size_bytes?: number | null
storage_message?: string | null
message?: string | null
}
export interface MonitoringSystemStatus {
timestamp: string
users: {
total: number
active: number
}
providers: {
total: number
active: number
}
api_keys: {
total: number
active: number
}
today_stats: {
requests: number
tokens: number
cost_usd: number
}
recent_errors: number
system_metrics?: {
cpu: MonitoringCpuMetric
memory: MonitoringMemoryMetric
redis: MonitoringServiceMetric
postgres: MonitoringPostgresMetric
}
}
function parseNumericLike(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return 0
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : 0
}
return 0
}
function parseNullableNumericLike(value: unknown): number | null {
if (value == null) {
return null
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const normalized = value.trim().replace(/[$,\s]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
return {
...data,
users: {
total: parseNumericLike(data.users?.total),
active: parseNumericLike(data.users?.active),
},
providers: {
total: parseNumericLike(data.providers?.total),
active: parseNumericLike(data.providers?.active),
},
api_keys: {
total: parseNumericLike(data.api_keys?.total),
active: parseNumericLike(data.api_keys?.active),
},
today_stats: {
requests: parseNumericLike(data.today_stats?.requests),
tokens: parseNumericLike(data.today_stats?.tokens),
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
},
recent_errors: parseNumericLike(data.recent_errors),
system_metrics: data.system_metrics ? {
cpu: {
status: data.system_metrics.cpu?.status ?? 'unknown',
label: data.system_metrics.cpu?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
message: data.system_metrics.cpu?.message ?? null,
},
memory: {
status: data.system_metrics.memory?.status ?? 'unknown',
label: data.system_metrics.memory?.label ?? '未知',
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
message: data.system_metrics.memory?.message ?? null,
},
redis: {
status: data.system_metrics.redis?.status ?? 'unknown',
label: data.system_metrics.redis?.label ?? '未知',
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
message: data.system_metrics.redis?.message ?? null,
},
postgres: {
status: data.system_metrics.postgres?.status ?? 'unknown',
label: data.system_metrics.postgres?.label ?? '未知',
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
storage_message: data.system_metrics.postgres?.storage_message ?? null,
message: data.system_metrics.postgres?.message ?? null,
},
} : undefined,
}
}
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
@@ -78,6 +275,11 @@ export const auditApi = {
return response.data
},
async getSystemStatus(): Promise<MonitoringSystemStatus> {
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
return normalizeMonitoringSystemStatus(response.data)
},
// 分析用户行为 (管理员)
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
analysis: Record<string, unknown>
+3 -23
View File
@@ -9,7 +9,6 @@ export interface LoginRequest {
export interface LoginResponse {
access_token: string
refresh_token?: string
token_type?: string
expires_in?: number
user_id?: string // UUID
@@ -25,13 +24,6 @@ export interface UserPreferences {
[key: string]: unknown // 允许扩展其他偏好设置
}
export interface UserStats {
total_requests?: number
total_cost?: number
last_request_at?: string
[key: string]: unknown // 允许扩展其他统计数据
}
export interface SendVerificationCodeRequest {
email: string
}
@@ -81,6 +73,7 @@ export interface RegistrationSettingsResponse {
enable_registration: boolean
require_email_verification: boolean
email_configured: boolean
password_policy_level: string
}
export interface AuthSettingsResponse {
@@ -113,23 +106,15 @@ export interface User {
role: string // 'admin' or 'user'
is_active: boolean
billing?: BillingSummary
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
created_at: string
last_login_at?: string
preferences?: UserPreferences
stats?: UserStats
}
export const authApi = {
async login(credentials: LoginRequest): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
apiClient.setToken(response.data.access_token)
// 后端暂时没有返回 refresh_token
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},
@@ -151,14 +136,9 @@ export const authApi = {
return response.data
},
async refreshToken(refreshToken: string): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
refresh_token: refreshToken
})
async refreshToken(): Promise<LoginResponse> {
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {})
apiClient.setToken(response.data.access_token)
if (response.data.refresh_token) {
localStorage.setItem('refresh_token', response.data.refresh_token)
}
return response.data
},
+19 -3
View File
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): Promise<TTLAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data
},
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
api_key_id?: string
hours?: number
}): Promise<CacheHitAnalysisResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
scope: { kind: 'global' },
user_id: params?.user_id ?? null,
api_key_id: params?.api_key_id ?? null,
hours: params?.hours,
})
return response.data
},
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
return cachedRequest(
cacheKey,
async () => {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
const response = await api.post('/api/analytics/interval-timeline', {
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
user_id: params?.user_id ?? null,
hours: params?.hours,
limit: params?.limit,
include_user_info: params?.include_user_info ?? false,
})
return response.data
},
30000
+87 -48
View File
@@ -3,10 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosReq
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { isDemoMode } from '@/config/demo'
import { handleMockRequest, setMockUserToken } from '@/mocks'
import { getClientDeviceId } from '@/utils/deviceId'
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
import { log } from '@/utils/logger'
// 在开发环境下使用代理,生产环境使用环境变量
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
/**
* 判断请求是否为公共端点
@@ -31,17 +34,15 @@ function isAuthRequest(url?: string): boolean {
}
/**
* 判断是否为可刷新的认证错误
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
*/
function isRefreshableAuthError(errorDetail: string): boolean {
const nonRefreshableErrors = [
function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
if (status !== 403) return false
const accountErrors = [
'用户不存在或已禁用',
'需要管理员权限',
'权限不足',
'用户已禁用',
]
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
return accountErrors.some((msg) => errorDetail.includes(msg))
}
/**
@@ -83,12 +84,21 @@ class ApiClient {
private client: AxiosInstance
private token: string | null = null
private isRefreshing = false
private refreshPromise: Promise<AxiosResponse> | null = null
private refreshPromise: Promise<string> | null = null
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
private readonly onStorageSync = (event: StorageEvent): void => {
if (event.key !== 'access_token') {
return
}
this.syncTokenState(event.newValue)
}
constructor() {
this.client = axios.create({
baseURL: API_BASE_URL,
timeout: NETWORK_CONFIG.API_TIMEOUT,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
@@ -99,6 +109,7 @@ class ApiClient {
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
this.setupInterceptors()
this.setupCrossTabAuthSync()
}
/**
@@ -108,6 +119,10 @@ class ApiClient {
// 请求拦截器 - 仅处理认证
this.client.interceptors.request.use(
(config) => {
if (config.url?.includes('/api/')) {
config.headers['X-Client-Device-Id'] = getClientDeviceId()
}
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
config.url?.includes('/api/')
@@ -129,6 +144,23 @@ class ApiClient {
)
}
private setupCrossTabAuthSync(): void {
if (typeof window !== 'undefined') {
window.addEventListener('storage', this.onStorageSync)
}
}
private emitAuthStateChange(token: string | null): void {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
detail: { token },
})
)
}
/**
* 处理响应错误
*/
@@ -155,8 +187,22 @@ class ApiClient {
return Promise.reject(error)
}
const status = error.response?.status ?? 0
// 处理 403 用户账号级别错误(被禁用/删除)
if (status === 403) {
const rawDetail = (error.response?.data as Record<string, unknown>)?.detail
const errorDetail = typeof rawDetail === 'string' ? rawDetail : ''
if (isAccountLevelForbidden(status, errorDetail)) {
log.info('User account issue detected, clearing auth', { errorDetail })
this.clearAuth()
window.location.href = '/'
return Promise.reject(error)
}
}
// 处理401错误
if (error.response?.status === 401) {
if (status === 401) {
return this.handle401Error(error, originalRequest)
}
@@ -177,25 +223,7 @@ class ApiClient {
return Promise.reject(error)
}
const errorDetail = (error.response?.data as Record<string, unknown>)?.detail as string || ''
log.debug('Got 401 error, attempting token refresh', { errorDetail })
// 检查是否为业务相关的401错误(用户被禁用/删除等)
if (!isRefreshableAuthError(errorDetail)) {
log.info('User account issue detected, logging out and redirecting to home', { errorDetail })
this.clearAuth()
// 跳转到首页
window.location.href = '/'
return Promise.reject(error)
}
// 获取refresh token
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) {
log.info('No refresh token available, clearing invalid token')
this.clearAuth()
return Promise.reject(error)
}
log.debug('Got 401 error, attempting token refresh')
// 标记为已重试
originalRequest._retry = true
@@ -210,8 +238,8 @@ class ApiClient {
// 如果正在刷新,等待刷新完成
if (this.isRefreshing) {
try {
await this.refreshPromise
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
const accessToken = await this.refreshPromise
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return this.client.request(originalRequest)
} catch {
return Promise.reject(error)
@@ -219,29 +247,27 @@ class ApiClient {
}
// 开始刷新token
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
return this.refreshTokenAndRetry(originalRequest, error)
}
/**
* 刷新token并重试原始请求
*/
private async refreshTokenAndRetry(
refreshToken: string,
originalRequest: InternalAxiosRequestConfig,
originalError: import('axios').AxiosError
): Promise<AxiosResponse> {
this.isRefreshing = true
this.refreshPromise = this.refreshToken(refreshToken)
this.refreshPromise = this.coordinatedRefresh()
try {
const response = await this.refreshPromise
this.setToken(response.data.access_token)
localStorage.setItem('refresh_token', response.data.refresh_token)
const accessToken = await this.refreshPromise
this.setToken(accessToken)
this.isRefreshing = false
this.refreshPromise = null
// 重试原始请求
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return this.client.request(originalRequest)
} catch (refreshError: unknown) {
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
@@ -252,15 +278,29 @@ class ApiClient {
}
}
setToken(token: string): void {
private async coordinatedRefresh(): Promise<string> {
return this.refreshCoordinator.run(async () => {
const response = await this.refreshToken()
const accessToken = response.data.access_token
if (!accessToken) {
throw new Error('Refresh response missing access token')
}
return accessToken
})
}
private syncTokenState(token: string | null): void {
this.token = token
localStorage.setItem('access_token', token)
// 同步到 mock handler
if (isDemoMode()) {
setMockUserToken(token)
}
}
setToken(token: string): void {
this.syncTokenState(token)
localStorage.setItem('access_token', token)
}
getToken(): string | null {
if (!this.token) {
this.token = localStorage.getItem('access_token')
@@ -273,18 +313,17 @@ class ApiClient {
}
clearAuth(): void {
this.token = null
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
this.syncTokenState(null)
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// 同步清除 mock token
if (isDemoMode()) {
setMockUserToken(null)
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
if (hadAuth) {
this.emitAuthStateChange(null)
}
}
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
// refreshToken 会通过 adapter 处理 Demo 模式
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
async refreshToken(): Promise<AxiosResponse> {
return this.client.post('/api/auth/refresh', {})
}
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
-370
View File
@@ -1,370 +0,0 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface DashboardStat {
name: string
value: string
subValue?: string
change?: string
changeType?: 'increase' | 'decrease' | 'neutral'
extraBadge?: string
icon: string
}
export interface RecentRequest {
id: string // UUID
user: string
model: string
tokens: number
time: string
}
export interface ProviderStatus {
name: string
status: 'active' | 'inactive'
requests: number
}
// 系统健康指标(管理员专用)
export interface SystemHealth {
avg_response_time: number
error_rate: number
error_requests: number
fallback_count: number
total_requests: number
}
// 成本统计(管理员专用)
export interface CostStats {
total_cost: number
total_actual_cost: number
cost_savings: number
}
// 缓存统计
export interface CacheStats {
cache_creation_tokens: number
cache_read_tokens: number
cache_creation_cost?: number
cache_read_cost?: number
cache_hit_rate?: number
total_cache_tokens: number
}
// 用户统计(管理员专用)
export interface UserStats {
total: number
active: number
}
// Token 详细分类
export interface TokenBreakdown {
input: number
output: number
cache_creation: number
cache_read: number
}
export interface DashboardStatsResponse {
stats: DashboardStat[]
today?: {
requests: number
tokens: number
cost: number
actual_cost?: number
cache_creation_tokens?: number
cache_read_tokens?: number
}
api_keys?: {
total: number
active: number
}
tokens?: {
month: number
}
// 管理员专用字段
system_health?: SystemHealth
cost_stats?: CostStats
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {
requests: RecentRequest[]
}
export interface ProviderStatusResponse {
providers: ProviderStatus[]
}
// 视频/图像/音频计费信息
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number // 视频时长(秒)
resolution?: string // 分辨率
video_price_per_second?: number // 每秒单价
video_cost?: number // 视频费用
cost?: number // 总费用
rule_name?: string // 计费规则名称
expression?: string // 计费公式
status?: string // 计费状态
}
export interface RequestDetail {
id: string // UUID
request_id: string
user: {
id: string // UUID
username: string
email: string
}
api_key: {
id: string // UUID
name: string
display: string
}
provider: string
api_format?: string
model: string
target_model?: string | null // 映射后的目标模型名
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
// Additional token fields
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
// Additional cost fields
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number // 按次计费费用
// Historical pricing fields (per 1M tokens)
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
request_type: string
is_stream: boolean
status_code: number
status?: string // pending, streaming, completed, failed, cancelled
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
// 阶梯计费信息
tiered_pricing?: {
total_input_context: number // 总输入上下文 (input + cache_read)
tier_index: number // 命中的阶梯索引 (0-based)
tier_count: number // 阶梯总数
source?: 'provider' | 'global' // 定价来源: 提供商或全局
current_tier: { // 当前命中的阶梯配置
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{ // 完整阶梯配置列表
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null
// 视频/图像/音频计费信息
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
}
export interface ModelBreakdown {
model: string
requests: number
tokens: number
cost: number
}
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
avg_response_time: number
cost_per_request: number
tokens_per_request: number
}
export interface ProviderSummary {
provider: string
requests: number
tokens: number
cost: number
}
export interface DailyStat {
date: string // ISO date string
requests: number
tokens: number
cost: number
avg_response_time: number // in seconds
unique_models: number
unique_providers?: number // 仅管理员返回
model_breakdown: ModelBreakdown[]
}
export interface DailyStatsResponse {
daily_stats: DailyStat[]
model_summary: ModelSummary[]
provider_summary?: ProviderSummary[] // 仅管理员返回
period: {
start_date: string
end_date: string
days: number
}
}
export interface TimeRangeParams {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
}
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const cacheKey = buildCacheKey('dashboard:stats', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
10 * 1000
)
},
// 获取最近的请求记录
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
params: { limit }
})
return response.data.requests
},
// 获取提供商状态
async getProviderStatus(): Promise<ProviderStatus[]> {
return cachedRequest(
'dashboard:provider-status',
async () => {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
return response.data.providers
},
20 * 1000
)
},
// 获取请求详情
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},
// 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params
})
return response.data
},
20 * 1000
)
},
// 获取 cURL 命令数据(含明文 API Key)
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
// 回放请求到提供商
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {}
)
return response.data
}
}
+73
View File
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
// Re-export types for convenience
export type { EndpointAPIKey, AllowedModels }
export interface GroupedFormatKey {
id: string
provider_id: string
name: string
auth_type?: string
api_key_masked: string
internal_priority: number
global_priority_by_format: Record<string, number> | null
format_priority: number | null
rate_multipliers: Record<string, number> | null
is_active: boolean
provider_active: boolean
pool_enabled: boolean
circuit_breaker_open: boolean
provider_name: string
api_format: string
api_formats: string[]
capabilities: string[]
health_score: number | null
success_rate: number | null
avg_response_time_ms: number | null
request_count: number
}
function toNumberOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* 能力定义类型
*/
@@ -125,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
return allKeys
}
/**
* 获取按 API 格式分组的 Key 列表
*/
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
const result: Record<string, GroupedFormatKey[]> = {}
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
if (!Array.isArray(keys)) continue
result[apiFormat] = keys.map((key) => ({
id: String(key.id || ''),
provider_id: String(key.provider_id || ''),
name: String(key.name || 'Unnamed Key'),
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
api_key_masked: String(key.api_key_masked || '***'),
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
global_priority_by_format:
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
? (key.global_priority_by_format as Record<string, number>)
: null,
format_priority: toNumberOrNull(key.format_priority),
rate_multipliers:
key.rate_multipliers && typeof key.rate_multipliers === 'object'
? (key.rate_multipliers as Record<string, number>)
: null,
is_active: key.is_active !== false,
provider_active: key.provider_active !== false,
pool_enabled: key.pool_enabled === true,
circuit_breaker_open: key.circuit_breaker_open === true,
provider_name: String(key.provider_name || 'Unknown Provider'),
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
health_score: toNumberOrNull(key.health_score),
success_rate: toNumberOrNull(key.success_rate),
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
request_count: toNumberOrNull(key.request_count) ?? 0,
}))
}
return result
}
/**
* 为 Provider 添加 Key
*/
+56 -8
View File
@@ -1,6 +1,7 @@
import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, ProxyConfig } from './types/provider'
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
@@ -29,7 +30,7 @@ export interface PoolStatusResponse {
* 获取 Provider 的号池状态
*/
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/pool-status`)
const response = await client.get<PoolStatusResponse>(`/api/admin/providers/${providerId}/pool-status`)
return response.data
}
@@ -40,7 +41,7 @@ export async function clearPoolCooldown(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
)
return response.data
@@ -53,7 +54,7 @@ export async function resetPoolCost(
providerId: string,
keyId: string,
): Promise<{ message: string }> {
const response = await client.post(
const response = await client.post<{ message: string }>(
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
)
return response.data
@@ -99,9 +100,20 @@ export interface PoolKeyDetail {
is_active: boolean
auth_type: string
oauth_expires_at?: number | null
oauth_invalid_at?: number | null
oauth_invalid_reason?: string | null
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_plan_type?: string | null
oauth_account_id?: string | null
oauth_account_user_id?: string | null
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
status_snapshot?: ProviderKeyStatusSnapshot | null
quota_updated_at?: number | null
health_score?: number
circuit_breaker_open?: boolean
@@ -125,6 +137,8 @@ export interface PoolKeyDetail {
cost_window_usage: number
cost_limit: number | null
request_count: number
total_tokens: number
total_cost_usd: string
sticky_sessions: number
lru_score: number | null
created_at: string | null
@@ -167,6 +181,24 @@ export interface PoolKeysQuery {
page_size?: number
search?: string
status?: 'all' | 'active' | 'cooldown' | 'inactive'
quick_selectors?: string[]
search_scope?: 'name' | 'full'
}
export interface PoolKeySelectionRequest {
search?: string
quick_selectors?: string[]
}
export interface PoolKeySelectionItem {
key_id: string
key_name: string
auth_type: string
}
export interface PoolKeySelectionResponse {
total: number
items: PoolKeySelectionItem[]
}
export interface PoolBatchAction {
@@ -201,13 +233,29 @@ export async function listPoolKeys(
providerId: string,
params: PoolKeysQuery = {},
): Promise<PoolKeysPageResponse> {
const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
const normalizedParams = {
...params,
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
}
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
return dedupedRequest(key, async () => {
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
return response.data
})
}
export async function resolvePoolKeySelection(
providerId: string,
body: PoolKeySelectionRequest,
): Promise<PoolKeySelectionResponse> {
const response = await client.post<PoolKeySelectionResponse>(
`/api/admin/pool/${providerId}/keys/resolve-selection`,
body,
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
)
return response.data
}
export async function batchActionPoolKeys(
providerId: string,
body: PoolBatchAction,
+3 -1
View File
@@ -18,6 +18,8 @@ export interface ProviderOAuthCompleteResponse {
expires_at?: number | null
has_refresh_token: boolean
email?: string | null
account_state_recheck_attempted?: boolean
account_state_recheck_error?: string | null
}
export interface ProviderOAuthCompleteResponseWithKey {
@@ -92,7 +94,7 @@ export async function completeProviderLevelOAuth(
export async function importProviderRefreshToken(
providerId: string,
data: { refresh_token: string; name?: string; proxy_node_id?: string }
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
): Promise<ProviderOAuthCompleteResponseWithKey> {
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
return resp.data
+37 -2
View File
@@ -114,8 +114,36 @@ export async function createProvider(
/**
* 删除 Provider
*/
export async function deleteProvider(providerId: string): Promise<{ message: string }> {
const response = await client.delete(`/api/admin/providers/${providerId}`)
export interface ProviderDeleteSubmitResponse {
task_id: string
status: string
message: string
}
export interface ProviderDeleteTaskResponse {
task_id: string
provider_id: string
status: string
stage: string
total_keys: number
deleted_keys: number
total_endpoints: number
deleted_endpoints: number
message: string
}
export async function deleteProvider(providerId: string): Promise<ProviderDeleteSubmitResponse> {
const response = await client.delete<ProviderDeleteSubmitResponse>(`/api/admin/providers/${providerId}`)
return response.data
}
export async function getProviderDeleteTask(
providerId: string,
taskId: string,
): Promise<ProviderDeleteTaskResponse> {
const response = await client.get<ProviderDeleteTaskResponse>(
`/api/admin/providers/${providerId}/delete-task/${taskId}`,
)
return response.data
}
@@ -166,6 +194,8 @@ export interface TestModelFailoverRequest {
api_format?: string
endpoint_id?: string
message?: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
request_id?: string
concurrency?: number
}
@@ -184,6 +214,11 @@ export interface TestAttemptDetail {
error_message?: string | null
status_code?: number | null
latency_ms?: number | null
request_url?: string | null
request_headers?: Record<string, unknown> | null
request_body?: unknown
response_headers?: Record<string, unknown> | null
response_body?: unknown
}
export interface TestModelFailoverResponse {
+36 -5
View File
@@ -1,3 +1,5 @@
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
/**
* 代理配置类型
* 支持两种模式:
@@ -12,6 +14,13 @@ export interface ProxyConfig {
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
}
export interface OAuthOrganizationInfo {
id?: string | null
title?: string | null
is_default?: boolean | null
role?: string | null
}
/**
* 请求头规则类型
* - set: 设置/覆盖请求头
@@ -35,8 +44,6 @@ export interface HeaderRuleRename {
to: string
}
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
/**
* 请求体规则类型
* - set: 设置/覆盖字段
@@ -129,10 +136,28 @@ export type BodyRuleConditionOp =
| 'exists' | 'not_exists'
| 'in' | 'type_is'
export interface BodyRuleCondition {
export interface BodyRuleConditionLeaf {
path: string
op: BodyRuleConditionOp
value?: unknown // exists / not_exists 不需要 value
source?: 'original' | 'current'
}
export interface BodyRuleConditionAll {
all: BodyRuleCondition[]
}
export interface BodyRuleConditionAny {
any: BodyRuleCondition[]
}
export type BodyRuleCondition =
| BodyRuleConditionLeaf
| BodyRuleConditionAll
| BodyRuleConditionAny
export type HeaderRule = (HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename) & {
condition?: BodyRuleCondition
}
/**
@@ -264,8 +289,13 @@ export interface EndpointAPIKey {
oauth_email?: string | null // OAuth 授权的邮箱
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_account_name?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
status_snapshot?: ProviderKeyStatusSnapshot | null
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理)
@@ -565,6 +595,7 @@ export interface HealthSummary {
total: number
active: number
unhealthy: number
circuit_open?: number
}
}
@@ -0,0 +1,36 @@
export interface OAuthStatusSnapshot {
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
label?: string | null
reason?: string | null
expires_at?: number | null
invalid_at?: number | null
source?: string | null
requires_reauth?: boolean
expiring_soon?: boolean
}
export interface AccountStatusSnapshot {
code: string
label?: string | null
reason?: string | null
blocked: boolean
source?: string | null
recoverable?: boolean
}
export interface QuotaStatusSnapshot {
code: 'unknown' | 'ok' | 'exhausted'
label?: string | null
reason?: string | null
exhausted: boolean
usage_ratio?: number | null
updated_at?: number | null
reset_seconds?: number | null
plan_type?: string | null
}
export interface ProviderKeyStatusSnapshot {
oauth: OAuthStatusSnapshot
account: AccountStatusSnapshot
quota: QuotaStatusSnapshot
}
+36 -175
View File
@@ -1,8 +1,9 @@
import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { UserSession } from '@/types/session'
export type { UserSession }
export interface Profile {
id: string // UUID
@@ -22,8 +23,6 @@ export interface Profile {
export interface UserPreferences {
avatar_url?: string
bio?: string
default_provider_id?: string // UUID
default_provider?: Record<string, unknown>
theme: string
language: string
timezone?: string
@@ -34,87 +33,6 @@ export interface UserPreferences {
}
}
// 提供商配置接口
export interface ProviderConfig {
provider_id: string
priority: number // 优先级(越高越优先)
weight: number // 负载均衡权重
enabled: boolean // 是否启用
}
// 使用记录接口
export interface UsageRecordDetail {
id: string
provider: string
model: string
input_tokens: number
output_tokens: number
total_tokens: number
cost: number // 官方费率
actual_cost?: number // 倍率消耗(仅管理员可见)
rate_multiplier?: number // 成本倍率(仅管理员可见)
response_time_ms?: number
is_stream: boolean
created_at: string
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
status_code: number
error_message?: string
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number // 按次计费价格
api_key?: {
id: string
name: string
display: string
}
}
// 模型统计接口
export interface ModelSummary {
model: string
requests: number
input_tokens: number
output_tokens: number
total_tokens: number
total_cost_usd: number
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
}
// 提供商统计接口
export interface ProviderSummary {
provider: string
requests: number
total_tokens: number
total_cost_usd: number
success_rate: number | null
avg_response_time_ms: number | null
}
// 使用统计响应接口
export interface UsageResponse {
total_requests: number
total_input_tokens: number
total_output_tokens: number
total_tokens: number
total_cost: number // 官方费率
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
billing: BillingSummary
summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
pagination?: {
total: number
limit: number
offset: number
has_more: boolean
}
records: UsageRecordDetail[]
activity_heatmap?: ActivityHeatmap | null
}
export interface ApiKey {
id: string // UUID
name: string
@@ -126,7 +44,7 @@ export interface ApiKey {
created_at: string
total_requests?: number
total_cost_usd?: number
allowed_providers?: ProviderConfig[]
rate_limit?: number | null
force_capabilities?: Record<string, boolean> | null // 强制能力配置
}
@@ -159,14 +77,36 @@ export const meApi = {
return response.data
},
async listSessions(): Promise<UserSession[]> {
const response = await apiClient.get<UserSession[]>('/api/users/me/sessions')
return response.data
},
async updateSessionLabel(sessionId: string, deviceLabel: string): Promise<UserSession> {
const response = await apiClient.patch<UserSession>(`/api/users/me/sessions/${sessionId}`, {
device_label: deviceLabel,
})
return response.data
},
async revokeSession(sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`)
return response.data
},
async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete('/api/users/me/sessions/others')
return response.data
},
// API密钥管理
async getApiKeys(): Promise<ApiKey[]> {
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
return response.data
},
async createApiKey(name: string): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> {
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
return response.data
},
@@ -196,48 +136,14 @@ export const meApi = {
return response.data
},
// 使用统计
async getUsage(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:密钥名、模型名
limit?: number
offset?: number
}): Promise<UsageResponse> {
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
return response.data
},
// 获取活跃请求状态(用于轮询更新)
async getActiveRequests(ids?: string): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
}>
}> {
const params = ids ? { ids } : {}
const response = await apiClient.get('/api/users/me/usage/active', { params })
return response.data
},
// 获取可用的提供商
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get('/api/users/me/providers')
async updateApiKey(
keyId: string,
data: { name?: string; rate_limit?: number | null }
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/users/me/api-keys/${keyId}`,
data
)
return response.data
},
@@ -281,16 +187,6 @@ export const meApi = {
return response.data
},
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
// API密钥提供商关联
async updateApiKeyProviders(keyId: string, data: {
allowed_providers?: ProviderConfig[]
}): Promise<{ message: string }> {
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
return response.data
},
// API密钥能力配置
async updateApiKeyCapabilities(keyId: string, data: {
force_capabilities?: Record<string, boolean> | null
@@ -317,39 +213,4 @@ export const meApi = {
return response.data
},
// 获取请求间隔时间线(用于散点图)
async getIntervalTimeline(params?: {
hours?: number
limit?: number
}): Promise<{
analysis_period_hours: number
total_points: number
points: Array<{ x: string; y: number; model?: string }>
models?: string[]
}> {
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
30000
)
},
/**
* 获取活跃度热力图数据(用户)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest(
'me-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
},
60000
)
}
}
+161
View File
@@ -0,0 +1,161 @@
import apiClient from './client'
export interface VideoBilling {
task_type: 'video' | 'image' | 'audio'
duration_seconds?: number
resolution?: string
video_price_per_second?: number
video_cost?: number
cost?: number
rule_name?: string
expression?: string
status?: string
}
export interface RequestDetail {
id: string
request_id: string
user: {
id: string | null
username: string | null
email: string | null
}
api_key: {
id: string | null
name: string | null
display: string | null
}
provider_api_key: {
id: string | null
name: string | null
}
provider: string
api_format?: string
model: string
target_model?: string | null
tokens: {
input: number
output: number
total: number
}
cost: {
input: number
output: number
total: number
}
input_tokens?: number
output_tokens?: number
total_tokens?: number
cache_creation_input_tokens?: number
cache_creation_input_tokens_5m?: number
cache_creation_input_tokens_1h?: number
cache_read_input_tokens?: number
input_cost?: number
output_cost?: number
total_cost?: number
cache_creation_cost?: number
cache_read_cost?: number
request_cost?: number
input_price_per_1m?: number
output_price_per_1m?: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
price_per_request?: number
request_type: string
is_stream: boolean
status_code: number
status?: string
error_message?: string
response_time_ms: number
created_at: string
request_headers?: Record<string, unknown>
request_body?: Record<string, unknown>
provider_request_headers?: Record<string, unknown>
provider_request_body?: Record<string, unknown>
response_headers?: Record<string, unknown>
client_response_headers?: Record<string, unknown>
response_body?: Record<string, unknown>
client_response_body?: Record<string, unknown>
has_request_body?: boolean
has_provider_request_body?: boolean
has_response_body?: boolean
has_client_response_body?: boolean
metadata?: Record<string, unknown>
tiered_pricing?: {
total_input_context: number
tier_index: number
tier_count: number
source?: 'provider' | 'global'
current_tier: {
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}
tiers: Array<{
up_to?: number | null
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: Array<{
ttl_minutes: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
}>
}>
} | null
video_billing?: VideoBilling | null
}
export interface CurlData {
url: string
method: string
headers: Record<string, string>
body: Record<string, unknown>
curl: string
}
export interface ReplayRequest {
provider_id?: string
endpoint_id?: string
api_key_id?: string
body_override?: Record<string, unknown>
}
export interface ReplayResponse {
url: string
provider: string
status_code: number
response_headers: Record<string, string>
response_body: Record<string, unknown>
response_time_ms: number
}
export const requestDetailsApi = {
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
params: { include_bodies: options.includeBodies ?? true },
})
return response.data
},
async getCurlData(requestId: string): Promise<CurlData> {
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
return response.data
},
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
const response = await apiClient.post<ReplayResponse>(
`/api/admin/usage/${requestId}/replay`,
params || {},
)
return response.data
},
}
-241
View File
@@ -1,241 +0,0 @@
import apiClient from './client'
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity'
export interface UsageRecord {
id: string // UUID
user_id: string // UUID
username?: string
provider_id?: string // UUID
provider_name?: string
model: string
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
total_tokens: number
cost?: number
response_time?: number
created_at: string
has_fallback?: boolean // 🆕 是否发生了 fallback
}
export interface UsageStats {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost?: number
avg_response_time: number
today?: {
requests: number
tokens: number
cost: number
}
activity_heatmap?: ActivityHeatmap | null
}
export interface UsageByModel {
model: string
request_count: number
total_tokens: number
total_cost: number
avg_response_time?: number
}
export interface UsageByUser {
user_id: string // UUID
email: string
username: string
request_count: number
total_tokens: number
total_cost: number
}
export interface UsageByProvider {
provider_id: string
provider: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
success_rate: number
error_count: number
}
export interface UsageByApiFormat {
api_format: string
request_count: number
total_tokens: number
total_cost: number
actual_cost: number
avg_response_time_ms: number
}
export interface UsageFilters {
user_id?: string // UUID
provider_id?: string // UUID
model?: string
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
page?: number
page_size?: number
}
export const usageApi = {
async getUsageRecords(filters?: UsageFilters): Promise<{
records: UsageRecord[]
total: number
page: number
page_size: number
}> {
const response = await apiClient.get('/api/usage', { params: filters })
return response.data
},
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
// 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
return response.data
},
30000 // 30秒缓存
)
},
/**
* Get usage aggregation by dimension (RESTful API)
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
* @param filters Optional filters
*/
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number }
): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
params: { group_by: groupBy, ...filters }
})
return response.data
},
30000 // 30秒缓存
)
},
// Shorthand methods using getUsageAggregation
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters)
},
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters)
},
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
},
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
},
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
records: UsageRecord[]
stats: UsageStats
}> {
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
return response.data
},
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
const response = await apiClient.get('/api/usage/export', {
params: { ...filters, format },
responseType: 'blob'
})
return response.data
},
async getAllUsageRecords(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
user_id?: string // UUID
username?: string
model?: string
provider?: string
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
status?: string // 'stream' | 'standard' | 'error'
limit?: number
offset?: number
}): Promise<{
records: Array<Record<string, unknown>>
total: number
limit: number
offset: number
}> {
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return dedupedRequest(key, async () => {
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
})
},
/**
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
* @param ids 可选,逗号分隔的请求 ID 列表
*/
async getActiveRequests(ids?: string[]): Promise<{
requests: Array<{
id: string
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number | null
cache_read_input_tokens?: number | null
cost: number
actual_cost?: number | null
rate_multiplier?: number | null
response_time_ms: number | null
first_byte_time_ms: number | null
provider?: string | null
api_key_name?: string | null
api_format?: string | null
endpoint_api_format?: string | null
has_format_conversion?: boolean | null
target_model?: string | null
}>
}> {
const params = ids?.length ? { ids: ids.join(',') } : {}
const response = await apiClient.get('/api/admin/usage/active', { params })
return response.data
},
/**
* 获取活跃度热力图数据(管理员)
* 后端已缓存5分钟
*/
async getActivityHeatmap(): Promise<ActivityHeatmap> {
return cachedRequest(
'admin-usage-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
},
60000
)
}
}
+46 -8
View File
@@ -10,6 +10,7 @@ export interface User {
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
allowed_models: string[] | null // 允许使用的模型名称列表
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
created_at: string
updated_at?: string
last_login_at?: string | null
@@ -25,6 +26,7 @@ export interface CreateUserRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
}
export interface UpdateUserRequest {
@@ -36,6 +38,7 @@ export interface UpdateUserRequest {
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number | null
}
export interface ApiKey {
@@ -49,11 +52,18 @@ export interface ApiKey {
is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key
rate_limit?: number // 速率限制(请求/分钟)
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
total_requests?: number // 总请求数
total_cost_usd?: number // 总费用
}
export interface UpsertUserApiKeyRequest {
name?: string
rate_limit?: number | null
}
export type { UserSession } from '@/types/session'
export const usersApi = {
async getAllUsers(): Promise<User[]> {
const response = await apiClient.get<User[]>('/api/admin/users')
@@ -84,8 +94,41 @@ export const usersApi = {
return response.data.api_keys
},
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
async getUserSessions(userId: string): Promise<UserSession[]> {
const response = await apiClient.get<UserSession[]>(`/api/admin/users/${userId}/sessions`)
return response.data
},
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions/${sessionId}`)
return response.data
},
async revokeAllUserSessions(userId: string): Promise<{ message: string; revoked_count: number }> {
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions`)
return response.data
},
async createApiKey(
userId: string,
data: UpsertUserApiKeyRequest
): Promise<ApiKey & { key: string }> {
const response = await apiClient.post<ApiKey & { key: string }>(
`/api/admin/users/${userId}/api-keys`,
data
)
return response.data
},
async updateApiKey(
userId: string,
keyId: string,
data: UpsertUserApiKeyRequest
): Promise<ApiKey & { message: string }> {
const response = await apiClient.put<ApiKey & { message: string }>(
`/api/admin/users/${userId}/api-keys/${keyId}`,
data
)
return response.data
},
@@ -98,10 +141,5 @@ export const usersApi = {
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
)
return response.data
},
// 管理员统计
async getUsageStats(): Promise<Record<string, unknown>> {
const response = await apiClient.get('/api/admin/usage/stats')
return response.data
}
}
+40
View File
@@ -60,6 +60,36 @@ export interface WalletTransactionsResponse extends WalletBalanceResponse {
offset: number
}
export interface DailyUsageRecord {
id?: string | null
date: string | null
timezone?: string | null
total_cost: number
total_requests: number
input_tokens: number
output_tokens: number
cache_creation_tokens: number
cache_read_tokens: number
first_finalized_at?: string | null
last_finalized_at?: string | null
aggregated_at?: string | null
is_today: boolean
}
export type FlowItem =
| { type: 'transaction'; data: WalletTransaction }
| { type: 'daily_usage'; data: DailyUsageRecord }
export interface WalletFlowResponse extends WalletBalanceResponse {
today_entry: DailyUsageRecord | null
items: FlowItem[]
total: number
limit: number
offset: number
}
export type TodayCostResponse = DailyUsageRecord
export interface PaymentOrder {
id: string
order_no: string
@@ -131,6 +161,16 @@ export const walletApi = {
return response.data
},
async getFlow(params?: { limit?: number; offset?: number }): Promise<WalletFlowResponse> {
const response = await apiClient.get<WalletFlowResponse>('/api/wallet/flow', { params })
return response.data
},
async getTodayCost(): Promise<TodayCostResponse> {
const response = await apiClient.get<TodayCostResponse>('/api/wallet/today-cost')
return response.data
},
async createRechargeOrder(payload: WalletRechargeCreateRequest): Promise<{
order: PaymentOrder
payment_instructions: Record<string, unknown>
+30 -16
View File
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -18,6 +21,7 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -44,6 +48,7 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'bar'> = {
responsive: true,
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
}
}
function createChart() {
if (!chartRef.value) return
function buildChartOptions(): ChartOptions<'bar'> {
const stackedOptions = props.stacked ? {
scales: {
x: { ...defaultOptions.scales?.x, stacked: true },
@@ -106,20 +109,26 @@ function createChart() {
}
}
return resolveChartTheme({
...defaultOptions,
...stackedOptions,
...props.options
})
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: props.data,
options: {
...defaultOptions,
...stackedOptions,
...props.options
}
data: resolveChartTheme(props.data),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -127,9 +136,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -139,11 +156,8 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
chart.options = buildChartOptions()
chart.update('none')
}
}, { deep: true })
</script>
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,10 +19,12 @@ import {
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
options: undefined
options: undefined,
showLegend: true,
})
ChartJS.register(
@@ -34,15 +39,22 @@ interface Props {
data: ChartData<'doughnut'>
options?: ChartOptions<'doughnut'>
height?: number
showLegend?: boolean
}
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'doughnut'> | null = null
let stopThemeObserver: (() => void) | null = null
const defaultOptions: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
cutout: '60%',
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
interaction: {
mode: 'nearest',
intersect: true,
},
plugins: {
legend: {
position: 'right',
@@ -71,22 +83,59 @@ const defaultOptions: ChartOptions<'doughnut'> = {
}
}
function resolvedOptions(): ChartOptions<'doughnut'> {
const incomingOptions = props.options ?? {}
const incomingPlugins = (incomingOptions.plugins ?? {}) as Record<string, unknown>
const incomingLegend = (incomingPlugins.legend ?? {}) as Record<string, unknown>
const incomingTooltip = (incomingPlugins.tooltip ?? {}) as Record<string, unknown>
const defaultPlugins = (defaultOptions.plugins ?? {}) as Record<string, unknown>
const defaultLegend = (defaultPlugins.legend ?? {}) as Record<string, unknown>
const defaultTooltip = (defaultPlugins.tooltip ?? {}) as Record<string, unknown>
return resolveChartTheme({
...defaultOptions,
...incomingOptions,
interaction: {
...defaultOptions.interaction,
...incomingOptions.interaction,
},
plugins: {
...defaultPlugins,
...incomingPlugins,
legend: {
...defaultLegend,
...incomingLegend,
display: incomingLegend.display ?? props.showLegend,
labels: {
...(defaultLegend.labels as Record<string, unknown> | undefined),
...(incomingLegend.labels as Record<string, unknown> | undefined),
},
},
tooltip: {
...defaultTooltip,
...incomingTooltip,
callbacks: {
...(defaultTooltip.callbacks as Record<string, unknown> | undefined),
...(incomingTooltip.callbacks as Record<string, unknown> | undefined),
},
},
},
})
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'doughnut',
data: props.data,
options: {
...defaultOptions,
...props.options
}
data: resolveChartTheme(props.data),
options: resolvedOptions(),
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = resolveChartTheme(props.data)
chart.update('none')
}
}
@@ -94,9 +143,17 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = resolveChartTheme(props.data)
chart.options = resolvedOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
@@ -106,8 +163,15 @@ onUnmounted(() => {
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = { ...defaultOptions, ...props.options }
chart.update()
chart.options = resolvedOptions()
chart.update('none')
}
}, { deep: true })
watch(() => props.showLegend, () => {
if (chart) {
chart.options = resolvedOptions()
chart.update('none')
}
})
</script>
+37 -17
View File
@@ -1,5 +1,8 @@
<template>
<div class="w-full h-full">
<div
class="w-full"
:style="{ height: `${props.height}px` }"
>
<canvas ref="chartRef" />
</div>
</template>
@@ -16,9 +19,11 @@ import {
Title,
Tooltip,
Legend,
Filler,
type ChartData,
type ChartOptions
} from 'chart.js'
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
const props = withDefaults(defineProps<Props>(), {
height: 300,
@@ -34,7 +39,8 @@ ChartJS.register(
LineController,
Title,
Tooltip,
Legend
Legend,
Filler
)
interface Props {
@@ -45,6 +51,18 @@ interface Props {
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null
let stopThemeObserver: (() => void) | null = null
function buildChartOptions(): ChartOptions<'line'> {
return resolveChartTheme({
...defaultOptions,
...props.options
})
}
function buildChartData(): ChartData<'line'> {
return resolveChartTheme(props.data)
}
const defaultOptions: ChartOptions<'line'> = {
responsive: true,
@@ -88,17 +106,14 @@ function createChart() {
chart = new ChartJS(chartRef.value, {
type: 'line',
data: props.data,
options: {
...defaultOptions,
...props.options
}
data: buildChartData(),
options: buildChartOptions()
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.data = buildChartData()
chart.update('none') // 禁用动画以提高性能
}
}
@@ -106,24 +121,29 @@ function updateChart() {
onMounted(async () => {
await nextTick()
createChart()
stopThemeObserver = observeChartThemeChanges(() => {
if (!chart) return
chart.data = buildChartData()
chart.options = buildChartOptions()
chart.update('none')
})
})
onUnmounted(() => {
stopThemeObserver?.()
stopThemeObserver = null
if (chart) {
chart.destroy()
chart = null
}
})
// 监听数据变化
watch(() => props.data, updateChart, { deep: true })
// 监听引用变化,避免深监听触发整图重算
watch(() => props.data, updateChart)
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
chart.options = buildChartOptions()
chart.update('none')
}
}, { deep: true })
</script>
})
</script>
+38 -33
View File
@@ -119,6 +119,11 @@ let chart: ChartJS<'scatter'> | null = null
const crosshairY = ref<number | null>(null)
const gapInfoList = ref<GapInfo[]>([])
interface PreparedRenderData {
chartData: ChartData<'scatter'>
gaps: GapInfo[]
}
const crosshairStats = computed<CrosshairStats | null>(() => {
if (crosshairY.value === null || !props.data.datasets) return null
@@ -294,6 +299,22 @@ function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
}
}
function prepareRenderData(): PreparedRenderData {
let dataToUse = props.data
let gaps: GapInfo[] = []
if (props.compressGaps) {
const compressedResult = compressTimeGaps(props.data)
dataToUse = compressedResult.data
gaps = compressedResult.gaps
}
return {
chartData: transformData(dataToUse),
gaps
}
}
// 格式化时长
function formatDuration(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60))
@@ -516,22 +537,12 @@ function handleMouseLeave() {
function createChart() {
if (!chartRef.value) return
let dataToUse = props.data
gapInfoList.value = []
// 如果启用间隙压缩
if (props.compressGaps) {
const { data: compressedData, gaps } = compressTimeGaps(props.data)
dataToUse = compressedData
gapInfoList.value = gaps
}
// 转换数据
const transformedData = transformData(dataToUse)
const { chartData, gaps } = prepareRenderData()
gapInfoList.value = gaps
chart = new ChartJS(chartRef.value, {
type: 'scatter',
data: transformedData,
data: chartData,
options: {
...defaultOptions,
...props.options
@@ -544,16 +555,9 @@ function createChart() {
function updateChart() {
if (chart) {
let dataToUse = props.data
gapInfoList.value = []
if (props.compressGaps) {
const { data: compressedData, gaps } = compressTimeGaps(props.data)
dataToUse = compressedData
gapInfoList.value = gaps
}
chart.data = transformData(dataToUse)
const { chartData, gaps } = prepareRenderData()
gapInfoList.value = gaps
chart.data = chartData
chart.update('none')
}
}
@@ -573,21 +577,22 @@ onUnmounted(() => {
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.compressGaps, () => {
if (chart) {
chart.destroy()
chart = null
}
createChart()
})
watch(
[
() => props.data,
() => props.compressGaps,
() => props.gapThreshold,
() => props.compressedGapSize
],
updateChart
)
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
chart.update('none')
}
}, { deep: true })
})
</script>
@@ -4,7 +4,7 @@
ref="fileInputRef"
type="file"
:accept="accept"
multiple
:multiple="multiple"
class="hidden"
@change="handleFileSelect"
>
@@ -94,6 +94,7 @@ const props = withDefaults(defineProps<{
disabled?: boolean
resetKey?: string | number
accept?: string
multiple?: boolean
dropTitle?: string
dropHint?: string
manualLabel?: string
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<{
disabled: false,
resetKey: '',
accept: '.json,.txt',
multiple: true,
dropTitle: '拖入导入文件或点击选择',
dropHint: '支持 .json / .txt,可多选',
manualLabel: '',
@@ -218,13 +220,19 @@ function mergeFileContents(contents: string[]): string {
}
async function readFiles(files: File[]) {
const validFiles = files.filter(isValidFileType)
const sourceFiles = props.multiple ? files : files.slice(0, 1)
if (!props.multiple && files.length > 1) {
emitError('仅支持选择 1 个文件,已读取第一个文件', '提示')
}
const validFiles = sourceFiles.filter(isValidFileType)
if (validFiles.length === 0) {
emitError('仅支持 .json 或 .txt 文件', '格式错误')
return
}
if (validFiles.length < files.length) {
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
if (validFiles.length < sourceFiles.length) {
emitError(`已忽略 ${sourceFiles.length - validFiles.length} 个不支持的文件`, '提示')
}
try {
@@ -1,117 +0,0 @@
<template>
<div class="space-y-2">
<Label class="text-sm font-medium">允许的模型</Label>
<div class="relative">
<button
type="button"
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
@click="isOpen = !isOpen"
>
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
{{ modelValue.length ? `已选择 ${modelValue.length} 个` : '全部可用' }}
<span
v-if="invalidModels.length"
class="text-destructive"
>({{ invalidModels.length }} 个已失效)</span>
</span>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform"
:class="isOpen ? 'rotate-180' : ''"
/>
</button>
<div
v-if="isOpen"
class="fixed inset-0 z-[80]"
@click.stop="isOpen = false"
/>
<div
v-if="isOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
>
<!-- 失效模型(置顶显示,只能取消选择) -->
<div
v-for="modelName in invalidModels"
:key="modelName"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
@click="removeModel(modelName)"
>
<input
type="checkbox"
:checked="true"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="removeModel(modelName)"
>
<span class="text-sm text-destructive">{{ modelName }}</span>
<span class="text-xs text-destructive/70">(已失效)</span>
</div>
<!-- 有效模型 -->
<div
v-for="model in models"
:key="model.name"
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
@click="toggleModel(model.name)"
>
<input
type="checkbox"
:checked="modelValue.includes(model.name)"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
@click.stop
@change="toggleModel(model.name)"
>
<span class="text-sm">{{ model.name }}</span>
</div>
<div
v-if="models.length === 0 && invalidModels.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
暂无可用模型
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Label } from '@/components/ui'
import { ChevronDown } from 'lucide-vue-next'
import { useInvalidModels } from '@/composables/useInvalidModels'
export interface ModelWithName {
name: string
}
const props = defineProps<{
modelValue: string[]
models: ModelWithName[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const isOpen = ref(false)
// 检测失效模型
const { invalidModels } = useInvalidModels(
computed(() => props.modelValue),
computed(() => props.models)
)
function toggleModel(name: string) {
const newValue = [...props.modelValue]
const index = newValue.indexOf(name)
if (index === -1) {
newValue.push(name)
} else {
newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
}
function removeModel(name: string) {
const newValue = props.modelValue.filter(m => m !== name)
emit('update:modelValue', newValue)
}
</script>
+140 -42
View File
@@ -2,21 +2,28 @@
<div class="relative">
<button
type="button"
:class="cn(
'h-9 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors gap-1',
triggerClass,
)"
:class="
cn(
'flex h-10 w-full items-center justify-between rounded-lg border bg-background px-3 text-left transition-colors',
disabled ? 'cursor-not-allowed opacity-60 hover:bg-background' : 'hover:bg-muted/50',
triggerClass,
)
"
:disabled="disabled"
@click="isOpen = !isOpen"
>
<span
:class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'"
class="text-xs truncate"
class="truncate text-sm"
>
{{ displayText }}
<span
v-if="invalidItems.length"
class="text-destructive"
>({{ invalidItems.length }} 个已失效)</span>
</span>
<ChevronDown
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
:class="isOpen ? 'rotate-180' : ''"
/>
</button>
@@ -27,76 +34,163 @@
/>
<div
v-if="isOpen"
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
class="absolute z-[90] w-full mt-1 rounded-lg border bg-popover shadow-lg"
:style="dropdownMinWidth ? { minWidth: dropdownMinWidth } : undefined"
>
<div
v-for="item in options"
:key="item.value"
class="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer text-xs"
@click="toggle(item.value)"
v-if="showSearch"
class="sticky top-0 z-10 border-b bg-popover/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-popover/85"
>
<input
type="checkbox"
:checked="modelValue.includes(item.value)"
class="h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm accent-primary cursor-pointer"
@click.stop
@change="toggle(item.value)"
>
<span class="text-sm">{{ item.label }}</span>
<div class="relative">
<Search
class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="h-8 rounded-md border-border/60 bg-popover pl-8 text-xs"
@keydown.stop
/>
</div>
</div>
<div
v-if="options.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ emptyText }}
<div class="max-h-48 overflow-y-auto">
<div
v-for="item in filteredInvalidItems"
:key="'invalid-' + item"
class="flex cursor-pointer items-center gap-2 bg-destructive/5 px-3 py-2 hover:bg-muted/50"
@click="remove(item)"
>
<input
type="checkbox"
:checked="true"
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
@click.stop
@change="remove(item)"
>
<span class="min-w-0 truncate text-sm text-destructive">{{ item }}</span>
<span class="shrink-0 text-xs text-destructive/70">(已失效)</span>
</div>
<div
v-for="item in filteredOptions"
:key="item.value"
class="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/50"
@click="toggle(item.value)"
>
<input
type="checkbox"
:checked="modelValue.includes(item.value)"
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
@click.stop
@change="toggle(item.value)"
>
<span class="min-w-0 truncate text-sm">{{ item.label }}</span>
</div>
<div
v-if="filteredOptions.length === 0 && filteredInvalidItems.length === 0"
class="px-3 py-2 text-sm text-muted-foreground"
>
{{ searchQuery.trim() ? noResultsText : emptyText }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Search } from 'lucide-vue-next'
import { Input } from '@/components/ui'
import { cn } from '@/lib/utils'
import { matchesSearchQuery } from '@/utils/search'
export interface MultiSelectOption {
value: string
label: string
}
const props = withDefaults(defineProps<{
modelValue: string[]
options: MultiSelectOption[]
placeholder?: string
emptyText?: string
triggerClass?: string
dropdownMinWidth?: string
disabled?: boolean
}>(), {
placeholder: '请选择',
emptyText: '暂无选项',
triggerClass: '',
dropdownMinWidth: undefined,
disabled: false,
})
const props = withDefaults(
defineProps<{
modelValue: string[]
options: MultiSelectOption[]
placeholder?: string
emptyText?: string
noResultsText?: string
triggerClass?: string
dropdownMinWidth?: string
disabled?: boolean
searchable?: boolean
searchThreshold?: number
searchPlaceholder?: string
}>(),
{
placeholder: '请选择',
emptyText: '暂无选项',
noResultsText: '未找到匹配项',
triggerClass: '',
dropdownMinWidth: undefined,
disabled: false,
searchable: true,
searchThreshold: 8,
searchPlaceholder: '输入关键词搜索...',
},
)
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const isOpen = ref(false)
const searchQuery = ref('')
const validValues = computed(() => new Set(props.options.map(o => o.value)))
const invalidItems = computed(() =>
props.modelValue.filter(v => !validValues.value.has(v)),
)
const totalCount = computed(() => props.options.length + invalidItems.value.length)
const showSearch = computed(
() => props.searchable && totalCount.value >= props.searchThreshold,
)
const filteredInvalidItems = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return invalidItems.value
}
return invalidItems.value.filter((item) =>
matchesSearchQuery(searchQuery.value, item),
)
})
const filteredOptions = computed(() => {
if (!showSearch.value || !searchQuery.value.trim()) {
return props.options
}
return props.options.filter((item) =>
matchesSearchQuery(searchQuery.value, item.label, item.value),
)
})
const displayText = computed(() => {
if (props.modelValue.length === 0) return props.placeholder
if (props.modelValue.length <= 2) {
return props.modelValue
.map(v => props.options.find(o => o.value === v)?.label ?? v)
.map((v) => props.options.find((o) => o.value === v)?.label ?? v)
.join(', ')
}
return `已选择 ${props.modelValue.length} 项`
})
watch(isOpen, (open) => {
if (!open) {
searchQuery.value = ''
}
})
function toggle(value: string) {
const newValue = [...props.modelValue]
const index = newValue.indexOf(value)
@@ -107,4 +201,8 @@ function toggle(value: string) {
}
emit('update:modelValue', newValue)
}
function remove(value: string) {
emit('update:modelValue', props.modelValue.filter(v => v !== value))
}
</script>
@@ -3,39 +3,24 @@
<Select
v-model="selectedPreset"
>
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
<SelectTrigger :class="presetTriggerClass">
<SelectValue placeholder="选择时间段" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">
今天
</SelectItem>
<SelectItem value="yesterday">
昨天
</SelectItem>
<SelectItem value="last7days">
最近7天
</SelectItem>
<SelectItem value="last30days">
最近30天
</SelectItem>
<SelectItem value="last90days">
最近90天
<SelectItem value="last180days">
最近180天
</SelectItem>
<SelectItem value="this_week">
本周
</SelectItem>
<SelectItem value="last_week">
上周
</SelectItem>
<SelectItem value="this_month">
本月
</SelectItem>
<SelectItem value="last_month">
上月
</SelectItem>
<SelectItem value="this_year">
今年
<SelectItem value="last1year">
最近一年
</SelectItem>
<SelectItem value="custom">
自定义
@@ -50,13 +35,13 @@
<Input
v-model="startDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
<span class="text-xs text-muted-foreground">至</span>
<Input
v-model="endDate"
type="date"
class="h-8 w-36 text-xs border-border/60"
:class="dateInputClass"
/>
</div>
@@ -64,10 +49,16 @@
v-if="showGranularity"
v-model="selectedGranularity"
>
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
<SelectTrigger :class="granularityTriggerClass">
<SelectValue placeholder="粒度" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-if="includeAutoGranularity"
value="auto"
>
自动
</SelectItem>
<SelectItem
v-if="allowHourly && canUseHourly"
value="hour"
@@ -100,26 +91,51 @@ import {
} from '@/components/ui'
import type { DateRangeParams } from '@/features/usage/types'
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
const props = defineProps<{
modelValue: DateRangeParams
showGranularity?: boolean
allowHourly?: boolean
includeAutoGranularity?: boolean
compact?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: DateRangeParams]
}>()
const selectedPreset = ref(props.modelValue.preset || 'last7days')
function getDefaultGranularity(): PickerGranularity {
return props.includeAutoGranularity ? 'auto' : 'day'
}
const selectedPreset = ref(props.modelValue.preset || 'today')
const startDate = ref(props.modelValue.start_date || '')
const endDate = ref(props.modelValue.end_date || '')
const selectedGranularity = ref(props.modelValue.granularity || 'day')
const selectedGranularity = ref<PickerGranularity>(props.modelValue.granularity || getDefaultGranularity())
const showGranularity = computed(() => props.showGranularity !== false)
const allowHourly = computed(() => props.allowHourly === true)
const includeAutoGranularity = computed(() => props.includeAutoGranularity === true)
const compact = computed(() => props.compact === true)
const presetTriggerClass = computed(() =>
compact.value
? 'h-8 w-28 text-xs border-border/60'
: 'h-8 w-32 text-xs border-border/60',
)
const dateInputClass = computed(() =>
compact.value
? 'h-8 w-32 text-xs border-border/60'
: 'h-8 w-36 text-xs border-border/60',
)
const granularityTriggerClass = computed(() =>
compact.value
? 'h-8 w-20 text-xs border-border/60'
: 'h-8 w-24 text-xs border-border/60',
)
const canUseHourly = computed(() => {
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
if (selectedPreset.value === 'today') return true
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
return startDate.value === endDate.value
}
@@ -165,7 +181,7 @@ watch(() => props.modelValue, (value) => {
if (value.preset) selectedPreset.value = value.preset
if (value.start_date !== undefined) startDate.value = value.start_date || ''
if (value.end_date !== undefined) endDate.value = value.end_date || ''
if (value.granularity) selectedGranularity.value = value.granularity
selectedGranularity.value = value.granularity || getDefaultGranularity()
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
lastEmittedValue = getValueKey(value)
}, { deep: true })
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
if (!allowHourly.value || !canUseHourly.value) {
if (selectedGranularity.value === 'hour') {
selectedGranularity.value = 'day'
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
}
}
-1
View File
@@ -9,6 +9,5 @@ export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'
// 表单组件
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export { default as TimeRangePicker } from './TimeRangePicker.vue'
@@ -13,8 +13,11 @@
<p class="mt-0.5">
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
</p>
<p class="text-[11px] text-muted-foreground">
成本 {{ formatCurrency(tooltip.day.total_cost) }}
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
{{ valueLabel }} {{ formatCurrency(tooltip.day.total_cost) }}
<template v-if="actualValueLabel && tooltip.day.actual_total_cost !== undefined">
· {{ actualValueLabel }} {{ formatCurrency(tooltip.day.actual_total_cost) }}
</template>
</p>
</div>
</Teleport>
@@ -90,7 +93,7 @@
class="flex items-center invisible"
>周六</span>
</div>
<div class="flex-1 min-w-[200px]">
<div class="flex-1 min-w-0">
<div
ref="heatmapWrapper"
class="relative block w-full"
@@ -100,12 +103,12 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
</div>
</div>
<div
@@ -113,7 +116,7 @@
:style="horizontalGapStyle"
>
<div
v-for="(week, weekIndex) in weekColumns"
v-for="(week, weekIndex) in visibleWeekColumns"
:key="weekIndex"
class="flex flex-col"
:style="verticalGapStyle"
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
title?: string
subtitle?: string
showHeader?: boolean
valueLabel?: string
actualValueLabel?: string
}>(), {
data: undefined,
title: undefined,
subtitle: undefined,
showHeader: true
showHeader: true,
valueLabel: '费用',
actualValueLabel: undefined,
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
if (month === lastMonth) {
return
}
markers[index] = `${month + 1}月`
markers[index] = String(month + 1)
lastMonth = month
})
return markers
})
const MIN_CELL_SIZE = 6
// 根据容器宽度计算最多能放多少周,然后从后面截取(保留最新数据)
const maxVisibleWeeks = computed(() => {
const total = weekColumns.value.length
if (!heatmapWidth.value || !total) return total
// 先尝试用理想尺寸放全部
const totalGapAll = Math.max(total - 1, 0) * cellGap.value
const rawSizeAll = (heatmapWidth.value - totalGapAll) / total
if (rawSizeAll >= MIN_CELL_SIZE) return total
// 放不下,算容器最多能容纳几周(用最小尺寸)
// n * MIN_CELL_SIZE + (n-1) * gap <= width
const maxN = Math.floor((heatmapWidth.value + cellGap.value) / (MIN_CELL_SIZE + cellGap.value))
return Math.max(1, Math.min(maxN, total))
})
const visibleWeekColumns = computed(() => {
const all = weekColumns.value
const n = maxVisibleWeeks.value
if (n >= all.length) return all
return all.slice(all.length - n)
})
const visibleMonthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = visibleWeekColumns.value
let lastMonth: number | null = null
columns.forEach((week, index) => {
const firstValid = week.find((day): day is DayWithMeta => day !== null)
if (!firstValid) return
const month = firstValid.dateObj.getUTCMonth()
if (month === lastMonth) return
markers[index] = String(month + 1)
lastMonth = month
})
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
const columnCount = visibleWeekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 自适应尺寸,最小 6px
cellSize.value = Math.max(6, rawSize)
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipWidth = 280
const tooltipHeight = 72
// Calculate horizontal position (centered on cell)
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = day.date
const costLabel = formatCurrency(day.total_cost || 0)
const costLabel = `${props.valueLabel} ${formatCurrency(day.total_cost || 0)}`
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
}
return parts.join(' · ')
}
@@ -1,38 +0,0 @@
<template>
<Card class="p-4 space-y-2">
<div class="text-xs text-muted-foreground">
{{ label }}
</div>
<div class="text-lg font-semibold">
{{ value }}
</div>
<div
class="text-xs"
:class="changeClass"
>
<span v-if="changePercent !== null">{{ changePercent }}%</span>
<span v-else>--</span>
<span class="ml-1 text-muted-foreground">vs 对比期</span>
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Card } from '@/components/ui'
interface Props {
label: string
value: string
changePercent: number | null
}
const props = defineProps<Props>()
const changeClass = computed(() => {
if (props.changePercent === null) return 'text-muted-foreground'
if (props.changePercent > 0) return 'text-emerald-500'
if (props.changePercent < 0) return 'text-rose-500'
return 'text-muted-foreground'
})
</script>
@@ -1,93 +0,0 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else
class="h-[280px]"
>
<LineChart
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
interface Props {
title: string
subtitle?: string
history: Array<{ date: string; total_cost: number }>
forecast: Array<{ date: string; total_cost: number }>
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
})
const labels = computed(() => [
...props.history.map(item => item.date),
...props.forecast.map(item => item.date)
])
const chartData = computed(() => {
const historyValues = props.history.map(item => item.total_cost)
const forecastValues = props.forecast.map(item => item.total_cost)
return {
labels: labels.value,
datasets: [
{
label: '实际成本',
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.15)',
tension: 0.25,
pointRadius: 2
},
{
label: '预测成本',
data: new Array(historyValues.length).fill(null).concat(forecastValues),
borderColor: 'rgb(234, 179, 8)',
backgroundColor: 'rgba(234, 179, 8, 0.15)',
borderDash: [6, 4],
tension: 0.25,
pointRadius: 2
}
]
}
})
const chartOptions = computed(() => ({
plugins: {
tooltip: {
callbacks: {
label: (context: { parsed?: { y?: number }; dataset: { label?: string } }) => {
const value = context.parsed?.y ?? 0
return `${context.dataset.label}: ${formatCurrency(value)}`
}
}
}
}
}))
</script>
@@ -1,14 +1,34 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<p
v-if="subtitle"
class="mt-1 text-[11px] text-muted-foreground"
>
{{ subtitle }}
</p>
</div>
<div
v-if="!unavailable"
class="flex flex-wrap gap-2"
>
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
总错误 {{ totalErrors }}
</div>
<div
v-if="topCategory"
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
>
TOP {{ topCategory.label }} · {{ topCategory.count }}
</div>
</div>
</div>
<div
v-if="loading"
class="p-6"
@@ -16,57 +36,116 @@
<LoadingState />
</div>
<div
v-else
class="h-[260px]"
v-else-if="unavailable"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
<DoughnutChart
:data="chartData"
:options="chartOptions"
/>
{{ unavailableText }}
</div>
<div
v-else-if="!hasData"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
暂无错误分布数据
</div>
<div
v-else
class="rounded-2xl border border-border/60 bg-background p-3"
>
<div class="mx-auto w-full max-w-[320px]">
<DoughnutChart
:data="chartData"
:options="chartOptions"
:show-legend="true"
:height="248"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
import { LoadingState } from '@/components/common'
import type { ErrorDistributionItem } from '@/api/admin'
interface ErrorDistributionItem {
category: string
label: string
count: number
}
interface Props {
title: string
subtitle?: string
distribution: ErrorDistributionItem[]
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
})
const chartData = computed(() => ({
labels: props.distribution.map(item => item.category),
const chartColors = [
'rgba(226, 85, 118, 0.88)',
'rgba(84, 135, 237, 0.86)',
'rgba(225, 165, 52, 0.84)',
'rgba(68, 187, 132, 0.82)',
'rgba(148, 163, 184, 0.8)',
'rgba(145, 118, 214, 0.82)',
]
const totalErrors = computed(() => props.distribution.reduce((sum, item) => sum + item.count, 0))
const hasData = computed(() => totalErrors.value > 0)
const distributionWithShare = computed(() => props.distribution.map(item => ({
...item,
label: item.label?.trim() || item.category,
share: totalErrors.value > 0 ? `${((item.count / totalErrors.value) * 100).toFixed(1)}%` : '0.0%',
})))
const topCategory = computed(() => (
distributionWithShare.value.slice().sort((left, right) => right.count - left.count)[0] ?? null
))
const chartData = computed<ChartData<'doughnut'>>(() => ({
labels: distributionWithShare.value.map(item => item.label),
datasets: [
{
data: props.distribution.map(item => item.count),
backgroundColor: [
'rgba(239, 68, 68, 0.7)',
'rgba(59, 130, 246, 0.7)',
'rgba(234, 179, 8, 0.7)',
'rgba(34, 197, 94, 0.7)',
'rgba(148, 163, 184, 0.7)'
],
borderWidth: 0
}
]
data: distributionWithShare.value.map(item => item.count),
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
borderColor: 'rgba(255,255,255,0.92)',
borderWidth: 2,
hoverOffset: 6,
},
],
}))
const chartOptions = computed(() => ({
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
cutout: '68%',
plugins: {
legend: {
position: 'bottom' as const
}
}
position: 'bottom',
align: 'center',
labels: {
usePointStyle: true,
pointStyle: 'circle',
boxWidth: 10,
padding: 14,
},
},
tooltip: {
callbacks: {
label: (context) => {
const value = Number(context.raw ?? 0)
const percentage = totalErrors.value > 0 ? ((value / totalErrors.value) * 100).toFixed(1) : '0.0'
return `${context.label ?? '错误'}: ${value} (${percentage}%)`
},
},
},
},
}))
</script>
@@ -1,34 +1,30 @@
<template>
<TableCard :title="title">
<template #actions>
<Select
v-if="showMetricSelect"
:model-value="metric"
@update:model-value="emitMetric"
<div
v-if="isRefreshing"
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
>
<SelectTrigger class="h-8 text-xs w-28">
<SelectValue placeholder="指标" />
</SelectTrigger>
<SelectContent>
<SelectItem value="requests">
请求数
</SelectItem>
<SelectItem value="tokens">
Tokens
</SelectItem>
<SelectItem value="cost">
成本
</SelectItem>
</SelectContent>
</Select>
更新中
</div>
</template>
<div
v-if="loading"
v-if="showLoadingState"
class="p-6"
>
<LoadingState />
</div>
<div
v-else-if="showUnavailableState"
class="p-6"
>
<EmptyState
type="error"
:title="unavailableTitle"
:description="unavailableDescription"
/>
</div>
<div
v-else-if="items.length === 0"
class="p-6"
@@ -38,20 +34,29 @@
description="当前时间范围内没有统计结果"
/>
</div>
<Table v-else>
<Table
v-else
class="table-fixed"
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
>
<TableHeader>
<TableRow>
<TableHead class="w-16">
<TableHead class="w-14 px-2.5">
排名
</TableHead>
<TableHead>名称</TableHead>
<TableHead class="text-right">
<TableHead class="w-[34%] px-2.5">
名称
</TableHead>
<TableHead class="w-[15%] px-2.5 text-right">
请求数
</TableHead>
<TableHead class="text-right">
<TableHead class="w-[18%] px-2.5 text-right">
Tokens
</TableHead>
<TableHead class="text-right">
<TableHead class="w-[16%] px-2.5 text-right">
费用
</TableHead>
<TableHead class="w-[17%] px-2.5 text-right">
成本
</TableHead>
</TableRow>
@@ -60,68 +65,118 @@
<TableRow
v-for="item in items"
:key="item.id"
class="cursor-pointer"
:class="item.id === selectedId
? 'bg-[linear-gradient(90deg,rgba(232,145,89,0.16),rgba(232,145,89,0.05),transparent)] shadow-[inset_4px_0_0_0_rgba(232,145,89,0.9)] hover:bg-[linear-gradient(90deg,rgba(232,145,89,0.2),rgba(232,145,89,0.06),transparent)]'
: 'hover:bg-muted/25'"
@click="emit('select', item.id)"
>
<TableCell class="font-medium">
{{ item.rank }}
<TableCell class="px-2.5 py-2.5">
<div
class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums transition-colors"
:class="item.id === selectedId
? 'border-amber-500/70 bg-amber-500/15 text-amber-700 dark:text-amber-300'
: 'border-border/70 bg-muted/35 text-muted-foreground'"
>
{{ item.rank }}
</div>
</TableCell>
<TableCell>{{ item.name }}</TableCell>
<TableCell class="text-right">
{{ item.requests }}
<TableCell class="px-2.5 py-2.5">
<div class="min-w-0">
<div
class="truncate text-sm font-medium"
:title="item.name"
>
{{ item.name }}
</div>
<div class="mt-0.5 truncate text-[10px] text-muted-foreground">
费用优先
</div>
</div>
</TableCell>
<TableCell class="text-right">
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
{{ item.requests.toLocaleString() }}
</TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
{{ formatTokens(item.tokens) }}
</TableCell>
<TableCell class="text-right">
<TableCell class="px-2.5 py-2.5 text-right text-[11px] font-medium tabular-nums text-amber-700 dark:text-amber-300">
{{ formatCurrency(item.cost) }}
</TableCell>
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
{{ formatCurrency(item.actualCost) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
<template v-if="showPagination" #pagination>
<Pagination
:current="currentPage"
:total="totalItems"
:page-size="pageSize"
:show-page-size-selector="false"
@update:current="emit('update:currentPage', $event)"
/>
</template>
</TableCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { EmptyState, LoadingState } from '@/components/common'
import { TableCard } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui'
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
import { formatCurrency, formatTokens } from '@/utils/format'
import type { LeaderboardItem } from '@/api/admin'
interface LeaderboardItem {
rank: number
id: string
name: string
requests: number
tokens: number
cost: number
actualCost: number
}
interface Props {
title: string
items: LeaderboardItem[]
metric: 'requests' | 'tokens' | 'cost'
selectedId?: string
loading?: boolean
showMetricSelect?: boolean
hasLoaded?: boolean
currentPage?: number
totalItems?: number
pageSize?: number
showPagination?: boolean
unavailable?: boolean
unavailableTitle?: string
unavailableDescription?: string
}
const props = withDefaults(defineProps<Props>(), {
selectedId: undefined,
loading: false,
showMetricSelect: true
hasLoaded: false,
currentPage: 1,
totalItems: 0,
pageSize: 5,
showPagination: false,
unavailable: false,
unavailableTitle: '排行榜暂不可用',
unavailableDescription: '接口未返回结果,请稍后重试',
})
const emit = defineEmits<{
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
(e: 'select', value: string): void
(e: 'update:currentPage', value: number): void
}>()
const metric = computed(() => props.metric)
function emitMetric(value: string) {
if (value === 'requests' || value === 'tokens' || value === 'cost') {
emit('update:metric', value)
}
}
const selectedId = computed(() => props.selectedId)
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
const currentPage = computed(() => props.currentPage)
const totalItems = computed(() => props.totalItems)
const pageSize = computed(() => props.pageSize)
const showPagination = computed(() => props.showPagination && props.totalItems > props.pageSize)
</script>
+193 -53
View File
@@ -1,27 +1,59 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
<div class="space-y-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<p
v-if="subtitle"
class="mt-1 text-[11px] text-muted-foreground"
>
{{ subtitle }}
</p>
</div>
<div
v-if="summaryMetrics.length > 0 && !unavailable"
class="flex flex-wrap gap-2"
>
<div
v-for="metric in summaryMetrics"
:key="metric.label"
class="rounded-full border px-2.5 py-1 text-[10px] font-medium"
:class="metric.className"
>
{{ metric.label }} {{ metric.value }}
</div>
</div>
</div>
<div
v-if="loading"
class="p-6"
>
<LoadingState />
</div>
<div
v-else-if="unavailable"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
{{ unavailableText }}
</div>
<div
v-else-if="!hasData"
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
>
暂无延迟数据
</div>
<div
v-else
class="h-[260px]"
class="rounded-2xl border border-border/60 bg-background p-3"
>
<LineChart
:data="chartData"
:options="chartOptions"
:height="248"
/>
</div>
</div>
@@ -29,9 +61,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ChartData, ChartOptions } from 'chart.js'
import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState } from '@/components/common'
import type { PercentileItem } from '@/api/admin'
interface PercentileItem {
date: string
p50_response_time_ms?: number | null
p90_response_time_ms?: number | null
p99_response_time_ms?: number | null
p50_first_byte_time_ms?: number | null
p90_first_byte_time_ms?: number | null
p99_first_byte_time_ms?: number | null
}
interface Props {
title: string
@@ -39,61 +81,159 @@ interface Props {
series: PercentileItem[]
mode: 'response' | 'ttfb'
loading?: boolean
unavailable?: boolean
unavailableText?: string
}
const props = withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
loading: false,
unavailable: false,
unavailableText: '数据暂不可用',
})
const labels = computed(() => props.series.map(item => item.date))
// 毫秒转秒
function msToSeconds(ms: number | null | undefined): number | null {
if (ms == null) return null
return ms / 1000
return Number((ms / 1000).toFixed(3))
}
const chartData = computed(() => {
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
return {
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => msToSeconds(item[p50Key])),
borderColor: 'rgb(59, 130, 246)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P90',
data: props.series.map(item => msToSeconds(item[p90Key])),
borderColor: 'rgb(234, 179, 8)',
tension: 0.25,
pointRadius: 2
},
{
label: 'P99',
data: props.series.map(item => msToSeconds(item[p99Key])),
borderColor: 'rgb(239, 68, 68)',
tension: 0.25,
pointRadius: 2
}
]
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
if (props.mode === 'response') {
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
return msToSeconds(item.p99_response_time_ms)
}
})
const chartOptions = computed(() => ({
if (percentile === '50') return msToSeconds(item.p50_first_byte_time_ms)
if (percentile === '90') return msToSeconds(item.p90_first_byte_time_ms)
return msToSeconds(item.p99_first_byte_time_ms)
}
function getLatestMetricValue(percentile: '50' | '90' | '99') {
for (let index = props.series.length - 1; index >= 0; index -= 1) {
const value = getPercentileValue(props.series[index], percentile)
if (value != null) return value
}
return null
}
function formatSeconds(value: number | null) {
if (value == null) return '--'
if (value < 1) return `${Math.round(value * 1000)}ms`
return `${value.toFixed(2)}s`
}
const labels = computed(() => props.series.map(item => item.date?.slice(5) ?? ''))
const hasData = computed(() => props.series.some(item => (
getPercentileValue(item, '50') != null
|| getPercentileValue(item, '90') != null
|| getPercentileValue(item, '99') != null
)))
const summaryMetrics = computed(() => ([
{
label: 'P50',
value: formatSeconds(getLatestMetricValue('50')),
className: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
},
{
label: 'P90',
value: formatSeconds(getLatestMetricValue('90')),
className: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
},
{
label: 'P99',
value: formatSeconds(getLatestMetricValue('99')),
className: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
},
]))
const chartData = computed<ChartData<'line'>>(() => ({
labels: labels.value,
datasets: [
{
label: 'P50',
data: props.series.map(item => getPercentileValue(item, '50')),
borderColor: 'rgb(70, 136, 240)',
backgroundColor: 'rgba(70, 136, 240, 0.10)',
borderWidth: 2.4,
fill: false,
tension: 0.32,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
{
label: 'P90',
data: props.series.map(item => getPercentileValue(item, '90')),
borderColor: 'rgb(223, 166, 55)',
backgroundColor: 'rgba(223, 166, 55, 0.08)',
borderWidth: 2.1,
fill: false,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
{
label: 'P99',
data: props.series.map(item => getPercentileValue(item, '99')),
borderColor: 'rgb(214, 92, 111)',
backgroundColor: 'rgba(214, 92, 111, 0.08)',
borderWidth: 2,
borderDash: [6, 4],
fill: false,
tension: 0.28,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 14,
},
],
}))
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
boxWidth: 10,
padding: 14,
},
},
tooltip: {
callbacks: {
title: items => props.series[items[0]?.dataIndex ?? -1]?.date ?? '',
label: context => `${context.dataset.label}: ${formatSeconds(Number(context.raw ?? 0))}`,
},
},
},
scales: {
y: {
x: {
grid: {
color: 'rgba(148, 163, 184, 0.06)',
},
ticks: {
callback: (value: number) => `${value}s`
}
}
}
color: 'rgb(100, 116, 139)',
maxRotation: 0,
},
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.07)',
},
ticks: {
color: 'rgb(100, 116, 139)',
callback: value => formatSeconds(Number(value)),
},
},
},
}))
</script>
@@ -1,81 +0,0 @@
<template>
<Card class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold">
{{ title }}
</h3>
<span
v-if="subtitle"
class="text-xs text-muted-foreground"
>{{ subtitle }}</span>
</div>
<div
v-if="loading"
class="p-4"
>
<LoadingState />
</div>
<div
v-else-if="providers.length === 0"
class="p-4"
>
<EmptyState
title="暂无数据"
description="暂无月卡配额数据"
/>
</div>
<div
v-else
class="space-y-4"
>
<div
v-for="provider in providers"
:key="provider.id"
class="space-y-2"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium">{{ provider.name }}</span>
<span class="text-muted-foreground">
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted">
<div
class="h-2 rounded-full bg-primary"
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
/>
</div>
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
<span v-if="provider.estimated_exhaust_at">
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common'
import { formatCurrency } from '@/utils/format'
import type { QuotaUsageProvider } from '@/api/admin'
interface Props {
title: string
subtitle?: string
providers: QuotaUsageProvider[]
loading?: boolean
}
withDefaults(defineProps<Props>(), {
subtitle: undefined,
loading: false
})
function formatDate(value: string) {
return new Date(value).toLocaleDateString()
}
</script>
-3
View File
@@ -1,7 +1,4 @@
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
export { default as LeaderboardTable } from './LeaderboardTable.vue'
export { default as CostForecastChart } from './CostForecastChart.vue'
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
export { default as PercentileChart } from './PercentileChart.vue'
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
export { default as ComparisonCard } from './ComparisonCard.vue'
+2 -1
View File
@@ -109,6 +109,7 @@ const props = defineProps<{
zIndex?: number // Custom z-index for nested dialogs (default: 60)
noPadding?: boolean // Disable default content padding
persistent?: boolean // Prevent closing on backdrop click
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
}>()
// Emits 定义
@@ -145,7 +146,7 @@ function handleClose() {
// 处理背景点击
function handleBackdropClick() {
if (!props.persistent) {
if (!props.persistent && props.closeOnBackdrop !== false) {
handleClose()
}
}
+14 -37
View File
@@ -6,7 +6,7 @@
<input
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:style="maskStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
@@ -42,7 +42,7 @@
v-else
ref="inputRef"
:class="inputClass"
:style="inputStyle"
:style="maskStyle"
:value="modelValue"
:type="effectiveType"
:autocomplete="autocompleteAttr"
@@ -69,24 +69,6 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// 开发环境警告:type="password" 已被弃用
const warnPasswordType = import.meta.env.DEV
? (() => {
let warned = false
return () => {
if (!warned) {
warned = true
// eslint-disable-next-line no-console
console.warn(
'[Input] type="password" 已被弃用,请使用 masked 属性代替。\n' +
'示例:<Input v-model="apiKey" masked />\n' +
'masked 属性使用 CSS 遮蔽而非 password 类型,不会触发浏览器密码管理器。'
)
}
}
})()
: () => {}
interface Props {
modelValue?: string | number
class?: string
@@ -99,9 +81,8 @@ interface Props {
size?: 'default' | 'sm'
/**
* 遮蔽显示内容(用于 API Key 等敏感信息)
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
* 始终使用 text 输入框,隐藏态通过样式进行遮蔽
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
* 注意:Firefox 不支持 -webkit-text-security,会显示明文(但仍可通过按钮切换)
*/
masked?: boolean
/**
@@ -134,17 +115,23 @@ const shouldDisableAutofill = computed(() => {
return props.disableAutofill ?? false
})
// 始终使用 text 类型,永远不用 password
const effectiveType = computed(() => {
const attrType = attrs.type as string | undefined
// 如果传入 password,强制转为 text(配合 masked 使用)
if (attrType === 'password') {
warnPasswordType()
const attrType = (attrs.type as string | undefined) ?? 'text'
if (props.masked) {
return 'text'
}
return attrType
})
const maskStyle = computed(() => {
if (!props.masked || isVisible.value) {
return undefined
}
return {
WebkitTextSecurity: 'disc'
}
})
// 过滤掉 type 和 class 属性,因为我们会单独处理
const filteredAttrs = computed(() => {
const { type: _type, class: _class, ...rest } = attrs
@@ -182,16 +169,6 @@ const inputClass = computed(() =>
)
)
// 当 masked 为 true 且未显示时,用 CSS 遮蔽文字
const inputStyle = computed(() => {
if (props.masked && !isVisible.value) {
// 使用 -webkit-text-security(Chrome, Safari, Edge 支持)
// Firefox 不支持此属性,会显示明文,但仍可通过小眼睛按钮切换
return { '-webkit-text-security': 'disc' }
}
return undefined
})
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
+134 -4
View File
@@ -10,7 +10,32 @@
:align-offset="alignOffset"
>
<SelectViewport :class="viewportClass">
<div
v-if="showSearchInput"
class="sticky top-0 z-10 bg-card/95 px-1 pt-1 pb-2 backdrop-blur supports-[backdrop-filter]:bg-card/85"
>
<div class="relative">
<Search
class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref="searchInputRef"
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="h-9 rounded-xl border-border/60 bg-background/80 pl-9 pr-3 text-sm"
@keydown.stop
/>
</div>
</div>
<slot />
<div
v-if="showEmptyState"
class="px-3 py-2 text-sm text-muted-foreground"
>
未找到匹配项
</div>
</SelectViewport>
</SelectContentPrimitive>
</SelectPortal>
@@ -22,9 +47,25 @@ import {
SelectPortal,
SelectViewport,
} from 'radix-vue'
import { Search } from 'lucide-vue-next'
import {
computed,
inject,
nextTick,
onMounted,
onUnmounted,
provide,
ref,
watch,
} from 'vue'
import Input from './input.vue'
import { cn } from '@/lib/utils'
import { computed, inject } from 'vue'
import { DIALOG_CONTEXT_KEY } from './dialog/context'
import {
SELECT_SEARCH_CONTEXT_KEY,
type RegisteredSelectItem,
} from './select-search-context'
import { matchesSearchQuery, preloadPinyin } from '@/utils/search'
interface Props {
class?: string
@@ -34,6 +75,9 @@ interface Props {
align?: 'start' | 'center' | 'end'
alignOffset?: number
disablePortal?: boolean
searchable?: boolean
searchThreshold?: number
searchPlaceholder?: string
}
const props = withDefaults(defineProps<Props>(), {
@@ -44,19 +88,105 @@ const props = withDefaults(defineProps<Props>(), {
align: undefined,
alignOffset: undefined,
disablePortal: false,
searchable: true,
searchThreshold: 8,
searchPlaceholder: '输入关键词搜索...',
})
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
const shouldDisablePortal = computed(() => props.disablePortal || isInsideDialog)
const shouldDisablePortal = computed(
() => props.disablePortal || isInsideDialog,
)
const searchQuery = ref('')
const searchInputRef = ref<InstanceType<typeof Input> | null>(null)
const registeredItems = ref<Record<string, RegisteredSelectItem>>({})
const hiddenValues = computed(() => {
const query = searchQuery.value.trim()
if (!query) return new Set<string>()
const hidden = new Set<string>()
for (const item of Object.values(registeredItems.value)) {
if (!matchesSearchQuery(query, item.value, item.text)) {
hidden.add(item.value)
}
}
return hidden
})
provide(SELECT_SEARCH_CONTEXT_KEY, {
searchQuery,
hiddenValues,
registerItem(id, item) {
registeredItems.value = {
...registeredItems.value,
[id]: item,
}
},
updateItem(id, item) {
const currentItem = registeredItems.value[id]
if (!currentItem) {
return
}
registeredItems.value = {
...registeredItems.value,
[id]: {
...currentItem,
...item,
},
}
},
unregisterItem(id) {
if (!(id in registeredItems.value)) {
return
}
const nextItems = { ...registeredItems.value }
delete nextItems[id]
registeredItems.value = nextItems
},
})
const itemCount = computed(() => Object.keys(registeredItems.value).length)
const showSearchInput = computed(
() => props.searchable && itemCount.value >= props.searchThreshold,
)
const showEmptyState = computed(
() =>
showSearchInput.value &&
searchQuery.value.trim().length > 0 &&
hiddenValues.value.size === itemCount.value,
)
onMounted(() => {
if (props.searchable) {
preloadPinyin()
}
})
watch(showSearchInput, async (visible) => {
if (!visible) {
searchQuery.value = ''
return
}
await nextTick()
searchInputRef.value?.inputRef?.focus()
})
const contentClass = computed(() =>
cn(
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
props.class,
),
)
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
onUnmounted(() => {
searchQuery.value = ''
})
</script>
+84 -8
View File
@@ -1,40 +1,116 @@
<script setup lang="ts">
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'radix-vue'
import {
SelectItem as SelectItemPrimitive,
SelectItemIndicator,
SelectItemText,
} from 'radix-vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
import {
computed,
getCurrentInstance,
inject,
onBeforeUnmount,
onMounted,
useSlots,
watch,
} from 'vue'
import { SELECT_SEARCH_CONTEXT_KEY } from './select-search-context'
interface Props {
class?: string
value: string
disabled?: boolean
textValue?: string
}
const props = defineProps<Props>()
const slots = useSlots()
const searchContext = inject(SELECT_SEARCH_CONTEXT_KEY, null)
const instance = getCurrentInstance()
const itemId = instance?.uid
? `select-item-${instance.uid}`
: `select-item-${Math.random().toString(36).slice(2, 10)}`
function extractText(node: unknown): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}
if (Array.isArray(node)) {
return node.map(extractText).join(' ')
}
if (node && typeof node === 'object') {
const vnode = node as { children?: unknown }
return extractText(vnode.children)
}
return ''
}
const normalizedText = computed(() => {
const slotText = extractText(slots.default?.()).replace(/\s+/g, ' ').trim()
return (props.textValue ?? slotText ?? props.value).trim()
})
const isHidden = computed(
() => searchContext?.hiddenValues.value.has(props.value) ?? false,
)
const itemClass = computed(() =>
cn(
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
'relative flex min-w-0 w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
isHidden.value && 'hidden',
props.class,
),
)
onMounted(() => {
searchContext?.registerItem(itemId, {
value: props.value,
text: normalizedText.value,
})
})
watch(
() => [props.value, normalizedText.value] as const,
([value, text]) => {
searchContext?.updateItem(itemId, {
value,
text,
})
},
)
onBeforeUnmount(() => {
searchContext?.unregisterItem(itemId)
})
</script>
<template>
<SelectItemPrimitive
:class="itemClass"
:value="value"
:disabled="disabled"
:disabled="disabled || isHidden"
:text-value="normalizedText"
>
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
<SelectItemText
as="div"
class="min-w-0 flex-1 overflow-hidden"
>
<span
class="block w-full min-w-0 truncate"
:title="normalizedText"
>
<slot />
</span>
</SelectItemText>
</SelectItemPrimitive>
</template>
@@ -0,0 +1,17 @@
import type { InjectionKey, Ref } from 'vue'
export interface RegisteredSelectItem {
value: string
text: string
}
export interface SelectSearchContext {
searchQuery: Ref<string>
hiddenValues: Ref<Set<string>>
registerItem: (id: string, item: RegisteredSelectItem) => void
updateItem: (id: string, item: Partial<RegisteredSelectItem>) => void
unregisterItem: (id: string) => void
}
export const SELECT_SEARCH_CONTEXT_KEY: InjectionKey<SelectSearchContext> =
Symbol('select-search-context')
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
:class="triggerClass"
:disabled="disabled"
>
<span class="truncate">
<span class="min-w-0 flex-1 truncate">
<slot />
</span>
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
@@ -0,0 +1,3 @@
export { useLeaderboardData } from './useLeaderboardData'
export type { LeaderboardDimension } from './useLeaderboardData'
export { usePerformanceData } from './usePerformanceData'
@@ -0,0 +1,23 @@
import type { AnalyticsLeaderboardItem } from '@/api/analytics'
export interface LeaderboardTableRow {
rank: number
id: string
name: string
requests: number
tokens: number
cost: number
actualCost: number
}
export function mapAnalyticsLeaderboardItem(item: AnalyticsLeaderboardItem): LeaderboardTableRow {
return {
rank: item.rank,
id: item.id,
name: item.label,
requests: item.requests_total,
tokens: item.total_tokens,
cost: item.total_cost_usd,
actualCost: item.actual_total_cost_usd,
}
}
@@ -0,0 +1,272 @@
import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
import { analyticsApi, type AnalyticsFilterOption, type AnalyticsLeaderboardItem, type AnalyticsTimeseriesBucket } from '@/api/analytics'
import { buildTimeRangeParams, createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
import { isAnalyticsSingleDayRange, resolveAnalyticsAutoGranularity, type AnalyticsGranularityOption } from '@/utils/analyticsGranularity'
import { mapAnalyticsLeaderboardItem, type LeaderboardTableRow } from './mappers'
export type LeaderboardDimension = 'users' | 'api_keys'
export interface LeaderboardSummary {
total_requests: number
total_tokens: number
total_cost: number
total_actual_cost: number
avg_response_time: number
}
function dimensionToEntity(dimension: LeaderboardDimension): 'user' | 'api_key' {
return dimension === 'users' ? 'user' : 'api_key'
}
export function useLeaderboardData(dimension: Ref<LeaderboardDimension>) {
const filters = useInjectedAnalyticsFilters()
const leaderboardItems = ref<AnalyticsLeaderboardItem[]>([])
const latestUserOptions = ref<AnalyticsFilterOption[]>([])
const latestApiKeyOptions = ref<AnalyticsFilterOption[]>([])
const leaderboardLoading = ref(false)
const leaderboardError = ref(false)
const leaderboardHasLoaded = ref(false)
const selectedId = ref<string>('')
const selectedSummary = ref<LeaderboardSummary | null>(null)
const selectedTrendBuckets = ref<AnalyticsTimeseriesBucket[]>([])
const panelLoading = ref(false)
const panelError = ref(false)
const panelHasLoaded = ref(false)
const leaderboardGuard = createRequestGuard()
const filterOptionsGuard = createRequestGuard()
const panelGuard = createRequestGuard()
const entityOptions = computed(() => (
dimension.value === 'users' ? latestUserOptions.value : latestApiKeyOptions.value
))
const entityLabelMap = computed(() => (
new Map(entityOptions.value.map(option => [option.value, option.label]))
))
const leaderboard = computed<LeaderboardTableRow[]>(() => (
leaderboardItems.value.map(item => ({
...mapAnalyticsLeaderboardItem(item),
name: entityLabelMap.value.get(item.id) ?? item.label,
}))
))
const selectedItem = computed(() => (
leaderboard.value.find(item => item.id === selectedId.value) ?? null
))
const selectedLabel = computed(() => (
selectedItem.value?.name ?? entityLabelMap.value.get(selectedId.value) ?? null
))
const resolvedTrendGranularity = computed(() => resolveAnalyticsAutoGranularity(filters.timeRange.value))
const baseTimeRangePayload = computed(() => ({
start_date: filters.timeRange.value.start_date,
end_date: filters.timeRange.value.end_date,
preset: filters.timeRange.value.preset,
timezone: filters.timeRange.value.timezone,
tz_offset_minutes: filters.timeRange.value.tz_offset_minutes,
}))
const trendTimeRangePayload = computed(() => ({
...baseTimeRangePayload.value,
granularity: resolvedTrendGranularity.value,
}))
const currentTrendGranularitySelection = computed<AnalyticsGranularityOption>({
get: () => filters.timeRange.value.granularity || 'auto',
set: (value) => {
if (value === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
value = 'auto'
}
filters.timeRange.value = {
...filters.timeRange.value,
granularity: value,
}
},
})
function resetPanelData(invalidate = false) {
if (invalidate) panelGuard.invalidate()
panelLoading.value = false
panelError.value = false
selectedSummary.value = null
selectedTrendBuckets.value = []
}
async function loadFilterOptions() {
const requestId = filterOptionsGuard.next()
const response = await analyticsApi.getFilterOptions({
scope: { kind: 'global' },
time_range: baseTimeRangePayload.value,
filters: {},
}).catch(() => null)
if (filterOptionsGuard.isStale(requestId) || !response) return
latestUserOptions.value = response.users ?? []
latestApiKeyOptions.value = response.api_keys ?? []
}
async function loadLeaderboard() {
const requestId = leaderboardGuard.next()
leaderboardLoading.value = true
leaderboardError.value = false
try {
const response = await analyticsApi.getLeaderboard({
scope: { kind: 'global' },
time_range: baseTimeRangePayload.value,
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
entity: dimensionToEntity(dimension.value),
metric: 'total_cost_usd',
limit: 20,
})
if (leaderboardGuard.isStale(requestId)) return
leaderboardItems.value = response.items
const nextSelectedId = response.items.some(item => item.id === selectedId.value)
? selectedId.value
: (response.items[0]?.id ?? '')
if (selectedId.value !== nextSelectedId) {
selectedId.value = nextSelectedId
}
leaderboardHasLoaded.value = true
} catch {
if (leaderboardGuard.isStale(requestId)) return
leaderboardError.value = true
} finally {
if (leaderboardGuard.isCurrent(requestId)) {
leaderboardLoading.value = false
}
}
}
async function loadPanel() {
if (!selectedId.value) {
resetPanelData(true)
return
}
const requestId = panelGuard.next()
panelLoading.value = true
panelError.value = false
try {
const scope = dimension.value === 'users'
? { kind: 'user' as const, user_id: selectedId.value }
: { kind: 'api_key' as const, api_key_id: selectedId.value }
const basePayload = {
scope,
time_range: baseTimeRangePayload.value,
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
}
const [overview, timeseries] = await Promise.all([
analyticsApi.getOverview(basePayload),
analyticsApi.getTimeseries({
...basePayload,
time_range: trendTimeRangePayload.value,
}),
])
if (panelGuard.isStale(requestId)) return
selectedSummary.value = {
total_requests: overview.summary.requests_total,
total_tokens: overview.summary.total_tokens,
total_cost: overview.summary.total_cost_usd,
total_actual_cost: overview.summary.actual_total_cost_usd,
avg_response_time: overview.summary.avg_response_time_ms / 1000,
}
selectedTrendBuckets.value = fillMissingTimeseriesBuckets(
timeseries.buckets,
trendTimeRangePayload.value,
)
panelHasLoaded.value = true
} catch {
if (panelGuard.isStale(requestId)) return
panelError.value = true
} finally {
if (panelGuard.isCurrent(requestId)) {
panelLoading.value = false
}
}
}
const filterOptionsLoader = createLoader(loadFilterOptions)
const leaderboardLoader = createLoader(loadLeaderboard)
const panelLoader = createLoader(loadPanel)
watch(
() => [filters.timeRange.value.preset, filters.timeRange.value.start_date, filters.timeRange.value.end_date],
() => {
if (filters.timeRange.value.granularity === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
filters.timeRange.value = {
...filters.timeRange.value,
granularity: 'auto',
}
}
},
)
watch(
() => [baseTimeRangePayload.value, dimension.value],
() => {
filterOptionsLoader.schedule()
leaderboardLoader.schedule()
},
{ deep: true },
)
watch(
() => [
selectedId.value,
dimension.value,
baseTimeRangePayload.value,
trendTimeRangePayload.value,
],
() => panelLoader.schedule(),
{ deep: true },
)
onMounted(() => {
void (async () => {
await filterOptionsLoader.execute()
await leaderboardLoader.execute()
await panelLoader.execute()
})()
})
onUnmounted(() => {
filterOptionsLoader.cleanup()
leaderboardLoader.cleanup()
panelLoader.cleanup()
filterOptionsGuard.invalidate()
leaderboardGuard.invalidate()
panelGuard.invalidate()
})
return {
leaderboard,
leaderboardLoading,
leaderboardError,
leaderboardHasLoaded,
selectedId,
selectedItem,
selectedLabel,
selectedSummary,
selectedTrendBuckets,
panelLoading,
panelError,
panelHasLoaded,
resolvedTrendGranularity,
currentTrendGranularitySelection,
reloadLeaderboard: leaderboardLoader.schedule,
}
}
@@ -0,0 +1,98 @@
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { analyticsApi, type AnalyticsErrorCategory, type AnalyticsErrorTrendItem, type AnalyticsPercentilePoint, type AnalyticsPerformanceResponse, type AnalyticsProviderHealthItem } from '@/api/analytics'
import { auditApi, type MonitoringSystemStatus } from '@/api/audit'
import { getHealthSummary, type HealthSummary } from '@/api/endpoints'
import { createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
const EMPTY_LATENCY: AnalyticsPerformanceResponse['latency'] = {
response_time_ms: { avg: 0, p50: null, p90: null, p99: null },
first_byte_time_ms: { avg: 0, p50: null, p90: null, p99: null },
}
export function usePerformanceData() {
const filters = useInjectedAnalyticsFilters()
const latency = ref<AnalyticsPerformanceResponse['latency']>(EMPTY_LATENCY)
const percentiles = ref<AnalyticsPercentilePoint[]>([])
const errorDistribution = ref<AnalyticsErrorCategory[]>([])
const errorTrend = ref<AnalyticsErrorTrendItem[]>([])
const errorTotal = ref(0)
const errorRate = ref(0)
const providerStatus = ref<AnalyticsProviderHealthItem[]>([])
const healthSummary = ref<HealthSummary | null>(null)
const systemStatus = ref<MonitoringSystemStatus | null>(null)
const loading = ref(false)
const loadError = ref(false)
const hasLoaded = ref(false)
const guard = createRequestGuard()
async function load() {
const requestId = guard.next()
loading.value = true
loadError.value = false
try {
const [response, summaryResponse, systemResponse] = await Promise.all([
analyticsApi.getPerformance({
scope: { kind: 'global' },
time_range: filters.getTimeRangeParams(),
filters: {
statuses: ['completed', 'failed', 'cancelled'],
},
}),
getHealthSummary().catch(() => null),
auditApi.getSystemStatus().catch(() => null),
])
if (guard.isStale(requestId)) return
latency.value = response.latency
percentiles.value = response.percentiles
errorTotal.value = response.errors.total
errorRate.value = response.errors.rate
errorDistribution.value = response.errors.categories
errorTrend.value = response.errors.trend
providerStatus.value = response.provider_health
if (summaryResponse) {
healthSummary.value = summaryResponse
}
if (systemResponse) {
systemStatus.value = systemResponse
}
hasLoaded.value = true
} catch {
if (guard.isStale(requestId)) return
loadError.value = true
} finally {
if (guard.isCurrent(requestId)) {
loading.value = false
}
}
}
const loader = createLoader(load)
watch(
() => [
filters.getTimeRangeParams(),
],
() => loader.schedule(),
{ deep: true },
)
onMounted(() => { void loader.execute() })
onUnmounted(() => { loader.cleanup(); guard.invalidate() })
return {
latency,
percentiles,
errorDistribution,
errorTrend,
errorTotal,
errorRate,
providerStatus,
healthSummary,
systemStatus,
loading,
loadError,
hasLoaded,
}
}
@@ -0,0 +1,144 @@
import { inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
/**
* 构建时间范围 API 参数(从各页面提取的公共逻辑)
*/
export function buildTimeRangeParams(timeRange: DateRangeParams) {
return {
start_date: timeRange.start_date,
end_date: timeRange.end_date,
preset: timeRange.preset,
timezone: timeRange.timezone,
tz_offset_minutes: timeRange.tz_offset_minutes,
granularity: timeRange.granularity && timeRange.granularity !== 'auto'
? timeRange.granularity
: 'day',
}
}
/**
* 通用防抖加载器工厂
* 封装 120ms debounce + in-flight promise 去重 + requestId 过期检查
*/
export function createLoader(loadFn: () => Promise<void>) {
let loadPromise: Promise<void> | null = null
let hasPending = false
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function schedule() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void execute()
}, 120)
}
async function execute() {
if (loadPromise) {
hasPending = true
return loadPromise
}
loadPromise = loadFn()
.finally(() => {
loadPromise = null
if (hasPending) {
hasPending = false
void execute()
}
})
return loadPromise
}
function cleanup() {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
hasPending = false
loadPromise = null
}
return { schedule, execute, cleanup }
}
/**
* 请求 ID 守卫:用于确保异步回调中的数据不被过期请求覆盖
*/
export function createRequestGuard() {
let id = 0
function next() { return ++id }
function isStale(requestId: number) { return requestId !== id }
function isCurrent(requestId: number) { return requestId === id }
function invalidate() { id++ }
return { next, isStale, isCurrent, invalidate }
}
export interface AnalyticsFiltersOptions {
defaultPreset?: PeriodValue
syncToUrl?: boolean
}
/**
* 统一的分析页面筛选状态
* - timeRange: 时间范围
* - modelFilter / providerFilter / apiKeyFilter / userFilter: 多维度筛选
* - activeTab: 当前 Tab
* - URL query 双向同步
*/
export function useAnalyticsFilters(options: AnalyticsFiltersOptions = {}) {
const { defaultPreset = 'last30days', syncToUrl = true } = options
const route = useRoute()
const router = useRouter()
// 从 URL query 初始化,无则用默认值
const initialPreset = (syncToUrl && route.query.preset as PeriodValue) || defaultPreset
const initialTab = (syncToUrl && route.query.tab as string) || 'detail'
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod(initialPreset))
const activeTab = ref(initialTab)
const modelFilter = ref<string[]>([])
const providerFilter = ref<string[]>([])
const apiKeyFilter = ref<string[]>([])
const userFilter = ref<string[]>([])
// URL 同步:仅同步 preset 和 tab(各类多选筛选太冗长不入 URL)
if (syncToUrl) {
watch([() => timeRange.value.preset, activeTab], ([preset, tab]) => {
const query: Record<string, string> = {}
if (preset && preset !== defaultPreset) query.preset = preset
if (tab && tab !== 'detail') query.tab = tab
// 保留其他 query 参数
const currentQuery = { ...route.query }
delete currentQuery.preset
delete currentQuery.tab
router.replace({ query: { ...currentQuery, ...query } })
}, { flush: 'post' })
}
function getTimeRangeParams() {
return buildTimeRangeParams(timeRange.value)
}
return {
timeRange,
activeTab,
modelFilter,
providerFilter,
apiKeyFilter,
userFilter,
getTimeRangeParams,
}
}
export type AnalyticsFilters = ReturnType<typeof useAnalyticsFilters>
export function useInjectedAnalyticsFilters(): AnalyticsFilters {
const filters = inject<AnalyticsFilters>('analyticsFilters', null)
if (!filters) {
throw new Error('analyticsFilters provider is missing')
}
return filters
}
+85 -2
View File
@@ -68,6 +68,86 @@ export function getProbeCountdown(nextProbeAt: string | null | undefined, _tick:
return '探测中'
}
/**
* Codex 配额重置倒计时状态
*/
export interface CodexResetStatus {
text: string
isUrgent: boolean
isCritical: boolean
isExpired: boolean
}
/**
* 计算 Codex 配额重置倒计时
* @param resetAt 绝对重置时间(Unix 秒)
* @param resetSecs 相对剩余秒数(用于 fallback)
* @param updatedAt 元数据更新时间(Unix 秒)
* @param _tick 响应式触发器(传入 tick.value 以触发响应式更新)
* @param remainingPercent 当前窗口剩余额度百分比(0-100,100 表示满额不启动倒计时)
*/
export function getCodexResetCountdown(
resetAt: number | null | undefined,
resetSecs: number | null | undefined,
updatedAt: number | null | undefined,
_tick: number,
remainingPercent?: number | null
): CodexResetStatus | null {
void _tick
if (remainingPercent != null) {
const normalizedRemaining = Number(remainingPercent)
if (Number.isFinite(normalizedRemaining) && normalizedRemaining >= 100) {
return null
}
}
const nowSec = Math.floor(Date.now() / 1000)
let remaining: number
if (resetAt != null && resetAt > 0) {
// 优先绝对时间戳,避免相对秒数快照漂移。
remaining = resetAt - nowSec
} else if (resetSecs != null && resetSecs >= 0) {
if (updatedAt != null && updatedAt > 0) {
// 时钟偏移下 updatedAt 可能晚于当前时间,elapsed 需要下限钳制到 0。
const elapsedSec = Math.max(nowSec - updatedAt, 0)
remaining = resetSecs - elapsedSec
} else {
remaining = resetSecs
}
} else {
return null
}
if (remaining <= 0) {
return { text: '已重置', isUrgent: false, isCritical: false, isExpired: true }
}
const total = Math.floor(remaining)
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
const pad = (n: number) => n.toString().padStart(2, '0')
let text: string
if (days > 0) {
text = `${days}天 ${hours}:${pad(minutes)}:${pad(seconds)}`
} else if (hours > 0) {
text = `${hours}:${pad(minutes)}:${pad(seconds)}`
} else {
text = `${minutes}:${pad(seconds)}`
}
return {
text,
isUrgent: total < 3600,
isCritical: total < 300,
isExpired: false,
}
}
/**
* OAuth Token 状态信息
*/
@@ -94,15 +174,18 @@ export function getOAuthExpiresCountdown(
invalidReason?: string | null
): OAuthStatusInfo | null {
void _tick
const normalizedInvalidReason = typeof invalidReason === 'string'
? invalidReason.trim()
: ''
// 优先检查失效状态(失效比过期更严重)
if (invalidAt != null) {
if (invalidAt != null || normalizedInvalidReason) {
return {
text: '已失效',
isExpired: false,
isExpiringSoon: false,
isInvalid: true,
invalidReason: invalidReason || undefined
invalidReason: normalizedInvalidReason || undefined
}
}
@@ -1,34 +0,0 @@
import { computed, type Ref, type ComputedRef } from 'vue'
/**
* 检测失效模型的 composable
*
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
* 这些模型可能已被删除但引用未清理。
*
* @example
* ```typescript
* const { invalidModels } = useInvalidModels(
* computed(() => form.value.allowed_models),
* globalModels
* )
* ```
*/
export interface ModelWithName {
name: string
}
export function useInvalidModels<T extends ModelWithName>(
allowedModels: Ref<string[]> | ComputedRef<string[]>,
globalModels: Ref<T[]>
): { invalidModels: ComputedRef<string[]> } {
const validModelNames = computed(() =>
new Set(globalModels.value.map(m => m.name))
)
const invalidModels = computed(() =>
allowedModels.value.filter(name => !validModelNames.value.has(name))
)
return { invalidModels }
}
+23 -2
View File
@@ -15,6 +15,8 @@ export interface StartTestParams {
apiFormat?: string
endpointId?: string
message?: string
requestHeaders?: Record<string, unknown>
requestBody?: Record<string, unknown>
concurrency?: number
onSuccess?: (result: TestModelFailoverResponse) => void
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
@@ -61,6 +63,16 @@ export function useModelTest(options: UseModelTestOptions) {
}
}
async function refreshTraceSnapshot(reqId: string) {
try {
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
if (requestId.value !== reqId) return
testTrace.value = trace
} catch (err: unknown) {
if (isAxiosError(err) && err.response?.status === 404) return
}
}
function stopPolling(opts: { clearState?: boolean } = {}) {
tracePollToken += 1
if (tracePollTimer) {
@@ -117,13 +129,19 @@ export function useModelTest(options: UseModelTestOptions) {
startPolling(reqId)
try {
const normalizedMessage = typeof params.message === 'string' && params.message.trim()
? params.message.trim()
: undefined
const result = await testModelFailover({
provider_id: providerId(),
mode: params.mode,
model_name: params.modelName,
api_format: params.apiFormat,
endpoint_id: params.endpointId,
message: params.message ?? 'hello',
...(normalizedMessage ? { message: normalizedMessage } : {}),
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
...(params.requestBody ? { request_body: params.requestBody } : {}),
request_id: reqId,
concurrency: params.concurrency,
}, {
@@ -131,6 +149,9 @@ export function useModelTest(options: UseModelTestOptions) {
})
if (result.success) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
testResult.value = result
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
@@ -138,10 +159,10 @@ export function useModelTest(options: UseModelTestOptions) {
: ''
params.onSuccess?.(result)
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
resetState()
return
}
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
const handled = params.onFailure?.(result)
if (!handled) {
+287
View File
@@ -0,0 +1,287 @@
import { computed, ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
import {
analyticsApi,
type AnalyticsBreakdownRow,
type AnalyticsFilterOption,
type AnalyticsScope,
type AnalyticsSummary,
type AnalyticsTimeseriesBucket,
} from '@/api/analytics'
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
import { getDateRangeFromPeriod } from '@/features/usage/composables'
import { createLoader, createRequestGuard, buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
import { getAnalyticsRangeDaysInclusive, resolveAnalyticsAutoGranularity, type ResolvedAnalyticsGranularity } from '@/utils/analyticsGranularity'
import { type DailyUsageBreakdown } from '@/utils/usageBreakdown'
export interface ModelSummary {
model: string
requests: number
tokens: number
cost: number
inputTokens: number
outputTokens: number
cacheCreationTokens: number
cacheReadTokens: number
cacheHitRate: number
avg_response_time?: number
avg_first_byte_time?: number
}
export interface ReportBucketStat {
bucket_start: string
bucket_end: string
requests: number
tokens: number
cost: number
avg_response_time: number
avg_first_byte_time: number
models_used_count: number
}
function mapBucketToReportStat(bucket: AnalyticsTimeseriesBucket): ReportBucketStat {
return {
bucket_start: bucket.bucket_start,
bucket_end: bucket.bucket_end,
requests: bucket.requests_total,
tokens: bucket.total_tokens,
cost: bucket.total_cost_usd,
avg_response_time: bucket.avg_response_time_ms / 1000,
avg_first_byte_time: bucket.avg_first_byte_time_ms / 1000,
models_used_count: bucket.models_used_count,
}
}
function mapBucketToBreakdown(bucket: AnalyticsTimeseriesBucket): DailyUsageBreakdown {
return {
date: bucket.bucket_start,
inputTokens: bucket.input_tokens,
outputTokens: bucket.output_tokens,
cacheCreationTokens: bucket.cache_creation_input_tokens,
cacheReadTokens: bucket.cache_read_input_tokens,
cacheCreationCost: bucket.cache_creation_cost_usd,
cacheReadCost: bucket.cache_read_cost_usd,
cacheHitRate: bucket.cache_hit_rate,
totalCacheCost: bucket.cache_cost_usd,
totalCacheTokens: bucket.cache_creation_input_tokens + bucket.cache_read_input_tokens,
totalTrackedTokens: bucket.total_tokens,
baseTokens: bucket.input_output_total_tokens,
}
}
function mapBreakdownRowToModelSummary(row: AnalyticsBreakdownRow): ModelSummary {
const requests = row.requests_total
const tokens = row.total_tokens
const cost = row.total_cost_usd
return {
model: row.label,
requests,
tokens,
cost,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
cacheCreationTokens: row.cache_creation_input_tokens,
cacheReadTokens: row.cache_read_input_tokens,
cacheHitRate: row.cache_hit_rate,
avg_response_time: row.avg_response_time_ms / 1000,
avg_first_byte_time: row.avg_first_byte_time_ms / 1000,
}
}
type ReportGranularity = ResolvedAnalyticsGranularity
function resolveReportGranularity(range: DateRangeParams): ReportGranularity {
return resolveAnalyticsAutoGranularity(range)
}
function areStringArraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
export interface UseReportsDataOptions {
scope?: AnalyticsScope
defaultPreset?: PeriodValue
timeRange?: Ref<DateRangeParams>
userFilter?: Ref<string[]>
apiKeyFilter?: Ref<string[]>
loadApiKeyOptions?: boolean
}
export function useReportsData(options: UseReportsDataOptions = {}) {
const {
scope = { kind: 'me' as const },
defaultPreset = 'today',
timeRange: externalTimeRange,
userFilter: externalUserFilter,
apiKeyFilter: externalApiKeyFilter,
loadApiKeyOptions = scope.kind === 'me',
} = options
const timeRange = externalTimeRange ?? ref<DateRangeParams>({
...getDateRangeFromPeriod(defaultPreset),
granularity: 'auto',
})
const userFilter = externalUserFilter ?? ref<string[]>([])
const apiKeyFilter = externalApiKeyFilter ?? ref<string[]>([])
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
const summary = ref<AnalyticsSummary | null>(null)
const bucketStats = ref<ReportBucketStat[]>([])
const bucketBreakdowns = ref<Record<string, DailyUsageBreakdown>>({})
const modelSummary = ref<ModelSummary[]>([])
const loading = ref(false)
const loadError = ref(false)
const hasLoaded = ref(false)
const guard = createRequestGuard()
const apiKeysGuard = createRequestGuard()
let suppressNextApiKeyFilterLoad = false
const resolvedGranularity = computed(() => resolveReportGranularity(timeRange.value))
const timeseriesTimeRange = computed(() => ({
...buildTimeRangeParams(timeRange.value),
granularity: resolvedGranularity.value,
}))
watch(
() => [timeRange.value.preset, timeRange.value.start_date, timeRange.value.end_date],
() => {
if (timeRange.value.granularity === 'hour' && getAnalyticsRangeDaysInclusive(timeRange.value) !== 1) {
timeRange.value = {
...timeRange.value,
granularity: 'auto',
}
}
},
)
async function load() {
const requestId = guard.next()
loading.value = true
loadError.value = false
try {
const params = buildTimeRangeParams(timeRange.value)
const timeseriesParams = timeseriesTimeRange.value
const basePayload = {
scope,
time_range: params,
filters: {
user_ids: userFilter.value,
api_key_ids: apiKeyFilter.value,
},
}
const [overviewData, timeseriesData, modelBreakdownData] = await Promise.all([
analyticsApi.getOverview(basePayload),
analyticsApi.getTimeseries({
...basePayload,
time_range: timeseriesParams,
}),
analyticsApi.getBreakdown({
...basePayload,
dimension: 'model',
limit: 50,
}),
])
if (guard.isStale(requestId)) return
const filledBuckets = fillMissingTimeseriesBuckets(timeseriesData.buckets, timeseriesParams)
summary.value = overviewData.summary
bucketStats.value = filledBuckets.map(mapBucketToReportStat)
bucketBreakdowns.value = Object.fromEntries(
filledBuckets.map(bucket => {
const breakdown = mapBucketToBreakdown(bucket)
return [breakdown.date, breakdown]
}),
)
modelSummary.value = modelBreakdownData.rows.map(mapBreakdownRowToModelSummary)
hasLoaded.value = true
} catch {
if (guard.isStale(requestId)) return
loadError.value = true
} finally {
if (guard.isCurrent(requestId)) {
loading.value = false
}
}
}
async function loadApiKeys() {
if (!loadApiKeyOptions) return
const requestId = apiKeysGuard.next()
const filterOptions = await analyticsApi.getFilterOptions({
scope,
time_range: buildTimeRangeParams(timeRange.value),
filters: {},
}).catch(() => null)
if (apiKeysGuard.isStale(requestId)) return
const nextOptions = filterOptions?.api_keys ?? []
apiKeyOptions.value = nextOptions
const validOptionValues = new Set(nextOptions.map(option => option.value))
const nextFilter = apiKeyFilter.value.filter(value => validOptionValues.has(value))
if (!areStringArraysEqual(nextFilter, apiKeyFilter.value)) {
suppressNextApiKeyFilterLoad = true
apiKeyFilter.value = nextFilter
}
}
const loader = createLoader(load)
const apiKeysLoader = createLoader(loadApiKeys)
watch(
timeRange,
() => {
void (async () => {
if (loadApiKeyOptions) {
await apiKeysLoader.execute()
}
loader.schedule()
})()
},
{ deep: true },
)
watch(
[userFilter, apiKeyFilter],
() => {
if (suppressNextApiKeyFilterLoad) {
suppressNextApiKeyFilterLoad = false
return
}
loader.schedule()
},
{ deep: true },
)
onMounted(() => {
void (async () => {
if (loadApiKeyOptions) {
await apiKeysLoader.execute()
}
await loader.execute()
})()
})
onUnmounted(() => {
loader.cleanup()
apiKeysLoader.cleanup()
guard.invalidate()
apiKeysGuard.invalidate()
})
return {
timeRange,
apiKeyFilter,
apiKeyOptions,
summary,
bucketStats,
bucketBreakdowns,
modelSummary,
loading,
loadError,
hasLoaded,
resolvedGranularity,
}
}
+44
View File
@@ -0,0 +1,44 @@
import { useRoute, useRouter, type LocationQueryValue, type LocationQuery } from 'vue-router'
type QueryValue = LocationQueryValue | LocationQueryValue[]
function normalizeQueryValue(value: QueryValue): string | undefined {
if (Array.isArray(value)) {
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
}
return typeof value === 'string' ? value : undefined
}
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
for (const key of keys) {
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
return false
}
}
return true
}
export function useRouteQuery() {
const route = useRoute()
const router = useRouter()
function getQueryValue(key: string): string | undefined {
return normalizeQueryValue(route.query[key])
}
function patchQuery(patch: Record<string, string | undefined | null>) {
const next: LocationQuery = { ...route.query }
for (const [key, value] of Object.entries(patch)) {
if (value == null || value.trim() === '') {
delete next[key]
} else {
next[key] = value
}
}
if (queriesEqual(route.query, next)) return
void router.replace({ query: next }).catch(() => {})
}
return { route, router, getQueryValue, patchQuery }
}

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