🎨 增加并发访问

This commit is contained in:
2026-03-05 21:28:41 +08:00
commit 84c66ccaa7
114 changed files with 35396 additions and 0 deletions

54
.dockerignore Normal file
View File

@@ -0,0 +1,54 @@
# Rust build artifacts
target/
Cargo.lock
# Node.js dependencies and build artifacts
admin-ui/node_modules/
admin-ui/dist/
admin-ui/pnpm-lock.yaml
admin-ui/tsconfig.tsbuildinfo
admin-ui/.vite/
# Version control
.git/
.gitignore
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
# Claude/AI
.claude/
CLAUDE.md
AGENTS.md
# CI/CD
.github/
# Documentation and examples
*.md
README.md
config.example.json
credentials.example.*.json
# Development and test files
src/test.rs
src/debug.rs
test.json
tools/
# OS-specific files
.DS_Store
Thumbs.db
# Local configuration (keep templates only)
config.json
credentials.json
# Docker files
Dockerfile
.dockerignore
docker-compose*.yml

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
/target
/config.json
/credentials.json
/.idea
/test.json
/admin-ui/node_modules/
/admin-ui/dist/
/admin-ui/pnpm-lock.yaml
/admin-ui/package-lock.json
/admin-ui/tsconfig.tsbuildinfo
.claude/
!.claude/skills/
/credentials.*
*.json
logs/
data/
/kiro_balance_cache.json
/kiro_stats.json
refs/
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
config/

779
CHANGELOG.md Normal file
View File

@@ -0,0 +1,779 @@
# Changelog
## [v1.1.5] - 2026-03-02
### Fixed
- **防止孤立 tool_result 导致空消息** — `convert_user_message` 新增最终兜底逻辑,当 tool_result 被过滤为孤立块后 content 变空时插入占位符 ".",避免上游返回 400 "Improperly formed request";新增回归测试覆盖孤立 tool_result 场景 (`src/anthropic/converter.rs`)
## [v1.1.4] - 2026-03-02
### Fixed
- **WebSearch 历史消息上下文保留** — `convert_assistant_message` 新增对 `server_tool_use`(忽略)和 `web_search_tool_result`(提取 title、url、snippet、page_age 为纯文本)的处理,修复多轮对话中搜索结果被静默丢弃导致 Kiro 丢失上下文的问题;使用纯文本格式彻底规避特殊字符破坏格式的风险;新增 2 个单元测试覆盖上述路径 (`src/anthropic/converter.rs`)
## [v1.1.3] - 2026-02-27
### Changed
- **合并三个导入对话框为一个** — 删除冗余的 `KamImportDialog``BatchImportDialog`,统一使用 `ImportTokenJsonDialog`,自动识别 KAM 嵌套格式、扁平凭据格式和 Token JSON 格式 (`admin-ui/src/components/`)
- **上游网络错误归类为瞬态错误** — `error sending request`/`connection closed`/`connection reset` 纳入 `is_transient_upstream_error`,返回 502 且不输出请求体 (`src/anthropic/handlers.rs`)
- **上游报错不再输出完整请求体** — `sensitive-logs` 模式下仅输出请求体字节数 (`src/anthropic/handlers.rs`)
- **瞬态错误匹配大小写归一化** — `is_transient_upstream_error` 统一 `.to_lowercase()` 后匹配,提取 `NETWORK_ERROR_PATTERNS` 常量消除重复 (`src/anthropic/handlers.rs`)
### Removed
- **移除负载均衡模式切换** — 删除前端按钮/API/hooks 和后端路由/handler/service/types/config 字段/token_manager 方法及测试,该设置实际未被使用 (`admin-ui/`, `src/admin/`, `src/kiro/token_manager.rs`, `src/model/config.rs`)
### Added
- **Usage 诊断日志** — 流式/缓冲流式/非流式三条路径均增加 `sensitive-logs` 保护的 usage 日志,输出 estimated/context/final input_tokens 及来源、output_tokens (`src/anthropic/stream.rs`, `src/anthropic/handlers.rs`)
## [v1.1.2] - 2026-02-27
### Added
- **Kiro Account Manager 导入** — 支持导入 KAM 导出的 JSON 凭据文件 (`admin-ui/src/components/kam-import-dialog.tsx`)
- **批量导入对话框** — 新增独立的批量导入凭据组件 (`admin-ui/src/components/batch-import-dialog.tsx`)
- **凭证 disabled 字段持久化** — 从配置文件读取 disabled 状态,支持手动禁用凭据跨重启保留 (`src/kiro/model/credentials.rs`, `src/kiro/token_manager.rs`)
- **凭据级 Region 编辑** — Admin UI 支持对已有凭据在线修改 `region``apiRegion`,点击凭据卡片内联编辑,保存后持久化 (`src/admin/`, `admin-ui/src/components/credential-card.tsx`)
- **Admin API `POST /credentials/:id/region`** — 新增 Region 修改接口,支持清除(传 null或覆盖两个 region 字段 (`src/admin/handlers.rs`, `src/admin/router.rs`)
### Fixed
- **WebSearch SSE 事件序列修正** — 调整 server_tool_use 位置、content block index、page_age 转换、usage 统计 (`src/anthropic/websearch.rs`)
- **Token Manager 统计回写** — 立即回写统计数据,清除已删除凭据残留 (`src/kiro/token_manager.rs`)
- **HTTP 非安全地址批量导入** — 修复 admin-ui 在 HTTP 下的导入错误 (`admin-ui/src/lib/utils.ts`)
- **Docker 端口绑定优化** — 修正端口绑定和配置目录挂载 (`docker-compose.yml`)
- **移除重复 Sonnet 4.6 模型项** — 删除 `/v1/models` 中重复的 claude-sonnet-4-6 条目,避免 id 冲突 (`src/anthropic/handlers.rs`)
- **防止自动禁用状态被持久化** — `persist_credentials()` 仅持久化手动禁用,避免重启后自动禁用变为手动禁用导致无法自愈 (`src/kiro/token_manager.rs`)
- **sha256Hex digest 异常回退** — 在 `crypto.subtle.digest` 外围加 try/catch失败时回退到纯 JS 实现 (`admin-ui/src/lib/utils.ts`)
- **parseKamJson null 输入保护** — 对 JSON null 输入增加类型检查,避免 TypeError (`admin-ui/src/components/kam-import-dialog.tsx`)
- **额度查询 region 修复** — `get_usage_limits` 改用 `effective_api_region`,凭据指定 region 时不再因走错 endpoint 而 403 (`src/kiro/token_manager.rs`)
- **批量导入丢失 apiRegion** — `TokenJsonItem` 补充 `api_region` 字段,导入 JSON 中的 `apiRegion` 不再被丢弃 (`src/admin/types.rs`, `src/admin/service.rs`)
- **API 请求使用凭据级 region** — `provider.rs``base_url`/`mcp_url`/`base_domain` 改用 `credentials.effective_api_region()`,凭据配置了 region 时不再错误地走全局 config region 导致 403 (`src/kiro/provider.rs`)
- **Region 编辑 stale state** — 点击编辑 Region 时同步最新 props 到 input避免后台刷新后提交旧值覆盖服务端数据 (`admin-ui/src/components/credential-card.tsx`)
- **Region 值未 trim** — `set_region` 保存前对 region/apiRegion 做 trim防止带空格的值持久化后生成无效 URL (`src/admin/service.rs`)
- **过滤超长工具名** — `convert_tools` 过滤掉 name 超过 64 字符的工具,避免上游拒绝整个请求 (`src/anthropic/converter.rs`)
- **429 错误不再输出完整请求体** — 瞬态上游错误429/5xx走独立分支返回 429不触发 sensitive-logs 的请求体诊断日志 (`src/anthropic/handlers.rs`)
- **兼容旧 authRegion 配置** — `credentials.region` 增加 `#[serde(alias = "authRegion")]`,旧配置文件中的 `authRegion` 字段不再被静默忽略 (`src/kiro/model/credentials.rs`)
- **导入凭据 region 规范化** — token.json 导入路径对 region/apiRegion 做 trim + 空字符串转 None`set_region` 逻辑一致 (`src/admin/service.rs`)
### Changed
- **默认 kiro_version 更新至 0.10.0** (`src/model/config.rs`)
- **Opus 模型映射调整** — opus 默认映射到 claude-opus-4.6,仅 4.5/4-5 显式映射到 claude-opus-4.5 (`src/anthropic/converter.rs`)
- **Sonnet 4.6 Model 字段补全** — 添加 context_length、max_completion_tokens、thinking 字段 (`src/anthropic/handlers.rs`)
- **Region 配置精简** — 删除 `credentials.auth_region``config.auth_region` 冗余字段;凭据的 `region` 同时用于 Token 刷新和 API 请求默认值,`api_region` 可单独覆盖 API 路由 (`src/kiro/model/credentials.rs`, `src/model/config.rs`)
- **添加凭据 UI Region 字段语义调整** — 前端"Auth Region"改为"Region"(对应 `credentials.region`"API Region"保持,去除无意义的 `authRegion` 前端字段 (`admin-ui/`)
## [v1.1.1] - 2026-02-18
### Fixed
- **修复 Sonnet 4.6 thinking 配置变量名错误** (`src/anthropic/handlers.rs`)
- 修正 thinking 配置覆写逻辑中的变量名拼写错误
## [v1.1.0] - 2026-02-18
### Added
- **Sonnet 4.6 模型支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`, `src/anthropic/types.rs`)
- 添加 claude-sonnet-4-6 及其 thinking/agentic 变体到模型列表
- 更新模型映射逻辑以正确识别 Sonnet 4.6 版本
- 为 Sonnet 4.6 启用 1M 上下文窗口和 64K 最大输出 tokens
- 更新 thinking 配置覆写逻辑以支持 Sonnet 4.6 的 adaptive thinking
## [v1.0.21] - 2026-02-17
### Changed
- **图片处理限制放宽** (`src/model/config.rs`)
- 单图总像素限制从 1.15M 放宽至 4M支持更高分辨率图片直接透传
- 图片长边限制从 1568 放宽至 4000减少不必要的缩放压缩
## [v1.0.20] - 2026-02-17
### Fixed
- **空消息内容验证与错误分类改进** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
- 新增 `ConversionError::EmptyMessageContent` 错误类型,在请求转换阶段验证消息内容不为空
- 在 prefill 处理后验证最后一条消息内容有效性,支持字符串和数组两种 content 格式
- 检测空字符串、空白字符、空数组等情况,避免向上游发送无效请求
- 修复 `is_input_too_long_error` 错误地将 "Improperly formed request" 归类为上下文过长错误
- 新增 `is_improperly_formed_request_error` 函数专门处理格式错误,返回准确的错误信息
- 新增 3 个测试用例验证空消息内容检测功能
- **图片文件大小压缩优化** (`src/image.rs`)
- 新增基于文件大小的强制重新编码逻辑:即使图片尺寸符合要求,如果文件大小超过 200KB 也会重新编码降低质量
- 修复小尺寸高质量图片(如 483x480 但 631KB直接透传导致请求体过大的问题
- 新增日志输出追踪大文件重新编码过程和压缩率
## [v1.0.19] - 2026-02-17
### Changed
- **自适应压缩策略优化** (`src/anthropic/handlers.rs`)
- 请求体大小校验改为以实际序列化后的总字节数为准,不再扣除图片 base64 字节(上游存在约 5MiB 的硬性请求体大小限制,图片也必须计入)
- 压缩层级重排:当单条 user content 已超过阈值时优先截断超长消息(第三层),再移除历史消息(第四层),避免移除历史后仍无法降到阈值内
- 新增 `has_any_tool_results_or_tools` / `has_any_tool_uses` 预检,跳过无效的 tool 阈值降低迭代
- 历史消息移除改为批量 drain单轮最多 16 条),提升大上下文场景的压缩效率
- **请求体大小阈值默认上调至 4.5MiB** (`src/model/config.rs`, `config.example.json`)
- `compression.maxRequestBodyBytes` 从 400KB 上调至 4,718,592 字节4.5MiB),匹配上游实际限制
### Fixed
- **cargo fmt 格式化** (`src/anthropic/converter.rs`, `src/image.rs`)
## [v1.0.18] - 2026-02-17
### Added
- **GIF 动图抽帧采样与强制重编码** (`src/image.rs`, `src/anthropic/converter.rs`)
- 新增 `process_gif_frames()` 函数,将 GIF 动图抽帧为多张静态 JPEG避免动图 base64 体积巨大导致 upstream 400 错误
- 采样策略:总帧数不超过 20 帧,每秒最多 5 帧,超长 GIF 自动降低采样频率均匀抽取
- 新增 `process_image_to_format()` 函数,支持将任意图片强制重编码为指定格式
- GIF 抽帧失败时多级回退JPEG 重编码 → 静态 GIF 处理 → 原始数据透传
- `process_image()` 对 GIF 格式强制重编码为静态帧,即使无需缩放也避免透传体积巨大的动图
- `ImageProcessResult` 新增 `was_reencoded``original_bytes_len``final_bytes_len` 字段
### Changed
- **请求体大小阈值默认上调** (`src/model/config.rs`, `config.example.json`)
- 上游存在请求体大小硬限制(实测约 5MiB 左右会触发 400默认将 `compression.maxRequestBodyBytes` 上调至 4.5MiB 预留安全余量
- **日志分析脚本同步更新** (`tools/analyze_compression.py`, `tools/diagnose_improper_request.py`)
- 修复 ANSI 序列污染解析,并增加“自适应二次压缩/本地超限拒绝”的统计输出
## [v1.0.17] - 2026-02-15
### Added
- **自适应压缩第四层:超长用户消息内容截断** (`src/anthropic/compressor.rs`, `src/anthropic/handlers.rs`)
- 新增 `compress_long_messages_pass()` 函数,截断超长的 User 消息 content保留头部尾部附加省略标记
-`adaptive_shrink_request_body` 的三层策略之后增加第四层兜底,解决单条消息过大(如粘贴整个文件)导致自适应压缩空转的问题
- 动态计算截断阈值:初始为最大消息字符数的 3/4每轮递减 3/4最低 8192 字符
- 日志新增 `final_message_content_max_chars` 字段便于排查
## [v1.0.16] - 2026-02-15
### Fixed
- **请求体日志仅在 upstream 报错时输出完整内容** (`src/anthropic/handlers.rs`)
- 移除发送前的完整请求体 DEBUG 日志(`sensitive-logs` 模式下每次请求都输出几十 KB JSON统一只输出字节大小
- upstream 报错时在 `sensitive-logs` 模式下以 ERROR 级别输出完整请求体(截断 base64用于诊断 400/502 等错误
## [v1.0.15] - 2026-02-15
### Added
- **Opus 4.6 1M 上下文窗口支持** (`src/anthropic/types.rs`, `src/anthropic/handlers.rs`, `src/anthropic/stream.rs`)
- 新增 `get_context_window_size()` 函数Opus 4.6 返回 1,000,000 tokens其他模型返回 200,000 tokens
- 删除硬编码 `CONTEXT_WINDOW_SIZE` 常量,改用动态计算
- `MAX_BUDGET_TOKENS` 从 24,576 提升到 128,000
- `Model` 结构体新增 `context_length``max_completion_tokens``thinking` 字段
- **Agentic 模型变体** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
- 新增 sonnet-agentic、opus-4.5-agentic、opus-4.6-agentic、haiku-agentic 四个模型变体
- `map_model()` 自动剥离 `-agentic` 后缀再映射
- Agentic 模型注入专用系统提示,引导自主工作模式
- **Thinking level 后缀** (`src/anthropic/handlers.rs`)
- 支持 `-thinking-minimal`(512)、`-thinking-low`(1024)、`-thinking-medium`(8192)、`-thinking-high`(24576)、`-thinking-xhigh`(32768) 后缀
- **工具压缩** (`src/anthropic/tool_compression.rs` 新建)
- 20KB 阈值两步压缩:简化 input_schema → 按比例截断 description最短 50 字符)
- **截断检测** (`src/anthropic/truncation.rs` 新建)
- 4 种截断类型的启发式检测(空输入、无效 JSON、缺少字段、未闭合字符串
- 工具 JSON 解析失败时自动检测截断并生成软失败消息
### Changed
- 工具调用仅含 tool_use 时占位符从 `" "` 改为 `"."`,提升语义清晰度
## [v1.0.14] - 2026-02-15
### Fixed
- **sensitive-logs 模式下请求体日志截断** (`src/kiro/provider.rs`, `src/anthropic/handlers.rs`)
- 400 错误日志中的 `request_body` 字段改用 `truncate_body_for_log()` 截断(保留头尾各 1200 字符),避免输出包含大量 base64 图片数据的完整请求体
- 工具输入 JSON 解析失败日志中的 `request_body` 字段改用 `truncate_middle()` 截断
- 新增 `KiroProvider::truncate_body_for_log()` 函数,正确处理 UTF-8 多字节字符边界
## [v1.0.13] - 2026-02-14
### Fixed
- **请求体大小预检输出 image_bytes 归因信息** (`src/anthropic/handlers.rs`)
- 新增 `total_image_bytes()` 函数,计算 KiroRequest 中所有图片 base64 数据的总字节数
- 错误提示信息增加 image_bytes 和 non-image bytes 字段,便于排查请求体大小归因
## [v1.0.12] - 2026-02-14
### Fixed
- **WebSearch 仅纯搜索请求走本地处理** (`src/anthropic/websearch.rs`, `src/anthropic/handlers.rs`)
- 新增 `should_handle_websearch_request()` 精确判断:仅当 tool_choice 强制选择 web_search、tools 仅含 web_search 单工具、或用户消息包含 `Perform a web search for the query:` 前缀时,才路由到本地 WebSearch 处理
- 混合工具场景web_search + 其他工具)改为剔除 web_search 后转发 upstream避免普通对话被误当成搜索查询
- 新增 `strip_web_search_tools()` 从 tools 列表中移除 web_search 工具
- 搜索查询提取增加空白归一化处理
## [v1.0.11] - 2026-02-14
### Added
- **自适应二次压缩策略** (`src/anthropic/handlers.rs`)
- 请求体超过 `max_request_body_bytes` 阈值时,自动迭代压缩:逐步降低 tool_result/tool_use_input 截断阈值,最后按轮移除最老历史消息
- 最多迭代 32 轮,避免极端输入导致过长 CPU 消耗
- `post_messages``post_messages_cc` 均支持自适应压缩
- **压缩后 tool_use/tool_result 配对修复** (`src/anthropic/compressor.rs`)
- 新增 `repair_tool_pairing_pass()`:历史截断后自动移除孤立的 tool_use 和 tool_result
- 解决截断破坏跨消息 tool_use→tool_result 配对导致 upstream 返回 400 "Improperly formed request" 的问题
- **stable 版 `floor_char_boundary` 工具函数** (`src/common/utf8.rs`)
- 新增 `common::utf8` 模块,提供 stable Rust 下的 `floor_char_boundary()` 实现
- 统一替换项目中散落的 `str::floor_char_boundary()` nightly 调用
### Fixed
- **WebSearch 支持混合工具列表** (`src/anthropic/websearch.rs`)
- `has_web_search_tool()` 改为只要 tools 中包含 web_search按 name 或 type 判断)即走本地处理,不再要求 tools 仅有一个
- `extract_search_query()` 改为取最后一条 user 消息,更符合多轮对话场景
- 新增非流式(`stream: false`)响应支持,返回完整 JSON 而非 SSE 流
### Changed
- **迁移 `floor_char_boundary` 调用到 `common::utf8` 模块** (`src/anthropic/compressor.rs`, `src/anthropic/stream.rs`, `src/admin/service.rs`, `src/kiro/token_manager.rs`, `src/kiro/provider.rs`)
- 移除各文件中重复的 `floor_char_boundary` 内联实现,统一使用 `crate::common::utf8::floor_char_boundary`
## [v1.0.10] - 2026-02-12
### Fixed
- **配额耗尽返回 429 而非 502** (`src/anthropic/handlers.rs`, `src/kiro/provider.rs`)
- 所有凭据配额耗尽时返回 `429 Too Many Requests``rate_limit_error`),而非 `502 Bad Gateway`
- 余额刷新时主动禁用低余额凭据(余额 < 1.0402 分支同步清零余额缓存
- **亲和性检查不再触发限流** (`src/kiro/token_manager.rs`)
- 亲和性检查改用 `check_rate_limit` 只读探测消除"检查本身消耗速率配额"的恶性循环
- 亲和性分流日志提升至 info 级别并脱敏 user_id便于生产监控热点凭据
### Added
- **请求体大小预检** (`src/anthropic/handlers.rs`, `src/model/config.rs`)
- 新增 `max_request_body_bytes` 配置项序列化后拦截超大请求避免无效 upstream 往返
### Changed
- **移除无意义的 max_tokens 调整逻辑** (`src/anthropic/handlers.rs`)
- 删除 max_tokens 超限警告日志和调整逻辑因为该值实际不传递给 Kiro upstream
## [v1.0.9] - 2026-02-12
### Fixed
- **修复 upstream 合并丢失的功能** (`src/anthropic/stream.rs`)
- 恢复 `stop_reason` 优先级逻辑高优先级原因可覆盖低优先级model_context_window_exceeded > max_tokens > tool_use > end_turn
- 注释掉重复的 `content_block_start` 日志,避免日志噪音
- 修复 `contextUsageEvent` 日志格式保留4位小数
- 移除冗余的 `find_char_boundary` 函数,改用标准库 `str::floor_char_boundary()`
## [v1.0.8] - 2026-02-12
### Added
- **批量导入 token.json** (`src/admin/service.rs`, `src/admin/types.rs`)
- 新增 `import_token_json` 接口,支持批量导入官方 token.json 格式凭据
- 自动映射 provider/authMethod 字段,支持 dry-run 预览模式
- 去重检测:通过 refreshToken 前缀匹配避免重复导入
- **缓存余额查询接口** (`src/admin/handlers.rs`, `src/admin/router.rs`)
- 新增 `GET /admin/credentials/balances/cached` 端点
- 返回所有凭据的缓存余额信息(含 TTL 和缓存时间)
### Changed
- **用户亲和性支持** (`src/kiro/token_manager.rs`, `src/kiro/provider.rs`)
- 新增 `UserAffinityManager`:用户与凭据绑定,保持会话连续性
- `acquire_context_for_user` 方法支持按 user_id 获取绑定凭据
- 亲和性过期时间可配置,默认 30 分钟
- **余额缓存动态 TTL** (`src/kiro/token_manager.rs`)
- 基于使用频率动态调整缓存 TTL高频用户更短 TTL
- 新增 `update_balance_cache``get_all_cached_balances` 方法
- 缓存持久化到 `kiro_balance_cache.json`
- **请求压缩管道** (`src/anthropic/converter.rs`, `src/anthropic/middleware.rs`)
- `AppState` 新增 `compression_config` 字段
- `convert_request` 支持 `CompressionConfig` 参数
- 图片压缩、空白压缩、上下文截断等功能集成
- **凭据级代理配置** (`src/kiro/provider.rs`)
- KiroProvider 支持按凭据动态选择代理
- 新增 `get_client_for_credential` 方法,缓存凭据级 client
- API 请求和 MCP 请求均使用凭据的 `effective_proxy()`
- **API Region 分离** (`src/kiro/provider.rs`)
- `base_url``mcp_url``base_domain` 使用 `effective_api_region()`
- 支持 Token 刷新和 API 调用使用不同 region
- **handlers 传递 user_id** (`src/anthropic/handlers.rs`)
- 从请求 metadata 提取 user_id 并传递给 provider
- 支持用户亲和性功能
### Fixed
- **修复 mask_user_id UTF-8 panic** (`src/anthropic/handlers.rs`)
- 使用 `chars()` 按字符而非字节切片,避免多字节字符导致 panic
## [v1.0.7] - 2026-02-10
### Added
- **图片 Token 估算与压缩模块** (`src/image.rs`)
- 新增 `estimate_image_tokens()`: 从 base64 数据解析图片尺寸并估算 token 数
- 新增 `process_image()`: 根据配置对大图进行缩放压缩
- 实现 Anthropic 官方公式: `tokens = (width × height) / 750`
- 支持 JPEG/PNG/GIF/WebP 格式
### Changed
- **Token 计算支持图片** (`src/token.rs`)
- `count_all_tokens_local()` 现在处理 `type: "image"` 的 ContentBlock
- 调用 `estimate_image_tokens()` 计算图片 token解决之前图片 token 被计为 0 的问题
- **协议转换支持图片压缩** (`src/anthropic/converter.rs`)
- `process_message_content()` 新增图片压缩处理,根据配置自动缩放超限图片
- 新增 `count_images_in_request()` 统计请求中图片总数,用于判断多图模式
- 缩放后记录日志: `图片已缩放: (原始尺寸) -> (缩放后尺寸), tokens: xxx`
- **压缩配置新增图片参数** (`src/model/config.rs`)
- `image_max_long_edge`: 长边最大像素,默认 1568Anthropic 推荐值)
- `image_max_pixels_single`: 单张图片最大总像素,默认 1,150,000约 1600 tokens
- `image_max_pixels_multi`: 多图模式下单张最大像素,默认 4,000,0002000×2000
- `image_multi_threshold`: 触发多图限制的图片数量阈值,默认 20
### Dependencies
- 新增 `image` crate (0.25): 图片处理(支持 JPEG/PNG/GIF/WebP
- 新增 `base64` crate (0.22): Base64 编解码
## [v1.0.6] - 2026-02-10
### Changed
- **压缩统计日志改用字节单位** (`src/anthropic/handlers.rs`)
- 移除不准确的 token 估算(`compressed_input_tokens``tokens_saved`),改为直接输出字节数
- 字段重命名:`whitespace_saved``whitespace_bytes_saved` 等,明确单位语义
- 注释更新:说明字节统计用于排查 upstream 请求体大小限制
### Added
- **日志脱敏工具模块** (`src/common/redact.rs`)
- `redact_opt_string`: Option<String> 脱敏为存在性表示
- `mask_email`: 邮箱脱敏(保留首字符)
- `mask_aws_account_id_in_arn`: AWS ARN 中 account id 脱敏
- `mask_url_userinfo`: URL userinfo 脱敏
- `mask_user_agent_machine_id`: User-Agent 中 machine_id 脱敏
## [v1.0.5] - 2026-02-09
### Changed
- **请求日志输出压缩前后 token 估算** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
- `ConversionResult` 新增 `compression_stats` 字段,将压缩统计从 converter 内部返回给调用方
- `post_messages` / `post_messages_cc``convert_request()` 之前估算 input tokens`Received` 日志追加 `estimated_input_tokens`
- 压缩完成后输出 token 对比日志:`estimated_input_tokens``compressed_input_tokens``tokens_saved` 及各项压缩明细
- WebSearch 分支复用已有估算值,消除重复的 `count_all_tokens` 调用
- **`count_all_tokens` 改为接受借用** (`src/token.rs`)
- 参数从按值传递改为引用(`&str``&[Message]``&Option<Vec<T>>`),消除 handlers 中的深拷贝开销
- **历史截断统计增加字节数** (`src/anthropic/compressor.rs`)
- `CompressionStats` 新增 `history_bytes_saved` 字段,`total_saved()` 包含历史截断字节数
- `compress_history_pass` 返回 `(turns_removed, bytes_saved)` 元组token 估算准确计入历史截断
## [v1.0.4] - 2026-02-09
### Changed
- **Docker 构建优化**: 引入 `cargo-chef` 实现依赖层缓存,大幅加速重复构建
- 新增 `planner` 阶段生成依赖 recipe`builder` 阶段先编译依赖再编译源码
- Docker 层缓存使得仅源码变更时无需重新编译所有依赖
- **GitHub Actions Docker 构建缓存**: 启用 GHA 缓存(`cache-from/cache-to: type=gha`
- **Cargo.toml 优化**:
- `lto``true`fat LTO改为 `"thin"`,加快编译速度同时保持较好的优化效果
- `tokio` features 从 `full` 精简为实际使用的 5 个 feature`rt-multi-thread`, `macros`, `net`, `time`, `sync`),减小编译体积
## [v1.0.3] - 2026-02-09
### Fixed
- **opus4-6 上下文超限错误识别** (`src/anthropic/handlers.rs`)
-`Improperly formed request` 纳入输入过长错误检测,返回 Claude Code 可识别的 `400 Input is too long` 而非 `502 Bad Gateway`
- **Opus 4.6 模型 ID 移除时间日期** (`src/anthropic/handlers.rs`)
- `claude-opus-4-6-20260206``claude-opus-4-6`
- **post_messages 借用冲突编译错误** (`src/anthropic/handlers.rs`)
-`override_thinking_from_model_name` 调用移至 `user_id` 提取之前,修复 E0502 借用冲突
### Added
- **模型 "thinking" 后缀支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`)
- 模型名含 `-thinking` 后缀时自动覆写 thinking 配置opus4.6 用 adaptive + high effort其他用 enabled
- 模型列表新增 sonnet/opus4.5/opus4.6/haiku 的 thinking 变体
### Docs
- **README.md 补充输入压缩配置文档**
- 新增"输入压缩"章节,说明 5 层压缩管道机制和 10 个可配置参数
## [v1.0.2] - 2026-02-09
### Fixed
- **opus4-6 上下文超限错误识别** (`src/anthropic/handlers.rs`)
-`Improperly formed request` 纳入输入过长错误检测,返回 Claude Code 可识别的 `400 Input is too long` 而非 `502 Bad Gateway`
- **Opus 4.6 模型 ID 移除时间日期** (`src/anthropic/handlers.rs`)
- `claude-opus-4-6-20260206``claude-opus-4-6`
- **post_messages 借用冲突编译错误** (`src/anthropic/handlers.rs`)
-`override_thinking_from_model_name` 调用移至 `user_id` 提取之前,修复 E0502 借用冲突
### Added
- **模型 "thinking" 后缀支持** (`src/anthropic/handlers.rs`, `src/anthropic/converter.rs`)
- 模型名含 `-thinking` 后缀时自动覆写 thinking 配置opus4.6 用 adaptive + high effort其他用 enabled
- 模型列表新增 sonnet/opus4.5/opus4.6/haiku 的 thinking 变体
### Docs
- **README.md 补充输入压缩配置文档**
- 新增"输入压缩"章节,说明 5 层压缩管道机制和 10 个可配置参数
## [v1.0.1] - 2026-02-08
### Fixed
- **历史截断字符统计口径修正** (`src/anthropic/compressor.rs`)
- `max_history_chars` 从按字节 `.len()` 改为按字符 `.chars().count()`,与配置语义一致
- **remove_thinking_blocks 不再全局 trim** (`src/anthropic/compressor.rs`)
- 移除末尾 `.trim()`,避免意外吞掉原始内容的首尾空白
- **token.json 导入注释/逻辑统一** (`src/admin/service.rs`)
- 更新注释:`builder-id → idc`(与实际映射一致)
- 删除 `auth_method == "builder-id"` 死分支
- **Admin UI 验活开关 added=0 卡住修复** (`admin-ui/src/components/import-token-json-dialog.tsx`)
- `enableVerify` 开启但无新增凭据时,直接跳转 result 步骤而非卡在 preview
- **工具描述截断 max_description_chars=0 语义修正** (`src/anthropic/converter.rs`)
- `0` 现在表示"不截断",而非截断为空字符串
### Added
- **输入压缩管道** (`src/anthropic/compressor.rs`)
- 新增 5 层压缩管道,规避 Kiro upstream 请求体大小限制
- 空白压缩:连续空行(3+)→2行行尾空格移除保留行首缩进
- thinking 块处理:支持 discard/truncate/keep 三种策略
- tool_result 智能截断:按行截断保留头尾,行数不足时回退字符级截断
- tool_use input 截断:递归截断 JSON 值中的大字符串
- 历史截断:保留系统消息对,按轮数/字符数从前往后成对移除
- 12 个单元测试覆盖所有压缩层和边界条件
- **CompressionConfig 配置结构体** (`src/model/config.rs`)
- 新增 `compression` 配置字段,支持通过 JSON 配置文件调整参数
- 10 个可配置参数总开关、空白压缩、thinking 策略、各截断阈值、历史限制
- 工具描述截断阈值从硬编码 10000 改为可配置(默认 4000
- **合并 upstream 新功能**: 从 upstream/master 拉取并融合大量新特性
- **负载均衡模式** (`src/model/config.rs`, `src/kiro/token_manager.rs`, `src/admin/`)
- 新增 `loadBalancingMode` 配置项,支持 `priority`(默认)和 `balanced`Least-Used两种模式
- Admin API 新增 `GET/PUT /config/load-balancing` 端点
- 前端新增负载均衡模式切换开关
- **凭据统计与持久化** (`src/kiro/token_manager.rs`)
- 新增 `success_count``last_used_at` 字段,跟踪每个凭据的调用统计
- 新增 `save_stats_debounced` 防抖持久化机制,`Drop` 时自动保存
- 新增 `refreshTokenHash` 字段用于前端重复检测
- **前端批量操作** (`admin-ui/src/components/dashboard.tsx`)
- 批量导入对话框 (`BatchImportDialog`)
- 批量验活对话框 (`BatchVerifyDialog`)
- 分页控件、批量选择/删除/恢复/验活功能
- 凭据卡片新增 Checkbox 选择、email 显示、订阅等级、成功次数、剩余用量等信息
- **配置文件路径管理** (`src/model/config.rs`)
- `Config` 新增 `config_path` 字段和 `load()`/`save()` 方法,支持配置回写
- **前端依赖**: 新增 `@radix-ui/react-checkbox` 组件
### Changed
- `convert_request()` 签名新增 `&CompressionConfig` 参数 (`src/anthropic/converter.rs`)
- `convert_tools()` 描述截断阈值参数化 (`src/anthropic/converter.rs`)
- `AppState` 新增 `compression_config` 字段 (`src/anthropic/middleware.rs`)
- `create_router_with_provider()` 新增 `CompressionConfig` 参数 (`src/anthropic/router.rs`)
- 重构 README.md 配置文档,提升新用户上手体验
- 明确配置文件默认路径:当前工作目录(或通过 `-c`/`--config``--credentials` 参数指定)
- 添加 JSON 注释警告:移除所有带 `//` 注释的示例,提供可直接复制的配置
- 修正字段必填性:仅 `apiKey` 为必填,其他字段均有默认值
- 新增命令行参数说明表格(`-c`, `--credentials`, `-h`, `-V`
- 补充遗漏的 `credentialRpm` 字段说明(凭据级 RPM 限流)
- 使用表格形式展示配置字段,标注必填/可选和默认值
- 优化 debug 日志中请求体的输出长度 (`src/anthropic/handlers.rs`)
- 新增 `truncate_middle()` 函数:截断字符串中间部分,保留头尾各 1200 字符
- 正确处理 UTF-8 多字节字符边界,不会截断中文
- 仅在启用 `sensitive-logs` feature 时生效,减少日志噪音
### Fixed
- **[P0] API Key 日志泄露修复** (`src/main.rs`)
- info 级别不再打印 API Key 前半段,仅显示末 4 位和长度
- 完整前缀仅在 `sensitive-logs` feature 的 debug 级别输出
- **[P2] 占位工具大小写变体重复插入** (`src/anthropic/converter.rs`)
- `collect_history_tool_names` 改为小写去重,避免 `read`/`Read` 等变体重复
- 占位工具 push 后同步更新 `existing_tool_names` 集合
- **[P1] 统计与缓存写盘非原子操作** (`src/kiro/token_manager.rs`, `src/admin/service.rs`)
- 统计数据和余额缓存改为临时文件 + 原子重命名,防止写入中断导致文件损坏
- **[P1] stop_reason 覆盖策略可能丢失信息** (`src/anthropic/stream.rs`)
- `set_stop_reason()` 改为基于优先级覆盖,高优先级原因可覆盖低优先级原因
- **[P2] snapshot 重复计算 SHA-256** (`src/kiro/token_manager.rs`)
- `CredentialEntry` 新增 `refresh_token_hash` 缓存字段
- Token 刷新时自动更新哈希,`snapshot()` 优先使用缓存避免重复计算
- **Clippy 警告修复** (`src/model/config.rs`)
- 修复 `field_reassign_with_default` 警告,改用结构体初始化语法
- **[P2] Assistant Prefill 静默丢弃** (`src/anthropic/converter.rs`, `src/anthropic/handlers.rs`)
- 末尾 `assistant` 消息prefill 场景)不再返回 400 错误,改为静默丢弃并回退到最后一条 `user` 消息
- Claude 4.x 已弃用 assistant prefillKiro API 也不支持,转换器在入口处截断消息列表
- 移除 `InvalidLastMessageRole` 错误变体,`build_history` 接受预处理后的消息切片
- **[P2] 凭据回写原子性** (`src/kiro/token_manager.rs`)
- `persist_credentials` 改为临时文件 + `rename` 原子替换
- 新增 `resolve_symlink_target` 辅助函数:优先 `canonicalize`,失败时用 `read_link` 解析 symlink
- 保留原文件权限,防止 umask 导致凭据文件权限放宽
- Windows 兼容:`rename` 前先删除已存在的目标文件
- 避免进程崩溃或并发调用导致凭据文件损坏
- 限制 `max_tokens` 最大值为 32000Kiro upstream 限制)
- 当用户设置超出限制的值时自动调整为 32000
- 记录 WARN 级别日志,包含原始值和调整后的值
- 涉及文件:`src/anthropic/handlers.rs`
- 工具输入 JSON 解析失败时的日志输出改为受 `sensitive-logs` feature 控制
- 默认仅输出 `buffer_len``request_body_bytes`(长度信息)
- 启用 `--features sensitive-logs` 时输出完整 `buffer``request_body`
- 涉及文件:`src/anthropic/handlers.rs`
- 修复 Kiro upstream 请求兼容性问题
- 空 content仅 tool_result/image时使用占位符避免 400
- 规范化工具 JSON Schema、补全空 description
- 禁用 reqwest 系统代理探测(仅支持显式 `config.proxy_url`
- 新增离线诊断脚本:`tools/diagnose_improper_request.py`
- 涉及文件:`src/anthropic/converter.rs``src/http_client.rs``tools/diagnose_improper_request.py``.gitignore`
- 优化 400 Bad Request "输入过长" 错误的日志输出 (`src/kiro/provider.rs`)
- 对于 `CONTENT_LENGTH_EXCEEDS_THRESHOLD` / `Input is too long` 错误,不再输出完整请求体(太占空间且无调试价值)
- 改为记录 `request_body_bytes`(字节数)和 `estimated_input_tokens`(估算 token 数)
- 新增 `estimate_tokens()` 函数:基于 CJK/非 CJK 字符比例估算 token 数量
- CJK 字符(中/日/韩): token 数 = 字符数 / 1.5
- 其他字符(英文等): token 数 = 字符数 / 3.5
- 新增 `is_input_too_long()``is_cjk_char()` 辅助函数
- 新增多维度设备指纹系统 (`src/kiro/fingerprint.rs`)
- 每个凭据生成独立的确定性指纹,模拟真实 Kiro IDE 客户端
- 支持 10+ 维度设备信息SDK 版本、Kiro 版本、Node.js 版本、操作系统、屏幕分辨率、CPU 核心数、时区等
- 提供 `user_agent()``x_amz_user_agent()` 方法构建请求头
- 参考 CLIProxyAPIPlus 实现,降低被检测风险
- 新增精细化速率限制系统 (`src/kiro/rate_limiter.rs`)
- 每日请求限制(默认 500 次/天)
- 请求间隔控制1-2 秒 + 30% 抖动)
- 指数退避策略30s → 5min倍数 1.5
- 暂停检测关键词匹配suspended, banned, quota exceeded 等)
- 新增独立冷却管理模块 (`src/kiro/cooldown.rs`)
- 分类冷却原因7 种类型速率限制、账户暂停、配额耗尽、Token 刷新失败等)
- 差异化冷却时长短冷却1-5 分钟vs 长冷却1-24 小时)
- 递增冷却机制(连续触发时延长冷却时间)
- 自动清理过期冷却
- 新增后台 Token 刷新模块 (`src/kiro/background_refresh.rs`)
- 独立后台任务定期检查即将过期的 Token
- 支持批量并发刷新(信号量控制)
- 可配置检查间隔、批处理大小、并发数
- 优雅关闭机制
- `MultiTokenManager` 新增增强方法
- `get_fingerprint()`: 获取凭据的设备指纹
- `is_credential_available()`: 综合检查凭据可用性(未禁用、未冷却、未超速率限制)
- `set_credential_cooldown()` / `clear_credential_cooldown()`: 冷却管理
- `get_expiring_credential_ids()`: 获取即将过期的凭据列表
- `start_background_refresh()`: 启动后台 Token 刷新任务
- `refresh_token_for_credential()`: 带优雅降级的 Token 刷新
- `record_api_success()` / `record_api_failure()`: 更新速率限制器状态
- `CredentialEntry` 结构体新增 `fingerprint` 字段,每个凭据独立生成设备指纹
- 修复 IDC 凭据 `fetch_profile_arn` 在某些 region 返回 `UnknownOperationException` 的问题
- 新增 `ListAvailableCustomizations` API 作为 `ListProfiles` 的回退方案
- 支持多 region 尝试:先尝试用户配置的 region失败则回退到 `us-east-1`
- 涉及文件:`src/kiro/token_manager.rs`
- 修复 `start_background_refresh` 后台刷新器生命周期问题Codex Review P1
- 问题:`refresher` 作为局部变量在函数返回后被 drop导致后台任务立即停止
- 解决:方法现在返回 `Arc<BackgroundRefresher>`,调用方需保持引用以维持任务运行
- 涉及文件:`src/kiro/token_manager.rs`
- 修复 `calculate_backoff` 退避时间可能超过配置上限的问题Codex Review P2
- 问题:添加抖动后未再次进行上限约束,可能导致实际等待时间超过 `backoff_max_ms`
- 解决:在添加抖动后再进行 `.min(max)` 约束
- 涉及文件:`src/kiro/rate_limiter.rs`
- 改进 `persist_credentials` 并发写入安全性Codex Review P1
- 问题:在锁外执行文件写入可能导致并发写入时旧快照覆盖新数据
- 解决:在持有 entries 锁的情况下完成序列化,确保快照一致性
- 涉及文件:`src/kiro/token_manager.rs`
- 修复 IDC 凭据返回 403 "The bearer token included in the request is invalid" 的问题
- 根本原因:`profile_arn` 只从第一个凭据获取并存储在全局 `AppState` 中,当使用 IDC 凭据时Bearer Token 来自 IDC 凭据,但 `profile_arn` 来自第一个凭据(可能是 Social 类型),导致 Token 和 profile_arn 不匹配
- 解决方案 1`call_api_with_retry` 中动态注入当前凭据的 `profile_arn`,确保 Token 和 profile_arn 始终匹配
- 解决方案 2IDC Token 刷新后自动调用 `ListProfiles` API 获取 `profileArn`IDC 的 OIDC 刷新不返回此字段)
- 新增 `inject_profile_arn()` 辅助方法,解析请求体 JSON 并覆盖 `profileArn` 字段
- 新增 `fetch_profile_arn()` 方法,通过 CodeWhisperer ListProfiles API 获取 profileArn
- 涉及文件:`src/kiro/provider.rs`, `src/kiro/token_manager.rs`
- 新增批量导入 token.json 功能
- 后端:新增 `POST /api/admin/credentials/import-token-json` 端点
- 支持解析官方 token.json 格式(含 `provider``refreshToken``clientId``clientSecret` 等字段)
-`provider` 字段自动映射 `authMethod`BuilderId → idc, IdC → idc, Social → social
- 支持 dry-run 预览模式,返回详细的导入结果(成功/跳过/无效)
- 通过 refreshToken 前缀匹配自动去重,避免重复导入
- 前端:新增"导入 token.json"对话框组件
- 支持拖放上传 JSON 文件或直接粘贴 JSON 内容
- 三步流程:输入 → 预览 → 结果
- 涉及文件:
- `src/admin/types.rs`(新增 `TokenJsonItem``ImportTokenJsonRequest``ImportTokenJsonResponse` 等类型)
- `src/admin/service.rs`(新增 `import_token_json()` 方法)
- `src/admin/handlers.rs`(新增 `import_token_json` handler
- `src/admin/router.rs`(添加路由)
- `src/kiro/token_manager.rs`(新增 `has_refresh_token_prefix()` 方法)
- `admin-ui/src/types/api.ts`(新增导入相关类型)
- `admin-ui/src/api/credentials.ts`(新增 `importTokenJson()` 函数)
- `admin-ui/src/hooks/use-credentials.ts`(新增 `useImportTokenJson()` hook
- `admin-ui/src/components/import-token-json-dialog.tsx`(新建)
- `admin-ui/src/components/dashboard.tsx`(添加导入按钮)
- 修复字符串切片在多字节字符中间切割导致 panic 的风险DoS 漏洞)
- `generate_fingerprint()``has_refresh_token_prefix()` 使用 `floor_char_boundary()` 安全截断
- 涉及文件:`src/admin/service.rs`, `src/kiro/token_manager.rs`
- 修复日志截断在多字节字符中间切割导致 panic 的问题
- `truncate_for_log()` 使用 `floor_char_boundary()` 安全截断 UTF-8 字符串
- 删除 `stream.rs` 中冗余的 `find_char_boundary()` 函数,直接使用标准库方法
- 涉及文件:`src/kiro/provider.rs`, `src/anthropic/stream.rs`
- 移除历史消息中孤立的 tool_use无对应 tool_result
- Kiro API 要求 tool_use 必须有配对的 tool_result否则返回 400 Bad Request
- 新增 `remove_orphaned_tool_uses()` 函数清理孤立的 tool_use
- 涉及文件:`src/anthropic/converter.rs`
- 修复 `/cc/v1/messages` 缓冲流 ping 定时器首次立即触发的问题
-`interval()` 改为 `interval_at(Instant::now() + ping_period, ping_period)`
- 现在首个 ping 会在 25 秒后触发,与 `/v1/messages` 行为一致
- 涉及文件:`src/anthropic/handlers.rs`
- 修复 Clippy `collapsible_if` 警告
- 使用 let-chains 语法合并嵌套 if 语句
- 涉及文件:`src/anthropic/stream.rs`
- 增强 400 Bad Request 错误日志,记录完整请求信息
- 移除请求体截断限制,记录完整的 `request_body`
- 新增 `request_url``request_headers` 字段
- 新增 `format_headers_for_log()` 辅助函数,对 Authorization 头进行脱敏处理
- 删除不再使用的 `truncate_for_log()` 函数YAGNI 原则)
- 涉及文件:`src/kiro/provider.rs`
- 改进凭据选择算法:同优先级内实现负载均衡
- 第一优先级:使用次数最少
- 第二优先级:余额最多(使用次数相同时)
- 第三优先级:轮询选择(使用次数和余额完全相同时,避免总选第一个)
- 新增 `selection_rr` 原子计数器用于轮询抖动
- 新增 `select_best_candidate_id()` 方法实现三级排序逻辑
- 涉及文件:`src/kiro/token_manager.rs`
- 修复测试代码使用 `serde_json::json!` 构造 Tool 对象导致的类型不匹配问题
- 改用 `Tool` 结构体直接构造,确保类型安全
- 涉及文件:`src/anthropic/websearch.rs`
- 修复 `select_best_candidate_id()` 中 NaN 余额处理问题
- 在评分阶段将 NaN/Infinity 余额归一化为 0.0
- 避免 NaN 被 `total_cmp` 视为最大值导致错误的凭据选择
- 避免 NaN 导致 `scored` 被完全过滤后除零 panic
- 涉及文件:`src/kiro/token_manager.rs`
- 新增 `system` 字段格式兼容性支持(`src/anthropic/types.rs`
- 支持字符串格式:`"system": "You are a helpful assistant"`new-api 等网关添加的系统提示词)
- 支持数组格式:`"system": [{"type": "text", "text": "..."}]`Claude Code 原生格式)
- 自动将字符串格式转换为单元素数组,保持内部处理一致性
- 新增 6 个单元测试验证格式兼容性
- 新增请求体大小限制50MB`DefaultBodyLimit::max(50 * 1024 * 1024)`
- 涉及文件:`src/anthropic/router.rs`
- 调整全局禁用恢复时间:`GLOBAL_DISABLE_RECOVERY_MINUTES` 从 10 分钟降至 5 分钟
- 加快模型暂时不可用后的自动恢复速度
- 调整总重试次数硬上限:`MAX_TOTAL_RETRIES` 从 5 降至 3
- 进一步减少无效重试开销,加快故障转移速度
- 余额初始化改为顺序查询,每次间隔 0.5 秒避免触发限流
- 从并发查询改为顺序查询(`initialize_balances()`
- 移除 30 秒整体超时机制
- 涉及文件:`src/kiro/token_manager.rs`
- 修复 assistant 消息仅包含 tool_use 时 content 为空导致 Kiro API 报错的问题
- 当 text_content 为空且存在 tool_uses 时,使用 "OK" 作为占位符
- 涉及文件:`src/anthropic/converter.rs`
- 修复 `MODEL_TEMPORARILY_UNAVAILABLE` 错误检测逻辑未实际调用的问题
-`call_mcp()``call_api()` 中添加错误检测和熔断触发逻辑
- 移除 `report_model_unavailable()``disable_all_credentials()``#[allow(dead_code)]` 标记
- 现在当检测到该错误时会正确触发全局熔断机制
- 新增 WebSearch 工具支持(`src/anthropic/websearch.rs`
- 实现 Anthropic WebSearch 请求到 Kiro MCP 的转换
- 支持 SSE 流式响应,生成完整的搜索结果事件序列
- 自动检测纯 WebSearch 请求tools 仅包含 web_search并路由到专用处理器
- 新增 MCP API 调用支持(`src/kiro/provider.rs`
- 新增 `call_mcp()` 方法,支持 WebSearch 等工具调用
- 新增 `mcp_url()``build_mcp_headers()` 方法
- 完整的重试和故障转移逻辑
- 新增凭据级 `region` 字段,用于 OIDC token 刷新时指定 endpoint 区域
- 未配置时回退到 config.json 的全局 region
- API 调用仍使用 config.json 的 region
- 新增凭据级 `machineId` 字段,支持每个凭据使用独立的机器码
- 支持 64 字符十六进制和 UUID 格式(自动标准化)
- 未配置时回退到 config.json 的 machineId都未配置时由 refreshToken 派生
- 启动时自动补全并持久化到配置文件
- 新增 GitHub Actions Docker 构建工作流(`.github/workflows/docker-build.yaml`
- 支持 linux/amd64 和 linux/arm64 双架构
- 推送到 GitHub Container Registry
- 版本号升级至 2026.1.5
- TLS 库从 native-tls 切换至 rustlsreqwest 依赖调整)
- `authMethod` 自动推断:未指定时根据是否有 clientId/clientSecret 自动判断为 idc 或 social
- 移除 web_search/websearch 工具过滤(`is_unsupported_tool` 现在返回 false
- 修复 machineId 格式兼容性问题,支持 UUID 格式自动转换为 64 字符十六进制
### Removed
- 移除 `current_id` 概念(后端和前端)
- 后端:移除 `MultiTokenManager.current_id` 字段和相关方法(`switch_to_next``select_highest_priority``select_by_balance``credentials`
- 后端:移除 `ManagerSnapshot.current_id` 字段
- 后端:移除 `CredentialStatusItem.is_current` 字段
- 前端:移除 `CredentialsStatusResponse.currentId``CredentialStatusItem.isCurrent`
- 原因:多用户并发访问时,"当前凭据"概念无意义,凭据选择由 `acquire_context_for_user()` 动态决定
- 新增启动时余额初始化功能
- `initialize_balances()`: 启动时并发查询所有凭据余额并更新缓存
- 整体超时 30 秒,避免阻塞启动流程
- 初始化失败或超时时输出警告日志
- 改进凭据选择算法:从单一"使用次数最少"改为两级排序
- 第一优先级:使用次数最少
- 第二优先级:余额最多(使用次数相同时)
- 未初始化余额的凭据会被降级处理,避免被优先选中
- 移除前端"当前活跃"凭据展示
- 前端:移除凭据卡片的"当前"高亮和 Badge
- 前端:移除 Dashboard 中的"当前活跃"统计卡片
- 统计卡片布局从 3 列调整为 2 列
- 新增 `sensitive-logs` feature flag显式启用才允许打印潜在敏感信息仅用于排障
- 默认关闭Kiro 请求体只输出长度,凭证只输出摘要信息
- 启用方式:`cargo build --features sensitive-logs`
- 修复 SSE 流 ping 保活首次立即触发的问题
- 使用 `interval_at(Instant::now() + ping_period, ping_period)` 延迟首次触发
- 避免连接建立后立即发送无意义的 ping 事件
- 改进服务启动错误处理
- 绑定监听地址失败时输出错误日志并退出exit code 1
- HTTP 服务异常退出时输出错误日志并退出exit code 1
- 修复合并 upstream 后 `CredentialEntry` 结构体字段缺失导致的编译错误
- 添加 `disable_reason: Option<DisableReason>` 字段(公共 API 展示用)
- 添加 `auto_heal_reason: Option<AutoHealReason>` 字段(内部自愈逻辑用)
- 修复禁用原因字段不同步问题
- `report_failure()`: 禁用时同步设置两个字段
- `set_disabled()`: 启用/禁用时同步设置/清除两个字段
- `reset_and_enable()`: 重置时同步清除两个字段
- 自愈循环:重新启用凭据时同步清除 `disable_reason`
- `mark_insufficient_balance()`: 清除 `auto_heal_reason` 防止被自愈循环错误恢复
- 重命名内部字段以提高可读性
- `DisabledReason``AutoHealReason`(自愈原因枚举)
- `disabled_reason``auto_heal_reason`(自愈原因字段)
- 日志中的 `user_id` 现在会进行掩码处理,保护用户隐私
- 长度 > 25保留前13后8字符`user_f516339a***897ac7`
- 长度 13-25保留前4后4字符
- 长度 ≤ 12完全掩码为 `***`
- 新增缓存余额查询 API`GET /credentials/balances/cached`
- 后端:`CachedBalanceInfo` 结构体、`get_all_cached_balances()` 方法
- 前端:凭据卡片直接显示缓存余额和更新时间
- 30 秒自动轮询更新,缓存超过 1 分钟时点击强制刷新
- 新增 Bonus 用量包支持(`src/kiro/model/usage_limits.rs`
- 新增 `Bonus` 结构体,支持 GIFT 类型的额外用量包
- 新增 `Bonus::is_active()` 方法,按状态/过期时间判断是否激活
- `usage_limit()``current_usage()` 现在会合并基础额度、免费试用额度和所有激活的 bonuses
- 新增 Kiro Web Portal API 模块(`src/kiro/web_portal.rs`
- 支持 CBOR 协议与 app.kiro.dev 通信
- 实现 `get_user_info()``get_user_usage_and_limits()` API
- 新增 `aggregate_account_info()` 聚合账号信息(套餐/用量/邮箱等)
- Admin UI 前端增强
- 新增数字格式化工具(`admin-ui/src/lib/format.ts`K/M/B 显示、Token 对格式化、过期时间格式化
- 新增统计相关 API 和 Hooks`getCredentialStats`, `resetCredentialStats`, `resetAllStats`
- 新增账号信息 API`getCredentialAccountInfo`, `useCredentialAccountInfo`
- 扩展 `CredentialStatusItem` 添加统计字段调用次数、Token 用量、最后调用时间等)
- 新增完整的账号信息类型定义(`AccountAggregateInfo`, `CreditsUsageSummary` 等)
- 新增 `serde_cbor` 依赖用于 CBOR 编解码
- 修复手动查询余额后列表页面不显示缓存余额的问题
- `get_balance()` 成功后调用 `update_balance_cache()` 更新缓存
- 现在点击"查看余额"后,列表页面会正确显示缓存的余额值
- 修复关闭余额弹窗后卡片不更新缓存余额的问题
- 弹窗关闭时调用 `queryClient.invalidateQueries({ queryKey: ['cached-balances'] })`
- 确保卡片和弹窗使用的两个独立数据源保持同步
- 增强 Token 刷新日志,添加凭证 ID 追踪
- 新增 `refresh_token_with_id()` 函数支持传入凭证 ID
- 日志现在包含 `credential_id` 字段,便于多凭据环境下的问题排查
- 调整重试策略:单凭据最大重试次数 3→2单请求最大重试次数 9→5
- `MAX_RETRIES_PER_CREDENTIAL`: 3 → 2
- `MAX_TOTAL_RETRIES`: 9 → 5
- `MAX_FAILURES_PER_CREDENTIAL`: 3 → 2
- 减少无效凭据的重试开销,加快故障转移速度
- 新增用户亲和性绑定功能:连续对话优先使用同一凭据(基于 `metadata.user_id`
- 新增 `src/kiro/affinity.rs` 模块,实现 `UserAffinityManager`
- 新增 `acquire_context_for_user()` 方法支持亲和性查询
- 亲和性绑定 TTL 为 30 分钟
- 新增余额感知故障转移:凭据失效时自动切换到余额最高的可用凭据
- 新增动态余额缓存 TTL 策略:
- 高频渠道10分钟内 ≥20 次调用10 分钟刷新
- 低频渠道30 分钟刷新
- 低余额渠道(余额 < 1.024 小时刷新
- 新增 `record_usage()` 方法自动记录凭据使用频率
- 新增负载均衡无亲和性绑定时优先分配到使用频率最低的凭据
- 新增 `DisableReason` 枚举区分凭据禁用原因失败次数余额不足模型不可用手动禁用
- 成功请求时自动重置 `MODEL_TEMPORARILY_UNAVAILABLE` 计数器避免跨时间累计触发
- 新增 `MODEL_TEMPORARILY_UNAVAILABLE` 错误检测和全局禁用机制
- 当该 500 错误发生 2 次时自动禁用所有凭据
- 5 分钟后自动恢复余额不足的凭据除外
- `CredentialEntrySnapshot` 新增 `disable_reason` 字段支持查询禁用原因
- 新增自动余额刷新成功请求后自动在后台刷新余额缓存基于动态 TTL 策略
- 新增 `spawn_balance_refresh()` 方法使用 `tokio::spawn` 异步刷新
- 新增 `should_refresh_balance()` 方法根据 TTL 判断是否需要刷新

100
CLAUDE.md Normal file
View File

@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
kiro-rs 是一个用 Rust 编写的 Anthropic Claude API 兼容代理服务,将 Anthropic API 请求转换为 Kiro API 请求。支持多凭据管理、自动故障转移、流式响应和 Web 管理界面。
**技术栈**: Rust (Axum 0.8 + Tokio) + React 18 + TypeScript + Tailwind CSS
## 常用命令
```bash
# 构建(必须先构建前端)
cd admin-ui && pnpm install && pnpm build
cargo build --release
# 开发运行
cargo run -- -c config.json --credentials credentials.json
# 测试
cargo test
cargo test <test_name> # 运行单个测试
# 代码检查
cargo fmt # 格式化
cargo clippy # lint
# 启用敏感日志构建(排障用,输出 token 用量等诊断信息)
cargo run --features sensitive-logs -- -c config.json --credentials credentials.json
# 前端开发
cd admin-ui
pnpm install
pnpm dev # 开发服务器
pnpm build # 生产构建
```
## 请求处理流程
```
POST /v1/messages (Anthropic 格式)
→ auth_middleware: 验证 x-api-key / Bearer tokensubtle 常量时间比较)
→ post_messages handler:
1. 判断 WebSearch 触发条件,决定本地处理或剔除后转发
2. converter::convert_request() 转换为 Kiro 请求格式
3. provider.call_api() 发送请求(含重试和故障转移)
4. stream.rs 解析 AWS Event Stream → 转换为 Anthropic SSE 格式返回
```
## 核心设计模式
1. **Provider Pattern** - `kiro/provider.rs`: 统一的 API 提供者接口,处理请求转发和重试。支持凭据级代理(每个凭据可配独立 HTTP/SOCKS5 代理,缓存对应 HTTP Client 避免重复创建)
2. **Multi-Token Manager** - `kiro/token_manager.rs`: 多凭据管理,按优先级故障转移,后台异步刷新 Token支持 Social 和 IdC 两种认证方式)。余额缓存动态 TTL高频用户 10 分钟、低频用户 30 分钟、低余额用户 24 小时,过期时异步刷新不阻塞请求
3. **Protocol Converter** - `anthropic/converter.rs`: Anthropic ↔ Kiro 双向协议转换包括模型映射sonnet/opus/haiku → Kiro 模型 ID、JSON Schema 规范化(修复 MCP 工具的 `required: null` / `properties: null`)、工具占位符生成、图片格式转换
4. **Event Stream Parser** - `kiro/parser/`: AWS Event Stream 二进制协议解析header + payload + CRC32C 校验)
5. **Buffered Stream** - `anthropic/stream.rs`: 两种流模式 — `StreamContext`(直接转发)和 `BufferedStreamContext`(缓冲所有事件,等 `contextUsageEvent` 到达后修正 input_tokens 再一次性发送)
## 共享状态
```rust
AppState {
api_key: String, // Anthropic API 认证密钥
kiro_provider: Option<Arc<KiroProvider>>, // 核心 API 提供者Arc 线程安全共享)
profile_arn: Option<String>, // AWS Profile ARN
compression_config: CompressionConfig, // 输入压缩配置
}
```
通过 Axum `State` extractor 注入到所有 handler 中。
## 凭据故障转移与冷却
- 凭据按 `priority` 字段排序,优先使用高优先级凭据
- 请求失败时 `report_failure()` 触发故障转移到下一个可用凭据
- 冷却分类管理:`FailureLimit` / `InsufficientBalance` / `ModelUnavailable` / `QuotaExceeded`
- `MODEL_TEMPORARILY_UNAVAILABLE` 触发全局熔断,禁用所有凭据
## API 端点
**代理端点**:
- `GET /v1/models` - 获取可用模型列表
- `POST /v1/messages` - 创建消息Anthropic 格式)
- `POST /v1/messages/count_tokens` - Token 计数
- `/cc/v1/*` - Claude Code 兼容端点(同上,路径别名)
**Admin API** (需配置 `adminApiKey`):
- 凭据 CRUD、状态监控、余额查询
## 重要注意事项
1. **构建顺序**: 必须先构建前端 `admin-ui`,再编译 Rust 后端(静态文件通过 `rust-embed` 嵌入derive 宏为 `#[derive(Embed)]`
2. **凭据格式**: 支持单凭据(向后兼容)和多凭据(数组格式,支持 priority 字段)
3. **重试策略**: 单凭据最多重试 2 次,单请求最多重试 3 次
4. **WebSearch 工具**: 仅当请求明确触发 WebSearch`tool_choice` 强制 / 仅提供 `web_search` 单工具 / 消息前缀匹配)时走本地 WebSearch否则从 `tools` 中剔除 `web_search` 后转发上游(避免误路由)
5. **安全**: 使用 `subtle` 库进行常量时间比较防止时序攻击Admin API Key 空字符串视为未配置
6. **Prefill 处理**: Claude 4.x 已弃用 assistant prefill末尾 assistant 消息被静默丢弃
7. **sensitive-logs 特性**: 编译时 feature flag启用后输出 token 用量诊断日志和请求体大小(默认关闭,仅用于排障)
8. **网络错误分类**: 连接关闭/重置、发送失败等网络错误被归类为瞬态上游错误,返回 502不记录请求体
9. **Rust edition**: 项目使用 Rust 2024 edition

2587
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
Cargo.toml Normal file
View File

@@ -0,0 +1,42 @@
[package]
name = "kiro-rs"
version = "1.1.5"
edition = "2024"
[profile.release]
lto = "thin"
strip = true
[features]
default = []
# 显式启用才允许打印潜在敏感信息(仅用于排障)
sensitive-logs = []
[dependencies]
axum = "0.8"
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
reqwest = { version = "0.12", default-features = false, features = ["stream", "json", "socks", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_cbor = "0.11" # CBOR 编解码Web Portal API
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1.0"
http = "1.0"
futures = "0.3"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.10", features = ["v1", "v4", "fast-rng"] }
fastrand = "2"
sha2 = "0.10"
hex = "0.4"
crc = "3" # CRC32C 计算
bytes = "1" # 高效的字节缓冲区
tower-http = { version = "0.6", features = ["cors"] }
clap = { version = "4.5", features = ["derive"] }
urlencoding = "2"
parking_lot = "0.12" # 高性能同步原语
subtle = "2.6" # 常量时间比较(防止时序攻击)
rust-embed = "8" # 嵌入静态文件
mime_guess = "2" # MIME 类型推断
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } # 图片处理
base64 = "0.22" # Base64 编解码

51
Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
FROM rust:1.93-alpine AS chef
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static
RUN cargo install cargo-chef
WORKDIR /app
FROM chef AS planner
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
RUN cargo chef prepare --recipe-path recipe.json
FROM node:24-alpine AS frontend-builder
WORKDIR /app/admin-ui
COPY admin-ui/package.json ./
RUN npm install -g pnpm && pnpm install
COPY admin-ui ./
RUN pnpm build
FROM chef AS builder
# 可选:启用敏感日志输出(仅用于排障)
ARG ENABLE_SENSITIVE_LOGS=false
COPY --from=planner /app/recipe.json recipe.json
RUN if [ "$ENABLE_SENSITIVE_LOGS" = "true" ]; then \
cargo chef cook --release --features sensitive-logs --recipe-path recipe.json; \
else \
cargo chef cook --release --recipe-path recipe.json; \
fi
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY --from=frontend-builder /app/admin-ui/dist /app/admin-ui/dist
RUN if [ "$ENABLE_SENSITIVE_LOGS" = "true" ]; then \
cargo build --release --features sensitive-logs; \
else \
cargo build --release; \
fi
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/target/release/kiro-rs /app/kiro-rs
VOLUME ["/app/config"]
EXPOSE 8990
CMD ["./kiro-rs", "-c", "/app/config/config.json", "--credentials", "/app/config/credentials.json"]

68
Makefile Normal file
View File

@@ -0,0 +1,68 @@
.PHONY: dev build release clean test lint fmt ui ui-dev docker help
# 默认目标
help:
@echo "Usage: make <target>"
@echo ""
@echo "开发:"
@echo " dev cargo rundebug 模式,需先 make ui"
@echo " ui-dev 启动前端 dev server"
@echo ""
@echo "构建:"
@echo " ui 构建前端"
@echo " build 构建前端 + 后端debug"
@echo " release 构建前端 + 后端release"
@echo " docker 构建 Docker 镜像"
@echo ""
@echo "质量:"
@echo " test 运行测试"
@echo " lint cargo clippy"
@echo " fmt cargo fmt"
@echo " check fmt + clippy + test"
@echo ""
@echo "其他:"
@echo " clean 清理构建产物"
# --- 前端 ---
ui:
cd admin-ui && pnpm install && pnpm build
ui-dev:
@echo "启动前端 dev server: http://localhost:5173"
cd admin-ui && pnpm install && pnpm dev
# --- 后端 ---
dev: ui
cargo run --features sensitive-logs -- -c config/config.json --credentials config/credentials.json
build: ui
cargo build
release: ui
cargo build --release
# --- 质量 ---
test:
cargo test
lint:
cargo clippy -- -D warnings
fmt:
cargo fmt
check: fmt lint test
# --- Docker ---
docker:
docker build -t kiro-rs .
# --- 清理 ---
clean:
cargo clean
rm -rf admin-ui/dist admin-ui/node_modules

525
README.md Normal file
View File

@@ -0,0 +1,525 @@
# kiro-rs
一个用 Rust 编写的 Anthropic Claude API 兼容代理服务,将 Anthropic API 请求转换为 Kiro API 请求。
---
## 免责声明
本项目仅供研究使用, Use at your own risk, 使用本项目所导致的任何后果由使用人承担, 与本项目无关。
本项目与 AWS/KIRO/Anthropic/Claude 等官方无关, 本项目不代表官方立场。
## 注意!
因 TLS 默认从 native-tls 切换至 rustls你可能需要专门安装证书后才能配置 HTTP 代理。可通过 `config.json``tlsBackend` 切回 `native-tls`
如果遇到请求报错, 尤其是无法刷新 token, 或者是直接返回 error request, 请尝试切换 tls 后端为 `native-tls`, 一般即可解决。
**Write Failed/会话卡死**: 如果遇到持续的 Write File / Write Failed 并导致会话不可用,参考 Issue [#22](https://github.com/hank9999/kiro.rs/issues/22) 和 [#49](https://github.com/hank9999/kiro.rs/issues/49) 的说明与临时解决方案(通常与输出过长被截断有关,可尝试调低输出相关 token 上限)
## 功能特性
- **Anthropic API 兼容**: 完整支持 Anthropic Claude API 格式
- **流式响应**: 支持 SSE (Server-Sent Events) 流式输出
- **Token 自动刷新**: 自动管理和刷新 OAuth Token
- **多凭据支持**: 支持配置多个凭据,按优先级自动故障转移
- **智能重试**: 单凭据最多重试 2 次,单请求最多重试 3 次
- **凭据回写**: 多凭据格式下自动回写刷新后的 Token
- **Thinking 模式**: 支持 Claude 的 extended thinking 功能
- **工具调用**: 完整支持 function calling / tool use
- **WebSearch**: 内置 WebSearch 工具转换逻辑
- **多模型支持**: 支持 Sonnet、Opus、Haiku 系列模型
- **Admin 管理**: 可选的 Web 管理界面和 API支持凭据管理、余额查询等
- **多级 Region 配置**: 支持全局和凭据级别的 Auth Region / API Region 配置
- **凭据级代理**: 支持为每个凭据单独配置 HTTP/SOCKS5 代理,优先级:凭据代理 > 全局代理 > 无代理
---
- [开始](#开始)
- [1. 编译](#1-编译)
- [2. 最小配置](#2-最小配置)
- [3. 启动](#3-启动)
- [4. 验证](#4-验证)
- [Docker](#docker)
- [配置详解](#配置详解)
- [config.json](#configjson)
- [credentials.json](#credentialsjson)
- [Region 配置](#region-配置)
- [代理配置](#代理配置)
- [认证方式](#认证方式)
- [环境变量](#环境变量)
- [API 端点](#api-端点)
- [标准端点 (/v1)](#标准端点-v1)
- [Claude Code 兼容端点 (/cc/v1)](#claude-code-兼容端点-ccv1)
- [Thinking 模式](#thinking-模式)
- [工具调用](#工具调用)
- [模型映射](#模型映射)
- [Admin可选](#admin可选)
- [注意事项](#注意事项)
- [项目结构](#项目结构)
- [技术栈](#技术栈)
- [License](#license)
- [致谢](#致谢)
## 开始
### 1. 编译
> PS: 如果不想编辑可以直接前往 Release 下载二进制文件
> **前置步骤**:编译前需要先构建前端 Admin UI用于嵌入到二进制中
> ```bash
> cd admin-ui && pnpm install && pnpm build
> ```
```bash
cargo build --release
```
### 2. 最小配置
创建 `config.json`
```json
{
"host": "127.0.0.1",
"port": 8990,
"apiKey": "sk-kiro-rs-qazWSXedcRFV123456",
"region": "us-east-1"
}
```
> PS: 如果你需要 Web 管理面板, 请注意配置 `adminApiKey`
创建 `credentials.json`(从 Kiro IDE 等中获取凭证信息):
> PS: 可以前往 Web 管理面板配置跳过本步骤
> 如果你对凭据地域有疑惑, 请查看 [Region 配置](#region-配置)
Social 认证:
```json
{
"refreshToken": "你的刷新token",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "social"
}
```
IdC 认证:
```json
{
"refreshToken": "你的刷新token",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "idc",
"clientId": "你的clientId",
"clientSecret": "你的clientSecret"
}
```
### 3. 启动
```bash
./target/release/kiro-rs
```
或指定配置文件路径:
```bash
./target/release/kiro-rs -c /path/to/config.json --credentials /path/to/credentials.json
```
### 4. 验证
```bash
curl http://127.0.0.1:8990/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: sk-kiro-rs-qazWSXedcRFV123456" \
-d '{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"stream": true,
"messages": [
{"role": "user", "content": "Hello, Claude!"}
]
}'
```
### Docker
也可以通过 Docker 启动:
```bash
docker-compose up
```
需要将 `config.json``credentials.json` 挂载到容器中,具体参见 `docker-compose.yml`
## 配置详解
### config.json
| 字段 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| `host` | string | `127.0.0.1` | 服务监听地址 |
| `port` | number | `8080` | 服务监听端口 |
| `apiKey` | string | - | 自定义 API Key用于客户端认证必配 |
| `region` | string | `us-east-1` | AWS 区域 |
| `authRegion` | string | - | Auth Region用于 Token 刷新),未配置时回退到 region |
| `apiRegion` | string | - | API Region用于 API 请求),未配置时回退到 region |
| `kiroVersion` | string | `0.10.0` | Kiro 版本号 |
| `machineId` | string | - | 自定义机器码64位十六进制不定义则自动生成 |
| `systemVersion` | string | 随机 | 系统版本标识 |
| `nodeVersion` | string | `22.21.1` | Node.js 版本标识 |
| `tlsBackend` | string | `rustls` | TLS 后端:`rustls``native-tls` |
| `countTokensApiUrl` | string | - | 外部 count_tokens API 地址 |
| `countTokensApiKey` | string | - | 外部 count_tokens API 密钥 |
| `countTokensAuthType` | string | `x-api-key` | 外部 API 认证类型:`x-api-key``bearer` |
| `proxyUrl` | string | - | HTTP/SOCKS5 代理地址 |
| `proxyUsername` | string | - | 代理用户名 |
| `proxyPassword` | string | - | 代理密码 |
| `adminApiKey` | string | - | Admin API 密钥,配置后启用凭据管理 API 和 Web 管理界面 |
完整配置示例:
```json
{
"host": "127.0.0.1",
"port": 8990,
"apiKey": "sk-kiro-rs-qazWSXedcRFV123456",
"region": "us-east-1",
"tlsBackend": "rustls",
"kiroVersion": "0.10.0",
"machineId": "64位十六进制机器码",
"systemVersion": "darwin#24.6.0",
"nodeVersion": "22.21.1",
"authRegion": "us-east-1",
"apiRegion": "us-east-1",
"countTokensApiUrl": "https://api.example.com/v1/messages/count_tokens",
"countTokensApiKey": "sk-your-count-tokens-api-key",
"countTokensAuthType": "x-api-key",
"proxyUrl": "http://127.0.0.1:7890",
"proxyUsername": "user",
"proxyPassword": "pass",
"adminApiKey": "sk-admin-your-secret-key"
}
```
### credentials.json
支持单对象格式(向后兼容)或数组格式(多凭据)。
#### 字段说明
| 字段 | 类型 | 描述 |
|----------------|--------|---------------------------------------------|
| `id` | number | 凭据唯一 ID可选仅用于 Admin API 管理;手写文件可不填) |
| `accessToken` | string | OAuth 访问令牌(可选,可自动刷新) |
| `refreshToken` | string | OAuth 刷新令牌 |
| `profileArn` | string | AWS Profile ARN可选登录时返回 |
| `expiresAt` | string | Token 过期时间 (RFC3339) |
| `authMethod` | string | 认证方式:`social``idc` |
| `clientId` | string | IdC 登录的客户端 IDIdC 认证必填) |
| `clientSecret` | string | IdC 登录的客户端密钥IdC 认证必填) |
| `priority` | number | 凭据优先级,数字越小越优先,默认为 0 |
| `region` | string | 凭据级 Auth Region, 兼容字段 |
| `authRegion` | string | 凭据级 Auth Region用于 Token 刷新, 未配置时回退到 region |
| `apiRegion` | string | 凭据级 API Region用于 API 请求 |
| `machineId` | string | 凭据级机器码64位十六进制 |
| `email` | string | 用户邮箱(可选,从 API 获取) |
| `proxyUrl` | string | 凭据级代理 URL可选特殊值 `direct` 表示不使用代理) |
| `proxyUsername`| string | 凭据级代理用户名(可选) |
| `proxyPassword`| string | 凭据级代理密码(可选) |
说明:
- IdC / Builder-ID / IAM 在本项目里属于同一种登录方式,配置时统一使用 `authMethod: "idc"`
- 为兼容旧配置,`builder-id` / `iam` 仍可被识别,但会按 `idc` 处理
#### 单凭据格式(旧格式,向后兼容)
```json
{
"accessToken": "请求token一般有效期一小时可选",
"refreshToken": "刷新token一般有效期7-30天不等",
"profileArn": "arn:aws:codewhisperer:us-east-1:111112222233:profile/QWER1QAZSDFGH",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "social",
"clientId": "IdC 登录需要",
"clientSecret": "IdC 登录需要"
}
```
#### 多凭据格式(支持故障转移和自动回写)
```json
[
{
"refreshToken": "第一个凭据的刷新token",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "social",
"priority": 0
},
{
"refreshToken": "第二个凭据的刷新token",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "idc",
"clientId": "xxxxxxxxx",
"clientSecret": "xxxxxxxxx",
"region": "us-east-2",
"priority": 1,
"proxyUrl": "socks5://proxy.example.com:1080",
"proxyUsername": "user",
"proxyPassword": "pass"
},
{
"refreshToken": "第三个凭据(显式不走代理)",
"expiresAt": "2025-12-31T02:32:45.144Z",
"authMethod": "social",
"priority": 2,
"proxyUrl": "direct"
}
]
```
多凭据特性:
-`priority` 字段排序,数字越小优先级越高(默认为 0
- 单凭据最多重试 3 次,单请求最多重试 9 次
- 自动故障转移到下一个可用凭据
- 多凭据格式下 Token 刷新后自动回写到源文件
### Region 配置
支持多级 Region 配置,分别控制 Token 刷新和 API 请求使用的区域。
**Auth Region**Token 刷新)优先级:
`凭据.authRegion` > `凭据.region` > `config.authRegion` > `config.region`
**API Region**API 请求)优先级:
`凭据.apiRegion` > `config.apiRegion` > `config.region`
### 代理配置
支持全局代理和凭据级代理凭据级代理会覆盖该凭据产生的所有出站连接API 请求、Token 刷新、额度查询)。
**代理优先级**`凭据.proxyUrl` > `config.proxyUrl` > 无代理
| 凭据 `proxyUrl` 值 | 行为 |
|---|---|
| 具体 URL`http://proxy:8080``socks5://proxy:1080` | 使用凭据指定的代理 |
| `direct` | 显式不使用代理(即使全局配置了代理) |
| 未配置(留空) | 回退到全局代理配置 |
凭据级代理示例:
```json
[
{
"refreshToken": "凭据A使用自己的代理",
"authMethod": "social",
"proxyUrl": "socks5://proxy-a.example.com:1080",
"proxyUsername": "user_a",
"proxyPassword": "pass_a"
},
{
"refreshToken": "凭据B显式不走代理直连",
"authMethod": "social",
"proxyUrl": "direct"
},
{
"refreshToken": "凭据C使用全局代理或直连取决于 config.json",
"authMethod": "social"
}
]
```
### 认证方式
客户端请求本服务时,支持两种认证方式:
1. **x-api-key Header**
```
x-api-key: sk-your-api-key
```
2. **Authorization Bearer**
```
Authorization: Bearer sk-your-api-key
```
### 环境变量
可通过环境变量配置日志级别:
```bash
RUST_LOG=debug ./target/release/kiro-rs
```
## API 端点
### 标准端点 (/v1)
| 端点 | 方法 | 描述 |
|------|------|------|
| `/v1/models` | GET | 获取可用模型列表 |
| `/v1/messages` | POST | 创建消息(对话) |
| `/v1/messages/count_tokens` | POST | 估算 Token 数量 |
### Claude Code 兼容端点 (/cc/v1)
| 端点 | 方法 | 描述 |
|------|------|------|
| `/cc/v1/messages` | POST | 创建消息(缓冲模式,确保 `input_tokens` 准确) |
| `/cc/v1/messages/count_tokens` | POST | 估算 Token 数量(与 `/v1` 相同) |
> **`/cc/v1/messages` 与 `/v1/messages` 的区别**
> - `/v1/messages`:实时流式返回,`message_start` 中的 `input_tokens` 是估算值
> - `/cc/v1/messages`:缓冲模式,等待上游流完成后,用从 `contextUsageEvent` 计算的准确 `input_tokens` 更正 `message_start`,然后一次性返回所有事件
> - 等待期间会每 25 秒发送 `ping` 事件保活
### Thinking 模式
支持 Claude 的 extended thinking 功能:
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 16000,
"thinking": {
"type": "enabled",
"budget_tokens": 10000
},
"messages": [...]
}
```
### 工具调用
完整支持 Anthropic 的 tool use 功能:
```json
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 1024,
"tools": [
{
"name": "get_weather",
"description": "获取指定城市的天气",
"input_schema": {
"type": "object",
"properties": {
"city": {"type": "string"}
},
"required": ["city"]
}
}
],
"messages": [...]
}
```
## 模型映射
| Anthropic 模型 | Kiro 模型 |
|----------------|-----------|
| `*sonnet*`(含 4-6/4.6 | `claude-sonnet-4.6` |
| `*sonnet*`(其他) | `claude-sonnet-4.5` |
| `*opus*`(含 4-5/4.5 | `claude-opus-4.5` |
| `*opus*`(其他) | `claude-opus-4.6` |
| `*haiku*` | `claude-haiku-4.5` |
## Admin可选
当 `config.json` 配置了非空 `adminApiKey` 时,会启用:
- **Admin API认证同 API Key**
- `GET /api/admin/credentials` - 获取所有凭据状态
- `POST /api/admin/credentials` - 添加新凭据
- `DELETE /api/admin/credentials/:id` - 删除凭据
- `POST /api/admin/credentials/:id/disabled` - 设置凭据禁用状态
- `POST /api/admin/credentials/:id/priority` - 设置凭据优先级
- `POST /api/admin/credentials/:id/region` - 设置凭据 Region
- `POST /api/admin/credentials/:id/reset` - 重置失败计数
- `GET /api/admin/credentials/:id/balance` - 获取凭据余额
- **Admin UI**
- `GET /admin` - 访问管理页面(需要在编译前构建 `admin-ui/dist`
## 注意事项
1. **凭证安全**: 请妥善保管 `credentials.json` 文件,不要提交到版本控制
2. **Token 刷新**: 服务会自动刷新过期的 Token无需手动干预
3. **WebSearch 工具**: 只要 `tools` 中包含 `web_search`(按 name 或 type 判断),就走内置 WebSearch 处理逻辑
## 项目结构
```
kiro-rs/
├── src/
│ ├── main.rs # 程序入口
│ ├── http_client.rs # HTTP 客户端构建
│ ├── token.rs # Token 计算模块
│ ├── debug.rs # 调试工具
│ ├── test.rs # 测试
│ ├── model/ # 配置和参数模型
│ │ ├── config.rs # 应用配置
│ │ └── arg.rs # 命令行参数
│ ├── anthropic/ # Anthropic API 兼容层
│ │ ├── router.rs # 路由配置
│ │ ├── handlers.rs # 请求处理器
│ │ ├── middleware.rs # 认证中间件
│ │ ├── types.rs # 类型定义
│ │ ├── converter.rs # 协议转换器
│ │ ├── stream.rs # 流式响应处理
│ │ └── websearch.rs # WebSearch 工具处理
│ ├── kiro/ # Kiro API 客户端
│ │ ├── provider.rs # API 提供者
│ │ ├── token_manager.rs # Token 管理
│ │ ├── machine_id.rs # 设备指纹生成
│ │ ├── model/ # 数据模型
│ │ │ ├── credentials.rs # OAuth 凭证
│ │ │ ├── events/ # 响应事件类型
│ │ │ ├── requests/ # 请求类型
│ │ │ ├── common/ # 共享类型
│ │ │ ├── token_refresh.rs # Token 刷新模型
│ │ │ └── usage_limits.rs # 使用额度模型
│ │ └── parser/ # AWS Event Stream 解析器
│ │ ├── decoder.rs # 流式解码器
│ │ ├── frame.rs # 帧解析
│ │ ├── header.rs # 头部解析
│ │ ├── error.rs # 错误类型
│ │ └── crc.rs # CRC 校验
│ ├── admin/ # Admin API 模块
│ │ ├── router.rs # 路由配置
│ │ ├── handlers.rs # 请求处理器
│ │ ├── service.rs # 业务逻辑服务
│ │ ├── types.rs # 类型定义
│ │ ├── middleware.rs # 认证中间件
│ │ └── error.rs # 错误处理
│ ├── admin_ui/ # Admin UI 静态文件嵌入
│ │ └── router.rs # 静态文件路由
│ └── common/ # 公共模块
│ └── auth.rs # 认证工具函数
├── admin-ui/ # Admin UI 前端工程(构建产物会嵌入二进制)
├── tools/ # 辅助工具
├── Cargo.toml # 项目配置
├── config.example.json # 配置示例
├── docker-compose.yml # Docker Compose 配置
└── Dockerfile # Docker 构建文件
```
## 技术栈
- **Web 框架**: [Axum](https://github.com/tokio-rs/axum) 0.8
- **异步运行时**: [Tokio](https://tokio.rs/)
- **HTTP 客户端**: [Reqwest](https://github.com/seanmonstar/reqwest)
- **序列化**: [Serde](https://serde.rs/)
- **日志**: [tracing](https://github.com/tokio-rs/tracing)
- **命令行**: [Clap](https://github.com/clap-rs/clap)
## License
MIT
## 致谢
本项目的实现离不开前辈的努力:
- [kiro2api](https://github.com/caidaoli/kiro2api)
- [proxycast](https://github.com/aiclientproxy/proxycast)
本项目部分逻辑参考了以上的项目, 再次由衷的感谢!

1
VERSION Normal file
View File

@@ -0,0 +1 @@
v1.1.5

480
admin-ui/bun.lock Normal file
View File

@@ -0,0 +1,480 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "kiro-admin-ui",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.20",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.4.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.5.tgz", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.1.2.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "https://registry.npmmirror.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "https://registry.npmmirror.com/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.47", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", {}, "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
"@swc/core": ["@swc/core@1.15.11", "https://registry.npmmirror.com/@swc/core/-/core-1.15.11.tgz", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.11", "@swc/core-darwin-x64": "1.15.11", "@swc/core-linux-arm-gnueabihf": "1.15.11", "@swc/core-linux-arm64-gnu": "1.15.11", "@swc/core-linux-arm64-musl": "1.15.11", "@swc/core-linux-x64-gnu": "1.15.11", "@swc/core-linux-x64-musl": "1.15.11", "@swc/core-win32-arm64-msvc": "1.15.11", "@swc/core-win32-ia32-msvc": "1.15.11", "@swc/core-win32-x64-msvc": "1.15.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.11.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.11", "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.11.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-S52Gu1QtPSfBYDiejlcfp9GlN+NjTZBRRNsz8PNwBgSE626/FUf2PcllVUix7jqkoMC+t0rS8t+2/aSWlMuQtA=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.11.tgz", { "os": "linux", "cpu": "arm" }, "sha512-lXJs8oXo6Z4yCpimpQ8vPeCjkgoHu5NoMvmJZ8qxDyU99KVdg6KwU9H79vzrmB+HfH+dCZ7JGMqMF//f8Cfvdg=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-chRsz1K52/vj8Mfq/QOugVphlKPWlMh10V99qfH41hbGvwAU6xSPd681upO4bKiOr9+mRIZZW+EfJqY42ZzRyA=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.11.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.11", "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.11.tgz", { "os": "linux", "cpu": "x64" }, "sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.11.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-6XnzORkZCQzvTQ6cPrU7iaT9+i145oLwnin8JrfsLG41wl26+5cNQ2XV3zcbrnFEV6esjOceom9YO1w9mGJByw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.11", "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.11.tgz", { "os": "win32", "cpu": "x64" }, "sha512-IQ2n6af7XKLL6P1gIeZACskSxK8jWtoKpJWLZmdXTDj1MGzktUy4i+FvpdtxFmJWNavRWH1VmTr6kAubRDHeKw=="],
"@swc/counter": ["@swc/counter@0.1.3", "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.25", "https://registry.npmmirror.com/@swc/types/-/types-0.1.25.tgz", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.90.20.tgz", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.20", "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.90.20.tgz", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/react": ["@types/react@19.2.10", "https://registry.npmmirror.com/@types/react/-/react-19.2.10.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.2.2", "https://registry.npmmirror.com/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.2.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.47", "@swc/core": "^1.13.5" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-x+rE6tsxq/gxrEJN3Nv3dIV60lFflPj94c90b+NNo6n1QV1QQUTLoL0MpaOVasUZ0zqVBn7ead1B5ecx1JAGfA=="],
"aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.24", "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.24.tgz", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw=="],
"axios": ["axios@1.13.4", "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
"browserslist": ["browserslist@4.28.1", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"clsx": ["clsx@2.1.1", "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.283", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.283.tgz", {}, "sha512-3vifjt1HgrGW/h76UEeny+adYApveS9dH2h3p57JYzBSXJIKUJAvtmIytDKjcSCt9xHfrNCFJ7gts6vkhuq++w=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.27.2", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.2.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@5.3.4", "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"lightningcss": ["lightningcss@1.30.2", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lucide-react": ["lucide-react@0.563.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.563.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="],
"magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"react": ["react@19.2.4", "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"rollup": ["rollup@4.57.1", "https://registry.npmmirror.com/rollup/-/rollup-4.57.1.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"sonner": ["sonner@2.0.7", "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"tapable": ["tapable@2.3.0", "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"vite": ["vite@7.3.1", "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}

13
admin-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kiro Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

1
admin-ui/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
admin-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react'
import { storage } from '@/lib/storage'
import { LoginPage } from '@/components/login-page'
import { Dashboard } from '@/components/dashboard'
import { Toaster } from '@/components/ui/sonner'
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
useEffect(() => {
// 检查是否已经有保存的 API Key
if (storage.getApiKey()) {
setIsLoggedIn(true)
}
}, [])
const handleLogin = () => {
setIsLoggedIn(true)
}
const handleLogout = () => {
setIsLoggedIn(false)
}
return (
<>
{isLoggedIn ? (
<Dashboard onLogout={handleLogout} />
) : (
<LoginPage onLogin={handleLogin} />
)}
<Toaster position="top-right" />
</>
)
}
export default App

View File

@@ -0,0 +1,147 @@
import axios from 'axios'
import { storage } from '@/lib/storage'
import type {
CredentialsStatusResponse,
BalanceResponse,
CachedBalancesResponse,
SuccessResponse,
SetDisabledRequest,
SetPriorityRequest,
AddCredentialRequest,
AddCredentialResponse,
CredentialStatsResponse,
CredentialAccountInfoResponse,
ImportTokenJsonRequest,
ImportTokenJsonResponse,
} from '@/types/api'
// 创建 axios 实例
const api = axios.create({
baseURL: '/api/admin',
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器添加 API Key
api.interceptors.request.use((config) => {
const apiKey = storage.getApiKey()
if (apiKey) {
config.headers['x-api-key'] = apiKey
}
return config
})
// 获取所有凭据状态
export async function getCredentials(): Promise<CredentialsStatusResponse> {
const { data } = await api.get<CredentialsStatusResponse>('/credentials')
return data
}
// 设置凭据禁用状态
export async function setCredentialDisabled(
id: number,
disabled: boolean
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(
`/credentials/${id}/disabled`,
{ disabled } as SetDisabledRequest
)
return data
}
// 设置凭据优先级
export async function setCredentialPriority(
id: number,
priority: number
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(
`/credentials/${id}/priority`,
{ priority } as SetPriorityRequest
)
return data
}
// 重置失败计数
export async function resetCredentialFailure(
id: number
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/reset`)
return data
}
// 设置凭据 Region
export async function setCredentialRegion(
id: number,
region: string | null,
apiRegion: string | null
): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/region`, {
region: region || null,
apiRegion: apiRegion || null,
})
return data
}
// 获取凭据余额
export async function getCredentialBalance(id: number): Promise<BalanceResponse> {
const { data } = await api.get<BalanceResponse>(`/credentials/${id}/balance`)
return data
}
// 获取所有凭据的缓存余额
export async function getCachedBalances(): Promise<CachedBalancesResponse> {
const { data } = await api.get<CachedBalancesResponse>('/credentials/balances/cached')
return data
}
// 获取凭据账号信息(套餐/用量/邮箱等)
export async function getCredentialAccountInfo(
id: number
): Promise<CredentialAccountInfoResponse> {
const { data } = await api.get<CredentialAccountInfoResponse>(`/credentials/${id}/account`)
return data
}
// 添加新凭据
export async function addCredential(
req: AddCredentialRequest
): Promise<AddCredentialResponse> {
const { data } = await api.post<AddCredentialResponse>('/credentials', req)
return data
}
// 删除凭据
export async function deleteCredential(id: number): Promise<SuccessResponse> {
const { data } = await api.delete<SuccessResponse>(`/credentials/${id}`)
return data
}
// 获取指定凭据统计
export async function getCredentialStats(id: number): Promise<CredentialStatsResponse> {
const { data } = await api.get<CredentialStatsResponse>(`/credentials/${id}/stats`)
return data
}
// 清空指定凭据统计
export async function resetCredentialStats(id: number): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>(`/credentials/${id}/stats/reset`)
return data
}
// 清空全部统计
export async function resetAllStats(): Promise<SuccessResponse> {
const { data } = await api.post<SuccessResponse>('/stats/reset')
return data
}
// 批量导入 token.json
export async function importTokenJson(
req: ImportTokenJsonRequest
): Promise<ImportTokenJsonResponse> {
const { data } = await api.post<ImportTokenJsonResponse>(
'/credentials/import-token-json',
req
)
return data
}

View File

@@ -0,0 +1,279 @@
import { useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useAddCredential } from '@/hooks/use-credentials'
import { extractErrorMessage } from '@/lib/utils'
interface AddCredentialDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
type AuthMethod = 'social' | 'idc'
export function AddCredentialDialog({ open, onOpenChange }: AddCredentialDialogProps) {
const [refreshToken, setRefreshToken] = useState('')
const [authMethod, setAuthMethod] = useState<AuthMethod>('social')
const [region, setRegion] = useState('')
const [apiRegion, setApiRegion] = useState('')
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [priority, setPriority] = useState('0')
const [machineId, setMachineId] = useState('')
const [proxyUrl, setProxyUrl] = useState('')
const [proxyUsername, setProxyUsername] = useState('')
const [proxyPassword, setProxyPassword] = useState('')
const { mutate, isPending } = useAddCredential()
const resetForm = () => {
setRefreshToken('')
setAuthMethod('social')
setRegion('')
setApiRegion('')
setClientId('')
setClientSecret('')
setPriority('0')
setMachineId('')
setProxyUrl('')
setProxyUsername('')
setProxyPassword('')
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// 验证必填字段
if (!refreshToken.trim()) {
toast.error('请输入 Refresh Token')
return
}
// IdC/Builder-ID/IAM 需要额外字段
if (authMethod === 'idc' && (!clientId.trim() || !clientSecret.trim())) {
toast.error('IdC/Builder-ID/IAM 认证需要填写 Client ID 和 Client Secret')
return
}
mutate(
{
refreshToken: refreshToken.trim(),
authMethod,
region: region.trim() || undefined,
apiRegion: apiRegion.trim() || undefined,
clientId: clientId.trim() || undefined,
clientSecret: clientSecret.trim() || undefined,
priority: parseInt(priority) || 0,
machineId: machineId.trim() || undefined,
proxyUrl: proxyUrl.trim() || undefined,
proxyUsername: proxyUsername.trim() || undefined,
proxyPassword: proxyPassword.trim() || undefined,
},
{
onSuccess: (data) => {
toast.success(data.message)
onOpenChange(false)
resetForm()
},
onError: (error: unknown) => {
toast.error(`添加失败: ${extractErrorMessage(error)}`)
},
}
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col min-h-0 flex-1">
<div className="space-y-4 py-4 overflow-y-auto flex-1 pr-1">
{/* Refresh Token */}
<div className="space-y-2">
<label htmlFor="refreshToken" className="text-sm font-medium">
Refresh Token <span className="text-red-500">*</span>
</label>
<Input
id="refreshToken"
type="password"
placeholder="请输入 Refresh Token"
value={refreshToken}
onChange={(e) => setRefreshToken(e.target.value)}
disabled={isPending}
/>
</div>
{/* 认证方式 */}
<div className="space-y-2">
<label htmlFor="authMethod" className="text-sm font-medium">
</label>
<select
id="authMethod"
value={authMethod}
onChange={(e) => setAuthMethod(e.target.value as AuthMethod)}
disabled={isPending}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="social">Social</option>
<option value="idc">IdC/Builder-ID/IAM</option>
</select>
</div>
{/* Region 配置 */}
<div className="space-y-2">
<label className="text-sm font-medium">Region </label>
<div className="grid grid-cols-2 gap-2">
<div>
<Input
id="region"
placeholder="Region"
value={region}
onChange={(e) => setRegion(e.target.value)}
disabled={isPending}
/>
</div>
<div>
<Input
id="apiRegion"
placeholder="API Region可选覆盖"
value={apiRegion}
onChange={(e) => setApiRegion(e.target.value)}
disabled={isPending}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Region Token 使API Region API Region
</p>
</div>
{/* IdC/Builder-ID/IAM 额外字段 */}
{authMethod === 'idc' && (
<>
<div className="space-y-2">
<label htmlFor="clientId" className="text-sm font-medium">
Client ID <span className="text-red-500">*</span>
</label>
<Input
id="clientId"
placeholder="请输入 Client ID"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
disabled={isPending}
/>
</div>
<div className="space-y-2">
<label htmlFor="clientSecret" className="text-sm font-medium">
Client Secret <span className="text-red-500">*</span>
</label>
<Input
id="clientSecret"
type="password"
placeholder="请输入 Client Secret"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
disabled={isPending}
/>
</div>
</>
)}
{/* 优先级 */}
<div className="space-y-2">
<label htmlFor="priority" className="text-sm font-medium">
</label>
<Input
id="priority"
type="number"
min="0"
placeholder="数字越小优先级越高"
value={priority}
onChange={(e) => setPriority(e.target.value)}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
0
</p>
</div>
{/* Machine ID */}
<div className="space-y-2">
<label htmlFor="machineId" className="text-sm font-medium">
Machine ID
</label>
<Input
id="machineId"
placeholder="留空使用配置中字段, 否则由刷新Token自动派生"
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
64 使, Token自动派生
</p>
</div>
{/* 代理配置 */}
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Input
id="proxyUrl"
placeholder='代理 URL留空使用全局配置"direct" 不使用代理)'
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
disabled={isPending}
/>
<div className="grid grid-cols-2 gap-2">
<Input
id="proxyUsername"
placeholder="代理用户名"
value={proxyUsername}
onChange={(e) => setProxyUsername(e.target.value)}
disabled={isPending}
/>
<Input
id="proxyPassword"
type="password"
placeholder="代理密码"
value={proxyPassword}
onChange={(e) => setProxyPassword(e.target.value)}
disabled={isPending}
/>
</div>
<p className="text-xs text-muted-foreground">
使 "direct" 使
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? '添加中...' : '添加'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,106 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { useCredentialBalance } from '@/hooks/use-credentials'
import { parseError } from '@/lib/utils'
interface BalanceDialogProps {
credentialId: number | null
open: boolean
onOpenChange: (open: boolean) => void
forceRefresh?: boolean
}
export function BalanceDialog({ credentialId, open, onOpenChange, forceRefresh }: BalanceDialogProps) {
const { data: balance, isLoading, isFetching, error } = useCredentialBalance(credentialId)
const showLoading = isLoading || (forceRefresh && isFetching)
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '未知'
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
const formatNumber = (num: number) => {
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
#{credentialId}
</DialogTitle>
</DialogHeader>
{showLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{error && (() => {
const parsed = parseError(error)
return (
<div className="py-6 space-y-3">
<div className="flex items-center justify-center gap-2 text-red-500">
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<span className="font-medium">{parsed.title}</span>
</div>
{parsed.detail && (
<div className="text-sm text-muted-foreground text-center px-4">
{parsed.detail}
</div>
)}
</div>
)
})()}
{balance && (
<div className="space-y-4">
{/* 订阅类型 */}
<div className="text-center">
<span className="text-lg font-semibold">
{balance.subscriptionTitle || '未知订阅类型'}
</span>
</div>
{/* 使用进度 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>已使用: ${formatNumber(balance.currentUsage)}</span>
<span>限额: ${formatNumber(balance.usageLimit)}</span>
</div>
<Progress value={balance.usagePercentage} />
<div className="text-center text-sm text-muted-foreground">
{balance.usagePercentage.toFixed(1)}% 使
</div>
</div>
{/* 详细信息 */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t text-sm">
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-600">
${formatNumber(balance.remaining)}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">
{formatDate(balance.nextResetAt)}
</span>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,434 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
interface BatchImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
interface CredentialInput {
refreshToken: string
clientId?: string
clientSecret?: string
region?: string
authRegion?: string
apiRegion?: string
priority?: number
machineId?: string
}
interface VerificationResult {
index: number
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed'
error?: string
usage?: string
email?: string
credentialId?: number
rollbackStatus?: 'success' | 'failed' | 'skipped'
rollbackError?: string
}
export function BatchImportDialog({ open, onOpenChange }: BatchImportDialogProps) {
const [jsonInput, setJsonInput] = useState('')
const [importing, setImporting] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [currentProcessing, setCurrentProcessing] = useState<string>('')
const [results, setResults] = useState<VerificationResult[]>([])
const { data: existingCredentials } = useCredentials()
const { mutateAsync: addCredential } = useAddCredential()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return {
success: false,
error: `禁用失败: ${extractErrorMessage(error)}`,
}
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return {
success: false,
error: `删除失败: ${extractErrorMessage(error)}`,
}
}
}
const resetForm = () => {
setJsonInput('')
setProgress({ current: 0, total: 0 })
setCurrentProcessing('')
setResults([])
}
const handleBatchImport = async () => {
// 先单独解析 JSON给出精准的错误提示
let credentials: CredentialInput[]
try {
const parsed = JSON.parse(jsonInput)
credentials = Array.isArray(parsed) ? parsed : [parsed]
} catch (error) {
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
return
}
if (credentials.length === 0) {
toast.error('没有可导入的凭据')
return
}
try {
setImporting(true)
setProgress({ current: 0, total: credentials.length })
// 2. 初始化结果
const initialResults: VerificationResult[] = credentials.map((_, i) => ({
index: i + 1,
status: 'pending'
}))
setResults(initialResults)
// 3. 检测重复
const existingTokenHashes = new Set(
existingCredentials?.credentials
.map(c => c.refreshTokenHash)
.filter((hash): hash is string => Boolean(hash)) || []
)
let successCount = 0
let duplicateCount = 0
let failCount = 0
let rollbackSuccessCount = 0
let rollbackFailedCount = 0
let rollbackSkippedCount = 0
// 4. 导入并验活
for (let i = 0; i < credentials.length; i++) {
const cred = credentials[i]
const token = cred.refreshToken.trim()
const tokenHash = await sha256Hex(token)
// 更新状态为检查中
setCurrentProcessing(`正在处理凭据 ${i + 1}/${credentials.length}`)
setResults(prev => {
const newResults = [...prev]
newResults[i] = { ...newResults[i], status: 'checking' }
return newResults
})
// 检查重复
if (existingTokenHashes.has(tokenHash)) {
duplicateCount++
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'duplicate',
error: '该凭据已存在',
email: existingCred?.email || undefined
}
return newResults
})
setProgress({ current: i + 1, total: credentials.length })
continue
}
// 更新状态为验活中
setResults(prev => {
const newResults = [...prev]
newResults[i] = { ...newResults[i], status: 'verifying' }
return newResults
})
let addedCredId: number | null = null
try {
// 添加凭据
const clientId = cred.clientId?.trim() || undefined
const clientSecret = cred.clientSecret?.trim() || undefined
const authMethod = clientId && clientSecret ? 'idc' : 'social'
// idc 模式下必须同时提供 clientId 和 clientSecret
if (authMethod === 'social' && (clientId || clientSecret)) {
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
}
const addedCred = await addCredential({
refreshToken: token,
authMethod,
authRegion: cred.authRegion?.trim() || cred.region?.trim() || undefined,
apiRegion: cred.apiRegion?.trim() || undefined,
clientId,
clientSecret,
priority: cred.priority || 0,
machineId: cred.machineId?.trim() || undefined,
})
addedCredId = addedCred.credentialId
// 延迟 1 秒
await new Promise(resolve => setTimeout(resolve, 1000))
// 验活
const balance = await getCredentialBalance(addedCred.credentialId)
// 验活成功
successCount++
existingTokenHashes.add(tokenHash)
setCurrentProcessing(addedCred.email ? `验活成功: ${addedCred.email}` : `验活成功: 凭据 ${i + 1}`)
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'verified',
usage: `${balance.currentUsage}/${balance.usageLimit}`,
email: addedCred.email || undefined,
credentialId: addedCred.credentialId
}
return newResults
})
} catch (error) {
// 验活失败,尝试回滚(先禁用再删除)
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
let rollbackError: string | undefined
if (addedCredId) {
const rollbackResult = await rollbackCredential(addedCredId)
if (rollbackResult.success) {
rollbackStatus = 'success'
rollbackSuccessCount++
} else {
rollbackStatus = 'failed'
rollbackFailedCount++
rollbackError = rollbackResult.error
}
} else {
rollbackSkippedCount++
}
failCount++
setResults(prev => {
const newResults = [...prev]
newResults[i] = {
...newResults[i],
status: 'failed',
error: extractErrorMessage(error),
email: undefined,
rollbackStatus,
rollbackError,
}
return newResults
})
}
setProgress({ current: i + 1, total: credentials.length })
}
// 显示结果
if (failCount === 0 && duplicateCount === 0) {
toast.success(`成功导入并验活 ${successCount} 个凭据`)
} else {
const failureSummary = failCount > 0
? `,失败 ${failCount} 个(已排除 ${rollbackSuccessCount},未排除 ${rollbackFailedCount},无需排除 ${rollbackSkippedCount}`
: ''
toast.info(`验活完成:成功 ${successCount} 个,重复 ${duplicateCount}${failureSummary}`)
if (rollbackFailedCount > 0) {
toast.warning(`${rollbackFailedCount} 个失败凭据回滚未完成,请手动禁用并删除`)
}
}
} catch (error) {
toast.error('导入失败: ' + extractErrorMessage(error))
} finally {
setImporting(false)
}
}
const getStatusIcon = (status: VerificationResult['status']) => {
switch (status) {
case 'pending':
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'checking':
case 'verifying':
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified':
return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'duplicate':
return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />
}
}
const getStatusText = (result: VerificationResult) => {
switch (result.status) {
case 'pending':
return '等待中'
case 'checking':
return '检查重复...'
case 'verifying':
return '验活中...'
case 'verified':
return '验活成功'
case 'duplicate':
return '重复凭据'
case 'failed':
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
return '验活失败(未创建)'
}
}
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 关闭时清空表单(但不在导入过程中清空)
if (!newOpen && !importing) {
resetForm()
}
onOpenChange(newOpen)
}}
>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
JSON
</label>
<textarea
placeholder={'粘贴 JSON 格式的凭据(支持单个对象或数组)\n例如: [{"refreshToken":"...","clientId":"...","clientSecret":"...","authRegion":"us-east-1","apiRegion":"us-west-2"}]\n支持 region 字段自动映射为 authRegion'}
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
disabled={importing}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
<p className="text-xs text-muted-foreground">
💡
</p>
</div>
{(importing || results.length > 0) && (
<>
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{importing ? '验活进度' : '验活完成'}</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
{importing && currentProcessing && (
<div className="text-xs text-muted-foreground">
{currentProcessing}
</div>
)}
</div>
{/* 统计 */}
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {results.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {results.filter(r => r.status === 'duplicate').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {results.filter(r => r.status === 'failed').length}
</span>
</div>
{/* 结果列表 */}
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{results.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{result.email || `凭据 #${result.index}`}
</span>
<span className="text-xs text-muted-foreground">
{getStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">
: {result.usage}
</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{result.error}
</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
: {result.rollbackError}
</div>
)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false)
resetForm()
}}
disabled={importing}
>
{importing ? '验活中...' : results.length > 0 ? '关闭' : '取消'}
</Button>
{results.length === 0 && (
<Button
type="button"
onClick={handleBatchImport}
disabled={importing || !jsonInput.trim()}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,152 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
export interface VerifyResult {
id: number
status: 'pending' | 'verifying' | 'success' | 'failed'
usage?: string
error?: string
}
interface BatchVerifyDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
verifying: boolean
progress: { current: number; total: number }
results: Map<number, VerifyResult>
onCancel: () => void
}
export function BatchVerifyDialog({
open,
onOpenChange,
verifying,
progress,
results,
onCancel,
}: BatchVerifyDialogProps) {
const resultsArray = Array.from(results.values())
const successCount = resultsArray.filter(r => r.status === 'success').length
const failedCount = resultsArray.filter(r => r.status === 'failed').length
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 进度显示 */}
{verifying && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span></span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* 统计信息 */}
{results.size > 0 && (
<div className="flex justify-between text-sm font-medium">
<span></span>
<span>
: {successCount} / : {failedCount}
</span>
</div>
)}
{/* 结果列表 */}
{results.size > 0 && (
<div className="max-h-[400px] overflow-y-auto border rounded-md p-2 space-y-1">
{resultsArray.map((result) => (
<div
key={result.id}
className={`text-sm p-2 rounded ${
result.status === 'success'
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300'
: result.status === 'failed'
? 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'
: result.status === 'verifying'
? 'bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300'
: 'bg-gray-50 text-gray-700 dark:bg-gray-950 dark:text-gray-300'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium"> #{result.id}</span>
{result.status === 'success' && result.usage && (
<Badge variant="secondary" className="text-xs">
{result.usage}
</Badge>
)}
</div>
<span>
{result.status === 'success' && '✓'}
{result.status === 'failed' && '✗'}
{result.status === 'verifying' && '⏳'}
{result.status === 'pending' && '⋯'}
</span>
</div>
{result.error && (
<div className="text-xs mt-1 opacity-90">
: {result.error}
</div>
)}
</div>
))}
</div>
)}
{/* 提示信息 */}
{verifying && (
<p className="text-xs text-muted-foreground">
💡 2
</p>
)}
</div>
<div className="flex justify-end gap-2">
{verifying ? (
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={onCancel}
>
</Button>
</>
) : (
<Button
type="button"
onClick={() => onOpenChange(false)}
>
</Button>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,476 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { RefreshCw, ChevronUp, ChevronDown, Wallet, Trash2, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { CredentialStatusItem, CachedBalanceInfo, BalanceResponse } from '@/types/api'
import {
useSetDisabled,
useSetPriority,
useSetRegion,
useResetFailure,
useDeleteCredential,
} from '@/hooks/use-credentials'
interface CredentialCardProps {
credential: CredentialStatusItem
cachedBalance?: CachedBalanceInfo
onViewBalance: (id: number, forceRefresh: boolean) => void
selected: boolean
onToggleSelect: () => void
balance: BalanceResponse | null
loadingBalance: boolean
}
function formatLastUsed(lastUsedAt: string | null): string {
if (!lastUsedAt) return '从未使用'
const date = new Date(lastUsedAt)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 0) return '刚刚'
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return `${seconds} 秒前`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export function CredentialCard({
credential,
cachedBalance,
onViewBalance,
selected,
onToggleSelect,
balance,
loadingBalance,
}: CredentialCardProps) {
const [editingPriority, setEditingPriority] = useState(false)
const [priorityValue, setPriorityValue] = useState(String(credential.priority))
const [editingRegion, setEditingRegion] = useState(false)
const [regionValue, setRegionValue] = useState(credential.region ?? '')
const [apiRegionValue, setApiRegionValue] = useState(credential.apiRegion ?? '')
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const setDisabled = useSetDisabled()
const setPriority = useSetPriority()
const setRegion = useSetRegion()
const resetFailure = useResetFailure()
const deleteCredential = useDeleteCredential()
const handleToggleDisabled = () => {
setDisabled.mutate(
{ id: credential.id, disabled: !credential.disabled },
{
onSuccess: (res) => {
toast.success(res.message)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handlePriorityChange = () => {
const newPriority = parseInt(priorityValue, 10)
if (isNaN(newPriority) || newPriority < 0) {
toast.error('优先级必须是非负整数')
return
}
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => {
toast.success(res.message)
setEditingPriority(false)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handleRegionChange = () => {
setRegion.mutate(
{
id: credential.id,
region: regionValue.trim() || null,
apiRegion: apiRegionValue.trim() || null,
},
{
onSuccess: (res) => {
toast.success(res.message)
setEditingRegion(false)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
}
)
}
const handleReset = () => {
resetFailure.mutate(credential.id, {
onSuccess: (res) => {
toast.success(res.message)
},
onError: (err) => {
toast.error('操作失败: ' + (err as Error).message)
},
})
}
const handleDelete = () => {
if (!credential.disabled) {
toast.error('请先禁用凭据再删除')
setShowDeleteDialog(false)
return
}
deleteCredential.mutate(credential.id, {
onSuccess: (res) => {
toast.success(res.message)
setShowDeleteDialog(false)
},
onError: (err) => {
toast.error('删除失败: ' + (err as Error).message)
},
})
}
// 格式化缓存时间(相对时间)
const formatCacheAge = (cachedAt: number) => {
const now = Date.now()
const diff = now - cachedAt
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return `${seconds}秒前`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}分钟前`
return `${Math.floor(minutes / 60)}小时前`
}
// 检查缓存是否过期(使用后端返回的 TTL
const isCacheStale = () => {
if (!cachedBalance) return true
const ageMs = Date.now() - cachedBalance.cachedAt
const ttlMs = (cachedBalance.ttlSecs ?? 60) * 1000
return ageMs > ttlMs
}
const handleViewBalance = () => {
onViewBalance(credential.id, isCacheStale())
}
return (
<>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selected}
onCheckedChange={onToggleSelect}
/>
<CardTitle className="text-lg flex items-center gap-2">
{credential.email || `凭据 #${credential.id}`}
{credential.disabled && (
<Badge variant="destructive"></Badge>
)}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Switch
checked={!credential.disabled}
onCheckedChange={handleToggleDisabled}
disabled={setDisabled.isPending}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 信息网格 */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"></span>
{editingPriority ? (
<div className="inline-flex items-center gap-1 ml-1">
<Input
type="number"
value={priorityValue}
onChange={(e) => setPriorityValue(e.target.value)}
className="w-16 h-7 text-sm"
min="0"
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handlePriorityChange}
disabled={setPriority.isPending}
>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
setEditingPriority(false)
setPriorityValue(String(credential.priority))
}}
>
</Button>
</div>
) : (
<span
className="font-medium cursor-pointer hover:underline ml-1"
onClick={() => setEditingPriority(true)}
>
{credential.priority}
<span className="text-xs text-muted-foreground ml-1">()</span>
</span>
)}
</div>
<div>
<span className="text-muted-foreground"></span>
<span className={credential.failureCount > 0 ? 'text-red-500 font-medium' : ''}>
{credential.failureCount}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">
{loadingBalance ? (
<Loader2 className="inline w-3 h-3 animate-spin" />
) : balance?.subscriptionTitle || '未知'}
</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{credential.successCount}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{formatLastUsed(credential.lastUsedAt)}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground"></span>
{loadingBalance ? (
<span className="text-sm ml-1">
<Loader2 className="inline w-3 h-3 animate-spin" /> ...
</span>
) : balance ? (
<span className="font-medium ml-1">
{balance.remaining.toFixed(2)} / {balance.usageLimit.toFixed(2)}
<span className="text-xs text-muted-foreground ml-1">
({(100 - balance.usagePercentage).toFixed(1)}% )
</span>
</span>
) : (
<span className="text-sm text-muted-foreground ml-1"></span>
)}
</div>
<div>
<span className="text-muted-foreground"></span>
{cachedBalance && cachedBalance.ttlSecs > 0 ? (
<>
<span className={`font-medium ${cachedBalance.remaining > 0 ? 'text-green-600' : 'text-red-500'}`}>
${cachedBalance.remaining.toFixed(2)}
</span>
<span className="text-xs text-muted-foreground ml-1">
({formatCacheAge(cachedBalance.cachedAt)})
</span>
</>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
{credential.hasProxy && (
<div className="col-span-2">
<span className="text-muted-foreground"></span>
<span className="font-medium">{credential.proxyUrl}</span>
</div>
)}
{/* Region 配置 */}
<div className="col-span-2">
<span className="text-muted-foreground">Region</span>
{editingRegion ? (
<div className="inline-flex items-center gap-1 ml-1 flex-wrap">
<Input
placeholder="Region留空清除"
value={regionValue}
onChange={(e) => setRegionValue(e.target.value)}
className="w-32 h-7 text-sm"
/>
<Input
placeholder="API Region可选"
value={apiRegionValue}
onChange={(e) => setApiRegionValue(e.target.value)}
className="w-36 h-7 text-sm"
/>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleRegionChange}
disabled={setRegion.isPending}
>
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={() => {
setEditingRegion(false)
setRegionValue(credential.region ?? '')
setApiRegionValue(credential.apiRegion ?? '')
}}
>
</Button>
</div>
) : (
<span
className="font-medium cursor-pointer hover:underline ml-1"
onClick={() => {
setRegionValue(credential.region ?? '')
setApiRegionValue(credential.apiRegion ?? '')
setEditingRegion(true)
}}
>
{credential.region || '全局默认'}
{credential.apiRegion && (
<span className="text-muted-foreground ml-1">
/ API: {credential.apiRegion}
</span>
)}
<span className="text-xs text-muted-foreground ml-1">()</span>
</span>
)}
</div>
{credential.hasProfileArn && (
<div className="col-span-2">
<Badge variant="secondary"> Profile ARN</Badge>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex flex-wrap gap-2 pt-2 border-t">
<Button
size="sm"
variant="outline"
onClick={handleReset}
disabled={resetFailure.isPending || credential.failureCount === 0}
>
<RefreshCw className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newPriority = Math.max(0, credential.priority - 1)
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => toast.success(res.message),
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
}
)
}}
disabled={setPriority.isPending || credential.priority === 0}
>
<ChevronUp className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
const newPriority = credential.priority + 1
setPriority.mutate(
{ id: credential.id, priority: newPriority },
{
onSuccess: (res) => toast.success(res.message),
onError: (err) => toast.error('操作失败: ' + (err as Error).message),
}
)
}}
disabled={setPriority.isPending}
>
<ChevronDown className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="default"
onClick={handleViewBalance}
>
<Wallet className="h-4 w-4 mr-1" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={!credential.disabled}
title={!credential.disabled ? '需要先禁用凭据才能删除' : undefined}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
</CardContent>
</Card>
{/* 删除确认对话框 */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
#{credential.id}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteCredential.isPending}
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteCredential.isPending || !credential.disabled}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,700 @@
import { useState, useEffect, useRef } from 'react'
import { RefreshCw, LogOut, Moon, Sun, Server, Plus, Upload, Trash2, RotateCcw, CheckCircle2 } from 'lucide-react'
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { storage } from '@/lib/storage'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CredentialCard } from '@/components/credential-card'
import { BalanceDialog } from '@/components/balance-dialog'
import { AddCredentialDialog } from '@/components/add-credential-dialog'
import { ImportTokenJsonDialog } from '@/components/import-token-json-dialog'
import { BatchVerifyDialog, type VerifyResult } from '@/components/batch-verify-dialog'
import { useCredentials, useCachedBalances, useDeleteCredential, useResetFailure } from '@/hooks/use-credentials'
import { getCredentialBalance } from '@/api/credentials'
import { extractErrorMessage } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import type { BalanceResponse } from '@/types/api'
interface DashboardProps {
onLogout: () => void
}
export function Dashboard({ onLogout }: DashboardProps) {
const [selectedCredentialId, setSelectedCredentialId] = useState<number | null>(null)
const [balanceDialogOpen, setBalanceDialogOpen] = useState(false)
const [forceRefreshBalance, setForceRefreshBalance] = useState(false)
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [verifyDialogOpen, setVerifyDialogOpen] = useState(false)
const [verifying, setVerifying] = useState(false)
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
const [verifyResults, setVerifyResults] = useState<Map<number, VerifyResult>>(new Map())
const [balanceMap, setBalanceMap] = useState<Map<number, BalanceResponse>>(new Map())
const [loadingBalanceIds, setLoadingBalanceIds] = useState<Set<number>>(new Set())
const [queryingInfo, setQueryingInfo] = useState(false)
const [queryInfoProgress, setQueryInfoProgress] = useState({ current: 0, total: 0 })
const cancelVerifyRef = useRef(false)
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12
const [darkMode, setDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('dark')
}
return false
})
const queryClient = useQueryClient()
const { data, isLoading, error, refetch } = useCredentials()
const { data: cachedBalancesData } = useCachedBalances()
const { mutate: deleteCredential } = useDeleteCredential()
const { mutate: resetFailure } = useResetFailure()
// 构建 id -> cachedBalance 的映射
const cachedBalanceMap = new Map(
cachedBalancesData?.balances.map((b) => [b.id, b]) ?? []
)
// 计算分页
const totalPages = Math.ceil((data?.credentials.length || 0) / itemsPerPage)
const startIndex = (currentPage - 1) * itemsPerPage
const endIndex = startIndex + itemsPerPage
const currentCredentials = data?.credentials.slice(startIndex, endIndex) || []
const disabledCredentialCount = data?.credentials.filter(credential => credential.disabled).length || 0
const selectedDisabledCount = Array.from(selectedIds).filter(id => {
const credential = data?.credentials.find(c => c.id === id)
return Boolean(credential?.disabled)
}).length
// 当凭据列表变化时重置到第一页
useEffect(() => {
setCurrentPage(1)
}, [data?.credentials.length])
// 只保留当前仍存在的凭据缓存,避免删除后残留旧数据
useEffect(() => {
if (!data?.credentials) {
setBalanceMap(new Map())
setLoadingBalanceIds(new Set())
return
}
const validIds = new Set(data.credentials.map(credential => credential.id))
setBalanceMap(prev => {
const next = new Map<number, BalanceResponse>()
prev.forEach((value, id) => {
if (validIds.has(id)) {
next.set(id, value)
}
})
return next.size === prev.size ? prev : next
})
setLoadingBalanceIds(prev => {
if (prev.size === 0) {
return prev
}
const next = new Set<number>()
prev.forEach(id => {
if (validIds.has(id)) {
next.add(id)
}
})
return next.size === prev.size ? prev : next
})
}, [data?.credentials])
const toggleDarkMode = () => {
setDarkMode(!darkMode)
document.documentElement.classList.toggle('dark')
}
const handleViewBalance = (id: number, forceRefresh: boolean) => {
setSelectedCredentialId(id)
setForceRefreshBalance(forceRefresh)
if (forceRefresh) {
// 清除该凭据的余额缓存,强制重新获取
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
}
setBalanceDialogOpen(true)
}
const handleRefresh = () => {
refetch()
toast.success('已刷新凭据列表')
}
const handleLogout = () => {
storage.removeApiKey()
queryClient.clear()
onLogout()
}
// 选择管理
const toggleSelect = (id: number) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setSelectedIds(newSelected)
}
const deselectAll = () => {
setSelectedIds(new Set())
}
// 批量删除(仅删除已禁用项)
const handleBatchDelete = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要删除的凭据')
return
}
const disabledIds = Array.from(selectedIds).filter(id => {
const credential = data?.credentials.find(c => c.id === id)
return Boolean(credential?.disabled)
})
if (disabledIds.length === 0) {
toast.error('选中的凭据中没有已禁用项')
return
}
const skippedCount = selectedIds.size - disabledIds.length
const skippedText = skippedCount > 0 ? `(将跳过 ${skippedCount} 个未禁用凭据)` : ''
if (!confirm(`确定要删除 ${disabledIds.length} 个已禁用凭据吗?此操作无法撤销。${skippedText}`)) {
return
}
let successCount = 0
let failCount = 0
for (const id of disabledIds) {
try {
await new Promise<void>((resolve, reject) => {
deleteCredential(id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
const skippedResultText = skippedCount > 0 ? `,已跳过 ${skippedCount} 个未禁用凭据` : ''
if (failCount === 0) {
toast.success(`成功删除 ${successCount} 个已禁用凭据${skippedResultText}`)
} else {
toast.warning(`删除已禁用凭据:成功 ${successCount} 个,失败 ${failCount}${skippedResultText}`)
}
deselectAll()
}
// 批量恢复异常
const handleBatchResetFailure = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要恢复的凭据')
return
}
const failedIds = Array.from(selectedIds).filter(id => {
const cred = data?.credentials.find(c => c.id === id)
return cred && cred.failureCount > 0
})
if (failedIds.length === 0) {
toast.error('选中的凭据中没有失败的凭据')
return
}
let successCount = 0
let failCount = 0
for (const id of failedIds) {
try {
await new Promise<void>((resolve, reject) => {
resetFailure(id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
if (failCount === 0) {
toast.success(`成功恢复 ${successCount} 个凭据`)
} else {
toast.warning(`成功 ${successCount} 个,失败 ${failCount}`)
}
deselectAll()
}
// 一键清除所有已禁用凭据
const handleClearAll = async () => {
if (!data?.credentials || data.credentials.length === 0) {
toast.error('没有可清除的凭据')
return
}
const disabledCredentials = data.credentials.filter(credential => credential.disabled)
if (disabledCredentials.length === 0) {
toast.error('没有可清除的已禁用凭据')
return
}
if (!confirm(`确定要清除所有 ${disabledCredentials.length} 个已禁用凭据吗?此操作无法撤销。`)) {
return
}
let successCount = 0
let failCount = 0
for (const credential of disabledCredentials) {
try {
await new Promise<void>((resolve, reject) => {
deleteCredential(credential.id, {
onSuccess: () => {
successCount++
resolve()
},
onError: (err) => {
failCount++
reject(err)
}
})
})
} catch (error) {
// 错误已在 onError 中处理
}
}
if (failCount === 0) {
toast.success(`成功清除所有 ${successCount} 个已禁用凭据`)
} else {
toast.warning(`清除已禁用凭据:成功 ${successCount} 个,失败 ${failCount}`)
}
deselectAll()
}
// 查询当前页凭据信息(逐个查询,避免瞬时并发)
const handleQueryCurrentPageInfo = async () => {
if (currentCredentials.length === 0) {
toast.error('当前页没有可查询的凭据')
return
}
const ids = currentCredentials
.filter(credential => !credential.disabled)
.map(credential => credential.id)
if (ids.length === 0) {
toast.error('当前页没有可查询的启用凭据')
return
}
setQueryingInfo(true)
setQueryInfoProgress({ current: 0, total: ids.length })
let successCount = 0
let failCount = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
setLoadingBalanceIds(prev => {
const next = new Set(prev)
next.add(id)
return next
})
try {
const balance = await getCredentialBalance(id)
successCount++
setBalanceMap(prev => {
const next = new Map(prev)
next.set(id, balance)
return next
})
} catch (error) {
failCount++
} finally {
setLoadingBalanceIds(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
}
setQueryInfoProgress({ current: i + 1, total: ids.length })
}
setQueryingInfo(false)
if (failCount === 0) {
toast.success(`查询完成:成功 ${successCount}/${ids.length}`)
} else {
toast.warning(`查询完成:成功 ${successCount} 个,失败 ${failCount}`)
}
}
// 批量验活
const handleBatchVerify = async () => {
if (selectedIds.size === 0) {
toast.error('请先选择要验活的凭据')
return
}
// 初始化状态
setVerifying(true)
cancelVerifyRef.current = false
const ids = Array.from(selectedIds)
setVerifyProgress({ current: 0, total: ids.length })
let successCount = 0
// 初始化结果,所有凭据状态为 pending
const initialResults = new Map<number, VerifyResult>()
ids.forEach(id => {
initialResults.set(id, { id, status: 'pending' })
})
setVerifyResults(initialResults)
setVerifyDialogOpen(true)
// 开始验活
for (let i = 0; i < ids.length; i++) {
// 检查是否取消
if (cancelVerifyRef.current) {
toast.info('已取消验活')
break
}
const id = ids[i]
// 更新当前凭据状态为 verifying
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, { id, status: 'verifying' })
return newResults
})
try {
const balance = await getCredentialBalance(id)
successCount++
// 更新为成功状态
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, {
id,
status: 'success',
usage: `${balance.currentUsage}/${balance.usageLimit}`
})
return newResults
})
} catch (error) {
// 更新为失败状态
setVerifyResults(prev => {
const newResults = new Map(prev)
newResults.set(id, {
id,
status: 'failed',
error: extractErrorMessage(error)
})
return newResults
})
}
// 更新进度
setVerifyProgress({ current: i + 1, total: ids.length })
// 添加延迟防止封号(最后一个不需要延迟)
if (i < ids.length - 1 && !cancelVerifyRef.current) {
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
setVerifying(false)
if (!cancelVerifyRef.current) {
toast.success(`验活完成:成功 ${successCount}/${ids.length}`)
}
}
// 取消验活
const handleCancelVerify = () => {
cancelVerifyRef.current = true
setVerifying(false)
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<div className="text-red-500 mb-4"></div>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<div className="space-x-2">
<Button onClick={() => refetch()}></Button>
<Button variant="outline" onClick={handleLogout}></Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="min-h-screen bg-background">
{/* 顶部导航 */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between px-4 md:px-8">
<div className="flex items-center gap-2">
<Server className="h-5 w-5" />
<span className="font-semibold">Kiro Admin</span>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleDarkMode}>
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Button variant="ghost" size="icon" onClick={handleRefresh}>
<RefreshCw className="h-5 w-5" />
</Button>
<Button variant="ghost" size="icon" onClick={handleLogout}>
<LogOut className="h-5 w-5" />
</Button>
</div>
</div>
</header>
{/* 主内容 */}
<main className="container mx-auto px-4 md:px-8 py-6">
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-2 mb-6">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data?.total || 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{data?.available || 0}</div>
</CardContent>
</Card>
</div>
{/* 凭据列表 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-xl font-semibold"></h2>
{selectedIds.size > 0 && (
<div className="flex items-center gap-2">
<Badge variant="secondary"> {selectedIds.size} </Badge>
<Button onClick={deselectAll} size="sm" variant="ghost">
</Button>
</div>
)}
</div>
<div className="flex gap-2">
{selectedIds.size > 0 && (
<>
<Button onClick={handleBatchVerify} size="sm" variant="outline">
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleBatchResetFailure} size="sm" variant="outline">
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
<Button
onClick={handleBatchDelete}
size="sm"
variant="destructive"
disabled={selectedDisabledCount === 0}
title={selectedDisabledCount === 0 ? '只能删除已禁用凭据' : undefined}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</>
)}
{verifying && !verifyDialogOpen && (
<Button onClick={() => setVerifyDialogOpen(true)} size="sm" variant="secondary">
<CheckCircle2 className="h-4 w-4 mr-2 animate-spin" />
... {verifyProgress.current}/{verifyProgress.total}
</Button>
)}
{data?.credentials && data.credentials.length > 0 && (
<Button
onClick={handleQueryCurrentPageInfo}
size="sm"
variant="outline"
disabled={queryingInfo}
>
<RefreshCw className={`h-4 w-4 mr-2 ${queryingInfo ? 'animate-spin' : ''}`} />
{queryingInfo ? `查询中... ${queryInfoProgress.current}/${queryInfoProgress.total}` : '查询信息'}
</Button>
)}
{data?.credentials && data.credentials.length > 0 && (
<Button
onClick={handleClearAll}
size="sm"
variant="outline"
className="text-destructive hover:text-destructive"
disabled={disabledCredentialCount === 0}
title={disabledCredentialCount === 0 ? '没有可清除的已禁用凭据' : undefined}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
)}
<Button variant="outline" onClick={() => setImportDialogOpen(true)} size="sm">
<Upload className="h-4 w-4 mr-2" />
</Button>
<Button onClick={() => setAddDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
{data?.credentials.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
</CardContent>
</Card>
) : (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{currentCredentials.map((credential) => (
<CredentialCard
key={credential.id}
credential={credential}
cachedBalance={cachedBalanceMap.get(credential.id)}
onViewBalance={handleViewBalance}
selected={selectedIds.has(credential.id)}
onToggleSelect={() => toggleSelect(credential.id)}
balance={balanceMap.get(credential.id) || null}
loadingBalance={loadingBalanceIds.has(credential.id)}
/>
))}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-4 mt-6">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-muted-foreground">
{currentPage} / {totalPages} {data?.credentials.length}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</>
)}
</div>
</main>
{/* 余额对话框 */}
<BalanceDialog
credentialId={selectedCredentialId}
open={balanceDialogOpen}
onOpenChange={(open) => {
setBalanceDialogOpen(open)
if (!open) {
setForceRefreshBalance(false)
// 关闭弹窗时刷新缓存余额,让卡片显示最新数据
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
}
}}
forceRefresh={forceRefreshBalance}
/>
{/* 添加凭据对话框 */}
<AddCredentialDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
/>
{/* 导入凭据对话框 */}
<ImportTokenJsonDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
/>
{/* 批量验活对话框 */}
<BatchVerifyDialog
open={verifyDialogOpen}
onOpenChange={setVerifyDialogOpen}
verifying={verifying}
progress={verifyProgress}
results={verifyResults}
onCancel={handleCancelVerify}
/>
</div>
)
}

View File

@@ -0,0 +1,671 @@
import { useState, useCallback, useRef } from 'react'
import { toast } from 'sonner'
import { Upload, FileJson, CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { useImportTokenJson, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage } from '@/lib/utils'
import type { TokenJsonItem, ImportItemResult, ImportSummary } from '@/types/api'
interface ImportTokenJsonDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
type Step = 'input' | 'preview' | 'result' | 'verifying'
// 验活结果
interface VerifyItemResult {
index: number
credentialId?: number
status: 'pending' | 'verifying' | 'verified' | 'failed' | 'skipped' | 'rolled_back' | 'rollback_failed'
usage?: string
error?: string
rollbackError?: string
}
export function ImportTokenJsonDialog({ open, onOpenChange }: ImportTokenJsonDialogProps) {
const [step, setStep] = useState<Step>('input')
const [jsonText, setJsonText] = useState('')
const [parsedItems, setParsedItems] = useState<TokenJsonItem[]>([])
const [previewResults, setPreviewResults] = useState<ImportItemResult[]>([])
const [previewSummary, setPreviewSummary] = useState<ImportSummary | null>(null)
const [finalResults, setFinalResults] = useState<ImportItemResult[]>([])
const [finalSummary, setFinalSummary] = useState<ImportSummary | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [enableVerify, setEnableVerify] = useState(false)
const [verifyResults, setVerifyResults] = useState<VerifyItemResult[]>([])
const [verifyProgress, setVerifyProgress] = useState({ current: 0, total: 0 })
const [isVerifying, setIsVerifying] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const { mutate: importMutate, isPending } = useImportTokenJson()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const resetState = useCallback(() => {
setStep('input')
setJsonText('')
setParsedItems([])
setPreviewResults([])
setPreviewSummary(null)
setFinalResults([])
setFinalSummary(null)
setEnableVerify(false)
setVerifyResults([])
setVerifyProgress({ current: 0, total: 0 })
setIsVerifying(false)
}, [])
const handleClose = useCallback(() => {
if (isVerifying) return // 验活中不允许关闭
onOpenChange(false)
setTimeout(resetState, 200)
}, [onOpenChange, resetState, isVerifying])
// 将 KAM 账号结构展平为 TokenJsonItem
const flattenKamAccount = useCallback((account: Record<string, unknown>): TokenJsonItem | null => {
const cred = account.credentials as Record<string, unknown> | undefined
if (!cred || typeof cred !== 'object') return null
// refreshToken 必须是非空字符串
if (typeof cred.refreshToken !== 'string' || !cred.refreshToken.trim()) return null
// 跳过 error 状态的账号
if (account.status === 'error') return null
const authMethod = cred.authMethod as string | undefined
return {
refreshToken: cred.refreshToken.trim(),
clientId: cred.clientId as string | undefined,
clientSecret: cred.clientSecret as string | undefined,
authMethod: (!authMethod && cred.clientId && cred.clientSecret) ? 'idc' : authMethod,
region: cred.region as string | undefined,
machineId: account.machineId as string | undefined,
}
}, [])
// 解析 JSON兼容 Token JSON / KAM 导出 / 批量导入格式)
const parseJson = useCallback((text: string): TokenJsonItem[] | null => {
try {
const parsed = JSON.parse(text)
let rawItems: unknown[]
// KAM 标准导出格式:{ version, accounts: [...] }
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.accounts)) {
rawItems = parsed.accounts
} else if (Array.isArray(parsed)) {
rawItems = parsed
} else if (parsed && typeof parsed === 'object') {
rawItems = [parsed]
} else {
toast.error('JSON 格式无效')
return null
}
const validItems: TokenJsonItem[] = []
for (const item of rawItems) {
if (!item || typeof item !== 'object') continue
const obj = item as Record<string, unknown>
// KAM 嵌套格式:{ credentials: { refreshToken, ... } }
if (obj.credentials && typeof obj.credentials === 'object') {
const flat = flattenKamAccount(obj)
if (flat) validItems.push(flat)
continue
}
// 扁平格式:{ refreshToken, ... }
if (typeof obj.refreshToken === 'string' && obj.refreshToken.trim()) {
const tokenItem = { ...obj, refreshToken: obj.refreshToken.trim() } as TokenJsonItem
// 兼容旧批量导入的 authRegion 字段
if (!tokenItem.region && obj.authRegion) {
tokenItem.region = obj.authRegion as string
}
if (!tokenItem.authMethod && tokenItem.clientId && tokenItem.clientSecret) {
tokenItem.authMethod = 'idc'
}
validItems.push(tokenItem)
}
}
if (validItems.length === 0) {
toast.error('JSON 中没有找到有效的凭据(需要包含 refreshToken 字段)')
return null
}
return validItems
} catch {
toast.error('JSON 格式无效')
return null
}
}, [flattenKamAccount])
// 文件拖放
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (!file) return
if (!file.name.endsWith('.json')) {
toast.error('请上传 JSON 文件')
return
}
const reader = new FileReader()
reader.onload = (event) => setJsonText(event.target?.result as string)
reader.readAsText(file)
}, [])
// 文件选择
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => setJsonText(event.target?.result as string)
reader.readAsText(file)
}, [])
// 预览dry-run
const handlePreview = useCallback(() => {
const items = parseJson(jsonText)
if (!items) return
setParsedItems(items)
importMutate(
{ dryRun: true, items },
{
onSuccess: (response) => {
setPreviewResults(response.items)
setPreviewSummary(response.summary)
setStep('preview')
},
onError: (error) => {
toast.error(`预览失败: ${extractErrorMessage(error)}`)
},
}
)
}, [jsonText, parseJson, importMutate])
// 回滚凭据(禁用 + 删除)
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
}
}
// 验活流程
const runVerification = useCallback(async (results: ImportItemResult[]) => {
const addedItems = results.filter(r => r.action === 'added' && r.credentialId)
if (addedItems.length === 0) {
toast.info('没有新增凭据需要验活')
return
}
setIsVerifying(true)
setStep('verifying')
setVerifyProgress({ current: 0, total: addedItems.length })
const initialVerifyResults: VerifyItemResult[] = addedItems.map(item => ({
index: item.index,
credentialId: item.credentialId,
status: 'pending',
}))
setVerifyResults(initialVerifyResults)
let successCount = 0
let failCount = 0
for (let i = 0; i < addedItems.length; i++) {
const item = addedItems[i]
const credId = item.credentialId!
// 更新为验活中
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'verifying' } : r
))
try {
await new Promise(resolve => setTimeout(resolve, 1000))
const balance = await getCredentialBalance(credId)
successCount++
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? { ...r, status: 'verified', usage: `${balance.currentUsage}/${balance.usageLimit}` } : r
))
} catch (error) {
failCount++
// 验活失败,回滚
const rollback = await rollbackCredential(credId)
setVerifyResults(prev => prev.map((r, idx) =>
idx === i ? {
...r,
status: rollback.success ? 'rolled_back' : 'rollback_failed',
error: extractErrorMessage(error),
rollbackError: rollback.error,
} : r
))
}
setVerifyProgress({ current: i + 1, total: addedItems.length })
}
setIsVerifying(false)
if (failCount === 0) {
toast.success(`全部 ${successCount} 个凭据验活成功`)
} else {
toast.info(`验活完成:成功 ${successCount},失败 ${failCount}`)
}
}, [deleteCredential])
// 确认导入
const handleConfirmImport = useCallback(() => {
importMutate(
{ dryRun: false, items: parsedItems },
{
onSuccess: (response) => {
setFinalResults(response.items)
setFinalSummary(response.summary)
if (enableVerify) {
// 开启验活模式:导入后自动验活
if (response.summary.added > 0) {
toast.success(`成功导入 ${response.summary.added} 个凭据,开始验活...`)
runVerification(response.items)
} else {
// 没有新增凭据,直接显示结果
setStep('result')
toast.info('没有新增凭据需要验活')
}
} else {
// 普通模式:直接显示结果
setStep('result')
if (response.summary.added > 0) {
toast.success(`成功导入 ${response.summary.added} 个凭据`)
}
}
},
onError: (error) => {
toast.error(`导入失败: ${extractErrorMessage(error)}`)
},
}
)
}, [parsedItems, importMutate, enableVerify, runVerification])
// 渲染图标
const renderActionIcon = (action: string) => {
switch (action) {
case 'added': return <CheckCircle2 className="h-4 w-4 text-green-500" />
case 'skipped': return <AlertCircle className="h-4 w-4 text-yellow-500" />
case 'invalid': return <XCircle className="h-4 w-4 text-red-500" />
default: return null
}
}
const renderActionText = (action: string) => {
switch (action) {
case 'added': return <span className="text-green-600"></span>
case 'skipped': return <span className="text-yellow-600"></span>
case 'invalid': return <span className="text-red-600"></span>
default: return action
}
}
const getVerifyStatusIcon = (status: VerifyItemResult['status']) => {
switch (status) {
case 'pending': return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'verifying': return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified': return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'failed':
case 'rollback_failed': return <XCircle className="w-5 h-5 text-red-500" />
case 'rolled_back': return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'skipped': return <AlertCircle className="w-5 h-5 text-gray-400" />
}
}
const getVerifyStatusText = (result: VerifyItemResult) => {
switch (result.status) {
case 'pending': return '等待中'
case 'verifying': return '验活中...'
case 'verified': return '验活成功'
case 'failed': return '验活失败'
case 'rolled_back': return '验活失败(已排除)'
case 'rollback_failed': return '验活失败(未排除)'
case 'skipped': return '跳过'
}
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileJson className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{step === 'input' && '粘贴或上传 JSON 文件以批量导入凭据'}
{step === 'preview' && '预览导入结果,确认后执行导入'}
{step === 'result' && '导入完成'}
{step === 'verifying' && '正在验活导入的凭据...'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto py-4">
{/* Step 1: Input */}
{step === 'input' && (
<div className="space-y-4">
{/* 拖放区域 */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-10 w-10 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-2">
JSON
</p>
<p className="text-xs text-muted-foreground">
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{/* 分隔线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground"></span>
</div>
</div>
{/* 文本输入 */}
<div className="space-y-2">
<label className="text-sm font-medium"> JSON</label>
<textarea
className="w-full h-48 p-3 text-sm font-mono border rounded-md bg-background resize-none focus:outline-none focus:ring-2 focus:ring-ring"
placeholder='{"refreshToken": "...", "provider": "BuilderId", ...}'
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
/>
</div>
</div>
)}
{/* Step 2: Preview */}
{step === 'preview' && previewSummary && (
<div className="space-y-4">
{/* 统计 */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">{previewSummary.parsed}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">{previewSummary.added}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{previewSummary.skipped}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
<div className="text-2xl font-bold text-red-600">{previewSummary.invalid}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{/* 预览列表 */}
<div className="border rounded-lg overflow-hidden">
<div className="max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-2 font-medium">#</th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
</tr>
</thead>
<tbody>
{previewResults.map((item) => (
<tr key={item.index} className="border-t">
<td className="p-2">{item.index + 1}</td>
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
<td className="p-2">
<div className="flex items-center gap-1">
{renderActionIcon(item.action)}
{renderActionText(item.action)}
</div>
</td>
<td className="p-2 text-muted-foreground text-xs">
{item.reason || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 验活开关 */}
{previewSummary.added > 0 && (
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">
</div>
</div>
<Switch checked={enableVerify} onCheckedChange={setEnableVerify} />
</div>
)}
</div>
)}
{/* Step 3: Result (普通模式) */}
{step === 'result' && finalSummary && (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold">{finalSummary.parsed}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600">{finalSummary.added}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-yellow-50 dark:bg-yellow-950 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{finalSummary.skipped}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center p-3 bg-red-50 dark:bg-red-950 rounded-lg">
<div className="text-2xl font-bold text-red-600">{finalSummary.invalid}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="max-h-64 overflow-auto">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-2 font-medium">#</th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"></th>
<th className="text-left p-2 font-medium"> ID</th>
</tr>
</thead>
<tbody>
{finalResults.map((item) => (
<tr key={item.index} className="border-t">
<td className="p-2">{item.index + 1}</td>
<td className="p-2 font-mono text-xs">{item.fingerprint}</td>
<td className="p-2">
<div className="flex items-center gap-1">
{renderActionIcon(item.action)}
{renderActionText(item.action)}
</div>
</td>
<td className="p-2">
{item.credentialId ? `#${item.credentialId}` : item.reason || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Step: Verifying (验活模式) */}
{step === 'verifying' && (
<div className="space-y-4">
{/* 进度条 */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isVerifying ? '验活进度' : '验活完成'}</span>
<span>{verifyProgress.current} / {verifyProgress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: verifyProgress.total > 0 ? `${(verifyProgress.current / verifyProgress.total) * 100}%` : '0%' }}
/>
</div>
</div>
{/* 统计 */}
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {verifyResults.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {verifyResults.filter(r => r.status === 'rolled_back').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {verifyResults.filter(r => r.status === 'rollback_failed').length}
</span>
</div>
{/* 结果列表 */}
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{verifyResults.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getVerifyStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
#{result.credentialId || result.index + 1}
</span>
<span className="text-xs text-muted-foreground">
{getVerifyStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">
: {result.usage}
</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{result.error}
</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
: {result.rollbackError}
</div>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
{step === 'input' && (
<>
<Button variant="outline" onClick={handleClose}>
</Button>
<Button onClick={handlePreview} disabled={!jsonText.trim() || isPending}>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'预览'
)}
</Button>
</>
)}
{step === 'preview' && (
<>
<Button variant="outline" onClick={() => setStep('input')}>
</Button>
<Button
onClick={handleConfirmImport}
disabled={isPending || (previewSummary?.added ?? 0) === 0}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : enableVerify ? (
`导入并验活 (${previewSummary?.added ?? 0})`
) : (
`确认导入 (${previewSummary?.added ?? 0})`
)}
</Button>
</>
)}
{step === 'result' && (
<Button onClick={handleClose}></Button>
)}
{step === 'verifying' && (
<Button onClick={handleClose} disabled={isVerifying}>
{isVerifying ? '验活中...' : '完成'}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,491 @@
import { useState, useMemo } from 'react'
import { toast } from 'sonner'
import { CheckCircle2, XCircle, AlertCircle, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useCredentials, useAddCredential, useDeleteCredential } from '@/hooks/use-credentials'
import { getCredentialBalance, setCredentialDisabled } from '@/api/credentials'
import { extractErrorMessage, sha256Hex } from '@/lib/utils'
interface KamImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
// KAM 导出 JSON 中的账号结构
interface KamAccount {
email?: string
userId?: string | null
nickname?: string
credentials: {
refreshToken: string
clientId?: string
clientSecret?: string
region?: string
authMethod?: string
startUrl?: string
}
machineId?: string
status?: string
}
interface VerificationResult {
index: number
status: 'pending' | 'checking' | 'verifying' | 'verified' | 'duplicate' | 'failed' | 'skipped'
error?: string
usage?: string
email?: string
credentialId?: number
rollbackStatus?: 'success' | 'failed' | 'skipped'
rollbackError?: string
}
// 校验元素是否为有效的 KAM 账号结构
function isValidKamAccount(item: unknown): item is KamAccount {
if (typeof item !== 'object' || item === null) return false
const obj = item as Record<string, unknown>
if (typeof obj.credentials !== 'object' || obj.credentials === null) return false
const cred = obj.credentials as Record<string, unknown>
return typeof cred.refreshToken === 'string' && cred.refreshToken.trim().length > 0
}
// 解析 KAM 导出 JSON支持单账号和多账号格式
function parseKamJson(raw: string): KamAccount[] {
const parsed = JSON.parse(raw)
let rawItems: unknown[]
// 标准 KAM 导出格式:{ version, accounts: [...] }
if (parsed.accounts && Array.isArray(parsed.accounts)) {
rawItems = parsed.accounts
}
// 兜底:如果直接是账号数组
else if (Array.isArray(parsed)) {
rawItems = parsed
}
// 单个账号对象(有 credentials 字段)
else if (parsed.credentials && typeof parsed.credentials === 'object') {
rawItems = [parsed]
}
else {
throw new Error('无法识别的 KAM JSON 格式')
}
const validAccounts = rawItems.filter(isValidKamAccount)
if (rawItems.length > 0 && validAccounts.length === 0) {
throw new Error(`${rawItems.length} 条记录,但均缺少有效的 credentials.refreshToken`)
}
if (validAccounts.length < rawItems.length) {
const skipped = rawItems.length - validAccounts.length
console.warn(`KAM 导入:跳过 ${skipped} 条缺少有效 credentials.refreshToken 的记录`)
}
return validAccounts
}
export function KamImportDialog({ open, onOpenChange }: KamImportDialogProps) {
const [jsonInput, setJsonInput] = useState('')
const [importing, setImporting] = useState(false)
const [skipErrorAccounts, setSkipErrorAccounts] = useState(true)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [currentProcessing, setCurrentProcessing] = useState<string>('')
const [results, setResults] = useState<VerificationResult[]>([])
const { data: existingCredentials } = useCredentials()
const { mutateAsync: addCredential } = useAddCredential()
const { mutateAsync: deleteCredential } = useDeleteCredential()
const rollbackCredential = async (id: number): Promise<{ success: boolean; error?: string }> => {
try {
await setCredentialDisabled(id, true)
} catch (error) {
return { success: false, error: `禁用失败: ${extractErrorMessage(error)}` }
}
try {
await deleteCredential(id)
return { success: true }
} catch (error) {
return { success: false, error: `删除失败: ${extractErrorMessage(error)}` }
}
}
const resetForm = () => {
setJsonInput('')
setProgress({ current: 0, total: 0 })
setCurrentProcessing('')
setResults([])
}
const handleImport = async () => {
// 先单独解析 JSON给出精准的错误提示
let validAccounts: KamAccount[]
try {
const accounts = parseKamJson(jsonInput)
if (accounts.length === 0) {
toast.error('没有可导入的账号')
return
}
validAccounts = accounts.filter(a => a.credentials?.refreshToken)
if (validAccounts.length === 0) {
toast.error('没有包含有效 refreshToken 的账号')
return
}
} catch (error) {
toast.error('JSON 格式错误: ' + extractErrorMessage(error))
return
}
try {
setImporting(true)
setProgress({ current: 0, total: validAccounts.length })
// 初始化结果,标记 error 状态的账号
const initialResults: VerificationResult[] = validAccounts.map((account, i) => {
if (skipErrorAccounts && account.status === 'error') {
return { index: i + 1, status: 'skipped' as const, email: account.email || account.nickname }
}
return { index: i + 1, status: 'pending' as const, email: account.email || account.nickname }
})
setResults(initialResults)
// 重复检测
const existingTokenHashes = new Set(
existingCredentials?.credentials
.map(c => c.refreshTokenHash)
.filter((hash): hash is string => Boolean(hash)) || []
)
let successCount = 0
let duplicateCount = 0
let failCount = 0
let skippedCount = 0
for (let i = 0; i < validAccounts.length; i++) {
const account = validAccounts[i]
// 跳过 error 状态的账号
if (skipErrorAccounts && account.status === 'error') {
skippedCount++
setProgress({ current: i + 1, total: validAccounts.length })
continue
}
const cred = account.credentials
const token = cred.refreshToken.trim()
const tokenHash = await sha256Hex(token)
setCurrentProcessing(`正在处理 ${account.email || account.nickname || `账号 ${i + 1}`}`)
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'checking' }
return next
})
// 检查重复
if (existingTokenHashes.has(tokenHash)) {
duplicateCount++
const existingCred = existingCredentials?.credentials.find(c => c.refreshTokenHash === tokenHash)
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'duplicate', error: '该凭据已存在', email: existingCred?.email || account.email }
return next
})
setProgress({ current: i + 1, total: validAccounts.length })
continue
}
// 验活中
setResults(prev => {
const next = [...prev]
next[i] = { ...next[i], status: 'verifying' }
return next
})
let addedCredId: number | null = null
try {
const clientId = cred.clientId?.trim() || undefined
const clientSecret = cred.clientSecret?.trim() || undefined
const authMethod = clientId && clientSecret ? 'idc' : 'social'
// idc 模式下必须同时提供 clientId 和 clientSecret
if (authMethod === 'social' && (clientId || clientSecret)) {
throw new Error('idc 模式需要同时提供 clientId 和 clientSecret')
}
const addedCred = await addCredential({
refreshToken: token,
authMethod,
authRegion: cred.region?.trim() || undefined,
clientId,
clientSecret,
machineId: account.machineId?.trim() || undefined,
})
addedCredId = addedCred.credentialId
await new Promise(resolve => setTimeout(resolve, 1000))
const balance = await getCredentialBalance(addedCred.credentialId)
successCount++
existingTokenHashes.add(tokenHash)
setCurrentProcessing(`验活成功: ${addedCred.email || account.email || `账号 ${i + 1}`}`)
setResults(prev => {
const next = [...prev]
next[i] = {
...next[i],
status: 'verified',
usage: `${balance.currentUsage}/${balance.usageLimit}`,
email: addedCred.email || account.email,
credentialId: addedCred.credentialId,
}
return next
})
} catch (error) {
let rollbackStatus: VerificationResult['rollbackStatus'] = 'skipped'
let rollbackError: string | undefined
if (addedCredId) {
const result = await rollbackCredential(addedCredId)
if (result.success) {
rollbackStatus = 'success'
} else {
rollbackStatus = 'failed'
rollbackError = result.error
}
}
failCount++
setResults(prev => {
const next = [...prev]
next[i] = {
...next[i],
status: 'failed',
error: extractErrorMessage(error),
rollbackStatus,
rollbackError,
}
return next
})
}
setProgress({ current: i + 1, total: validAccounts.length })
}
// 汇总
const parts: string[] = []
if (successCount > 0) parts.push(`成功 ${successCount}`)
if (duplicateCount > 0) parts.push(`重复 ${duplicateCount}`)
if (failCount > 0) parts.push(`失败 ${failCount}`)
if (skippedCount > 0) parts.push(`跳过 ${skippedCount}`)
if (failCount === 0 && duplicateCount === 0 && skippedCount === 0) {
toast.success(`成功导入并验活 ${successCount} 个凭据`)
} else {
toast.info(`导入完成:${parts.join('')}`)
}
} catch (error) {
toast.error('导入失败: ' + extractErrorMessage(error))
} finally {
setImporting(false)
}
}
const getStatusIcon = (status: VerificationResult['status']) => {
switch (status) {
case 'pending':
return <div className="w-5 h-5 rounded-full border-2 border-gray-300" />
case 'checking':
case 'verifying':
return <Loader2 className="w-5 h-5 animate-spin text-blue-500" />
case 'verified':
return <CheckCircle2 className="w-5 h-5 text-green-500" />
case 'duplicate':
return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'skipped':
return <AlertCircle className="w-5 h-5 text-gray-400" />
case 'failed':
return <XCircle className="w-5 h-5 text-red-500" />
}
}
const getStatusText = (result: VerificationResult) => {
switch (result.status) {
case 'pending': return '等待中'
case 'checking': return '检查重复...'
case 'verifying': return '验活中...'
case 'verified': return '验活成功'
case 'duplicate': return '重复凭据'
case 'skipped': return '已跳过error 状态)'
case 'failed':
if (result.rollbackStatus === 'success') return '验活失败(已排除)'
if (result.rollbackStatus === 'failed') return '验活失败(未排除)'
return '验活失败(未创建)'
}
}
// 预览解析结果
const { previewAccounts, parseError } = useMemo(() => {
if (!jsonInput.trim()) return { previewAccounts: [] as KamAccount[], parseError: '' }
try {
return { previewAccounts: parseKamJson(jsonInput), parseError: '' }
} catch (e) {
return { previewAccounts: [] as KamAccount[], parseError: extractErrorMessage(e) }
}
}, [jsonInput])
const errorAccountCount = previewAccounts.filter(a => a.status === 'error').length
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && importing) return
if (!newOpen) resetForm()
onOpenChange(newOpen)
}}
>
<DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>KAM </DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">KAM JSON</label>
<textarea
placeholder={'粘贴 Kiro Account Manager 导出的 JSON格式如下\n{\n "version": "1.5.0",\n "accounts": [\n {\n "email": "...",\n "credentials": {\n "refreshToken": "...",\n "clientId": "...",\n "clientSecret": "...",\n "region": "us-east-1"\n }\n }\n ]\n}'}
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
disabled={importing}
className="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
/>
</div>
{/* 解析预览 */}
{parseError && (
<div className="text-sm text-red-600 dark:text-red-400">: {parseError}</div>
)}
{previewAccounts.length > 0 && !importing && results.length === 0 && (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{previewAccounts.length}
{errorAccountCount > 0 && `(其中 ${errorAccountCount} 个为 error 状态)`}
</div>
{errorAccountCount > 0 && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={skipErrorAccounts}
onChange={(e) => setSkipErrorAccounts(e.target.checked)}
className="rounded border-gray-300"
/>
error
</label>
)}
</div>
)}
{/* 导入进度和结果 */}
{(importing || results.length > 0) && (
<>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{importing ? '导入进度' : '导入完成'}</span>
<span>{progress.current} / {progress.total}</span>
</div>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%` }}
/>
</div>
{importing && currentProcessing && (
<div className="text-xs text-muted-foreground">{currentProcessing}</div>
)}
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-600 dark:text-green-400">
: {results.filter(r => r.status === 'verified').length}
</span>
<span className="text-yellow-600 dark:text-yellow-400">
: {results.filter(r => r.status === 'duplicate').length}
</span>
<span className="text-red-600 dark:text-red-400">
: {results.filter(r => r.status === 'failed').length}
</span>
<span className="text-gray-500">
: {results.filter(r => r.status === 'skipped').length}
</span>
</div>
<div className="border rounded-md divide-y max-h-[300px] overflow-y-auto">
{results.map((result) => (
<div key={result.index} className="p-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{result.email || `账号 #${result.index}`}
</span>
<span className="text-xs text-muted-foreground">
{getStatusText(result)}
</span>
</div>
{result.usage && (
<div className="text-xs text-muted-foreground mt-1">: {result.usage}</div>
)}
{result.error && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">{result.error}</div>
)}
{result.rollbackError && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">: {result.rollbackError}</div>
)}
</div>
</div>
</div>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => { onOpenChange(false); resetForm() }}
disabled={importing}
>
{importing ? '导入中...' : results.length > 0 ? '关闭' : '取消'}
</Button>
{results.length === 0 && (
<Button
type="button"
onClick={handleImport}
disabled={importing || !jsonInput.trim() || previewAccounts.length === 0 || !!parseError}
>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react'
import { KeyRound } from 'lucide-react'
import { storage } from '@/lib/storage'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
interface LoginPageProps {
onLogin: (apiKey: string) => void
}
export function LoginPage({ onLogin }: LoginPageProps) {
const [apiKey, setApiKey] = useState('')
useEffect(() => {
// 从 storage 读取保存的 API Key
const savedKey = storage.getApiKey()
if (savedKey) {
setApiKey(savedKey)
}
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (apiKey.trim()) {
storage.setApiKey(apiKey.trim())
onLogin(apiKey.trim())
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<KeyRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Kiro Admin</CardTitle>
<CardDescription>
Admin API Key 访
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Input
type="password"
placeholder="Admin API Key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="text-center"
/>
</div>
<Button type="submit" className="w-full" disabled={!apiKey.trim()}>
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
success:
'border-transparent bg-green-500 text-white hover:bg-green-500/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,55 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -0,0 +1,119 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only"></span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,35 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number
max?: number
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, max = 100, ...props }, ref) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
return (
<div
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<div
className={cn(
'h-full transition-all',
percentage > 80 ? 'bg-red-500' : percentage > 60 ? 'bg-yellow-500' : 'bg-green-500'
)}
style={{ width: `${percentage}%` }}
/>
</div>
)
}
)
Progress.displayName = 'Progress'
export { Progress }

View File

@@ -0,0 +1,25 @@
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,26 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,175 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
getCredentials,
deleteCredential,
setCredentialDisabled,
setCredentialPriority,
setCredentialRegion,
resetCredentialFailure,
getCredentialBalance,
getCachedBalances,
getCredentialAccountInfo,
addCredential,
getCredentialStats,
resetCredentialStats,
resetAllStats,
importTokenJson,
} from '@/api/credentials'
import type { AddCredentialRequest, ImportTokenJsonRequest } from '@/types/api'
// 查询凭据列表
export function useCredentials() {
return useQuery({
queryKey: ['credentials'],
queryFn: getCredentials,
refetchInterval: 30000, // 每 30 秒刷新一次
})
}
// 查询凭据余额
export function useCredentialBalance(id: number | null) {
return useQuery({
queryKey: ['credential-balance', id],
queryFn: () => getCredentialBalance(id!),
enabled: id !== null,
retry: false, // 余额查询失败时不重试(避免重复请求被封禁的账号)
})
}
// 查询所有凭据的缓存余额(定时轮询,带退避策略)
export function useCachedBalances() {
return useQuery({
queryKey: ['cached-balances'],
queryFn: getCachedBalances,
refetchInterval: (query) => (query.state.error ? 60000 : 30000),
refetchIntervalInBackground: false, // 页面不可见时暂停轮询
})
}
// 查询凭据账号信息(套餐/用量/邮箱等)
export function useCredentialAccountInfo(id: number | null, enabled: boolean) {
return useQuery({
queryKey: ['credential-account', id],
queryFn: () => getCredentialAccountInfo(id!),
enabled: enabled && id !== null,
retry: false,
})
}
// 删除指定凭据
export function useDeleteCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => deleteCredential(id),
onSuccess: (_res, id) => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-balance', id] })
queryClient.invalidateQueries({ queryKey: ['credential-account', id] })
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
},
})
}
// 设置禁用状态
export function useSetDisabled() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, disabled }: { id: number; disabled: boolean }) =>
setCredentialDisabled(id, disabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 设置优先级
export function useSetPriority() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, priority }: { id: number; priority: number }) =>
setCredentialPriority(id, priority),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 设置 Region
export function useSetRegion() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, region, apiRegion }: { id: number; region: string | null; apiRegion: string | null }) =>
setCredentialRegion(id, region, apiRegion),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 重置失败计数
export function useResetFailure() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => resetCredentialFailure(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 添加新凭据
export function useAddCredential() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (req: AddCredentialRequest) => addCredential(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
},
})
}
// 查询指定凭据统计
export function useCredentialStats(id: number | null, enabled: boolean) {
return useQuery({
queryKey: ['credential-stats', id],
queryFn: () => getCredentialStats(id!),
enabled: enabled && id !== null,
retry: false,
})
}
// 清空指定凭据统计
export function useResetCredentialStats() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: number) => resetCredentialStats(id),
onSuccess: (_res, id) => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-stats', id] })
},
})
}
// 清空全部统计
export function useResetAllStats() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => resetAllStats(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['credential-stats'] })
},
})
}
// 批量导入 token.json
export function useImportTokenJson() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (req: ImportTokenJsonRequest) => importTokenJson(req),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['credentials'] })
queryClient.invalidateQueries({ queryKey: ['cached-balances'] })
},
})
}

90
admin-ui/src/index.css Normal file
View File

@@ -0,0 +1,90 @@
@import "tailwindcss";
/* 自定义主题配置 */
@theme {
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
}
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-feature-settings: "rlig" 1, "calt" 1;
}

View File

@@ -0,0 +1,44 @@
export function formatCompactNumber(value: number | null | undefined): string {
if (value === null || value === undefined) return '-'
if (!Number.isFinite(value)) return String(value)
const abs = Math.abs(value)
const sign = value < 0 ? '-' : ''
const formatScaled = (scale: number, suffix: string) => {
const scaled = abs / scale
const decimals = scaled < 10 ? 1 : 0
const fixed = scaled.toFixed(decimals)
const trimmed = fixed.endsWith('.0') ? fixed.slice(0, -2) : fixed
return `${sign}${trimmed}${suffix}`
}
if (abs >= 1_000_000_000) return formatScaled(1_000_000_000, 'B')
if (abs >= 1_000_000) return formatScaled(1_000_000, 'M')
if (abs >= 1_000) return formatScaled(1_000, 'K')
// 小于 1000按整数显示
return `${sign}${Math.round(abs)}`
}
export function formatTokensPair(inputTokens: number, outputTokens: number): string {
return `${formatCompactNumber(inputTokens)} in / ${formatCompactNumber(outputTokens)} out`
}
export function formatExpiry(expiresAt: string | null): string {
if (!expiresAt) return '未知'
const date = new Date(expiresAt)
if (isNaN(date.getTime())) return expiresAt
const now = new Date()
const diff = date.getTime() - now.getTime()
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes} 分钟`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时`
return `${Math.floor(hours / 24)}`
}

View File

@@ -0,0 +1,7 @@
const API_KEY_STORAGE_KEY = 'adminApiKey'
export const storage = {
getApiKey: () => localStorage.getItem(API_KEY_STORAGE_KEY),
setApiKey: (key: string) => localStorage.setItem(API_KEY_STORAGE_KEY, key),
removeApiKey: () => localStorage.removeItem(API_KEY_STORAGE_KEY),
}

202
admin-ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,202 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 解析后端错误响应,提取用户友好的错误信息
*/
export interface ParsedError {
/** 简短的错误标题 */
title: string
/** 详细的错误描述 */
detail?: string
/** 错误类型 */
type?: string
}
/**
* 从错误对象中提取错误消息
* 支持 Axios 错误和普通 Error 对象
*/
export function extractErrorMessage(error: unknown): string {
const parsed = parseError(error)
return parsed.title
}
/**
* 解析错误,返回结构化的错误信息
*/
export function parseError(error: unknown): ParsedError {
if (!error || typeof error !== 'object') {
return { title: '未知错误' }
}
const axiosError = error as Record<string, unknown>
const response = axiosError.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const errorObj = data?.error as Record<string, unknown> | undefined
// 尝试从后端错误响应中提取信息
if (errorObj && typeof errorObj.message === 'string') {
const message = errorObj.message
const type = typeof errorObj.type === 'string' ? errorObj.type : undefined
// 解析嵌套的错误信息(如:上游服务错误: 权限不足: 403 {...}
const parsed = parseNestedErrorMessage(message)
return {
title: parsed.title,
detail: parsed.detail,
type,
}
}
// 回退到 Error.message
if ('message' in axiosError && typeof axiosError.message === 'string') {
return { title: axiosError.message }
}
return { title: '未知错误' }
}
/**
* 解析嵌套的错误消息
* 例如:"上游服务错误: 权限不足,无法获取使用额度: 403 Forbidden {...}"
*/
function parseNestedErrorMessage(message: string): { title: string; detail?: string } {
// 尝试提取 HTTP 状态码(如 403、502 等)
const statusMatch = message.match(/(\d{3})\s+\w+/)
const statusCode = statusMatch ? statusMatch[1] : null
// 尝试提取 JSON 中的 message 字段
const jsonMatch = message.match(/\{[^{}]*"message"\s*:\s*"([^"]+)"[^{}]*\}/)
if (jsonMatch) {
const innerMessage = jsonMatch[1]
// 提取主要错误原因(去掉前缀)
const parts = message.split(':').map(s => s.trim())
const mainReason = parts.length > 1 ? parts[1].split(':')[0] : parts[0]
// 在 title 中包含状态码
const title = statusCode
? `${mainReason || '服务错误'} (${statusCode})`
: (mainReason || '服务错误')
return {
title,
detail: innerMessage,
}
}
// 尝试按冒号分割,提取主要信息
const colonParts = message.split(':')
if (colonParts.length >= 2) {
const mainPart = colonParts[1].trim().split(':')[0].trim()
const title = statusCode ? `${mainPart} (${statusCode})` : mainPart
return {
title,
detail: colonParts.slice(2).join(':').trim() || undefined,
}
}
return { title: message }
}
/**
* 计算字符串的 SHA-256 哈希(十六进制)
*
* 优先使用 Web Crypto APIcrypto.subtle在非安全上下文HTTP + 非 localhost
* 自动回退到纯 JS 实现,解决 Docker 部署时 crypto.subtle 不可用的问题。
*/
export async function sha256Hex(value: string): Promise<string> {
const encoded = new TextEncoder().encode(value)
// 安全上下文中使用原生 Web Crypto API性能更好
if (typeof crypto !== 'undefined' && crypto.subtle) {
try {
const digest = await crypto.subtle.digest('SHA-256', encoded)
const bytes = new Uint8Array(digest)
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
} catch {
// digest 可能在某些策略/上下文下抛错,回退到纯 JS 实现
}
}
// 非安全上下文 fallback纯 JS SHA-256 实现
return sha256Pure(encoded)
}
/**
* 纯 JS SHA-256 实现(无外部依赖)
* 仅在 crypto.subtle 不可用时使用
*/
function sha256Pure(data: Uint8Array): string {
// SHA-256 常量:前 64 个素数的立方根的小数部分
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n))
// 预处理:填充消息
const bitLen = data.length * 8
// 消息 + 1 字节 0x80 + 填充 + 8 字节长度,总长度对齐到 64 字节
const padLen = (((data.length + 9 + 63) >>> 6) << 6)
const buf = new Uint8Array(padLen)
buf.set(data)
buf[data.length] = 0x80
// 写入 64 位大端长度(仅低 32 位,高 32 位在 JS 中始终为 0
const view = new DataView(buf.buffer)
view.setUint32(padLen - 4, bitLen, false)
// 初始哈希值
let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a
let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19
const w = new Uint32Array(64)
for (let offset = 0; offset < padLen; offset += 64) {
for (let i = 0; i < 16; i++) {
w[i] = view.getUint32(offset + i * 4, false)
}
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0
}
let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
const ch = (e & f) ^ (~e & g)
const temp1 = (h + S1 + ch + K[i] + w[i]) | 0
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
const maj = (a & b) ^ (a & c) ^ (b & c)
const temp2 = (S0 + maj) | 0
h = g; g = f; f = e; e = (d + temp1) | 0
d = c; c = b; b = a; a = (temp1 + temp2) | 0
}
h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0
h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0
}
return [h0, h1, h2, h3, h4, h5, h6, h7]
.map(v => (v >>> 0).toString(16).padStart(8, '0'))
.join('')
}

22
admin-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

311
admin-ui/src/types/api.ts Normal file
View File

@@ -0,0 +1,311 @@
// 凭据状态响应
export interface CredentialsStatusResponse {
total: number
available: number
credentials: CredentialStatusItem[]
}
// 单个凭据状态
export interface CredentialStatusItem {
id: number
priority: number
disabled: boolean
failureCount: number
expiresAt: string | null
authMethod: string | null
hasProfileArn: boolean
accountEmail: string | null
email?: string
refreshTokenHash?: string
// ===== 统计(可持久化) =====
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
// ===== upstream 字段 =====
successCount: number
lastUsedAt: string | null
hasProxy: boolean
proxyUrl?: string
/** 凭据级 Region用于 Token 刷新) */
region: string | null
/** 凭据级 API Region单独覆盖 API 请求) */
apiRegion: string | null
}
// 余额响应
export interface BalanceResponse {
id: number
subscriptionTitle: string | null
currentUsage: number
usageLimit: number
remaining: number
usagePercentage: number
nextResetAt: number | null
}
// 缓存余额信息
export interface CachedBalanceInfo {
id: number
remaining: number
cachedAt: number // Unix 毫秒时间戳
ttlSecs: number
}
// 缓存余额响应
export interface CachedBalancesResponse {
balances: CachedBalanceInfo[]
}
// 成功响应
export interface SuccessResponse {
success: boolean
message: string
}
// ===== 统计(可持久化) =====
export interface StatsBucket {
// 按日YYYY-MM-DD按模型model id
key: string
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
}
export interface CredentialStatsResponse {
id: number
callsTotal: number
callsOk: number
callsErr: number
inputTokensTotal: number
outputTokensTotal: number
lastCallAt: string | null
lastSuccessAt: string | null
lastErrorAt: string | null
lastError: string | null
byDay: StatsBucket[]
byModel: StatsBucket[]
}
// 错误响应
export interface AdminErrorResponse {
error: {
type: string
message: string
}
}
// 请求类型
export interface SetDisabledRequest {
disabled: boolean
}
export interface SetPriorityRequest {
priority: number
}
// 添加凭据请求
export interface AddCredentialRequest {
refreshToken: string
authMethod?: 'social' | 'idc'
clientId?: string
clientSecret?: string
priority?: number
/** Region用于 Token 刷新及默认 API 请求),可被 apiRegion 单独覆盖 */
region?: string
/** 单独覆盖 API 请求使用的 region */
apiRegion?: string
machineId?: string
proxyUrl?: string
proxyUsername?: string
proxyPassword?: string
}
// 添加凭据响应
export interface AddCredentialResponse {
success: boolean
message: string
credentialId: number
email?: string
}
// ===== 账号信息(套餐/用量/邮箱等) =====
export interface CreditBonus {
code: string
name: string
current: number
limit: number
expiresAt: string | null
}
export interface CreditsResourceDetail {
displayName: string | null
displayNamePlural: string | null
resourceType: string | null
currency: string | null
unit: string | null
overageRate: number | null
overageCap: number | null
}
export interface CreditsUsageSummary {
current: number
limit: number
baseCurrent: number
baseLimit: number
freeTrialCurrent: number
freeTrialLimit: number
freeTrialExpiry: string | null
bonuses: CreditBonus[]
nextResetDate: string | null
overageEnabled: boolean | null
resourceDetail: CreditsResourceDetail | null
}
export interface AccountSubscriptionDetails {
rawType: string | null
managementTarget: string | null
upgradeCapability: string | null
overageCapability: string | null
}
export interface ResourceUsageSummary {
resourceType: string | null
displayName: string | null
unit: string | null
currency: string | null
current: number
limit: number
}
export interface UsageAndLimitsResponse {
userInfo: { email: string | null; userId: string | null } | null
subscriptionInfo:
| {
type: string | null
subscriptionTitle: string | null
upgradeCapability: string | null
overageCapability: string | null
subscriptionManagementTarget: string | null
}
| null
usageBreakdownList:
| Array<{
resourceType: string | null
currentUsage: number | null
currentUsageWithPrecision: number | null
usageLimit: number | null
usageLimitWithPrecision: number | null
displayName: string | null
displayNamePlural: string | null
currency: string | null
unit: string | null
overageRate: number | null
overageCap: number | null
freeTrialInfo:
| {
usageLimit: number | null
usageLimitWithPrecision: number | null
currentUsage: number | null
currentUsageWithPrecision: number | null
freeTrialExpiry: string | null
freeTrialStatus: string | null
}
| null
bonuses:
| Array<{
bonusCode: string | null
displayName: string | null
usageLimit: number | null
usageLimitWithPrecision: number | null
currentUsage: number | null
currentUsageWithPrecision: number | null
status: string | null
expiresAt: string | null
}>
| null
}>
| null
nextDateReset: string | null
overageConfiguration: { overageEnabled: boolean | null } | null
}
export interface AccountAggregateInfo {
email: string | null
userId: string | null
idp: string | null
status: string | null
featureFlags: string[] | null
subscriptionTitle: string | null
subscriptionType: string
subscription: AccountSubscriptionDetails
usage: CreditsUsageSummary
resources: ResourceUsageSummary[]
rawUsage: UsageAndLimitsResponse
}
export interface CredentialAccountInfoResponse {
id: number
account: AccountAggregateInfo
}
// ============ 批量导入 token.json ============
// 官方 token.json 格式(用于解析导入)
export interface TokenJsonItem {
provider?: string
refreshToken?: string
clientId?: string
clientSecret?: string
authMethod?: string
priority?: number
region?: string
machineId?: string
}
// 批量导入请求
export interface ImportTokenJsonRequest {
dryRun?: boolean
items: TokenJsonItem | TokenJsonItem[]
}
// 导入动作
export type ImportAction = 'added' | 'skipped' | 'invalid'
// 单项导入结果
export interface ImportItemResult {
index: number
fingerprint: string
action: ImportAction
reason?: string
credentialId?: number
}
// 导入汇总
export interface ImportSummary {
parsed: number
added: number
skipped: number
invalid: number
}
// 批量导入响应
export interface ImportTokenJsonResponse {
summary: ImportSummary
items: ImportItemResult[]
}

View File

@@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [],
}

28
admin-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
base: '/admin/',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

13
docker-compose.yml Normal file
View File

@@ -0,0 +1,13 @@
services:
kiro-rs:
# 使用国内镜像
# image: ghcr.nju.edu.cn/${IMAGE_OWNER:-hank9999}/kiro-rs:${IMAGE_TAG:-latest}
image: ghcr.io/${IMAGE_OWNER:-hank9999}/kiro-rs:${IMAGE_TAG:-latest}
container_name: kiro-rs
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8990:8990"
volumes:
- ./config/:/app/config/
restart: unless-stopped

75
docs/media.md Normal file
View File

@@ -0,0 +1,75 @@
# 多媒体输入(图片 / GIF / 视频)
本项目在 Anthropic → Kiro 的协议转换过程中,会把 `messages[].content` 里的 `image` 内容块转换为上游 Kiro API 的 `images[]` 字段。
为避免上游对“大请求体/异常输入”返回 `400 Improperly formed request`,对 GIF 做了专门处理(抽帧→静态图)。
## 图片(静态)
当前支持的静态图片 `source.media_type`
- `image/jpeg`
- `image/png`
- `image/webp`
处理策略:
- 读取尺寸后按配置做等比缩放(长边/像素上限)
- 保持原格式重编码(仅在需要缩放时)
## GIF动图
### 为什么不能直接透传 GIF
GIF 常见特征是「分辨率不大但字节体积巨大」(多帧 + 调色板 + 压缩特性),如果把整段 GIF base64 原样塞进上游请求体,极易触发上游的请求体大小/校验限制(表现为 `400 Improperly formed request` 这类兜底错误)。
### 当前实现:抽帧输出多张静态图
当检测到 `source.media_type=image/gif` 时,会:
1. 解码 GIF计算总时长与源帧数
2. 按时间轴采样(见下方采样策略)
3. 对被选中的帧按同样的缩放规则处理,并重编码为静态 `jpeg`
4. 将这些帧按时间顺序写入上游请求的 `images[]`
### 采样策略(固定上限,按时长自适应)
约束:
- 总输出帧数 `<= 20`
- 采样频率 `<= 5 fps`
规则(等价描述):
- `fps = min(5, floor(20 / ceil(duration_seconds)))`
- `interval_ms = 1000 / fps`
-`duration_seconds` 很大导致 `fps=0`,则按 `interval_ms = ceil(duration_ms / 20)` 做均匀采样
例子:
- 8 秒 GIF`floor(20/8)=2``2 fps``interval=500ms` → 最多约 `16`
- 4 秒 GIF`floor(20/4)=5``5 fps``interval=200ms` → 最多 `20`
## 视频mp4 / mov 等)
当前状态:**未实现**。
说明:
- Anthropic 的 `image` 内容块规范里通常不会发送 `video/*`,因此本项目当前也不会把 `video/mp4``video/quicktime` 等媒体类型转换成上游 `images[]`(等价于不支持/忽略)。
### 规划方案(记录,暂不实现)
如果未来需要支持“视频 → 多张静态图”的输入,推荐方案是引入视频解码能力(通常依赖 `ffmpeg/ffprobe`
1. `ffprobe` 读取视频时长(毫秒)
2. 复用 GIF 的采样策略计算 `interval_ms`,并限制 `<=20` 帧、`<=5fps`
3. `ffmpeg` 按时间点抽帧(导出 `jpeg/webp`
4. 每帧按现有缩放规则处理后 base64 化,写入 `images[]`
建议配套的安全/稳定性约束(避免 DoS 与请求体爆炸):
- 限制输入视频最大字节数与最大时长(超限直接 400
- 限制抽帧后总输出图片字节数(超限则降低 fps/分辨率/质量,或回退更少帧)
- 使用临时目录并确保清理;并发下避免落盘文件名冲突

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -0,0 +1,146 @@
use serde_json::Value;
use std::fs;
fn main() -> anyhow::Result<()> {
let path = std::env::args().nth(1).ok_or_else(|| {
anyhow::anyhow!("Usage: cargo run --example test_compression -- <path-to-json>")
})?;
let content = fs::read_to_string(&path)?;
let data: Value = serde_json::from_str(&content)?;
let (req, raw_body_len) = if let Some(body_str) = data["request_body"].as_str() {
(
serde_json::from_str::<Value>(body_str)?,
Some(body_str.len()),
)
} else {
(data, None)
};
let body_len = match raw_body_len {
Some(n) => n,
None => serde_json::to_string(&req).unwrap_or_default().len(),
};
println!(
"原始请求体大小: {} bytes ({:.1} KB)",
body_len,
body_len as f64 / 1024.0
);
if let Some(messages) = req["messages"].as_array() {
println!("消息数量: {}", messages.len());
let mut user_count = 0;
let mut assistant_count = 0;
let mut total_chars = 0;
let mut tool_result_chars = 0;
let mut tool_use_chars = 0;
for msg in messages {
match msg["role"].as_str() {
Some("user") => user_count += 1,
Some("assistant") => assistant_count += 1,
_ => {}
}
if let Some(content) = msg["content"].as_array() {
for item in content {
if let Some(text) = item["text"].as_str() {
total_chars += text.len();
}
// 统计 tool_result
if item["type"].as_str() == Some("tool_result")
&& let Some(result_content) = item["content"].as_array()
{
for result_item in result_content {
if let Some(text) = result_item["text"].as_str() {
tool_result_chars += text.len();
}
}
}
// 统计 tool_use
if item["type"].as_str() == Some("tool_use") {
let input_str =
serde_json::to_string(&item["input"]).unwrap_or_else(|_| "null".into());
tool_use_chars += input_str.len();
}
}
}
}
println!(" - user: {}", user_count);
println!(" - assistant: {}", assistant_count);
println!(
" - 文本字符数: {} ({:.1} KB)",
total_chars,
total_chars as f64 / 1024.0
);
println!(
" - tool_result 字符数: {} ({:.1} KB)",
tool_result_chars,
tool_result_chars as f64 / 1024.0
);
println!(
" - tool_use input 字符数: {} ({:.1} KB)",
tool_use_chars,
tool_use_chars as f64 / 1024.0
);
// 模拟历史截断
let max_history_turns = 80;
let max_history_chars = 400_000;
let turns = messages.len() / 2;
println!("\n压缩模拟(默认配置):");
println!(" - 当前轮数: {} (阈值: {})", turns, max_history_turns);
if turns > max_history_turns {
let to_remove = turns - max_history_turns;
println!(" - 需要移除: {} 轮 ({} 条消息)", to_remove, to_remove * 2);
} else {
println!(" - 轮数未超限");
}
let total_content_chars = total_chars + tool_result_chars + tool_use_chars;
println!(
" - 总内容字符数: {} ({:.1} KB)",
total_content_chars,
total_content_chars as f64 / 1024.0
);
if total_content_chars > max_history_chars {
println!(
" - 字符数超限: {} > {}",
total_content_chars, max_history_chars
);
} else {
println!(" - 字符数未超限");
}
}
if let Some(tools) = req["tools"].as_array() {
let tools_str = serde_json::to_string(tools).unwrap_or_default();
println!("\n工具数量: {}", tools.len());
println!(
"工具定义总大小: {} bytes ({:.1} KB)",
tools_str.len(),
tools_str.len() as f64 / 1024.0
);
// 统计每个工具描述的大小
let mut total_desc_chars = 0;
for tool in tools {
if let Some(desc) = tool["description"].as_str() {
total_desc_chars += desc.len();
}
}
println!(
"工具描述总字符数: {} ({:.1} KB)",
total_desc_chars,
total_desc_chars as f64 / 1024.0
);
}
Ok(())
}

64
src/admin/error.rs Normal file
View File

@@ -0,0 +1,64 @@
//! Admin API 错误类型定义
use std::fmt;
use axum::http::StatusCode;
use super::types::AdminErrorResponse;
/// Admin 服务错误类型
#[derive(Debug)]
pub enum AdminServiceError {
/// 凭据不存在
NotFound { id: u64 },
/// 上游服务调用失败网络、API 错误等)
UpstreamError(String),
/// 内部状态错误
InternalError(String),
/// 凭据无效(验证失败)
InvalidCredential(String),
}
impl fmt::Display for AdminServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AdminServiceError::NotFound { id } => {
write!(f, "凭据不存在: {}", id)
}
AdminServiceError::UpstreamError(msg) => write!(f, "上游服务错误: {}", msg),
AdminServiceError::InternalError(msg) => write!(f, "内部错误: {}", msg),
AdminServiceError::InvalidCredential(msg) => write!(f, "凭据无效: {}", msg),
}
}
}
impl std::error::Error for AdminServiceError {}
impl AdminServiceError {
/// 获取对应的 HTTP 状态码
pub fn status_code(&self) -> StatusCode {
match self {
AdminServiceError::NotFound { .. } => StatusCode::NOT_FOUND,
AdminServiceError::UpstreamError(_) => StatusCode::BAD_GATEWAY,
AdminServiceError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR,
AdminServiceError::InvalidCredential(_) => StatusCode::BAD_REQUEST,
}
}
/// 转换为 API 错误响应
pub fn into_response(self) -> AdminErrorResponse {
match &self {
AdminServiceError::NotFound { .. } => AdminErrorResponse::not_found(self.to_string()),
AdminServiceError::UpstreamError(_) => AdminErrorResponse::api_error(self.to_string()),
AdminServiceError::InternalError(_) => {
AdminErrorResponse::internal_error(self.to_string())
}
AdminServiceError::InvalidCredential(_) => {
AdminErrorResponse::invalid_request(self.to_string())
}
}
}
}

139
src/admin/handlers.rs Normal file
View File

@@ -0,0 +1,139 @@
//! Admin API HTTP 处理器
use axum::{
Json,
extract::{Path, State},
response::IntoResponse,
};
use super::{
middleware::AdminState,
types::{
AddCredentialRequest, ImportTokenJsonRequest, SetDisabledRequest, SetPriorityRequest,
SetRegionRequest, SuccessResponse,
},
};
/// GET /api/admin/credentials
/// 获取所有凭据状态
pub async fn get_all_credentials(State(state): State<AdminState>) -> impl IntoResponse {
let response = state.service.get_all_credentials();
Json(response)
}
/// POST /api/admin/credentials/:id/disabled
/// 设置凭据禁用状态
pub async fn set_credential_disabled(
State(state): State<AdminState>,
Path(id): Path<u64>,
Json(payload): Json<SetDisabledRequest>,
) -> impl IntoResponse {
match state.service.set_disabled(id, payload.disabled) {
Ok(_) => {
let action = if payload.disabled { "禁用" } else { "启用" };
Json(SuccessResponse::new(format!("凭据 #{}{}", id, action))).into_response()
}
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// POST /api/admin/credentials/:id/priority
/// 设置凭据优先级
pub async fn set_credential_priority(
State(state): State<AdminState>,
Path(id): Path<u64>,
Json(payload): Json<SetPriorityRequest>,
) -> impl IntoResponse {
match state.service.set_priority(id, payload.priority) {
Ok(_) => Json(SuccessResponse::new(format!(
"凭据 #{} 优先级已设置为 {}",
id, payload.priority
)))
.into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// POST /api/admin/credentials/:id/region
/// 设置凭据 Region
pub async fn set_credential_region(
State(state): State<AdminState>,
Path(id): Path<u64>,
Json(payload): Json<SetRegionRequest>,
) -> impl IntoResponse {
match state
.service
.set_region(id, payload.region, payload.api_region)
{
Ok(_) => Json(SuccessResponse::new(format!("凭据 #{} Region 已更新", id))).into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// POST /api/admin/credentials/:id/reset
/// 重置失败计数并重新启用
pub async fn reset_failure_count(
State(state): State<AdminState>,
Path(id): Path<u64>,
) -> impl IntoResponse {
match state.service.reset_and_enable(id) {
Ok(_) => Json(SuccessResponse::new(format!(
"凭据 #{} 失败计数已重置并重新启用",
id
)))
.into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// GET /api/admin/credentials/:id/balance
/// 获取指定凭据的余额
pub async fn get_credential_balance(
State(state): State<AdminState>,
Path(id): Path<u64>,
) -> impl IntoResponse {
match state.service.get_balance(id).await {
Ok(response) => Json(response).into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// GET /api/admin/credentials/balances/cached
/// 获取所有凭据的缓存余额
pub async fn get_cached_balances(State(state): State<AdminState>) -> impl IntoResponse {
Json(state.service.get_cached_balances())
}
/// POST /api/admin/credentials
/// 添加新凭据
pub async fn add_credential(
State(state): State<AdminState>,
Json(payload): Json<AddCredentialRequest>,
) -> impl IntoResponse {
match state.service.add_credential(payload).await {
Ok(response) => Json(response).into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// DELETE /api/admin/credentials/:id
/// 删除凭据
pub async fn delete_credential(
State(state): State<AdminState>,
Path(id): Path<u64>,
) -> impl IntoResponse {
match state.service.delete_credential(id) {
Ok(_) => Json(SuccessResponse::new(format!("凭据 #{} 已删除", id))).into_response(),
Err(e) => (e.status_code(), Json(e.into_response())).into_response(),
}
}
/// POST /api/admin/credentials/import-token-json
/// 批量导入 token.json
pub async fn import_token_json(
State(state): State<AdminState>,
Json(payload): Json<ImportTokenJsonRequest>,
) -> impl IntoResponse {
let response = state.service.import_token_json(payload).await;
Json(response)
}

50
src/admin/middleware.rs Normal file
View File

@@ -0,0 +1,50 @@
//! Admin API 中间件
use std::sync::Arc;
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Json, Response},
};
use super::service::AdminService;
use super::types::AdminErrorResponse;
use crate::common::auth;
/// Admin API 共享状态
#[derive(Clone)]
pub struct AdminState {
/// Admin API 密钥
pub admin_api_key: String,
/// Admin 服务
pub service: Arc<AdminService>,
}
impl AdminState {
pub fn new(admin_api_key: impl Into<String>, service: AdminService) -> Self {
Self {
admin_api_key: admin_api_key.into(),
service: Arc::new(service),
}
}
}
/// Admin API 认证中间件
pub async fn admin_auth_middleware(
State(state): State<AdminState>,
request: Request<Body>,
next: Next,
) -> Response {
let api_key = auth::extract_api_key(&request);
match api_key {
Some(key) if auth::constant_time_eq(&key, &state.admin_api_key) => next.run(request).await,
_ => {
let error = AdminErrorResponse::authentication_error();
(StatusCode::UNAUTHORIZED, Json(error)).into_response()
}
}
}

28
src/admin/mod.rs Normal file
View File

@@ -0,0 +1,28 @@
//! Admin API 模块
//!
//! 提供凭据管理和监控功能的 HTTP API
//!
//! # 功能
//! - 查询所有凭据状态
//! - 启用/禁用凭据
//! - 修改凭据优先级
//! - 重置失败计数
//! - 查询凭据余额
//!
//! # 使用
//! ```ignore
//! let admin_service = AdminService::new(token_manager.clone());
//! let admin_state = AdminState::new(admin_api_key, admin_service);
//! let admin_router = create_admin_router(admin_state);
//! ```
mod error;
mod handlers;
mod middleware;
mod router;
mod service;
pub mod types;
pub use middleware::AdminState;
pub use router::create_admin_router;
pub use service::AdminService;

53
src/admin/router.rs Normal file
View File

@@ -0,0 +1,53 @@
//! Admin API 路由配置
use axum::{
Router, middleware,
routing::{delete, get, post},
};
use super::{
handlers::{
add_credential, delete_credential, get_all_credentials, get_cached_balances,
get_credential_balance, import_token_json, reset_failure_count, set_credential_disabled,
set_credential_priority, set_credential_region,
},
middleware::{AdminState, admin_auth_middleware},
};
/// 创建 Admin API 路由
///
/// # 端点
/// - `GET /credentials` - 获取所有凭据状态
/// - `POST /credentials` - 添加新凭据
/// - `POST /credentials/import-token-json` - 批量导入 token.json
/// - `DELETE /credentials/:id` - 删除凭据
/// - `POST /credentials/:id/disabled` - 设置凭据禁用状态
/// - `POST /credentials/:id/priority` - 设置凭据优先级
/// - `POST /credentials/:id/reset` - 重置失败计数
/// - `GET /credentials/:id/balance` - 获取凭据余额
/// - `GET /credentials/balances/cached` - 获取所有凭据的缓存余额
///
/// # 认证
/// 需要 Admin API Key 认证,支持:
/// - `x-api-key` header
/// - `Authorization: Bearer <token>` header
pub fn create_admin_router(state: AdminState) -> Router {
Router::new()
.route(
"/credentials",
get(get_all_credentials).post(add_credential),
)
.route("/credentials/balances/cached", get(get_cached_balances))
.route("/credentials/import-token-json", post(import_token_json))
.route("/credentials/{id}", delete(delete_credential))
.route("/credentials/{id}/disabled", post(set_credential_disabled))
.route("/credentials/{id}/priority", post(set_credential_priority))
.route("/credentials/{id}/region", post(set_credential_region))
.route("/credentials/{id}/reset", post(reset_failure_count))
.route("/credentials/{id}/balance", get(get_credential_balance))
.layer(middleware::from_fn_with_state(
state.clone(),
admin_auth_middleware,
))
.with_state(state)
}

612
src/admin/service.rs Normal file
View File

@@ -0,0 +1,612 @@
//! Admin API 业务逻辑服务
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::common::utf8::floor_char_boundary;
use crate::kiro::model::credentials::KiroCredentials;
use crate::kiro::token_manager::MultiTokenManager;
use super::error::AdminServiceError;
use super::types::{
AddCredentialRequest, AddCredentialResponse, BalanceResponse, CachedBalanceItem,
CachedBalancesResponse, CredentialStatusItem, CredentialsStatusResponse, ImportAction,
ImportItemResult, ImportSummary, ImportTokenJsonRequest, ImportTokenJsonResponse,
TokenJsonItem,
};
/// 余额缓存过期时间5 分钟
const BALANCE_CACHE_TTL_SECS: i64 = 300;
/// 缓存的余额条目(含时间戳)
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedBalance {
/// 缓存时间Unix 秒)
cached_at: f64,
/// 缓存的余额数据
data: BalanceResponse,
}
/// Admin 服务
///
/// 封装所有 Admin API 的业务逻辑
pub struct AdminService {
token_manager: Arc<MultiTokenManager>,
balance_cache: Mutex<HashMap<u64, CachedBalance>>,
cache_path: Option<PathBuf>,
}
impl AdminService {
pub fn new(token_manager: Arc<MultiTokenManager>) -> Self {
let cache_path = token_manager
.cache_dir()
.map(|d| d.join("kiro_balance_cache.json"));
let balance_cache = Self::load_balance_cache_from(&cache_path);
Self {
token_manager,
balance_cache: Mutex::new(balance_cache),
cache_path,
}
}
/// 获取所有凭据状态
pub fn get_all_credentials(&self) -> CredentialsStatusResponse {
let snapshot = self.token_manager.snapshot();
let mut credentials: Vec<CredentialStatusItem> = snapshot
.entries
.into_iter()
.map(|entry| CredentialStatusItem {
id: entry.id,
priority: entry.priority,
disabled: entry.disabled,
failure_count: entry.failure_count,
expires_at: entry.expires_at,
auth_method: entry.auth_method,
has_profile_arn: entry.has_profile_arn,
refresh_token_hash: entry.refresh_token_hash,
email: entry.email,
success_count: entry.success_count,
last_used_at: entry.last_used_at.clone(),
region: entry.region,
api_region: entry.api_region,
})
.collect();
// 按优先级排序(数字越小优先级越高)
credentials.sort_by_key(|c| c.priority);
CredentialsStatusResponse {
total: snapshot.total,
available: snapshot.available,
credentials,
}
}
/// 设置凭据禁用状态
pub fn set_disabled(&self, id: u64, disabled: bool) -> Result<(), AdminServiceError> {
self.token_manager
.set_disabled(id, disabled)
.map_err(|e| self.classify_error(e, id))
}
/// 设置凭据优先级
pub fn set_priority(&self, id: u64, priority: u32) -> Result<(), AdminServiceError> {
self.token_manager
.set_priority(id, priority)
.map_err(|e| self.classify_error(e, id))
}
/// 设置凭据 Region
pub fn set_region(
&self,
id: u64,
region: Option<String>,
api_region: Option<String>,
) -> Result<(), AdminServiceError> {
// trim 后空字符串转 None
let region = region
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let api_region = api_region
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
self.token_manager
.set_region(id, region, api_region)
.map_err(|e| self.classify_error(e, id))
}
/// 重置失败计数并重新启用
pub fn reset_and_enable(&self, id: u64) -> Result<(), AdminServiceError> {
self.token_manager
.reset_and_enable(id)
.map_err(|e| self.classify_error(e, id))
}
/// 获取凭据余额(带缓存)
pub async fn get_balance(&self, id: u64) -> Result<BalanceResponse, AdminServiceError> {
// 先查缓存
{
let cache = self.balance_cache.lock();
if let Some(cached) = cache.get(&id) {
let now = Utc::now().timestamp() as f64;
if (now - cached.cached_at) < BALANCE_CACHE_TTL_SECS as f64 {
tracing::debug!("凭据 #{} 余额命中缓存", id);
return Ok(cached.data.clone());
}
}
}
// 缓存未命中或已过期,从上游获取
let balance = self.fetch_balance(id).await?;
// 更新缓存
{
let mut cache = self.balance_cache.lock();
cache.insert(
id,
CachedBalance {
cached_at: Utc::now().timestamp() as f64,
data: balance.clone(),
},
);
}
self.save_balance_cache();
Ok(balance)
}
/// 从上游获取余额(无缓存)
async fn fetch_balance(&self, id: u64) -> Result<BalanceResponse, AdminServiceError> {
let usage = self
.token_manager
.get_usage_limits_for(id)
.await
.map_err(|e| self.classify_balance_error(e, id))?;
let current_usage = usage.current_usage();
let usage_limit = usage.usage_limit();
let remaining = (usage_limit - current_usage).max(0.0);
let usage_percentage = if usage_limit > 0.0 {
(current_usage / usage_limit * 100.0).min(100.0)
} else {
0.0
};
// 更新缓存,使列表页面能显示最新余额
self.token_manager.update_balance_cache(id, remaining);
Ok(BalanceResponse {
id,
subscription_title: usage.subscription_title().map(|s| s.to_string()),
current_usage,
usage_limit,
remaining,
usage_percentage,
next_reset_at: usage.next_date_reset,
})
}
/// 获取所有凭据的缓存余额
pub fn get_cached_balances(&self) -> CachedBalancesResponse {
let balances = self
.token_manager
.get_all_cached_balances()
.into_iter()
.map(|info| CachedBalanceItem {
id: info.id,
remaining: info.remaining,
cached_at: info.cached_at,
ttl_secs: info.ttl_secs,
})
.collect();
CachedBalancesResponse { balances }
}
/// 添加新凭据
pub async fn add_credential(
&self,
req: AddCredentialRequest,
) -> Result<AddCredentialResponse, AdminServiceError> {
// 构建凭据对象
let email = req.email.clone();
let new_cred = KiroCredentials {
id: None,
access_token: None,
refresh_token: Some(req.refresh_token),
profile_arn: None,
expires_at: None,
auth_method: Some(req.auth_method),
client_id: req.client_id,
client_secret: req.client_secret,
priority: req.priority,
region: req.region,
api_region: req.api_region,
machine_id: req.machine_id,
email: req.email,
subscription_title: None,
proxy_url: req.proxy_url,
proxy_username: req.proxy_username,
proxy_password: req.proxy_password,
disabled: false, // 新添加的凭据默认启用
};
// 调用 token_manager 添加凭据
let credential_id = self
.token_manager
.add_credential(new_cred)
.await
.map_err(|e| self.classify_add_error(e))?;
Ok(AddCredentialResponse {
success: true,
message: format!("凭据添加成功ID: {}", credential_id),
credential_id,
email,
})
}
/// 删除凭据
pub fn delete_credential(&self, id: u64) -> Result<(), AdminServiceError> {
self.token_manager
.delete_credential(id)
.map_err(|e| self.classify_delete_error(e, id))?;
// 清理已删除凭据的余额缓存
{
let mut cache = self.balance_cache.lock();
cache.remove(&id);
}
self.save_balance_cache();
Ok(())
}
// ============ 余额缓存持久化 ============
fn load_balance_cache_from(cache_path: &Option<PathBuf>) -> HashMap<u64, CachedBalance> {
let path = match cache_path {
Some(p) => p,
None => return HashMap::new(),
};
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return HashMap::new(),
};
// 文件中使用字符串 key 以兼容 JSON 格式
let map: HashMap<String, CachedBalance> = match serde_json::from_str(&content) {
Ok(m) => m,
Err(e) => {
tracing::warn!("解析余额缓存失败,将忽略: {}", e);
return HashMap::new();
}
};
let now = Utc::now().timestamp() as f64;
map.into_iter()
.filter_map(|(k, v)| {
let id = k.parse::<u64>().ok()?;
// 丢弃超过 TTL 的条目
if (now - v.cached_at) < BALANCE_CACHE_TTL_SECS as f64 {
Some((id, v))
} else {
None
}
})
.collect()
}
fn save_balance_cache(&self) {
let path = match &self.cache_path {
Some(p) => p,
None => return,
};
// 快速 clone 数据后释放锁,减少锁持有时间
let map: HashMap<String, CachedBalance> = {
let cache = self.balance_cache.lock();
cache
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
};
// 锁外执行序列化和文件 IO
match serde_json::to_string_pretty(&map) {
Ok(json) => {
// 原子写入:先写临时文件,再重命名
let tmp_path = path.with_extension("json.tmp");
match std::fs::write(&tmp_path, json) {
Ok(_) => {
if let Err(e) = std::fs::rename(&tmp_path, path) {
tracing::warn!("原子重命名余额缓存失败: {}", e);
let _ = std::fs::remove_file(&tmp_path);
}
}
Err(e) => tracing::warn!("写入临时余额文件失败: {}", e),
}
}
Err(e) => tracing::warn!("序列化余额缓存失败: {}", e),
}
}
// ============ 错误分类 ============
/// 分类简单操作错误set_disabled, set_priority, reset_and_enable
fn classify_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
let msg = e.to_string();
if msg.contains("不存在") {
AdminServiceError::NotFound { id }
} else {
AdminServiceError::InternalError(msg)
}
}
/// 分类余额查询错误(可能涉及上游 API 调用)
fn classify_balance_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
let msg = e.to_string();
// 1. 凭据不存在
if msg.contains("不存在") {
return AdminServiceError::NotFound { id };
}
// 2. 上游服务错误特征HTTP 响应错误或网络错误
let is_upstream_error =
// HTTP 响应错误(来自 refresh_*_token 的错误消息)
msg.contains("凭证已过期或无效") ||
msg.contains("权限不足") ||
msg.contains("已被限流") ||
msg.contains("服务器错误") ||
msg.contains("Token 刷新失败") ||
msg.contains("暂时不可用") ||
// 网络错误reqwest 错误)
msg.contains("error trying to connect") ||
msg.contains("connection") ||
msg.contains("timeout") ||
msg.contains("timed out");
if is_upstream_error {
AdminServiceError::UpstreamError(msg)
} else {
// 3. 默认归类为内部错误(本地验证失败、配置错误等)
// 包括:缺少 refreshToken、refreshToken 已被截断、无法生成 machineId 等
AdminServiceError::InternalError(msg)
}
}
/// 分类添加凭据错误
fn classify_add_error(&self, e: anyhow::Error) -> AdminServiceError {
let msg = e.to_string();
// 凭据验证失败refreshToken 无效、格式错误等)
let is_invalid_credential = msg.contains("缺少 refreshToken")
|| msg.contains("refreshToken 为空")
|| msg.contains("refreshToken 已被截断")
|| msg.contains("凭据已存在")
|| msg.contains("refreshToken 重复")
|| msg.contains("凭证已过期或无效")
|| msg.contains("权限不足")
|| msg.contains("已被限流");
if is_invalid_credential {
AdminServiceError::InvalidCredential(msg)
} else if msg.contains("error trying to connect")
|| msg.contains("connection")
|| msg.contains("timeout")
{
AdminServiceError::UpstreamError(msg)
} else {
AdminServiceError::InternalError(msg)
}
}
/// 分类删除凭据错误
fn classify_delete_error(&self, e: anyhow::Error, id: u64) -> AdminServiceError {
let msg = e.to_string();
if msg.contains("不存在") {
AdminServiceError::NotFound { id }
} else if msg.contains("只能删除已禁用的凭据") || msg.contains("请先禁用凭据")
{
AdminServiceError::InvalidCredential(msg)
} else {
AdminServiceError::InternalError(msg)
}
}
/// 批量导入 token.json
///
/// 解析官方 token.json 格式,按 provider 字段自动映射 authMethod
/// - BuilderId/builder-id/idc → idc
/// - Social/social → social
pub async fn import_token_json(&self, req: ImportTokenJsonRequest) -> ImportTokenJsonResponse {
let items = req.items.into_vec();
let dry_run = req.dry_run;
let mut results = Vec::with_capacity(items.len());
let mut added = 0usize;
let mut skipped = 0usize;
let mut invalid = 0usize;
for (index, item) in items.into_iter().enumerate() {
let result = self.process_token_json_item(index, item, dry_run).await;
match result.action {
ImportAction::Added => added += 1,
ImportAction::Skipped => skipped += 1,
ImportAction::Invalid => invalid += 1,
}
results.push(result);
}
ImportTokenJsonResponse {
summary: ImportSummary {
parsed: results.len(),
added,
skipped,
invalid,
},
items: results,
}
}
/// 处理单个 token.json 项
async fn process_token_json_item(
&self,
index: usize,
item: TokenJsonItem,
dry_run: bool,
) -> ImportItemResult {
// 生成指纹(用于识别和去重)
let fingerprint = Self::generate_fingerprint(&item);
// 验证必填字段
let refresh_token = match &item.refresh_token {
Some(rt) if !rt.is_empty() => rt.clone(),
_ => {
return ImportItemResult {
index,
fingerprint,
action: ImportAction::Invalid,
reason: Some("缺少 refreshToken".to_string()),
credential_id: None,
};
}
};
// 映射 authMethod
let auth_method = Self::map_auth_method(&item);
// IdC 需要 clientId 和 clientSecret
if auth_method == "idc" && (item.client_id.is_none() || item.client_secret.is_none()) {
return ImportItemResult {
index,
fingerprint,
action: ImportAction::Invalid,
reason: Some(format!("{} 认证需要 clientId 和 clientSecret", auth_method)),
credential_id: None,
};
}
// 检查是否已存在(通过 refreshToken 前缀匹配)
if self.token_manager.has_refresh_token_prefix(&refresh_token) {
return ImportItemResult {
index,
fingerprint,
action: ImportAction::Skipped,
reason: Some("凭据已存在".to_string()),
credential_id: None,
};
}
// dry-run 模式只返回预览
if dry_run {
return ImportItemResult {
index,
fingerprint,
action: ImportAction::Added,
reason: Some("预览模式".to_string()),
credential_id: None,
};
}
// 实际添加凭据trim + 空字符串转 None与 set_region 逻辑一致)
let region = item
.region
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let api_region = item
.api_region
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let new_cred = KiroCredentials {
id: None,
access_token: None,
refresh_token: Some(refresh_token),
profile_arn: None,
expires_at: None,
auth_method: Some(auth_method),
client_id: item.client_id,
client_secret: item.client_secret,
priority: item.priority,
region,
api_region,
machine_id: item.machine_id,
email: None,
subscription_title: None,
proxy_url: None,
proxy_username: None,
proxy_password: None,
disabled: false,
};
match self.token_manager.add_credential(new_cred).await {
Ok(credential_id) => ImportItemResult {
index,
fingerprint,
action: ImportAction::Added,
reason: None,
credential_id: Some(credential_id),
},
Err(e) => ImportItemResult {
index,
fingerprint,
action: ImportAction::Invalid,
reason: Some(e.to_string()),
credential_id: None,
},
}
}
/// 生成凭据指纹(用于识别)
fn generate_fingerprint(item: &TokenJsonItem) -> String {
// 使用 refreshToken 前 16 字符作为指纹
// 使用 floor_char_boundary 安全截断,避免在多字节字符中间切割导致 panic
item.refresh_token
.as_ref()
.map(|rt| {
if rt.len() >= 16 {
let end = floor_char_boundary(rt, 16);
format!("{}...", &rt[..end])
} else {
rt.clone()
}
})
.unwrap_or_else(|| "(empty)".to_string())
}
/// 映射 provider/authMethod 到标准 authMethod
fn map_auth_method(item: &TokenJsonItem) -> String {
// 优先使用 authMethod 字段
if let Some(auth) = &item.auth_method {
let auth_lower = auth.to_lowercase();
return match auth_lower.as_str() {
"idc" | "builder-id" | "builderid" => "idc".to_string(),
"social" => "social".to_string(),
_ => auth_lower,
};
}
// 回退到 provider 字段
if let Some(provider) = &item.provider {
let provider_lower = provider.to_lowercase();
return match provider_lower.as_str() {
"builderid" | "builder-id" | "idc" => "idc".to_string(),
"social" => "social".to_string(),
_ => "social".to_string(), // 默认 social
};
}
// 默认 social
"social".to_string()
}
}

335
src/admin/types.rs Normal file
View File

@@ -0,0 +1,335 @@
//! Admin API 类型定义
use serde::{Deserialize, Serialize};
// ============ 凭据状态 ============
/// 所有凭据状态响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsStatusResponse {
/// 凭据总数
pub total: usize,
/// 可用凭据数量(未禁用)
pub available: usize,
/// 各凭据状态列表
pub credentials: Vec<CredentialStatusItem>,
}
/// 单个凭据的状态信息
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialStatusItem {
/// 凭据唯一 ID
pub id: u64,
/// 优先级(数字越小优先级越高)
pub priority: u32,
/// 是否被禁用
pub disabled: bool,
/// 连续失败次数
pub failure_count: u32,
/// Token 过期时间RFC3339 格式)
pub expires_at: Option<String>,
/// 认证方式
pub auth_method: Option<String>,
/// 是否有 Profile ARN
pub has_profile_arn: bool,
/// refreshToken 的 SHA-256 哈希(用于前端重复检测)
pub refresh_token_hash: Option<String>,
/// 用户邮箱(用于前端显示)
pub email: Option<String>,
/// API 调用成功次数
pub success_count: u64,
/// 最后一次 API 调用时间RFC3339 格式)
pub last_used_at: Option<String>,
/// 凭据级 Region用于 Token 刷新)
pub region: Option<String>,
/// 凭据级 API Region单独覆盖 API 请求)
pub api_region: Option<String>,
}
// ============ 操作请求 ============
/// 启用/禁用凭据请求
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetDisabledRequest {
/// 是否禁用
pub disabled: bool,
}
/// 修改优先级请求
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPriorityRequest {
/// 新优先级值
pub priority: u32,
}
/// 修改 Region 请求
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetRegionRequest {
/// 凭据级 Region用于 Token 刷新),空字符串表示清除
pub region: Option<String>,
/// 凭据级 API Region单独覆盖 API 请求),空字符串表示清除
pub api_region: Option<String>,
}
/// 添加凭据请求
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AddCredentialRequest {
/// 刷新令牌(必填)
pub refresh_token: String,
/// 认证方式(可选,默认 social
#[serde(default = "default_auth_method")]
pub auth_method: String,
/// OIDC Client IDIdC 认证需要)
pub client_id: Option<String>,
/// OIDC Client SecretIdC 认证需要)
pub client_secret: Option<String>,
/// 优先级(可选,默认 0
#[serde(default)]
pub priority: u32,
/// 凭据级 Region 配置(用于 Token 刷新)
/// 未配置时回退到 config.json 的全局 region
pub region: Option<String>,
/// 凭据级 API Region用于 API 调用)
pub api_region: Option<String>,
/// 凭据级 Machine ID可选64 位字符串)
/// 未配置时回退到 config.json 的 machineId
pub machine_id: Option<String>,
/// 用户邮箱(可选,用于前端显示)
pub email: Option<String>,
/// 凭据级代理 URL
pub proxy_url: Option<String>,
/// 凭据级代理用户名
pub proxy_username: Option<String>,
/// 凭据级代理密码
pub proxy_password: Option<String>,
}
fn default_auth_method() -> String {
"social".to_string()
}
/// 添加凭据成功响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AddCredentialResponse {
pub success: bool,
pub message: String,
/// 新添加的凭据 ID
pub credential_id: u64,
/// 用户邮箱(如果获取成功)
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
// ============ 余额查询 ============
/// 余额查询响应
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BalanceResponse {
/// 凭据 ID
pub id: u64,
/// 订阅类型
pub subscription_title: Option<String>,
/// 当前使用量
pub current_usage: f64,
/// 使用限额
pub usage_limit: f64,
/// 剩余额度
pub remaining: f64,
/// 使用百分比
pub usage_percentage: f64,
/// 下次重置时间Unix 时间戳)
pub next_reset_at: Option<f64>,
}
/// 缓存余额信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedBalanceItem {
/// 凭据 ID
pub id: u64,
/// 缓存的剩余额度
pub remaining: f64,
/// 缓存时间Unix 毫秒时间戳)
pub cached_at: u64,
/// 缓存存活时间(秒),缓存过期时间 = cached_at + ttl_secs * 1000
pub ttl_secs: u64,
}
/// 所有凭据的缓存余额响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CachedBalancesResponse {
/// 各凭据的缓存余额列表
pub balances: Vec<CachedBalanceItem>,
}
// ============ 负载均衡配置 ============
// ============ 通用响应 ============
/// 操作成功响应
#[derive(Debug, Serialize)]
pub struct SuccessResponse {
pub success: bool,
pub message: String,
}
impl SuccessResponse {
pub fn new(message: impl Into<String>) -> Self {
Self {
success: true,
message: message.into(),
}
}
}
// ============ 批量导入 token.json ============
/// 官方 token.json 格式(用于解析导入)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenJsonItem {
pub provider: Option<String>,
pub refresh_token: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub auth_method: Option<String>,
#[serde(default)]
pub priority: u32,
pub region: Option<String>,
pub api_region: Option<String>,
pub machine_id: Option<String>,
}
/// 批量导入请求
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportTokenJsonRequest {
#[serde(default = "default_dry_run")]
pub dry_run: bool,
pub items: ImportItems,
}
fn default_dry_run() -> bool {
true
}
/// 导入项(支持单个或数组)
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum ImportItems {
Single(TokenJsonItem),
Multiple(Vec<TokenJsonItem>),
}
impl ImportItems {
pub fn into_vec(self) -> Vec<TokenJsonItem> {
match self {
ImportItems::Single(item) => vec![item],
ImportItems::Multiple(items) => items,
}
}
}
/// 批量导入响应
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportTokenJsonResponse {
pub summary: ImportSummary,
pub items: Vec<ImportItemResult>,
}
/// 导入汇总
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportSummary {
pub parsed: usize,
pub added: usize,
pub skipped: usize,
pub invalid: usize,
}
/// 单项导入结果
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImportItemResult {
pub index: usize,
pub fingerprint: String,
pub action: ImportAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_id: Option<u64>,
}
/// 导入动作
#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ImportAction {
Added,
Skipped,
Invalid,
}
/// 错误响应
#[derive(Debug, Serialize)]
pub struct AdminErrorResponse {
pub error: AdminError,
}
#[derive(Debug, Serialize)]
pub struct AdminError {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
}
impl AdminErrorResponse {
pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: AdminError {
error_type: error_type.into(),
message: message.into(),
},
}
}
pub fn invalid_request(message: impl Into<String>) -> Self {
Self::new("invalid_request", message)
}
pub fn authentication_error() -> Self {
Self::new("authentication_error", "Invalid or missing admin API key")
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new("not_found", message)
}
pub fn api_error(message: impl Into<String>) -> Self {
Self::new("api_error", message)
}
pub fn internal_error(message: impl Into<String>) -> Self {
Self::new("internal_error", message)
}
}

7
src/admin_ui/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
//! Admin UI 静态文件服务模块
//!
//! 使用 rust-embed 嵌入前端构建产物
mod router;
pub use router::create_admin_ui_router;

109
src/admin_ui/router.rs Normal file
View File

@@ -0,0 +1,109 @@
//! Admin UI 路由配置
use axum::{
Router,
body::Body,
http::{Response, StatusCode, Uri, header},
response::IntoResponse,
routing::get,
};
use rust_embed::Embed;
/// 嵌入前端构建产物
#[derive(Embed)]
#[folder = "admin-ui/dist"]
struct Asset;
/// 创建 Admin UI 路由
pub fn create_admin_ui_router() -> Router {
Router::new()
.route("/", get(index_handler))
.route("/{*file}", get(static_handler))
}
/// 处理首页请求
async fn index_handler() -> impl IntoResponse {
serve_index()
}
/// 处理静态文件请求
async fn static_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
// 安全检查:拒绝包含 .. 的路径
if path.contains("..") {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("Invalid path"))
.expect("Failed to build response");
}
// 尝试获取请求的文件
if let Some(content) = Asset::get(path) {
let mime = mime_guess::from_path(path)
.first_or_octet_stream()
.to_string();
// 根据文件类型设置不同的缓存策略
let cache_control = get_cache_control(path);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache_control)
.body(Body::from(content.data.into_owned()))
.expect("Failed to build response");
}
// SPA fallback: 如果文件不存在且不是资源文件,返回 index.html
if !is_asset_path(path) {
return serve_index();
}
// 404
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Not found"))
.expect("Failed to build response")
}
/// 提供 index.html
fn serve_index() -> Response<Body> {
match Asset::get("index.html") {
Some(content) => Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(content.data.into_owned()))
.expect("Failed to build response"),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from(
"Admin UI not built. Run 'pnpm build' in admin-ui directory.",
))
.expect("Failed to build response"),
}
}
/// 根据文件类型返回合适的缓存策略
fn get_cache_control(path: &str) -> &'static str {
if path.ends_with(".html") {
// HTML 文件不缓存,确保用户获取最新版本
"no-cache"
} else if path.starts_with("assets/") {
// assets/ 目录下的文件带有内容哈希,可以长期缓存
"public, max-age=31536000, immutable"
} else {
// 其他文件(如 favicon使用较短的缓存
"public, max-age=3600"
}
}
/// 判断是否为资源文件路径(有扩展名的文件)
fn is_asset_path(path: &str) -> bool {
// 检查最后一个路径段是否包含扩展名
path.rsplit('/')
.next()
.map(|filename| filename.contains('.'))
.unwrap_or(false)
}

1063
src/anthropic/compressor.rs Normal file

File diff suppressed because it is too large Load Diff

2852
src/anthropic/converter.rs Normal file

File diff suppressed because it is too large Load Diff

1731
src/anthropic/handlers.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
//! Anthropic API 中间件
use std::sync::Arc;
use axum::{
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Json, Response},
};
use crate::common::auth;
use crate::kiro::provider::KiroProvider;
use crate::model::config::CompressionConfig;
use super::types::ErrorResponse;
/// 应用共享状态
#[derive(Clone)]
pub struct AppState {
/// API 密钥
pub api_key: String,
/// Kiro Provider可选用于实际 API 调用)
/// 内部使用 MultiTokenManager已支持线程安全的多凭据管理
pub kiro_provider: Option<Arc<KiroProvider>>,
/// Profile ARN可选用于请求
pub profile_arn: Option<String>,
/// 输入压缩配置
pub compression_config: CompressionConfig,
}
impl AppState {
/// 创建新的应用状态
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
kiro_provider: None,
profile_arn: None,
compression_config: CompressionConfig::default(),
}
}
/// 设置 KiroProvider
pub fn with_kiro_provider(mut self, provider: KiroProvider) -> Self {
self.kiro_provider = Some(Arc::new(provider));
self
}
/// 设置 Profile ARN
pub fn with_profile_arn(mut self, arn: impl Into<String>) -> Self {
self.profile_arn = Some(arn.into());
self
}
/// 设置压缩配置
pub fn with_compression_config(mut self, config: CompressionConfig) -> Self {
self.compression_config = config;
self
}
}
/// API Key 认证中间件
pub async fn auth_middleware(
State(state): State<AppState>,
request: Request<Body>,
next: Next,
) -> Response {
match auth::extract_api_key(&request) {
Some(key) if auth::constant_time_eq(&key, &state.api_key) => next.run(request).await,
_ => {
let error = ErrorResponse::authentication_error();
(StatusCode::UNAUTHORIZED, Json(error)).into_response()
}
}
}
/// CORS 中间件层
///
/// **安全说明**当前配置允许所有来源Any这是为了支持公开 API 服务。
/// 如果需要更严格的安全控制,请根据实际需求配置具体的允许来源、方法和头信息。
///
/// # 配置说明
/// - `allow_origin(Any)`: 允许任何来源的请求
/// - `allow_methods(Any)`: 允许任何 HTTP 方法
/// - `allow_headers(Any)`: 允许任何请求头
pub fn cors_layer() -> tower_http::cors::CorsLayer {
use tower_http::cors::{Any, CorsLayer};
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
}

36
src/anthropic/mod.rs Normal file
View File

@@ -0,0 +1,36 @@
//! Anthropic API 兼容服务模块
//!
//! 提供与 Anthropic Claude API 兼容的 HTTP 服务端点。
//!
//! # 支持的端点
//!
//! ## 标准端点 (/v1)
//! - `GET /v1/models` - 获取可用模型列表
//! - `POST /v1/messages` - 创建消息(对话)
//! - `POST /v1/messages/count_tokens` - 计算 token 数量
//!
//! ## Claude Code 兼容端点 (/cc/v1)
//! - `POST /cc/v1/messages` - 创建消息(流式响应会等待 contextUsageEvent 后再发送 message_start确保 input_tokens 准确)
//! - `POST /cc/v1/messages/count_tokens` - 计算 token 数量(与 /v1 相同)
//!
//! # 使用示例
//! ```rust,ignore
//! use kiro_rs::anthropic;
//!
//! let app = anthropic::create_router("your-api-key");
//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
//! axum::serve(listener, app).await?;
//! ```
mod compressor;
mod converter;
mod handlers;
mod middleware;
mod router;
mod stream;
mod tool_compression;
mod truncation;
pub mod types;
mod websearch;
pub use router::create_router_with_provider;

79
src/anthropic/router.rs Normal file
View File

@@ -0,0 +1,79 @@
//! Anthropic API 路由配置
use axum::{
Router,
extract::DefaultBodyLimit,
middleware,
routing::{get, post},
};
use crate::kiro::provider::KiroProvider;
use crate::model::config::CompressionConfig;
use super::{
handlers::{count_tokens, get_models, post_messages, post_messages_cc},
middleware::{AppState, auth_middleware, cors_layer},
};
/// 请求体最大大小限制 (50MB)
const MAX_BODY_SIZE: usize = 50 * 1024 * 1024;
/// 创建 Anthropic API 路由
///
/// # 端点
/// - `GET /v1/models` - 获取可用模型列表
/// - `POST /v1/messages` - 创建消息(对话)
/// - `POST /v1/messages/count_tokens` - 计算 token 数量
///
/// # 认证
/// 所有 `/v1` 路径需要 API Key 认证,支持:
/// - `x-api-key` header
/// - `Authorization: Bearer <token>` header
///
/// # 参数
/// - `api_key`: API 密钥,用于验证客户端请求
/// - `kiro_provider`: 可选的 KiroProvider用于调用上游 API
///
/// 创建带有 KiroProvider 的 Anthropic API 路由
pub fn create_router_with_provider(
api_key: impl Into<String>,
kiro_provider: Option<KiroProvider>,
profile_arn: Option<String>,
compression_config: CompressionConfig,
) -> Router {
let mut state = AppState::new(api_key);
if let Some(provider) = kiro_provider {
state = state.with_kiro_provider(provider);
}
if let Some(arn) = profile_arn {
state = state.with_profile_arn(arn);
}
state = state.with_compression_config(compression_config);
// 需要认证的 /v1 路由
let v1_routes = Router::new()
.route("/models", get(get_models))
.route("/messages", post(post_messages))
.route("/messages/count_tokens", post(count_tokens))
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
// 需要认证的 /cc/v1 路由Claude Code 兼容端点)
// 与 /v1 的区别:流式响应会等待 contextUsageEvent 后再发送 message_start
let cc_v1_routes = Router::new()
.route("/messages", post(post_messages_cc))
.route("/messages/count_tokens", post(count_tokens))
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
Router::new()
.nest("/v1", v1_routes)
.nest("/cc/v1", cc_v1_routes)
.layer(cors_layer())
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
.with_state(state)
}

1937
src/anthropic/stream.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
//! 工具定义压缩模块
//!
//! 当工具定义的总序列化大小超过阈值时,通过两步压缩减小体积:
//! 1. 简化 `input_schema`移除非必要字段description 等),仅保留结构骨架
//! 2. 按比例截断 `description`:根据超出比例缩短描述,最短保留 50 字符
use crate::kiro::model::requests::tool::{InputSchema, Tool as KiroTool, ToolSpecification};
/// 工具定义总大小阈值20KB
const TOOL_SIZE_THRESHOLD: usize = 20 * 1024;
/// description 最短保留字符数
const MIN_DESCRIPTION_CHARS: usize = 50;
/// 如果工具定义总大小超过阈值,执行压缩
///
/// 返回压缩后的工具列表(如果未超阈值则原样返回)
pub fn compress_tools_if_needed(tools: &[KiroTool]) -> Vec<KiroTool> {
let total_size = estimate_tools_size(tools);
if total_size <= TOOL_SIZE_THRESHOLD {
return tools.to_vec();
}
tracing::info!(
total_size,
threshold = TOOL_SIZE_THRESHOLD,
tool_count = tools.len(),
"工具定义超过阈值,开始压缩"
);
// 第一步:简化 input_schema
let mut compressed: Vec<KiroTool> = tools.iter().map(simplify_schema).collect();
let size_after_schema = estimate_tools_size(&compressed);
if size_after_schema <= TOOL_SIZE_THRESHOLD {
tracing::info!(
original_size = total_size,
compressed_size = size_after_schema,
"schema 简化后已低于阈值"
);
return compressed;
}
// 第二步:按比例截断 description基于字节大小
let ratio = TOOL_SIZE_THRESHOLD as f64 / size_after_schema as f64;
for tool in &mut compressed {
let desc = &tool.tool_specification.description;
let target_bytes = (desc.len() as f64 * ratio) as usize;
// 最短保留 MIN_DESCRIPTION_CHARS 个字符对应的字节数(至少 50 字符)
let min_bytes = desc
.char_indices()
.nth(MIN_DESCRIPTION_CHARS)
.map(|(idx, _)| idx)
.unwrap_or(desc.len());
let target_bytes = target_bytes.max(min_bytes);
if desc.len() > target_bytes {
// UTF-8 安全截断:找到不超过 target_bytes 的最大字符边界
let truncate_at = desc
.char_indices()
.take_while(|(idx, _)| *idx <= target_bytes)
.last()
.map(|(idx, ch)| idx + ch.len_utf8())
.unwrap_or(0);
tool.tool_specification.description = desc[..truncate_at].to_string();
}
}
let final_size = estimate_tools_size(&compressed);
tracing::info!(
original_size = total_size,
after_schema = size_after_schema,
final_size,
"工具压缩完成"
);
compressed
}
/// 估算工具列表的总序列化大小(字节)
fn estimate_tools_size(tools: &[KiroTool]) -> usize {
tools
.iter()
.map(|t| {
let spec = &t.tool_specification;
spec.name.len()
+ spec.description.len()
+ serde_json::to_string(&spec.input_schema.json)
.map(|s| s.len())
.unwrap_or(0)
})
.sum()
}
/// 简化工具的 input_schema
///
/// 保留结构骨架type, properties 的 key 和 type, required
/// 移除 properties 内部的 description、examples 等非必要字段
fn simplify_schema(tool: &KiroTool) -> KiroTool {
let schema = &tool.tool_specification.input_schema.json;
let simplified = simplify_json_schema(schema);
KiroTool {
tool_specification: ToolSpecification {
name: tool.tool_specification.name.clone(),
description: tool.tool_specification.description.clone(),
input_schema: InputSchema::from_json(simplified),
},
}
}
/// 递归简化 JSON Schema
fn simplify_json_schema(schema: &serde_json::Value) -> serde_json::Value {
let Some(obj) = schema.as_object() else {
return schema.clone();
};
let mut result = serde_json::Map::new();
// 保留顶层结构字段
for key in &["$schema", "type", "required", "additionalProperties"] {
if let Some(v) = obj.get(*key) {
result.insert(key.to_string(), v.clone());
}
}
// 简化 properties仅保留每个属性的 type
if let Some(serde_json::Value::Object(props)) = obj.get("properties") {
let mut simplified_props = serde_json::Map::new();
for (name, prop_schema) in props {
if let Some(prop_obj) = prop_schema.as_object() {
let mut simplified_prop = serde_json::Map::new();
// 保留 type
if let Some(ty) = prop_obj.get("type") {
simplified_prop.insert("type".to_string(), ty.clone());
}
// 递归简化嵌套 properties如 object 类型)
if let Some(nested_props) = prop_obj.get("properties") {
// 构造完整的子 schema保留 required 和 additionalProperties
let mut nested_schema = serde_json::Map::new();
nested_schema.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
nested_schema.insert("properties".to_string(), nested_props.clone());
if let Some(req) = prop_obj.get("required") {
nested_schema.insert("required".to_string(), req.clone());
}
if let Some(ap) = prop_obj.get("additionalProperties") {
nested_schema.insert("additionalProperties".to_string(), ap.clone());
}
let nested = simplify_json_schema(&serde_json::Value::Object(nested_schema));
if let Some(np) = nested.get("properties") {
simplified_prop.insert("properties".to_string(), np.clone());
}
if let Some(req) = nested.get("required") {
simplified_prop.insert("required".to_string(), req.clone());
}
if let Some(ap) = nested.get("additionalProperties") {
simplified_prop.insert("additionalProperties".to_string(), ap.clone());
}
}
// 保留 items数组类型
if let Some(items) = prop_obj.get("items") {
simplified_prop.insert("items".to_string(), simplify_json_schema(items));
}
// 保留 enum
if let Some(e) = prop_obj.get("enum") {
simplified_prop.insert("enum".to_string(), e.clone());
}
simplified_props.insert(name.clone(), serde_json::Value::Object(simplified_prop));
} else {
simplified_props.insert(name.clone(), prop_schema.clone());
}
}
result.insert(
"properties".to_string(),
serde_json::Value::Object(simplified_props),
);
}
serde_json::Value::Object(result)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(name: &str, desc: &str, schema: serde_json::Value) -> KiroTool {
KiroTool {
tool_specification: ToolSpecification {
name: name.to_string(),
description: desc.to_string(),
input_schema: InputSchema::from_json(schema),
},
}
}
#[test]
fn test_no_compression_under_threshold() {
let tools = vec![make_tool(
"test",
"A short description",
serde_json::json!({"type": "object", "properties": {}}),
)];
let result = compress_tools_if_needed(&tools);
assert_eq!(result.len(), 1);
assert_eq!(
result[0].tool_specification.description,
"A short description"
);
}
#[test]
fn test_compression_triggers_over_threshold() {
// 创建大量工具使总大小超过 20KB
let long_desc = "x".repeat(2000);
let tools: Vec<KiroTool> = (0..15)
.map(|i| {
make_tool(
&format!("tool_{}", i),
&long_desc,
serde_json::json!({
"type": "object",
"properties": {
"param1": {"type": "string", "description": "A very long parameter description that adds to the size"},
"param2": {"type": "number", "description": "Another long description for testing purposes"}
}
}),
)
})
.collect();
let original_size = estimate_tools_size(&tools);
assert!(original_size > TOOL_SIZE_THRESHOLD, "测试数据应超过阈值");
let result = compress_tools_if_needed(&tools);
let compressed_size = estimate_tools_size(&result);
assert!(
compressed_size < original_size,
"压缩后应更小: {} < {}",
compressed_size,
original_size
);
}
#[test]
fn test_simplify_schema_removes_descriptions() {
let tool = make_tool(
"test",
"desc",
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file path to read"
}
},
"required": ["path"]
}),
);
let simplified = simplify_schema(&tool);
let props = simplified
.tool_specification
.input_schema
.json
.get("properties")
.unwrap();
let path_prop = props.get("path").unwrap();
// description 应被移除
assert!(path_prop.get("description").is_none());
// type 应保留
assert_eq!(path_prop.get("type").unwrap(), "string");
}
}

282
src/anthropic/truncation.rs Normal file
View File

@@ -0,0 +1,282 @@
//! 工具调用截断检测模块
//!
//! 当上游返回的工具调用 JSON 被截断时(例如因为 max_tokens 限制),
//! 提供启发式检测和软失败恢复机制,引导模型分块重试。
/// 截断类型
#[derive(Debug, Clone, PartialEq)]
pub enum TruncationType {
/// 工具输入为空(上游可能完全截断了 input
EmptyInput,
/// JSON 解析失败(不完整的 JSON
InvalidJson,
/// 缺少必要字段JSON 有效但结构不完整)
#[allow(dead_code)]
MissingFields,
/// 未闭合的字符串JSON 字符串被截断)
IncompleteString,
}
impl std::fmt::Display for TruncationType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TruncationType::EmptyInput => write!(f, "empty_input"),
TruncationType::InvalidJson => write!(f, "invalid_json"),
TruncationType::MissingFields => write!(f, "missing_fields"),
TruncationType::IncompleteString => write!(f, "incomplete_string"),
}
}
}
/// 截断检测结果
#[derive(Debug, Clone)]
pub struct TruncationInfo {
/// 截断类型
pub truncation_type: TruncationType,
/// 工具名称
pub tool_name: String,
/// 工具调用 ID
pub tool_use_id: String,
/// 原始输入(可能不完整)
#[allow(dead_code)]
pub raw_input: String,
}
/// 检测工具调用输入是否被截断
///
/// 启发式判断规则:
/// 1. 空输入 → EmptyInput
/// 2. 未闭合的引号 → IncompleteString
/// 3. 括号不平衡 → InvalidJson
/// 4. 末尾字符异常(非 `}` / `]` / `"` / 数字 / `true` / `false` / `null`)→ InvalidJson
pub fn detect_truncation(
tool_name: &str,
tool_use_id: &str,
raw_input: &str,
) -> Option<TruncationInfo> {
let trimmed = raw_input.trim();
// 空输入
if trimmed.is_empty() {
return Some(TruncationInfo {
truncation_type: TruncationType::EmptyInput,
tool_name: tool_name.to_string(),
tool_use_id: tool_use_id.to_string(),
raw_input: raw_input.to_string(),
});
}
// 检查未闭合的字符串引号
if has_unclosed_string(trimmed) {
return Some(TruncationInfo {
truncation_type: TruncationType::IncompleteString,
tool_name: tool_name.to_string(),
tool_use_id: tool_use_id.to_string(),
raw_input: raw_input.to_string(),
});
}
// 检查括号平衡
if !are_brackets_balanced(trimmed) {
return Some(TruncationInfo {
truncation_type: TruncationType::InvalidJson,
tool_name: tool_name.to_string(),
tool_use_id: tool_use_id.to_string(),
raw_input: raw_input.to_string(),
});
}
None
}
/// 构建软失败的工具结果消息
///
/// 当检测到截断时,生成一条引导模型分块重试的错误消息,
/// 而不是直接返回解析错误。
pub fn build_soft_failure_result(info: &TruncationInfo) -> String {
match info.truncation_type {
TruncationType::EmptyInput => {
format!(
"Tool call '{}' (id: {}) was truncated: the input was empty. \
This usually means the response was cut off due to token limits. \
Please retry with a shorter input or break the operation into smaller steps.",
info.tool_name, info.tool_use_id
)
}
TruncationType::IncompleteString => {
format!(
"Tool call '{}' (id: {}) was truncated: a string value was not properly closed. \
The input appears to have been cut off mid-string. \
Please retry with shorter content or split the operation into multiple calls.",
info.tool_name, info.tool_use_id
)
}
TruncationType::InvalidJson => {
format!(
"Tool call '{}' (id: {}) was truncated: the JSON input is incomplete \
(unbalanced brackets). Please retry with a shorter input or break the \
operation into smaller steps.",
info.tool_name, info.tool_use_id
)
}
TruncationType::MissingFields => {
format!(
"Tool call '{}' (id: {}) was truncated: required fields are missing. \
Please retry with all required fields included.",
info.tool_name, info.tool_use_id
)
}
}
}
/// 检查字符串中是否有未闭合的引号
fn has_unclosed_string(s: &str) -> bool {
let mut in_string = false;
let mut escape_next = false;
for ch in s.chars() {
if escape_next {
escape_next = false;
continue;
}
match ch {
'\\' if in_string => {
escape_next = true;
}
'"' => {
in_string = !in_string;
}
_ => {}
}
}
in_string
}
/// 检查括号是否平衡
fn are_brackets_balanced(s: &str) -> bool {
let mut stack: Vec<char> = Vec::new();
let mut in_string = false;
let mut escape_next = false;
for ch in s.chars() {
if escape_next {
escape_next = false;
continue;
}
if in_string {
match ch {
'\\' => escape_next = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'{' | '[' => stack.push(ch),
'}' => {
if stack.last() != Some(&'{') {
return false;
}
stack.pop();
}
']' => {
if stack.last() != Some(&'[') {
return false;
}
stack.pop();
}
_ => {}
}
}
stack.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_empty_input() {
let result = detect_truncation("Write", "tool-1", "");
assert!(result.is_some());
assert_eq!(result.unwrap().truncation_type, TruncationType::EmptyInput);
}
#[test]
fn test_detect_whitespace_only_input() {
let result = detect_truncation("Write", "tool-1", " \n ");
assert!(result.is_some());
assert_eq!(result.unwrap().truncation_type, TruncationType::EmptyInput);
}
#[test]
fn test_detect_incomplete_string() {
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello world"#);
assert!(result.is_some());
assert_eq!(
result.unwrap().truncation_type,
TruncationType::IncompleteString
);
}
#[test]
fn test_detect_unbalanced_brackets() {
// 字符串已闭合但括号不平衡
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello","more": 123"#);
assert!(result.is_some());
assert_eq!(result.unwrap().truncation_type, TruncationType::InvalidJson);
}
#[test]
fn test_valid_json_no_truncation() {
let result = detect_truncation("Write", "tool-1", r#"{"content": "hello"}"#);
assert!(result.is_none());
}
#[test]
fn test_valid_empty_object() {
let result = detect_truncation("Write", "tool-1", "{}");
assert!(result.is_none());
}
#[test]
fn test_nested_brackets() {
let result = detect_truncation("Write", "tool-1", r#"{"a": {"b": [1, 2, {"c": 3}]}}"#);
assert!(result.is_none());
}
#[test]
fn test_escaped_quotes_in_string() {
let result = detect_truncation("Write", "tool-1", r#"{"content": "say \"hello\""}"#);
assert!(result.is_none());
}
#[test]
fn test_build_soft_failure_empty() {
let info = TruncationInfo {
truncation_type: TruncationType::EmptyInput,
tool_name: "Write".to_string(),
tool_use_id: "tool-1".to_string(),
raw_input: String::new(),
};
let msg = build_soft_failure_result(&info);
assert!(msg.contains("truncated"));
assert!(msg.contains("Write"));
assert!(msg.contains("tool-1"));
}
#[test]
fn test_build_soft_failure_incomplete_string() {
let info = TruncationInfo {
truncation_type: TruncationType::IncompleteString,
tool_name: "Edit".to_string(),
tool_use_id: "tool-2".to_string(),
raw_input: r#"{"old_string": "hello"#.to_string(),
};
let msg = build_soft_failure_result(&info);
assert!(msg.contains("string value was not properly closed"));
}
}

315
src/anthropic/types.rs Normal file
View File

@@ -0,0 +1,315 @@
//! Anthropic API 类型定义
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// === 错误响应 ===
/// API 错误响应
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: ErrorDetail,
}
/// 错误详情
#[derive(Debug, Serialize)]
pub struct ErrorDetail {
#[serde(rename = "type")]
pub error_type: String,
pub message: String,
}
impl ErrorResponse {
/// 创建新的错误响应
pub fn new(error_type: impl Into<String>, message: impl Into<String>) -> Self {
Self {
error: ErrorDetail {
error_type: error_type.into(),
message: message.into(),
},
}
}
/// 创建认证错误响应
pub fn authentication_error() -> Self {
Self::new("authentication_error", "Invalid API key")
}
}
// === Models 端点类型 ===
/// 模型信息
#[derive(Debug, Serialize)]
pub struct Model {
pub id: String,
pub object: String,
pub created: i64,
pub owned_by: String,
pub display_name: String,
#[serde(rename = "type")]
pub model_type: String,
pub max_tokens: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_length: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<bool>,
}
/// 模型列表响应
#[derive(Debug, Serialize)]
pub struct ModelsResponse {
pub object: String,
pub data: Vec<Model>,
}
// === Messages 端点类型 ===
/// 最大思考预算 tokens
const MAX_BUDGET_TOKENS: i32 = 128_000;
/// Thinking 配置
#[derive(Debug, Deserialize, Clone)]
pub struct Thinking {
#[serde(rename = "type")]
pub thinking_type: String,
#[serde(
default = "default_budget_tokens",
deserialize_with = "deserialize_budget_tokens"
)]
pub budget_tokens: i32,
}
impl Thinking {
/// 是否启用了 thinkingenabled 或 adaptive
pub fn is_enabled(&self) -> bool {
self.thinking_type == "enabled" || self.thinking_type == "adaptive"
}
}
fn default_budget_tokens() -> i32 {
20000
}
fn deserialize_budget_tokens<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = i32::deserialize(deserializer)?;
Ok(value.min(MAX_BUDGET_TOKENS))
}
/// OutputConfig 配置
#[derive(Debug, Deserialize, Clone)]
pub struct OutputConfig {
#[serde(default = "default_effort")]
pub effort: String,
}
fn default_effort() -> String {
"high".to_string()
}
/// Claude Code 请求中的 metadata
#[derive(Debug, Clone, Deserialize)]
pub struct Metadata {
/// 用户 ID格式如: user_xxx_account__session_0b4445e1-f5be-49e1-87ce-62bbc28ad705
pub user_id: Option<String>,
}
/// Messages 请求体
#[derive(Debug, Deserialize)]
pub struct MessagesRequest {
pub model: String,
/// 为 Anthropic API 兼容保留,实际不透传给 Kiro 上游
pub max_tokens: i32,
pub messages: Vec<Message>,
#[serde(default)]
pub stream: bool,
#[serde(default, deserialize_with = "deserialize_system")]
pub system: Option<Vec<SystemMessage>>,
pub tools: Option<Vec<Tool>>,
#[allow(dead_code)]
pub tool_choice: Option<serde_json::Value>,
pub thinking: Option<Thinking>,
pub output_config: Option<OutputConfig>,
/// Claude Code 请求中的 metadata包含 session 信息
pub metadata: Option<Metadata>,
}
/// 反序列化 system 字段,支持字符串或数组格式
fn deserialize_system<'de, D>(deserializer: D) -> Result<Option<Vec<SystemMessage>>, D::Error>
where
D: serde::Deserializer<'de>,
{
// 创建一个 visitor 来处理 string 或 array
struct SystemVisitor;
impl<'de> serde::de::Visitor<'de> for SystemVisitor {
type Value = Option<Vec<SystemMessage>>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or an array of system messages")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(Some(vec![SystemMessage {
text: value.to_string(),
}]))
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: serde::de::SeqAccess<'de>,
{
let mut messages = Vec::new();
while let Some(msg) = seq.next_element()? {
messages.push(msg);
}
Ok(if messages.is_empty() {
None
} else {
Some(messages)
})
}
fn visit_none<E>(self) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(None)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
serde::de::Deserialize::deserialize(deserializer)
}
}
deserializer.deserialize_any(SystemVisitor)
}
/// 消息
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Message {
pub role: String,
/// 可以是 string 或 ContentBlock 数组
pub content: serde_json::Value,
}
/// 系统消息
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SystemMessage {
pub text: String,
}
/// 工具定义
///
/// 支持两种格式:
/// 1. 普通工具:{ name, description, input_schema }
/// 2. WebSearch 工具:{ type: "web_search_20250305", name: "web_search", max_uses: 8 }
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Tool {
/// 工具类型,如 "web_search_20250305"(可选,仅 WebSearch 工具)
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub tool_type: Option<String>,
/// 工具名称
#[serde(default)]
pub name: String,
/// 工具描述普通工具必需WebSearch 工具可选)
#[serde(default)]
pub description: String,
/// 输入参数 schema普通工具必需WebSearch 工具无此字段)
#[serde(default)]
pub input_schema: HashMap<String, serde_json::Value>,
/// 最大使用次数(仅 WebSearch 工具)
#[serde(skip_serializing_if = "Option::is_none")]
pub max_uses: Option<i32>,
}
impl Tool {
/// 检查是否为 WebSearch 工具
#[allow(dead_code)]
pub fn is_web_search(&self) -> bool {
self.tool_type
.as_ref()
.is_some_and(|t| t.starts_with("web_search"))
}
}
/// 内容块
#[derive(Debug, Deserialize, Serialize)]
pub struct ContentBlock {
#[serde(rename = "type")]
pub block_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_use_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<ImageSource>,
}
/// 图片数据源
#[derive(Debug, Deserialize, Serialize)]
pub struct ImageSource {
#[serde(rename = "type")]
pub source_type: String,
pub media_type: String,
pub data: String,
}
// === Count Tokens 端点类型 ===
/// Token 计数请求
#[derive(Debug, Serialize, Deserialize)]
pub struct CountTokensRequest {
pub model: String,
pub messages: Vec<Message>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_system"
)]
pub system: Option<Vec<SystemMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
}
/// Token 计数响应
#[derive(Debug, Serialize, Deserialize)]
pub struct CountTokensResponse {
pub input_tokens: i32,
}
/// 根据模型名称获取上下文窗口大小
///
/// - Opus 4.6 和 Sonnet 4.6 系列: 1,000,000 tokens
/// - 其他模型: 200,000 tokens
pub fn get_context_window_size(model: &str) -> i32 {
let model_lower = model.to_lowercase();
if (model_lower.contains("opus") || model_lower.contains("sonnet"))
&& (model_lower.contains("4-6") || model_lower.contains("4.6"))
{
1_000_000
} else {
200_000
}
}

987
src/anthropic/websearch.rs Normal file
View File

@@ -0,0 +1,987 @@
//! WebSearch 工具处理模块
//!
//! 实现 Anthropic WebSearch 请求到 Kiro MCP 的转换和响应生成
use std::convert::Infallible;
use axum::{
body::Body,
http::{StatusCode, header},
response::{IntoResponse, Json, Response},
};
use bytes::Bytes;
use futures::{Stream, stream};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use super::stream::SseEvent;
use super::types::{ErrorResponse, MessagesRequest};
const WEB_SEARCH_PREFIX: &str = "Perform a web search for the query: ";
/// MCP 请求
#[derive(Debug, Serialize)]
pub struct McpRequest {
pub id: String,
pub jsonrpc: String,
pub method: String,
pub params: McpParams,
}
/// MCP 请求参数
#[derive(Debug, Serialize)]
pub struct McpParams {
pub name: String,
pub arguments: McpArguments,
}
/// MCP 参数
#[derive(Debug, Serialize)]
pub struct McpArguments {
pub query: String,
}
/// MCP 响应
#[derive(Debug, Deserialize)]
pub struct McpResponse {
pub error: Option<McpError>,
#[allow(dead_code)]
pub id: String,
#[allow(dead_code)]
pub jsonrpc: String,
pub result: Option<McpResult>,
}
/// MCP 错误
#[derive(Debug, Deserialize)]
pub struct McpError {
pub code: Option<i32>,
pub message: Option<String>,
}
/// MCP 结果
#[derive(Debug, Deserialize)]
pub struct McpResult {
pub content: Vec<McpContent>,
#[serde(rename = "isError")]
#[allow(dead_code)]
pub is_error: bool,
}
/// MCP 内容
#[derive(Debug, Deserialize)]
pub struct McpContent {
#[serde(rename = "type")]
pub content_type: String,
pub text: String,
}
/// WebSearch 搜索结果
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct WebSearchResults {
pub results: Vec<WebSearchResult>,
#[serde(rename = "totalResults")]
pub total_results: Option<i32>,
pub query: Option<String>,
pub error: Option<String>,
}
/// 单个搜索结果
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct WebSearchResult {
pub title: String,
pub url: String,
pub snippet: Option<String>,
#[serde(rename = "publishedDate")]
pub published_date: Option<i64>,
pub id: Option<String>,
pub domain: Option<String>,
#[serde(rename = "maxVerbatimWordLimit")]
pub max_verbatim_word_limit: Option<i32>,
#[serde(rename = "publicDomain")]
pub public_domain: Option<bool>,
}
/// 检查请求是否包含 WebSearch 工具
///
/// 只要 tools 中出现 web_search按 name 或 type 判断),就认为应走本地 WebSearch 处理。
pub fn has_web_search_tool(req: &MessagesRequest) -> bool {
req.tools.as_ref().is_some_and(|tools| {
tools
.iter()
.any(|t| t.name == "web_search" || t.is_web_search())
})
}
fn tool_choice_requests_web_search(req: &MessagesRequest) -> bool {
let Some(choice) = req.tool_choice.as_ref() else {
return false;
};
let Some(obj) = choice.as_object() else {
return false;
};
// Anthropic 常见形态:{"type":"tool","name":"web_search"}
let name = obj
.get("name")
.and_then(|v| v.as_str())
.or_else(|| obj.get("tool_name").and_then(|v| v.as_str()));
if name != Some("web_search") {
return false;
}
// 若包含 type 字段,仅当 type=tool 才视为“强制调用”
match obj.get("type").and_then(|v| v.as_str()) {
Some("tool") => true,
Some(_) => false,
None => true,
}
}
fn is_only_web_search_tool(req: &MessagesRequest) -> bool {
req.tools.as_ref().is_some_and(|tools| {
tools.len() == 1
&& tools
.first()
.is_some_and(|t| t.name == "web_search" || t.is_web_search())
})
}
fn extract_last_user_text(req: &MessagesRequest) -> Option<String> {
let msg = req
.messages
.iter()
.rev()
.find(|m| m.role == "user")
.or_else(|| req.messages.last())?;
match &msg.content {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(arr) => {
let first_block = arr.first()?;
if first_block.get("type")?.as_str()? == "text" {
Some(first_block.get("text")?.as_str()?.to_string())
} else {
None
}
}
_ => None,
}
}
fn request_explicit_web_search_prefix(req: &MessagesRequest) -> bool {
extract_last_user_text(req)
.map(|t| t.trim_start().starts_with(WEB_SEARCH_PREFIX))
.unwrap_or(false)
}
/// 判断当前请求是否应走“本地 WebSearch”处理。
///
/// 注意:`tools` 里包含 `web_search` 仅代表“可用工具”,并不代表这次一定要执行搜索。
/// 若不加额外条件,容易把普通对话/任务指令误当成搜索查询,导致 MCP 侧返回 -32602。
pub fn should_handle_websearch_request(req: &MessagesRequest) -> bool {
if !has_web_search_tool(req) {
return false;
}
// 1) tool_choice 强制选择 web_search
if tool_choice_requests_web_search(req) {
return true;
}
// 2) 兼容旧客户端:仅提供 web_search 单工具时,视为“纯 WebSearch 请求”
if is_only_web_search_tool(req) {
return true;
}
// 3) 兼容 Claude Code 风格前缀
request_explicit_web_search_prefix(req)
}
/// 从请求的 tools 列表中移除 web_search 工具。
///
/// 当请求包含混合工具web_search + 其他工具)时,剔除 web_search 后转发上游。
/// 若剔除后 tools 为空,则设为 None。
pub fn strip_web_search_tools(req: &mut MessagesRequest) {
if let Some(tools) = req.tools.as_mut() {
tools.retain(|t| t.name != "web_search" && !t.is_web_search());
if tools.is_empty() {
req.tools = None;
}
}
}
/// 从消息中提取搜索查询
///
/// 读取 messages 中最后一条 user 消息的第一个内容块(更符合多轮对话场景)
/// 并去除 "Perform a web search for the query: " 前缀
pub fn extract_search_query(req: &MessagesRequest) -> Option<String> {
// 优先取最后一条 user 消息,否则回退到最后一条消息
let msg = req
.messages
.iter()
.rev()
.find(|m| m.role == "user")
.or_else(|| req.messages.last())?;
// 提取文本内容
let text = match &msg.content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(arr) => {
// 获取第一个内容块
let first_block = arr.first()?;
if first_block.get("type")?.as_str()? == "text" {
first_block.get("text")?.as_str()?.to_string()
} else {
return None;
}
}
_ => return None,
};
// 去除前缀 "Perform a web search for the query: "
let query = text
.strip_prefix(WEB_SEARCH_PREFIX)
.map(|s| s.to_string())
.unwrap_or(text);
let query = query.split_whitespace().collect::<Vec<_>>().join(" ");
if query.is_empty() { None } else { Some(query) }
}
/// 生成22位大小写字母和数字的随机字符串
fn generate_random_id_22() -> String {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
(0..22)
.map(|_| {
let idx = fastrand::usize(..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// 生成8位小写字母和数字的随机字符串
fn generate_random_id_8() -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
(0..8)
.map(|_| {
let idx = fastrand::usize(..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// 创建 MCP 请求
///
/// ID 格式: web_search_tooluse_{22位随机}_{毫秒时间戳}_{8位随机}
pub fn create_mcp_request(query: &str) -> (String, McpRequest) {
let random_22 = generate_random_id_22();
let timestamp = chrono::Utc::now().timestamp_millis();
let random_8 = generate_random_id_8();
let request_id = format!(
"web_search_tooluse_{}_{}_{}",
random_22, timestamp, random_8
);
// tool_use_id 使用相同格式
let tool_use_id = format!(
"srvtoolu_{}",
&Uuid::new_v4().to_string().replace('-', "")[..32]
);
let request = McpRequest {
id: request_id,
jsonrpc: "2.0".to_string(),
method: "tools/call".to_string(),
params: McpParams {
name: "web_search".to_string(),
arguments: McpArguments {
query: query.to_string(),
},
},
};
(tool_use_id, request)
}
/// 解析 MCP 响应中的搜索结果
pub fn parse_search_results(mcp_response: &McpResponse) -> Option<WebSearchResults> {
let result = mcp_response.result.as_ref()?;
let content = result.content.first()?;
if content.content_type != "text" {
return None;
}
serde_json::from_str(&content.text).ok()
}
/// 生成 WebSearch SSE 响应流
pub fn create_websearch_sse_stream(
model: String,
query: String,
tool_use_id: String,
search_results: Option<WebSearchResults>,
input_tokens: i32,
) -> impl Stream<Item = Result<Bytes, Infallible>> {
let events =
generate_websearch_events(&model, &query, &tool_use_id, search_results, input_tokens);
stream::iter(
events
.into_iter()
.map(|e| Ok(Bytes::from(e.to_sse_string()))),
)
}
/// 生成 WebSearch SSE 事件序列
fn generate_websearch_events(
model: &str,
query: &str,
tool_use_id: &str,
search_results: Option<WebSearchResults>,
input_tokens: i32,
) -> Vec<SseEvent> {
let mut events = Vec::new();
let message_id = format!("msg_{}", &Uuid::new_v4().to_string().replace('-', "")[..24]);
// 1. message_start
events.push(SseEvent::new(
"message_start",
json!({
"type": "message_start",
"message": {
"id": message_id,
"type": "message",
"role": "assistant",
"model": model,
"content": [],
"stop_reason": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": 0,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}
}),
));
// 2. content_block_start (text - 搜索决策说明, index 0)
let decision_text = format!("I'll search for \"{}\".", query);
events.push(SseEvent::new(
"content_block_start",
json!({
"type": "content_block_start",
"index": 0,
"content_block": {
"type": "text",
"text": ""
}
}),
));
events.push(SseEvent::new(
"content_block_delta",
json!({
"type": "content_block_delta",
"index": 0,
"delta": {
"type": "text_delta",
"text": decision_text
}
}),
));
events.push(SseEvent::new(
"content_block_stop",
json!({
"type": "content_block_stop",
"index": 0
}),
));
// 3. content_block_start (server_tool_use, index 1)
// server_tool_use 是服务端工具input 在 content_block_start 中一次性完整发送,
// 不像客户端 tool_use 需要通过 input_json_delta 增量传输。
events.push(SseEvent::new(
"content_block_start",
json!({
"type": "content_block_start",
"index": 1,
"content_block": {
"id": tool_use_id,
"type": "server_tool_use",
"name": "web_search",
"input": {"query": query}
}
}),
));
// 4. content_block_stop (server_tool_use)
events.push(SseEvent::new(
"content_block_stop",
json!({
"type": "content_block_stop",
"index": 1
}),
));
// 5. content_block_start (web_search_tool_result, index 2)
// 官方 API 的 web_search_tool_result 没有 tool_use_id 字段
let search_content = if let Some(ref results) = search_results {
results
.results
.iter()
.map(|r| {
let page_age = r.published_date.and_then(|ms| {
chrono::DateTime::from_timestamp_millis(ms)
.map(|dt| dt.format("%B %-d, %Y").to_string())
});
json!({
"type": "web_search_result",
"title": r.title,
"url": r.url,
"encrypted_content": r.snippet.clone().unwrap_or_default(),
"page_age": page_age
})
})
.collect::<Vec<_>>()
} else {
vec![]
};
events.push(SseEvent::new(
"content_block_start",
json!({
"type": "content_block_start",
"index": 2,
"content_block": {
"type": "web_search_tool_result",
"content": search_content
}
}),
));
// 6. content_block_stop (web_search_tool_result)
events.push(SseEvent::new(
"content_block_stop",
json!({
"type": "content_block_stop",
"index": 2
}),
));
// 7. content_block_start (text, index 3)
events.push(SseEvent::new(
"content_block_start",
json!({
"type": "content_block_start",
"index": 3,
"content_block": {
"type": "text",
"text": ""
}
}),
));
// 8. content_block_delta (text_delta) - 生成搜索结果摘要
let summary = generate_search_summary(query, &search_results);
// 分块发送文本
let chunk_size = 100;
for chunk in summary.chars().collect::<Vec<_>>().chunks(chunk_size) {
let text: String = chunk.iter().collect();
events.push(SseEvent::new(
"content_block_delta",
json!({
"type": "content_block_delta",
"index": 3,
"delta": {
"type": "text_delta",
"text": text
}
}),
));
}
// 9. content_block_stop (text)
events.push(SseEvent::new(
"content_block_stop",
json!({
"type": "content_block_stop",
"index": 3
}),
));
// 10. message_delta
// 官方 API 的 message_delta.delta 中没有 stop_sequence 字段
let output_tokens = (summary.len() as i32 + 3) / 4; // 简单估算
events.push(SseEvent::new(
"message_delta",
json!({
"type": "message_delta",
"delta": {
"stop_reason": "end_turn"
},
"usage": {
"output_tokens": output_tokens,
"server_tool_use": {
"web_search_requests": 1
}
}
}),
));
// 11. message_stop
events.push(SseEvent::new(
"message_stop",
json!({
"type": "message_stop"
}),
));
events
}
/// 生成搜索结果摘要
fn generate_search_summary(query: &str, results: &Option<WebSearchResults>) -> String {
let mut summary = format!("Here are the search results for \"{}\":\n\n", query);
if let Some(results) = results {
for (i, result) in results.results.iter().enumerate() {
summary.push_str(&format!("{}. **{}**\n", i + 1, result.title));
if let Some(ref snippet) = result.snippet {
// 截断过长的摘要(安全处理 UTF-8 多字节字符)
let truncated = match snippet.char_indices().nth(200) {
Some((idx, _)) => format!("{}...", &snippet[..idx]),
None => snippet.clone(),
};
summary.push_str(&format!(" {}\n", truncated));
}
summary.push_str(&format!(" Source: {}\n\n", result.url));
}
} else {
summary.push_str("No results found.\n");
}
summary.push_str("\nPlease note that these are web search results and may not be fully accurate or up-to-date.");
summary
}
/// 处理 WebSearch 请求
pub async fn handle_websearch_request(
provider: std::sync::Arc<crate::kiro::provider::KiroProvider>,
payload: &MessagesRequest,
input_tokens: i32,
) -> Response {
// 1. 提取搜索查询
let query = match extract_search_query(payload) {
Some(q) => q,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse::new(
"invalid_request_error",
"无法从消息中提取搜索查询",
)),
)
.into_response();
}
};
tracing::info!(query = %query, "处理 WebSearch 请求");
// 2. 创建 MCP 请求
let (tool_use_id, mcp_request) = create_mcp_request(&query);
// 3. 调用 Kiro MCP API
let search_results = match call_mcp_api(&provider, &mcp_request).await {
Ok(response) => parse_search_results(&response),
Err(e) => {
tracing::warn!("MCP API 调用失败: {}", e);
None
}
};
// 4. 生成 SSE 响应
let model = payload.model.clone();
if payload.stream {
let stream =
create_websearch_sse_stream(model, query, tool_use_id, search_results, input_tokens);
return Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream")
.header(header::CACHE_CONTROL, "no-cache")
.header(header::CONNECTION, "keep-alive")
.body(Body::from_stream(stream))
.unwrap();
}
let summary = generate_search_summary(&query, &search_results);
let search_content = if let Some(ref results) = search_results {
results
.results
.iter()
.map(|r| {
json!({
"type": "web_search_result",
"title": r.title,
"url": r.url,
"encrypted_content": r.snippet.clone().unwrap_or_default(),
"page_age": null
})
})
.collect::<Vec<_>>()
} else {
vec![]
};
let output_tokens = (summary.len() as i32 + 3) / 4; // 简单估算
let response_body = json!({
"id": format!("msg_{}", &Uuid::new_v4().to_string().replace('-', "")[..24]),
"type": "message",
"role": "assistant",
"model": model,
"content": [
{
"type": "server_tool_use",
"id": tool_use_id,
"name": "web_search",
"input": { "query": query }
},
{
"type": "web_search_tool_result",
"tool_use_id": tool_use_id,
"content": search_content
},
{
"type": "text",
"text": summary
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": input_tokens,
"output_tokens": output_tokens
}
});
(StatusCode::OK, Json(response_body)).into_response()
}
/// 调用 Kiro MCP API
async fn call_mcp_api(
provider: &crate::kiro::provider::KiroProvider,
request: &McpRequest,
) -> anyhow::Result<McpResponse> {
let request_body = serde_json::to_string(request)?;
tracing::debug!("MCP request: {}", request_body);
let response = provider.call_mcp(&request_body).await?;
let body = response.text().await?;
tracing::debug!("MCP response: {}", body);
let mcp_response: McpResponse = serde_json::from_str(&body)?;
if let Some(ref error) = mcp_response.error {
anyhow::bail!(
"MCP error: {} - {}",
error.code.unwrap_or(-1),
error.message.as_deref().unwrap_or("Unknown error")
);
}
Ok(mcp_response)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_has_web_search_tool_only_one() {
use crate::anthropic::types::{Message, Tool};
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![Message {
role: "user".to_string(),
content: serde_json::json!("test"),
}],
stream: true,
system: None,
tools: Some(vec![Tool {
tool_type: Some("web_search_20250305".to_string()),
name: "web_search".to_string(),
description: String::new(),
input_schema: Default::default(),
max_uses: Some(8),
}]),
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
assert!(has_web_search_tool(&req));
}
#[test]
fn test_has_web_search_tool_when_multiple_tools() {
use crate::anthropic::types::{Message, Tool};
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![Message {
role: "user".to_string(),
content: serde_json::json!("test"),
}],
stream: true,
system: None,
tools: Some(vec![
Tool {
tool_type: Some("web_search_20250305".to_string()),
name: "web_search".to_string(),
description: String::new(),
input_schema: Default::default(),
max_uses: Some(8),
},
Tool {
tool_type: None,
name: "other_tool".to_string(),
description: "Other tool".to_string(),
input_schema: Default::default(),
max_uses: None,
},
]),
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
assert!(has_web_search_tool(&req));
}
#[test]
fn test_has_web_search_tool_when_name_missing_but_type_matches() {
use crate::anthropic::types::{Message, Tool};
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![Message {
role: "user".to_string(),
content: serde_json::json!("test"),
}],
stream: true,
system: None,
tools: Some(vec![Tool {
tool_type: Some("web_search_20250305".to_string()),
name: "".to_string(), // 模拟客户端只传 type 的情况
description: String::new(),
input_schema: Default::default(),
max_uses: Some(8),
}]),
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
assert!(has_web_search_tool(&req));
}
#[test]
fn test_extract_search_query_with_prefix() {
use crate::anthropic::types::Message;
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![Message {
role: "user".to_string(),
content: serde_json::json!([{
"type": "text",
"text": "Perform a web search for the query: rust latest version 2026"
}]),
}],
stream: true,
system: None,
tools: None,
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
let query = extract_search_query(&req);
// 前缀应该被去除
assert_eq!(query, Some("rust latest version 2026".to_string()));
}
#[test]
fn test_extract_search_query_plain_text() {
use crate::anthropic::types::Message;
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![Message {
role: "user".to_string(),
content: serde_json::json!("What is the weather today?"),
}],
stream: true,
system: None,
tools: None,
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
let query = extract_search_query(&req);
assert_eq!(query, Some("What is the weather today?".to_string()));
}
#[test]
fn test_extract_search_query_uses_last_user_message() {
use crate::anthropic::types::Message;
let req = MessagesRequest {
model: "claude-sonnet-4".to_string(),
max_tokens: 1024,
messages: vec![
Message {
role: "user".to_string(),
content: serde_json::json!("not a query"),
},
Message {
role: "assistant".to_string(),
content: serde_json::json!("ok"),
},
Message {
role: "user".to_string(),
content: serde_json::json!([{
"type": "text",
"text": "Perform a web search for the query: rust"
}]),
},
],
stream: true,
system: None,
tools: None,
tool_choice: None,
thinking: None,
output_config: None,
metadata: None,
};
let query = extract_search_query(&req);
assert_eq!(query, Some("rust".to_string()));
}
#[test]
fn test_create_mcp_request() {
let (tool_use_id, request) = create_mcp_request("test query");
assert!(tool_use_id.starts_with("srvtoolu_"));
assert_eq!(request.jsonrpc, "2.0");
assert_eq!(request.method, "tools/call");
assert_eq!(request.params.name, "web_search");
assert_eq!(request.params.arguments.query, "test query");
// 验证 ID 格式: web_search_tooluse_{22位}_{时间戳}_{8位}
assert!(request.id.starts_with("web_search_tooluse_"));
}
#[test]
fn test_mcp_request_id_format() {
let (_, request) = create_mcp_request("test");
// 格式: web_search_tooluse_{22位}_{毫秒时间戳}_{8位}
let id = &request.id;
assert!(id.starts_with("web_search_tooluse_"));
let suffix = &id["web_search_tooluse_".len()..];
let parts: Vec<&str> = suffix.split('_').collect();
assert_eq!(parts.len(), 3, "应该有3个部分: 22位随机_时间戳_8位随机");
// 第一部分: 22位大小写字母和数字
assert_eq!(parts[0].len(), 22);
assert!(parts[0].chars().all(|c| c.is_ascii_alphanumeric()));
// 第二部分: 毫秒时间戳
assert!(parts[1].parse::<i64>().is_ok());
// 第三部分: 8位小写字母和数字
assert_eq!(parts[2].len(), 8);
assert!(
parts[2]
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn test_parse_search_results() {
let response = McpResponse {
error: None,
id: "test_id".to_string(),
jsonrpc: "2.0".to_string(),
result: Some(McpResult {
content: vec![McpContent {
content_type: "text".to_string(),
text: r#"{"results":[{"title":"Test","url":"https://example.com","snippet":"Test snippet"}],"totalResults":1}"#.to_string(),
}],
is_error: false,
}),
};
let results = parse_search_results(&response);
assert!(results.is_some());
let results = results.unwrap();
assert_eq!(results.results.len(), 1);
assert_eq!(results.results[0].title, "Test");
}
#[test]
fn test_generate_search_summary() {
let results = WebSearchResults {
results: vec![WebSearchResult {
title: "Test Result".to_string(),
url: "https://example.com".to_string(),
snippet: Some("This is a test snippet".to_string()),
published_date: None,
id: None,
domain: None,
max_verbatim_word_limit: None,
public_domain: None,
}],
total_results: Some(1),
query: Some("test".to_string()),
error: None,
};
let summary = generate_search_summary("test", &Some(results));
assert!(summary.contains("Test Result"));
assert!(summary.contains("https://example.com"));
assert!(summary.contains("This is a test snippet"));
}
}

41
src/common/auth.rs Normal file
View File

@@ -0,0 +1,41 @@
//! 公共认证工具函数
use axum::{
body::Body,
http::{Request, header},
};
use subtle::ConstantTimeEq;
/// 从请求中提取 API Key
///
/// 支持两种认证方式:
/// - `x-api-key` header
/// - `Authorization: Bearer <token>` header
pub fn extract_api_key(request: &Request<Body>) -> Option<String> {
// 优先检查 x-api-key
if let Some(key) = request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok())
{
return Some(key.to_string());
}
// 其次检查 Authorization: Bearer
request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
/// 常量时间字符串比较,防止时序攻击
///
/// 无论字符串内容如何,比较所需的时间都是恒定的,
/// 这可以防止攻击者通过测量响应时间来猜测 API Key。
///
/// 使用经过安全审计的 `subtle` crate 实现
pub fn constant_time_eq(a: &str, b: &str) -> bool {
a.as_bytes().ct_eq(b.as_bytes()).into()
}

5
src/common/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
//! 公共工具模块
pub mod auth;
pub mod redact;
pub mod utf8;

97
src/common/redact.rs Normal file
View File

@@ -0,0 +1,97 @@
//! 日志脱敏工具
//!
//! 目标避免在日志中输出敏感信息Token、密钥、密码等
#![allow(dead_code)] // 工具模块,函数将在后续被调用
/// 统一的脱敏占位符
pub const REDACTED: &str = "<redacted>";
/// 将 `Option<String>` 映射为“是否存在”的脱敏表示:
/// - `Some(_)` -> `Some("<redacted>")`
/// - `None` -> `None`
#[inline]
pub fn redact_opt_string(value: &Option<String>) -> Option<&'static str> {
value.as_ref().map(|_| REDACTED)
}
/// 脱敏邮箱:
/// - `abc@example.com` -> `a***@example.com`
/// - 无法解析时返回 `<redacted>`
pub fn mask_email(email: &str) -> String {
let (local, domain) = match email.split_once('@') {
Some((l, d)) if !l.is_empty() && !d.is_empty() => (l, d),
_ => return REDACTED.to_string(),
};
// 保留首个完整字符(支持多字节 UTF-8
let first_char_end = local
.char_indices()
.nth(1)
.map(|(i, _)| i)
.unwrap_or(local.len());
format!("{}***@{}", &local[..first_char_end], domain)
}
/// 脱敏 AWS ARN 中的 account id第 5 段):
/// `arn:aws:service:region:123456789012:resource` -> `arn:aws:service:region:***:resource`
pub fn mask_aws_account_id_in_arn(arn: &str) -> String {
let mut parts = arn.splitn(6, ':').collect::<Vec<_>>();
if parts.len() != 6 || parts[0] != "arn" {
return arn.to_string();
}
if !parts[4].is_empty() {
parts[4] = "***";
}
parts.join(":")
}
/// 脱敏 URL 中的 userinfo仅当包含 `scheme://...@`
/// - `http://user:pass@host:port` -> `http://user:***@host:port`
/// - `http://user@host` -> `http://***@host`
pub fn mask_url_userinfo(url: &str) -> String {
let Some(scheme_end) = url.find("://").map(|i| i + 3) else {
return url.to_string();
};
// authority 结束于首个 '/'、'?'、'#' 或字符串末尾
let authority_end = url[scheme_end..]
.find(['/', '?', '#'])
.map(|i| scheme_end + i)
.unwrap_or(url.len());
// '@' 必须在 authority 内才是 userinfo 分隔符
let Some(at_pos) = url[scheme_end..authority_end]
.find('@')
.map(|i| scheme_end + i)
else {
return url.to_string();
};
let userinfo = &url[scheme_end..at_pos];
if userinfo.is_empty() {
return url.to_string();
}
let masked_userinfo = match userinfo.split_once(':') {
Some((user, _pass)) if !user.is_empty() => format!("{}:***", user),
_ => "***".to_string(),
};
format!(
"{}{}{}",
&url[..scheme_end],
masked_userinfo,
&url[at_pos..]
)
}
/// 脱敏 User-Agent 中的 machine_id常见形态为以 `-<machine_id>` 结尾)。
pub fn mask_user_agent_machine_id(value: &str) -> String {
let Some(pos) = value.rfind('-') else {
return value.to_string();
};
format!("{}{}", &value[..(pos + 1)], REDACTED)
}

46
src/common/utf8.rs Normal file
View File

@@ -0,0 +1,46 @@
//! UTF-8 字符边界工具
//!
//! 目前标准库的 `str::floor_char_boundary()` 仍是 nightly-only。
//! 为了在 stable 上安全按字节索引截断 UTF-8 字符串,这里提供等价实现。
/// 返回不大于 `idx` 的最大 UTF-8 字符边界byte index
///
/// - 若 `idx >= s.len()`,返回 `s.len()`
/// - 否则向左回退到最近的 `is_char_boundary()` 位置
#[inline]
pub fn floor_char_boundary(s: &str, idx: usize) -> usize {
if idx >= s.len() {
return s.len();
}
let mut i = idx;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_floor_char_boundary_ascii() {
let s = "abcdef";
assert_eq!(floor_char_boundary(s, 3), 3);
assert_eq!(floor_char_boundary(s, 999), s.len());
}
#[test]
fn test_floor_char_boundary_utf8() {
let s = "你好ab";
// "你" 占 3 字节idx=1/2 应回退到 0
assert_eq!(floor_char_boundary(s, 1), 0);
assert_eq!(floor_char_boundary(s, 2), 0);
assert_eq!(floor_char_boundary(s, 3), 3);
// idx 落在第二个汉字中间3..6
assert_eq!(floor_char_boundary(s, 4), 3);
assert_eq!(floor_char_boundary(s, 5), 3);
assert_eq!(floor_char_boundary(s, 6), 6);
}
}

210
src/debug.rs Normal file
View File

@@ -0,0 +1,210 @@
//! 调试工具模块
//!
//! 提供 hex 打印和 CRC 调试等功能
use crate::kiro::model::events::Event;
use std::io::Write;
/// 打印 hex 数据 (类似 xxd 格式)
pub fn print_hex(data: &[u8]) {
for (i, chunk) in data.chunks(16).enumerate() {
// 打印偏移
print!("{:08x}: ", i * 16);
// 打印 hex
for (j, byte) in chunk.iter().enumerate() {
if j == 8 {
print!(" ");
}
print!("{:02x} ", byte);
}
// 补齐空格
let padding = 16 - chunk.len();
for j in 0..padding {
if chunk.len() + j == 8 {
print!(" ");
}
print!(" ");
}
// 打印 ASCII
print!(" |");
for byte in chunk {
if *byte >= 0x20 && *byte < 0x7f {
print!("{}", *byte as char);
} else {
print!(".");
}
}
println!("|");
}
std::io::stdout().flush().ok();
}
/// 调试 CRC 计算 - 分析 AWS Event Stream 帧的 CRC
pub fn debug_crc(data: &[u8]) {
if data.len() < 12 {
println!("[CRC 调试] 数据不足 12 字节");
return;
}
use crc::{Crc, CRC_32_BZIP2, CRC_32_ISO_HDLC, CRC_32_ISCSI, CRC_32_JAMCRC};
let total_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
let header_length = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
let prelude_crc = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
println!("\n[CRC 调试]");
println!(" total_length: {} (0x{:08x})", total_length, total_length);
println!(
" header_length: {} (0x{:08x})",
header_length, header_length
);
println!(" prelude_crc (from data): 0x{:08x}", prelude_crc);
// 测试各种 CRC32 变种
let crc32c: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
let crc32_iso: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
let crc32_bzip2: Crc<u32> = Crc::<u32>::new(&CRC_32_BZIP2);
let crc32_jamcrc: Crc<u32> = Crc::<u32>::new(&CRC_32_JAMCRC);
let prelude = &data[..8];
println!(" CRC32C (ISCSI): 0x{:08x}", crc32c.checksum(prelude));
println!(
" CRC32 ISO-HDLC: 0x{:08x} {}",
crc32_iso.checksum(prelude),
if crc32_iso.checksum(prelude) == prelude_crc {
"<-- MATCH"
} else {
""
}
);
println!(" CRC32 BZIP2: 0x{:08x}", crc32_bzip2.checksum(prelude));
println!(
" CRC32 JAMCRC: 0x{:08x}",
crc32_jamcrc.checksum(prelude)
);
// 打印前 8 字节
print!(" 前 8 字节: ");
for byte in prelude {
print!("{:02x} ", byte);
}
println!();
}
/// 打印帧摘要信息
pub fn print_frame_summary(data: &[u8]) {
if data.len() < 12 {
println!("[帧摘要] 数据不足");
return;
}
let total_length = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
let header_length = u32::from_be_bytes([data[4], data[5], data[6], data[7]]) as usize;
println!("\n[帧摘要]");
println!(" 总长度: {} 字节", total_length);
println!(" 头部长度: {} 字节", header_length);
println!(" Payload 长度: {} 字节", total_length.saturating_sub(12 + header_length + 4));
println!(" 数据可用: {} 字节", data.len());
if data.len() >= total_length {
println!(" 状态: 完整帧");
} else {
println!(
" 状态: 不完整 (缺少 {} 字节)",
total_length - data.len()
);
}
}
/// 详细打印事件 (调试格式,包含事件类型和完整数据)
pub fn print_event_verbose(event: &Event) {
match event {
Event::AssistantResponse(e) => {
println!("\n[事件] AssistantResponse");
println!(" content: {:?}", e.content());
}
Event::ToolUse(e) => {
println!("\n[事件] ToolUse");
println!(" name: {:?}", e.name());
println!(" tool_use_id: {:?}", e.tool_use_id());
println!(" input: {:?}", e.input());
println!(" stop: {}", e.is_complete());
}
Event::Metering(e) => {
println!("\n[事件] Metering");
println!(" unit: {:?}", e.unit);
println!(" unit_plural: {:?}", e.unit_plural);
println!(" usage: {}", e.usage);
}
Event::ContextUsage(e) => {
println!("\n[事件] ContextUsage");
println!(" context_usage_percentage: {}", e.context_usage_percentage);
}
Event::Unknown { event_type, payload } => {
println!("\n[事件] Unknown");
println!(" event_type: {:?}", event_type);
println!(" payload ({} bytes):", payload.len());
print_hex(payload);
}
Event::Error {
error_code,
error_message,
} => {
println!("\n[事件] Error");
println!(" error_code: {:?}", error_code);
println!(" error_message: {:?}", error_message);
}
Event::Exception {
exception_type,
message,
} => {
println!("\n[事件] Exception");
println!(" exception_type: {:?}", exception_type);
println!(" message: {:?}", message);
}
}
}
/// 简洁打印事件 (用于正常输出)
pub fn print_event(event: &Event) {
match event {
Event::AssistantResponse(e) => {
// 实时打印助手响应,不换行
print!("{}", e.content());
std::io::stdout().flush().ok();
}
Event::ToolUse(e) => {
println!("\n[工具调用] {} (id: {})", e.name(), e.tool_use_id());
println!(" 输入: {}", e.input());
if e.is_complete() {
println!(" [调用结束]");
}
}
Event::Metering(e) => {
println!("\n[计费] {}", e);
}
Event::ContextUsage(e) => {
println!("\n[上下文使用率] {}", e);
}
Event::Unknown { event_type, .. } => {
println!("\n[未知事件] {}", event_type);
}
Event::Error {
error_code,
error_message,
} => {
println!("\n[错误] {}: {}", error_code, error_message);
}
Event::Exception {
exception_type,
message,
} => {
println!("\n[异常] {}: {}", exception_type, message);
}
}
}

105
src/http_client.rs Normal file
View File

@@ -0,0 +1,105 @@
//! HTTP Client 构建模块
//!
//! 提供统一的 HTTP Client 构建功能,支持代理配置
use reqwest::{Client, Proxy};
use std::time::Duration;
use crate::model::config::TlsBackend;
/// 代理配置
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub struct ProxyConfig {
/// 代理地址,支持 http/https/socks5
pub url: String,
/// 代理认证用户名
pub username: Option<String>,
/// 代理认证密码
pub password: Option<String>,
}
impl ProxyConfig {
/// 从 url 创建代理配置
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
username: None,
password: None,
}
}
/// 设置认证信息
pub fn with_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
self.username = Some(username.into());
self.password = Some(password.into());
self
}
}
/// 构建 HTTP Client
///
/// # Arguments
/// * `proxy` - 可选的代理配置
/// * `timeout_secs` - 超时时间(秒)
///
/// # Returns
/// 配置好的 reqwest::Client
pub fn build_client(
proxy: Option<&ProxyConfig>,
timeout_secs: u64,
tls_backend: TlsBackend,
) -> anyhow::Result<Client> {
let mut builder = Client::builder().timeout(Duration::from_secs(timeout_secs));
if tls_backend == TlsBackend::Rustls {
builder = builder.use_rustls_tls();
}
if let Some(proxy_config) = proxy {
let mut proxy = Proxy::all(&proxy_config.url)?;
// 设置代理认证
if let (Some(username), Some(password)) = (&proxy_config.username, &proxy_config.password) {
proxy = proxy.basic_auth(username, password);
}
builder = builder.proxy(proxy);
tracing::debug!("HTTP Client 使用代理: {}", proxy_config.url);
}
Ok(builder.build()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_proxy_config_new() {
let config = ProxyConfig::new("http://127.0.0.1:7890");
assert_eq!(config.url, "http://127.0.0.1:7890");
assert!(config.username.is_none());
assert!(config.password.is_none());
}
#[test]
fn test_proxy_config_with_auth() {
let config = ProxyConfig::new("socks5://127.0.0.1:1080").with_auth("user", "pass");
assert_eq!(config.url, "socks5://127.0.0.1:1080");
assert_eq!(config.username, Some("user".to_string()));
assert_eq!(config.password, Some("pass".to_string()));
}
#[test]
fn test_build_client_without_proxy() {
let client = build_client(None, 30, TlsBackend::Rustls);
assert!(client.is_ok());
}
#[test]
fn test_build_client_with_proxy() {
let config = ProxyConfig::new("http://127.0.0.1:7890");
let client = build_client(Some(&config), 30, TlsBackend::Rustls);
assert!(client.is_ok());
}
}

559
src/image.rs Normal file
View File

@@ -0,0 +1,559 @@
//! 图片处理模块
//!
//! 提供图片 token 计算和缩放功能。
//!
//! # Token 计算公式Anthropic 官方)
//! ```text
//! tokens = (width × height) / 750
//! ```
//!
//! # 缩放规则
//! 1. 长边超过 max_long_edge 时,等比缩放
//! 2. 总像素超过 max_pixels 时,等比缩放
//! 3. 多图模式(图片数 >= threshold使用独立的像素限制配置
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use image::AnimationDecoder;
use image::codecs::gif::GifDecoder;
use image::{DynamicImage, ImageFormat, ImageReader};
use std::io::{BufReader, Cursor};
use std::time::Duration;
use crate::model::config::CompressionConfig;
const GIF_MAX_OUTPUT_FRAMES: usize = 20;
const GIF_MAX_FPS: usize = 5;
const GIF_MIN_FRAME_DELAY: Duration = Duration::from_millis(10);
const GIF_FRAME_OUTPUT_FORMAT: &str = "jpeg";
#[derive(Debug)]
pub struct GifSamplingResult {
pub frames: Vec<ImageProcessResult>,
pub duration_ms: u64,
pub source_frames: usize,
pub sampling_interval_ms: u64,
pub output_format: &'static str,
}
/// 图片处理结果
#[derive(Debug)]
pub struct ImageProcessResult {
/// 处理后的 base64 数据
pub data: String,
/// 原始尺寸 (width, height)
pub original_size: (u32, u32),
/// 处理后尺寸 (width, height)
pub final_size: (u32, u32),
/// 估算的 token 数
pub tokens: u64,
/// 是否进行了缩放
pub was_resized: bool,
/// 是否进行了重新编码(即使无需缩放)
///
/// 主要用于 GIF即便尺寸已符合限制也会重新编码为静态帧
/// 避免把“体积巨大但分辨率很小的动图”原样发送到上游导致请求体过大。
pub was_reencoded: bool,
/// 原始图片字节数base64 解码后)
pub original_bytes_len: usize,
/// 处理后图片字节数编码后、base64 前)
pub final_bytes_len: usize,
}
/// 将 GIF 抽帧并重编码为多张静态图(用于降低请求体、提升“动图内容”识别效果)
///
/// 采样策略(符合你给的约束):
/// - 总帧数不超过 `GIF_MAX_OUTPUT_FRAMES`
/// - 采样频率不超过 `GIF_MAX_FPS`(每秒最多 5 张)
/// - 当 GIF 过长导致超出总帧数时,按“秒级上限”下调采样频率(例如 8 秒 GIF → 每秒最多 2 张)
pub fn process_gif_frames(
base64_data: &str,
config: &CompressionConfig,
image_count: usize,
) -> Result<GifSamplingResult, String> {
let gif_bytes = BASE64
.decode(base64_data)
.map_err(|e| format!("base64 解码失败: {}", e))?;
let original_bytes_len = gif_bytes.len();
// Pass 1计算时长ms与源帧数用于确定采样间隔
let (duration_ms, source_frames) = {
let decoder = GifDecoder::new(BufReader::new(Cursor::new(&gif_bytes)))
.map_err(|e| format!("GIF 解码失败: {}", e))?;
let mut total = 0u64;
let mut n = 0usize;
for frame in decoder.into_frames() {
let frame = frame.map_err(|e| format!("GIF 帧解码失败: {}", e))?;
let delay = Duration::from(frame.delay()).max(GIF_MIN_FRAME_DELAY);
total = total.saturating_add(delay.as_millis().min(u128::from(u64::MAX)) as u64);
n += 1;
}
if n == 0 {
return Err("GIF 不包含任何帧".to_string());
}
(total.max(1), n)
};
// 计算采样间隔:
// - 优先按“每秒最多 N 张N<=5”控制用户期望的直觉规则
// - 当 GIF 超长duration_secs > max_frames转为按 max_frames 均匀采样
let duration_secs_ceil = duration_ms.div_ceil(1000).max(1) as usize;
let fps_by_total = GIF_MAX_OUTPUT_FRAMES / duration_secs_ceil; // integer fps-per-second cap
let fps = fps_by_total.min(GIF_MAX_FPS);
let sampling_interval_ms = if fps > 0 {
(1000 / fps as u64).max(1000 / GIF_MAX_FPS as u64)
} else {
// duration_secs_ceil > GIF_MAX_OUTPUT_FRAMES平均 < 1 fps改为均匀抽取 max_frames 张
duration_ms.div_ceil(GIF_MAX_OUTPUT_FRAMES as u64).max(1)
};
// 根据图片数量选择像素限制(复用现有策略)
let max_pixels = if image_count >= config.image_multi_threshold {
config.image_max_pixels_multi
} else {
config.image_max_pixels_single
};
// Pass 2按采样间隔选择帧并重编码
let decoder = GifDecoder::new(BufReader::new(Cursor::new(&gif_bytes)))
.map_err(|e| format!("GIF 解码失败: {}", e))?;
let mut frames_out = Vec::new();
let mut elapsed_ms = 0u64; // 当前帧起始时间
let mut next_sample_ms = 0u64;
for frame in decoder.into_frames() {
if frames_out.len() >= GIF_MAX_OUTPUT_FRAMES {
break;
}
let frame = frame.map_err(|e| format!("GIF 帧解码失败: {}", e))?;
let delay = Duration::from(frame.delay()).max(GIF_MIN_FRAME_DELAY);
let frame_start_ms = elapsed_ms;
// 用帧起始时间做采样(避免同一帧被重复选中)
if frame_start_ms >= next_sample_ms {
let buffer = frame.into_buffer();
let original_size = (buffer.width(), buffer.height());
let (target_w, target_h) = apply_scaling_rules(
original_size.0,
original_size.1,
config.image_max_long_edge,
max_pixels,
);
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
let img = DynamicImage::ImageRgba8(buffer);
let processed = if needs_resize {
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
} else {
img
};
let final_size = (processed.width(), processed.height());
let (data, final_bytes_len) = encode_image(&processed, GIF_FRAME_OUTPUT_FORMAT)?;
frames_out.push(ImageProcessResult {
data,
original_size,
final_size,
tokens: calculate_tokens(final_size.0, final_size.1),
was_resized: needs_resize,
was_reencoded: true,
original_bytes_len,
final_bytes_len,
});
next_sample_ms = frame_start_ms.saturating_add(sampling_interval_ms);
}
elapsed_ms = elapsed_ms.saturating_add(delay.as_millis().min(u128::from(u64::MAX)) as u64);
}
if frames_out.is_empty() {
return Err("GIF 抽帧结果为空".to_string());
}
Ok(GifSamplingResult {
frames: frames_out,
duration_ms,
source_frames,
sampling_interval_ms,
output_format: GIF_FRAME_OUTPUT_FORMAT,
})
}
/// 强制将任意图片重编码为指定格式(可选缩放)
///
/// 用于需要把输入格式(如 GIF转换为上游更稳定支持的静态格式如 JPEG时。
pub fn process_image_to_format(
base64_data: &str,
output_format: &str,
config: &CompressionConfig,
image_count: usize,
) -> Result<ImageProcessResult, String> {
let bytes = BASE64
.decode(base64_data)
.map_err(|e| format!("base64 解码失败: {}", e))?;
let original_bytes_len = bytes.len();
let reader = ImageReader::new(Cursor::new(&bytes))
.with_guessed_format()
.map_err(|e| format!("图片格式识别失败: {}", e))?;
let original_size = reader
.into_dimensions()
.map_err(|e| format!("读取图片尺寸失败: {}", e))?;
let max_pixels = if image_count >= config.image_multi_threshold {
config.image_max_pixels_multi
} else {
config.image_max_pixels_single
};
let (target_w, target_h) = apply_scaling_rules(
original_size.0,
original_size.1,
config.image_max_long_edge,
max_pixels,
);
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
let img = image::load_from_memory(&bytes).map_err(|e| format!("图片加载失败: {}", e))?;
let processed = if needs_resize {
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
} else {
img
};
let final_size = (processed.width(), processed.height());
let (data, final_bytes_len) = encode_image(&processed, output_format)?;
Ok(ImageProcessResult {
data,
original_size,
final_size,
tokens: calculate_tokens(final_size.0, final_size.1),
was_resized: needs_resize,
was_reencoded: true,
original_bytes_len,
final_bytes_len,
})
}
/// 从 base64 数据计算图片 token不缩放
///
/// 返回 (tokens, width, height),解析失败返回 None
pub fn estimate_image_tokens(base64_data: &str) -> Option<(u64, u32, u32)> {
let bytes = BASE64.decode(base64_data).ok()?;
let reader = ImageReader::new(Cursor::new(&bytes))
.with_guessed_format()
.ok()?;
let (width, height) = reader.into_dimensions().ok()?;
// 应用 Anthropic 缩放规则计算 token
let (scaled_w, scaled_h) = apply_scaling_rules(width, height, 1568, 1_150_000);
let tokens = calculate_tokens(scaled_w, scaled_h);
Some((tokens, width, height))
}
/// 处理图片:根据配置缩放并返回处理结果
///
/// # 参数
/// - `base64_data`: 原始 base64 编码的图片数据
/// - `format`: 图片格式("jpeg", "png", "gif", "webp"
/// - `config`: 压缩配置
/// - `image_count`: 当前请求中的图片总数(用于判断是否启用多图模式)
pub fn process_image(
base64_data: &str,
format: &str,
config: &CompressionConfig,
image_count: usize,
) -> Result<ImageProcessResult, String> {
// 解码 base64
let bytes = BASE64
.decode(base64_data)
.map_err(|e| format!("base64 解码失败: {}", e))?;
let original_bytes_len = bytes.len();
// 先只读取图片头获取尺寸(避免不必要的全量解码)
let reader = ImageReader::new(Cursor::new(&bytes))
.with_guessed_format()
.map_err(|e| format!("图片格式识别失败: {}", e))?;
let original_size = reader
.into_dimensions()
.map_err(|e| format!("读取图片尺寸失败: {}", e))?;
// 根据图片数量选择像素限制
let max_pixels = if image_count >= config.image_multi_threshold {
config.image_max_pixels_multi
} else {
config.image_max_pixels_single
};
// 计算目标尺寸
let (target_w, target_h) = apply_scaling_rules(
original_size.0,
original_size.1,
config.image_max_long_edge,
max_pixels,
);
let needs_resize = target_w != original_size.0 || target_h != original_size.1;
// 判断是否需要重新编码:
// 1. GIF 特殊处理:即使不需要缩放,也强制重新编码为静态帧
// 原因:动图通常"像素不大但字节巨大",直接透传 base64 会显著放大请求体
// 2. 文件过大:即使尺寸符合要求,如果文件大小超过阈值也需要重新编码降低质量
// 阈值200KB经验值避免小图片被过度压缩同时拦截高质量大图
const MAX_IMAGE_BYTES: usize = 200_000; // 200KB
let force_reencode_gif = format.eq_ignore_ascii_case("gif");
let force_reencode_large = original_bytes_len > MAX_IMAGE_BYTES;
if force_reencode_large {
tracing::info!(
original_bytes = original_bytes_len,
threshold_bytes = MAX_IMAGE_BYTES,
width = original_size.0,
height = original_size.1,
format = format,
"图片文件过大,强制重新编码以降低质量"
);
}
let should_decode_and_encode = needs_resize || force_reencode_gif || force_reencode_large;
// 仅在需要缩放或强制重编码时才全量解码图片
let (output_data, final_size, final_bytes_len, was_reencoded) = if should_decode_and_encode {
let img = image::load_from_memory(&bytes).map_err(|e| format!("图片加载失败: {}", e))?;
let processed = if needs_resize {
img.resize(target_w, target_h, image::imageops::FilterType::Lanczos3)
} else {
img
};
let size = (processed.width(), processed.height());
let (data, bytes_len) = encode_image(&processed, format)?;
let was_reencoded = (force_reencode_gif || force_reencode_large) && !needs_resize;
if force_reencode_large && !needs_resize {
tracing::info!(
original_bytes = original_bytes_len,
final_bytes = bytes_len,
compression_ratio = format!(
"{:.1}%",
(1.0 - bytes_len as f64 / original_bytes_len as f64) * 100.0
),
"大文件重新编码完成"
);
}
(data, size, bytes_len, was_reencoded)
} else {
(
base64_data.to_string(),
original_size,
original_bytes_len,
false,
)
};
let tokens = calculate_tokens(final_size.0, final_size.1);
Ok(ImageProcessResult {
data: output_data,
original_size,
final_size,
tokens,
was_resized: needs_resize,
was_reencoded,
original_bytes_len,
final_bytes_len,
})
}
/// 应用 Anthropic 缩放规则
///
/// 1. 长边不超过 max_long_edge
/// 2. 总像素不超过 max_pixels
fn apply_scaling_rules(width: u32, height: u32, max_long_edge: u32, max_pixels: u32) -> (u32, u32) {
let mut w = width as f64;
let mut h = height as f64;
// 规则 1: 长边限制
let long_edge = w.max(h);
if long_edge > max_long_edge as f64 {
let scale = max_long_edge as f64 / long_edge;
w *= scale;
h *= scale;
}
// 规则 2: 总像素限制
let pixels = w * h;
if pixels > max_pixels as f64 {
let scale = (max_pixels as f64 / pixels).sqrt();
w *= scale;
h *= scale;
}
(w.floor().max(1.0) as u32, h.floor().max(1.0) as u32)
}
/// 计算 token 数
#[inline]
fn calculate_tokens(width: u32, height: u32) -> u64 {
((width as u64 * height as u64) + 375) / 750 // 四舍五入
}
/// 将图片编码为 base64
fn encode_image(img: &DynamicImage, format: &str) -> Result<(String, usize), String> {
let mut buffer = Cursor::new(Vec::new());
let image_format = match format {
"jpeg" | "jpg" => ImageFormat::Jpeg,
"png" => ImageFormat::Png,
"gif" => ImageFormat::Gif,
"webp" => ImageFormat::WebP,
_ => return Err(format!("不支持的图片格式: {}", format)),
};
img.write_to(&mut buffer, image_format)
.map_err(|e| format!("图片编码失败: {}", e))?;
let encoded = buffer.into_inner();
let bytes_len = encoded.len();
Ok((BASE64.encode(encoded), bytes_len))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scaling_rules() {
// 测试长边限制
assert_eq!(
apply_scaling_rules(2000, 1000, 1568, 10_000_000),
(1568, 784)
);
// 测试像素限制
assert_eq!(
apply_scaling_rules(1200, 1200, 1568, 1_000_000),
(1000, 1000)
);
// 测试无需缩放
assert_eq!(apply_scaling_rules(800, 600, 1568, 1_150_000), (800, 600));
}
#[test]
fn test_calculate_tokens() {
assert_eq!(calculate_tokens(1092, 1092), 1590); // 1:1 标准
assert_eq!(calculate_tokens(200, 200), 53); // 小图
}
#[test]
fn test_gif_is_reencoded_even_without_resize() {
use image::codecs::gif::{GifEncoder, Repeat};
use image::{Delay, Frame, Rgba, RgbaImage};
// 构造一个多帧 GIF像素小但包含多帧用于验证“强制重编码为静态帧”的行为。
let mut frames = Vec::new();
for i in 0..10u8 {
let mut img = RgbaImage::new(32, 32);
for p in img.pixels_mut() {
*p = Rgba([i, 255u8.saturating_sub(i), 0, 255]);
}
frames.push(Frame::from_parts(
img,
0,
0,
Delay::from_numer_denom_ms(10, 1),
));
}
let mut buf = Vec::new();
{
let mut encoder = GifEncoder::new(&mut buf);
encoder.set_repeat(Repeat::Infinite).unwrap();
encoder.encode_frames(frames).unwrap();
}
let base64_data = BASE64.encode(&buf);
let config = CompressionConfig::default();
let result = process_image(&base64_data, "gif", &config, 1).unwrap();
assert!(!result.was_resized);
assert!(result.was_reencoded);
assert_eq!(result.original_size, result.final_size);
assert!(result.final_bytes_len < result.original_bytes_len);
}
#[test]
fn test_process_gif_frames_sampling_8s_caps_to_2fps() {
use image::codecs::gif::{GifEncoder, Repeat};
use image::{Delay, Frame, Rgba, RgbaImage};
// 8 秒 GIF按 max 20 帧限制,每秒最多 2 帧20/8=2
let frame_delay = Delay::from_numer_denom_ms(100, 1); // 0.1s
let mut frames = Vec::new();
for i in 0..80u8 {
let mut img = RgbaImage::new(64, 64);
for p in img.pixels_mut() {
*p = Rgba([i, 0, 0, 255]);
}
frames.push(Frame::from_parts(img, 0, 0, frame_delay));
}
let mut buf = Vec::new();
{
let mut encoder = GifEncoder::new(&mut buf);
encoder.set_repeat(Repeat::Infinite).unwrap();
encoder.encode_frames(frames).unwrap();
}
let base64_data = BASE64.encode(&buf);
let config = CompressionConfig::default();
let res = process_gif_frames(&base64_data, &config, 1).unwrap();
assert_eq!(res.duration_ms, 8000);
assert_eq!(res.sampling_interval_ms, 500);
assert_eq!(res.frames.len(), 16);
assert!(res.frames.len() <= GIF_MAX_OUTPUT_FRAMES);
assert_eq!(res.output_format, GIF_FRAME_OUTPUT_FORMAT);
}
#[test]
fn test_process_gif_frames_sampling_4s_hits_5fps_and_20_frames() {
use image::codecs::gif::{GifEncoder, Repeat};
use image::{Delay, Frame, Rgba, RgbaImage};
// 4 秒 GIF每秒最多 5 帧20/4=5应能采满 20 帧
let frame_delay = Delay::from_numer_denom_ms(100, 1); // 0.1s
let mut frames = Vec::new();
for i in 0..40u8 {
let mut img = RgbaImage::new(64, 64);
for p in img.pixels_mut() {
*p = Rgba([0, i, 0, 255]);
}
frames.push(Frame::from_parts(img, 0, 0, frame_delay));
}
let mut buf = Vec::new();
{
let mut encoder = GifEncoder::new(&mut buf);
encoder.set_repeat(Repeat::Infinite).unwrap();
encoder.encode_frames(frames).unwrap();
}
let base64_data = BASE64.encode(&buf);
let config = CompressionConfig::default();
let res = process_gif_frames(&base64_data, &config, 1).unwrap();
assert_eq!(res.duration_ms, 4000);
assert_eq!(res.sampling_interval_ms, 200);
assert_eq!(res.frames.len(), 20);
assert!(res.frames.len() <= GIF_MAX_OUTPUT_FRAMES);
assert_eq!(res.output_format, GIF_FRAME_OUTPUT_FORMAT);
}
}

86
src/kiro/affinity.rs Normal file
View File

@@ -0,0 +1,86 @@
//! 用户亲和性管理模块
//!
//! 记录 user_id 与 credential_id 的绑定关系,
//! 使连续对话尽量使用同一凭据
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// 亲和性条目
struct AffinityEntry {
credential_id: u64,
last_used: Instant,
}
/// 用户亲和性管理器
pub struct UserAffinityManager {
affinity: Mutex<HashMap<String, AffinityEntry>>,
ttl: Duration,
}
impl Default for UserAffinityManager {
fn default() -> Self {
Self::new()
}
}
impl UserAffinityManager {
/// 创建新的亲和性管理器(默认 TTL 30 分钟)
pub fn new() -> Self {
Self {
affinity: Mutex::new(HashMap::new()),
ttl: Duration::from_secs(30 * 60),
}
}
/// 获取用户绑定的凭据 ID惰性清理过期条目
pub fn get(&self, user_id: &str) -> Option<u64> {
let mut map = self.affinity.lock();
if let Some(entry) = map.get(user_id) {
if entry.last_used.elapsed() < self.ttl {
tracing::debug!(user_id = %user_id, credential_id = %entry.credential_id, "亲和性命中");
return Some(entry.credential_id);
}
// 过期则删除
tracing::debug!(user_id = %user_id, credential_id = %entry.credential_id, "亲和性过期,已清除");
map.remove(user_id);
}
None
}
/// 设置用户与凭据的绑定
pub fn set(&self, user_id: &str, credential_id: u64) {
tracing::debug!(user_id = %user_id, credential_id = %credential_id, "建立亲和性绑定");
let mut map = self.affinity.lock();
map.insert(
user_id.to_string(),
AffinityEntry {
credential_id,
last_used: Instant::now(),
},
);
}
/// 更新最后使用时间(续期)
pub fn touch(&self, user_id: &str) {
let mut map = self.affinity.lock();
if let Some(entry) = map.get_mut(user_id) {
entry.last_used = Instant::now();
}
}
/// 移除指定凭据的所有绑定(凭据被禁用时调用)
pub fn remove_by_credential(&self, credential_id: u64) {
let mut map = self.affinity.lock();
map.retain(|_, entry| entry.credential_id != credential_id);
}
/// 清理过期条目
#[allow(dead_code)]
pub fn cleanup(&self) {
let mut map = self.affinity.lock();
let ttl = self.ttl;
map.retain(|_, entry| entry.last_used.elapsed() < ttl);
}
}

View File

@@ -0,0 +1,346 @@
//! 后台 Token 刷新模块
#![allow(dead_code)]
//!
//! 独立后台任务定期检查并刷新即将过期的 Token
//! 避免请求时的刷新延迟。
//! 参考 CLIProxyAPIPlus 的实现。
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use tokio::time::interval;
/// 默认检查间隔(秒)
const DEFAULT_CHECK_INTERVAL_SECS: u64 = 60;
/// 默认批处理大小
const DEFAULT_BATCH_SIZE: usize = 50;
/// 默认并发数
const DEFAULT_CONCURRENCY: usize = 10;
/// 默认提前刷新时间(分钟)
/// Token 在过期前多少分钟开始刷新
const DEFAULT_REFRESH_BEFORE_EXPIRY_MINS: i64 = 15;
/// 后台刷新配置
#[derive(Debug, Clone)]
pub struct BackgroundRefreshConfig {
/// 检查间隔(秒),必须 > 0
pub check_interval_secs: u64,
/// 批处理大小,必须 > 0
pub batch_size: usize,
/// 并发数,必须 > 0
pub concurrency: usize,
/// 提前刷新时间(分钟)
pub refresh_before_expiry_mins: i64,
}
impl BackgroundRefreshConfig {
/// 校验配置有效性
///
/// # Returns
/// - `Ok(())` - 配置有效
/// - `Err(String)` - 配置无效,返回错误描述
pub fn validate(&self) -> Result<(), String> {
if self.check_interval_secs == 0 {
return Err("check_interval_secs 必须大于 0".to_string());
}
if self.batch_size == 0 {
return Err("batch_size 必须大于 0".to_string());
}
if self.concurrency == 0 {
return Err("concurrency 必须大于 0".to_string());
}
Ok(())
}
}
impl Default for BackgroundRefreshConfig {
fn default() -> Self {
Self {
check_interval_secs: DEFAULT_CHECK_INTERVAL_SECS,
batch_size: DEFAULT_BATCH_SIZE,
concurrency: DEFAULT_CONCURRENCY,
refresh_before_expiry_mins: DEFAULT_REFRESH_BEFORE_EXPIRY_MINS,
}
}
}
/// 后台刷新器
///
/// 管理后台 Token 刷新任务
pub struct BackgroundRefresher {
config: BackgroundRefreshConfig,
running: Arc<AtomicBool>,
shutdown_notify: Arc<Notify>,
}
impl BackgroundRefresher {
/// 创建新的后台刷新器
pub fn new(config: BackgroundRefreshConfig) -> Self {
Self {
config,
running: Arc::new(AtomicBool::new(false)),
shutdown_notify: Arc::new(Notify::new()),
}
}
/// 使用默认配置创建后台刷新器
pub fn with_defaults() -> Self {
Self::new(BackgroundRefreshConfig::default())
}
/// 启动后台刷新任务
///
/// # Arguments
/// * `refresh_fn` - 刷新函数,接收凭据 ID返回是否成功
/// * `get_expiring_ids_fn` - 获取即将过期的凭据 ID 列表
///
/// # Returns
/// - `Ok(())` - 启动成功
/// - `Err(String)` - 配置无效或已在运行
pub fn start<F, G>(&self, refresh_fn: F, get_expiring_ids_fn: G) -> Result<(), String>
where
F: Fn(u64) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
+ Send
+ Sync
+ 'static,
G: Fn(i64) -> Vec<u64> + Send + Sync + 'static,
{
// P1 修复:启动前校验配置,避免 panic/hang
if let Err(e) = self.config.validate() {
tracing::error!("后台刷新器配置无效: {}", e);
return Err(e);
}
if self.running.swap(true, Ordering::SeqCst) {
tracing::warn!("后台刷新器已在运行");
return Err("后台刷新器已在运行".to_string());
}
let config = self.config.clone();
let running = Arc::clone(&self.running);
let shutdown_notify = Arc::clone(&self.shutdown_notify);
let refresh_fn = Arc::new(refresh_fn);
tokio::spawn(async move {
tracing::info!(
interval_secs = %config.check_interval_secs,
batch_size = %config.batch_size,
concurrency = %config.concurrency,
"后台 Token 刷新器已启动"
);
let mut check_interval = interval(Duration::from_secs(config.check_interval_secs));
loop {
tokio::select! {
_ = check_interval.tick() => {
if !running.load(Ordering::SeqCst) {
break;
}
// 获取即将过期的凭据
let expiring_ids = get_expiring_ids_fn(config.refresh_before_expiry_mins);
if expiring_ids.is_empty() {
tracing::debug!("没有需要刷新的 Token");
continue;
}
tracing::info!("发现 {} 个即将过期的 Token开始刷新", expiring_ids.len());
// 批量刷新
let mut success_count = 0;
let mut fail_count = 0;
for chunk in expiring_ids.chunks(config.batch_size) {
let semaphore = Arc::new(tokio::sync::Semaphore::new(config.concurrency));
let mut handles = Vec::new();
for &id in chunk {
let permit = semaphore.clone().acquire_owned().await;
let refresh_fn = Arc::clone(&refresh_fn);
let handle = tokio::spawn(async move {
let _permit = permit;
refresh_fn(id).await
});
handles.push(handle);
}
for handle in handles {
match handle.await {
Ok(true) => success_count += 1,
Ok(false) => fail_count += 1,
Err(e) => {
tracing::warn!("刷新任务 panic: {}", e);
fail_count += 1;
}
}
}
}
tracing::info!(
success = %success_count,
failed = %fail_count,
"后台 Token 刷新完成"
);
}
_ = shutdown_notify.notified() => {
tracing::info!("后台 Token 刷新器收到关闭信号");
break;
}
}
}
running.store(false, Ordering::SeqCst);
tracing::info!("后台 Token 刷新器已停止");
});
Ok(())
}
/// 停止后台刷新任务
pub fn stop(&self) {
if self.running.load(Ordering::SeqCst) {
self.running.store(false, Ordering::SeqCst);
self.shutdown_notify.notify_one();
tracing::info!("已发送后台刷新器停止信号");
}
}
/// 检查是否正在运行
pub fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
/// 获取配置
pub fn config(&self) -> &BackgroundRefreshConfig {
&self.config
}
}
impl Drop for BackgroundRefresher {
fn drop(&mut self) {
self.stop();
}
}
/// 刷新结果
#[derive(Debug, Clone)]
pub struct RefreshResult {
/// 凭据 ID
pub credential_id: u64,
/// 是否成功
pub success: bool,
/// 是否使用了降级方案
pub used_fallback: bool,
/// 错误信息(如果失败)
pub error: Option<String>,
/// 新的过期时间(如果成功)
pub new_expires_at: Option<String>,
}
impl RefreshResult {
/// 创建成功结果
pub fn success(credential_id: u64, new_expires_at: String) -> Self {
Self {
credential_id,
success: true,
used_fallback: false,
error: None,
new_expires_at: Some(new_expires_at),
}
}
/// 创建失败结果
pub fn failure(credential_id: u64, error: String) -> Self {
Self {
credential_id,
success: false,
used_fallback: false,
error: Some(error),
new_expires_at: None,
}
}
/// 创建降级结果(刷新失败但使用现有 Token
pub fn fallback(credential_id: u64, existing_expires_at: String) -> Self {
Self {
credential_id,
success: true,
used_fallback: true,
error: None,
new_expires_at: Some(existing_expires_at),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_background_refresh_config_default() {
let config = BackgroundRefreshConfig::default();
assert_eq!(config.check_interval_secs, 60);
assert_eq!(config.batch_size, 50);
assert_eq!(config.concurrency, 10);
assert_eq!(config.refresh_before_expiry_mins, 15);
}
#[test]
fn test_background_refresher_new() {
let refresher = BackgroundRefresher::with_defaults();
assert!(!refresher.is_running());
}
#[test]
fn test_refresh_result_success() {
let result = RefreshResult::success(1, "2025-01-01T00:00:00Z".to_string());
assert!(result.success);
assert!(!result.used_fallback);
assert!(result.error.is_none());
}
#[test]
fn test_refresh_result_failure() {
let result = RefreshResult::failure(1, "Token expired".to_string());
assert!(!result.success);
assert!(result.error.is_some());
}
#[test]
fn test_refresh_result_fallback() {
let result = RefreshResult::fallback(1, "2025-01-01T00:00:00Z".to_string());
assert!(result.success);
assert!(result.used_fallback);
}
#[tokio::test]
async fn test_background_refresher_stop() {
let refresher = BackgroundRefresher::with_defaults();
// 启动一个空的刷新任务
let _ = refresher.start(|_id| Box::pin(async { true }), |_mins| vec![]);
// 等待启动
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(refresher.is_running());
// 停止
refresher.stop();
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!refresher.is_running());
}
}

373
src/kiro/cooldown.rs Normal file
View File

@@ -0,0 +1,373 @@
//! 冷却管理模块
#![allow(dead_code)]
//!
//! 分类管理不同原因的冷却状态,支持差异化冷却时长和自动清理。
//! 参考 CLIProxyAPIPlus 的实现。
use parking_lot::Mutex;
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// 冷却原因
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CooldownReason {
/// 429 速率限制
RateLimitExceeded,
/// 账户暂停
AccountSuspended,
/// 配额耗尽
QuotaExhausted,
/// Token 刷新失败
TokenRefreshFailed,
/// 认证失败
AuthenticationFailed,
/// 服务器错误
ServerError,
/// 模型暂时不可用
ModelUnavailable,
}
impl CooldownReason {
/// 获取默认冷却时长
pub fn default_duration(&self) -> Duration {
match self {
// 短冷却1-5 分钟)
CooldownReason::RateLimitExceeded => Duration::from_secs(60),
CooldownReason::TokenRefreshFailed => Duration::from_secs(60),
CooldownReason::ServerError => Duration::from_secs(120),
CooldownReason::ModelUnavailable => Duration::from_secs(300),
// 长冷却1-24 小时)
CooldownReason::AuthenticationFailed => Duration::from_secs(3600),
CooldownReason::AccountSuspended => Duration::from_secs(86400),
CooldownReason::QuotaExhausted => Duration::from_secs(86400),
}
}
/// 是否可以自动恢复
pub fn is_auto_recoverable(&self) -> bool {
match self {
CooldownReason::RateLimitExceeded => true,
CooldownReason::TokenRefreshFailed => true,
CooldownReason::ServerError => true,
CooldownReason::ModelUnavailable => true,
CooldownReason::AuthenticationFailed => false,
CooldownReason::AccountSuspended => false,
CooldownReason::QuotaExhausted => false,
}
}
/// 获取原因描述
pub fn description(&self) -> &'static str {
match self {
CooldownReason::RateLimitExceeded => "速率限制",
CooldownReason::AccountSuspended => "账户暂停",
CooldownReason::QuotaExhausted => "配额耗尽",
CooldownReason::TokenRefreshFailed => "Token 刷新失败",
CooldownReason::AuthenticationFailed => "认证失败",
CooldownReason::ServerError => "服务器错误",
CooldownReason::ModelUnavailable => "模型暂时不可用",
}
}
}
/// 冷却条目
#[derive(Debug, Clone)]
struct CooldownEntry {
/// 冷却原因
reason: CooldownReason,
/// 冷却开始时间
started_at: Instant,
/// 冷却结束时间
expires_at: Instant,
/// 连续触发次数(用于递增冷却时长)
trigger_count: u32,
}
/// 冷却管理器
///
/// 管理所有凭据的冷却状态
pub struct CooldownManager {
/// 凭据冷却状态
entries: Mutex<HashMap<u64, CooldownEntry>>,
/// 最大短冷却时长(秒)
max_short_cooldown_secs: u64,
/// 长冷却时长(秒)
long_cooldown_secs: u64,
}
impl Default for CooldownManager {
fn default() -> Self {
Self::new()
}
}
impl CooldownManager {
/// 创建新的冷却管理器
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_short_cooldown_secs: 300, // 5 分钟
long_cooldown_secs: 86400, // 24 小时
}
}
/// 使用自定义配置创建冷却管理器
#[allow(dead_code)]
pub fn with_config(max_short_cooldown_secs: u64, long_cooldown_secs: u64) -> Self {
Self {
entries: Mutex::new(HashMap::new()),
max_short_cooldown_secs,
long_cooldown_secs,
}
}
/// 设置凭据冷却
///
/// 返回实际的冷却时长
pub fn set_cooldown(&self, credential_id: u64, reason: CooldownReason) -> Duration {
self.set_cooldown_with_duration(credential_id, reason, None)
}
/// 设置凭据冷却(自定义时长)
pub fn set_cooldown_with_duration(
&self,
credential_id: u64,
reason: CooldownReason,
custom_duration: Option<Duration>,
) -> Duration {
let mut entries = self.entries.lock();
let now = Instant::now();
// 获取或创建条目
let entry = entries
.entry(credential_id)
.or_insert_with(|| CooldownEntry {
reason,
started_at: now,
expires_at: now,
trigger_count: 0,
});
// 更新触发次数
if entry.reason == reason {
entry.trigger_count += 1;
} else {
entry.reason = reason;
entry.trigger_count = 1;
}
// 计算冷却时长
let duration = custom_duration
.unwrap_or_else(|| self.calculate_cooldown_duration(reason, entry.trigger_count));
entry.started_at = now;
entry.expires_at = now + duration;
tracing::info!(
credential_id = %credential_id,
reason = %reason.description(),
duration_secs = %duration.as_secs(),
trigger_count = %entry.trigger_count,
"凭据进入冷却"
);
duration
}
/// 检查凭据是否在冷却中
///
/// 返回 `None` 表示不在冷却中,`Some((reason, remaining))` 表示冷却原因和剩余时间
pub fn check_cooldown(&self, credential_id: u64) -> Option<(CooldownReason, Duration)> {
let entries = self.entries.lock();
let now = Instant::now();
entries.get(&credential_id).and_then(|entry| {
if now < entry.expires_at {
Some((
entry.reason,
entry.expires_at.saturating_duration_since(now),
))
} else {
None
}
})
}
/// 检查凭据是否可用(不在冷却中或冷却已过期)
pub fn is_available(&self, credential_id: u64) -> bool {
self.check_cooldown(credential_id).is_none()
}
/// 清除凭据冷却
pub fn clear_cooldown(&self, credential_id: u64) -> bool {
let mut entries = self.entries.lock();
entries.remove(&credential_id).is_some()
}
/// 清除所有已过期的冷却
pub fn cleanup_expired(&self) -> usize {
let mut entries = self.entries.lock();
let now = Instant::now();
let before_count = entries.len();
entries.retain(|_, entry| now < entry.expires_at);
let removed = before_count - entries.len();
if removed > 0 {
tracing::debug!("清理了 {} 个过期冷却条目", removed);
}
removed
}
/// 获取所有冷却中的凭据
pub fn get_all_cooldowns(&self) -> Vec<CooldownInfo> {
let entries = self.entries.lock();
let now = Instant::now();
entries
.iter()
.filter(|(_, entry)| now < entry.expires_at)
.map(|(&id, entry)| CooldownInfo {
credential_id: id,
reason: entry.reason,
started_at_ms: entry.started_at.elapsed().as_millis() as u64,
remaining_ms: entry.expires_at.saturating_duration_since(now).as_millis() as u64,
trigger_count: entry.trigger_count,
})
.collect()
}
/// 计算冷却时长
fn calculate_cooldown_duration(&self, reason: CooldownReason, trigger_count: u32) -> Duration {
let base = reason.default_duration();
if reason.is_auto_recoverable() {
// 可自动恢复的原因:递增冷却时长,但不超过最大值
let multiplier = 1.5_f64.powi((trigger_count.saturating_sub(1)) as i32);
let duration_secs = (base.as_secs() as f64 * multiplier) as u64;
let capped_secs = duration_secs.min(self.max_short_cooldown_secs);
Duration::from_secs(capped_secs)
} else {
// 不可自动恢复的原因:使用长冷却时长
Duration::from_secs(self.long_cooldown_secs)
}
}
}
/// 冷却信息(公开 API
#[derive(Debug, Clone)]
pub struct CooldownInfo {
/// 凭据 ID
pub credential_id: u64,
/// 冷却原因
pub reason: CooldownReason,
/// 冷却开始时间(毫秒前)
pub started_at_ms: u64,
/// 剩余冷却时间(毫秒)
pub remaining_ms: u64,
/// 连续触发次数
pub trigger_count: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cooldown_manager_new() {
let manager = CooldownManager::new();
assert!(manager.is_available(1));
}
#[test]
fn test_cooldown_set_and_check() {
let manager = CooldownManager::new();
let duration = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
assert!(duration.as_secs() >= 60);
let (reason, remaining) = manager.check_cooldown(1).unwrap();
assert_eq!(reason, CooldownReason::RateLimitExceeded);
assert!(remaining.as_secs() > 0);
assert!(!manager.is_available(1));
}
#[test]
fn test_cooldown_clear() {
let manager = CooldownManager::new();
manager.set_cooldown(1, CooldownReason::ServerError);
assert!(!manager.is_available(1));
assert!(manager.clear_cooldown(1));
assert!(manager.is_available(1));
}
#[test]
fn test_cooldown_incremental() {
let manager = CooldownManager::new();
// 第一次冷却
let d1 = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
// 清除后再次触发,应该有更长的冷却
manager.clear_cooldown(1);
let d2 = manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
// 由于触发次数增加,第二次冷却应该更长
assert!(d2 >= d1);
}
#[test]
fn test_cooldown_reason_auto_recoverable() {
assert!(CooldownReason::RateLimitExceeded.is_auto_recoverable());
assert!(CooldownReason::ServerError.is_auto_recoverable());
assert!(!CooldownReason::AccountSuspended.is_auto_recoverable());
assert!(!CooldownReason::QuotaExhausted.is_auto_recoverable());
}
#[test]
fn test_cooldown_custom_duration() {
let manager = CooldownManager::new();
let custom = Duration::from_secs(10);
let duration =
manager.set_cooldown_with_duration(1, CooldownReason::ServerError, Some(custom));
assert_eq!(duration, custom);
}
#[test]
fn test_cooldown_get_all() {
let manager = CooldownManager::new();
manager.set_cooldown(1, CooldownReason::RateLimitExceeded);
manager.set_cooldown(2, CooldownReason::ServerError);
let cooldowns = manager.get_all_cooldowns();
assert_eq!(cooldowns.len(), 2);
}
#[test]
fn test_cooldown_reason_description() {
assert_eq!(CooldownReason::RateLimitExceeded.description(), "速率限制");
assert_eq!(CooldownReason::AccountSuspended.description(), "账户暂停");
}
}

301
src/kiro/fingerprint.rs Normal file
View File

@@ -0,0 +1,301 @@
//! 多维度设备指纹系统
#![allow(dead_code)]
//!
//! 模拟真实 Kiro IDE 客户端的完整环境特征,降低被检测风险。
//! 参考 CLIProxyAPIPlus 的实现。
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// SDK 版本范围
const SDK_VERSIONS: &[&str] = &["1.0.20", "1.0.22", "1.0.24", "1.0.25", "1.0.27"];
/// Kiro IDE 版本范围
const KIRO_VERSIONS: &[&str] = &["0.3.0", "0.4.0", "0.5.0", "0.6.0", "0.7.0", "0.8.0"];
/// Node.js 版本范围
const NODE_VERSIONS: &[&str] = &["18.20.4", "20.18.0", "22.11.0", "22.21.1"];
/// 操作系统类型
const OS_TYPES: &[&str] = &["darwin", "win32", "linux"];
/// 操作系统版本(按类型分组)
const DARWIN_VERSIONS: &[&str] = &["24.0.0", "24.1.0", "24.2.0", "24.4.0", "24.6.0"];
const WIN32_VERSIONS: &[&str] = &["10.0.19045", "10.0.22621", "10.0.22631"];
const LINUX_VERSIONS: &[&str] = &["6.5.0", "6.8.0", "6.11.0"];
/// 语言偏好
const ACCEPT_LANGUAGES: &[&str] = &[
"en-US,en;q=0.9",
"en-GB,en;q=0.9",
"zh-CN,zh;q=0.9,en;q=0.8",
"ja-JP,ja;q=0.9,en;q=0.8",
"de-DE,de;q=0.9,en;q=0.8",
"fr-FR,fr;q=0.9,en;q=0.8",
];
/// 屏幕分辨率
const SCREEN_RESOLUTIONS: &[&str] = &[
"1920x1080",
"2560x1440",
"3840x2160",
"1440x900",
"2560x1600",
"3024x1964",
];
/// 颜色深度
const COLOR_DEPTHS: &[u8] = &[24, 30, 32];
/// CPU 核心数范围
const HARDWARE_CONCURRENCY_RANGE: (u8, u8) = (4, 32);
/// 时区偏移范围(分钟)
const TIMEZONE_OFFSET_RANGE: (i16, i16) = (-720, 720);
/// 设备指纹
///
/// 包含模拟真实 Kiro IDE 客户端的完整环境特征
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fingerprint {
/// AWS SDK 版本
pub sdk_version: String,
/// 操作系统类型
pub os_type: String,
/// 操作系统版本
pub os_version: String,
/// Node.js 版本
pub node_version: String,
/// Kiro IDE 版本
pub kiro_version: String,
/// Kiro IDE 哈希(模拟)
pub kiro_hash: String,
/// 语言偏好
pub accept_language: String,
/// 屏幕分辨率
pub screen_resolution: String,
/// 颜色深度
pub color_depth: u8,
/// CPU 核心数
pub hardware_concurrency: u8,
/// 时区偏移(分钟)
pub timezone_offset: i16,
/// Machine ID64 字符十六进制)
pub machine_id: String,
}
impl Fingerprint {
/// 基于种子生成确定性指纹
///
/// 使用种子确保同一凭据始终生成相同的指纹,
/// 避免每次请求都生成不同的指纹导致被检测。
///
/// # Arguments
/// * `seed` - 种子字符串(通常使用 refresh_token 或 machine_id
pub fn generate_from_seed(seed: &str) -> Self {
// 使用种子生成确定性随机数
let hash = sha256_bytes(seed);
// 从哈希中提取各个字段的索引
let sdk_idx = hash[0] as usize % SDK_VERSIONS.len();
let kiro_idx = hash[1] as usize % KIRO_VERSIONS.len();
let node_idx = hash[2] as usize % NODE_VERSIONS.len();
let os_idx = hash[3] as usize % OS_TYPES.len();
let lang_idx = hash[4] as usize % ACCEPT_LANGUAGES.len();
let screen_idx = hash[5] as usize % SCREEN_RESOLUTIONS.len();
let color_idx = hash[6] as usize % COLOR_DEPTHS.len();
let os_type = OS_TYPES[os_idx];
let os_version = match os_type {
"darwin" => DARWIN_VERSIONS[hash[7] as usize % DARWIN_VERSIONS.len()],
"win32" => WIN32_VERSIONS[hash[7] as usize % WIN32_VERSIONS.len()],
"linux" => LINUX_VERSIONS[hash[7] as usize % LINUX_VERSIONS.len()],
_ => DARWIN_VERSIONS[0],
};
// 生成 hardware_concurrency4-32
let (min_cores, max_cores) = HARDWARE_CONCURRENCY_RANGE;
let cores_range = max_cores - min_cores + 1;
let hardware_concurrency = min_cores + (hash[8] % cores_range);
// 生成 timezone_offset-720 到 720 分钟)
let (min_tz, max_tz) = TIMEZONE_OFFSET_RANGE;
let tz_range = (max_tz - min_tz + 1) as u16;
let tz_offset = hash[9] as u16 * 256 + hash[10] as u16;
let timezone_offset = min_tz + (tz_offset % tz_range) as i16;
// 生成 kiro_hash模拟 SHA256
let kiro_hash = sha256_hex(&format!("kiro-{}-{}", KIRO_VERSIONS[kiro_idx], seed));
// 生成 machine_id
let machine_id = sha256_hex(&format!("machine-{}", seed));
Self {
sdk_version: SDK_VERSIONS[sdk_idx].to_string(),
os_type: os_type.to_string(),
os_version: os_version.to_string(),
node_version: NODE_VERSIONS[node_idx].to_string(),
kiro_version: KIRO_VERSIONS[kiro_idx].to_string(),
kiro_hash,
accept_language: ACCEPT_LANGUAGES[lang_idx].to_string(),
screen_resolution: SCREEN_RESOLUTIONS[screen_idx].to_string(),
color_depth: COLOR_DEPTHS[color_idx],
hardware_concurrency,
timezone_offset,
machine_id,
}
}
/// 生成随机指纹
///
/// 用于测试或不需要确定性的场景
#[allow(dead_code)]
pub fn generate_random() -> Self {
let seed = format!("random-{}", fastrand::u64(..));
Self::generate_from_seed(&seed)
}
/// 构建 User-Agent 字符串
pub fn user_agent(&self) -> String {
format!(
"aws-sdk-js/{} ua/2.1 os/{}#{} lang/js md/nodejs#{} api/codewhispererstreaming#{} m/E KiroIDE-{}-{}",
self.sdk_version,
self.os_type,
self.os_version,
self.node_version,
self.sdk_version,
self.kiro_version,
self.machine_id
)
}
/// 构建 x-amz-user-agent 字符串
pub fn x_amz_user_agent(&self) -> String {
format!(
"aws-sdk-js/{} KiroIDE-{}-{}",
self.sdk_version, self.kiro_version, self.machine_id
)
}
/// 获取格式化的操作系统字符串(用于 User-Agent
pub fn os_string(&self) -> String {
format!("{}#{}", self.os_type, self.os_version)
}
}
/// SHA256 哈希(返回字节数组)
fn sha256_bytes(input: &str) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
hasher.finalize().into()
}
/// SHA256 哈希(返回十六进制字符串)
fn sha256_hex(input: &str) -> String {
hex::encode(sha256_bytes(input))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fingerprint_deterministic() {
// 相同种子应生成相同指纹
let fp1 = Fingerprint::generate_from_seed("test_seed");
let fp2 = Fingerprint::generate_from_seed("test_seed");
assert_eq!(fp1.sdk_version, fp2.sdk_version);
assert_eq!(fp1.os_type, fp2.os_type);
assert_eq!(fp1.kiro_version, fp2.kiro_version);
assert_eq!(fp1.machine_id, fp2.machine_id);
}
#[test]
fn test_fingerprint_different_seeds() {
// 不同种子应生成不同指纹
let fp1 = Fingerprint::generate_from_seed("seed1");
let fp2 = Fingerprint::generate_from_seed("seed2");
// machine_id 一定不同
assert_ne!(fp1.machine_id, fp2.machine_id);
}
#[test]
fn test_user_agent_format() {
let fp = Fingerprint::generate_from_seed("test");
let ua = fp.user_agent();
assert!(ua.contains("aws-sdk-js/"));
assert!(ua.contains("KiroIDE-"));
assert!(ua.contains("lang/js"));
assert!(ua.contains("md/nodejs#"));
}
#[test]
fn test_x_amz_user_agent_format() {
let fp = Fingerprint::generate_from_seed("test");
let amz_ua = fp.x_amz_user_agent();
assert!(amz_ua.contains("aws-sdk-js/"));
assert!(amz_ua.contains("KiroIDE-"));
}
#[test]
fn test_machine_id_length() {
let fp = Fingerprint::generate_from_seed("test");
assert_eq!(fp.machine_id.len(), 64);
}
#[test]
fn test_hardware_concurrency_range() {
for i in 0..100 {
let fp = Fingerprint::generate_from_seed(&format!("test_{}", i));
assert!(fp.hardware_concurrency >= 4);
assert!(fp.hardware_concurrency <= 32);
}
}
#[test]
fn test_timezone_offset_range() {
for i in 0..100 {
let fp = Fingerprint::generate_from_seed(&format!("test_{}", i));
assert!(fp.timezone_offset >= -720);
assert!(fp.timezone_offset <= 720);
}
}
#[test]
fn test_os_version_matches_type() {
// darwin 类型应该有 darwin 版本
let fp = Fingerprint {
sdk_version: "1.0.27".to_string(),
os_type: "darwin".to_string(),
os_version: "24.6.0".to_string(),
node_version: "22.21.1".to_string(),
kiro_version: "0.8.0".to_string(),
kiro_hash: "test".to_string(),
accept_language: "en-US".to_string(),
screen_resolution: "1920x1080".to_string(),
color_depth: 24,
hardware_concurrency: 8,
timezone_offset: -480,
machine_id: "a".repeat(64),
};
let os_str = fp.os_string();
assert!(os_str.starts_with("darwin#"));
}
}

171
src/kiro/machine_id.rs Normal file
View File

@@ -0,0 +1,171 @@
//! 设备指纹生成器
//!
use sha2::{Digest, Sha256};
use crate::kiro::model::credentials::KiroCredentials;
use crate::model::config::Config;
/// 标准化 machineId 格式
///
/// 支持以下格式:
/// - 64 字符十六进制字符串(直接返回)
/// - UUID 格式(如 "2582956e-cc88-4669-b546-07adbffcb894",移除连字符后补齐到 64 字符)
fn normalize_machine_id(machine_id: &str) -> Option<String> {
let trimmed = machine_id.trim();
// 如果已经是 64 字符,直接返回
if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(trimmed.to_string());
}
// 尝试解析 UUID 格式(移除连字符)
let without_dashes: String = trimmed.chars().filter(|c| *c != '-').collect();
// UUID 去掉连字符后是 32 字符
if without_dashes.len() == 32 && without_dashes.chars().all(|c| c.is_ascii_hexdigit()) {
// 补齐到 64 字符(重复一次)
return Some(format!("{}{}", without_dashes, without_dashes));
}
// 无法识别的格式
None
}
/// 根据凭证信息生成唯一的 Machine ID
///
/// 优先使用凭据级 machineId其次使用 config.machineId然后使用 refreshToken 生成
pub fn generate_from_credentials(credentials: &KiroCredentials, config: &Config) -> Option<String> {
// 如果配置了凭据级 machineId优先使用
if let Some(ref machine_id) = credentials.machine_id
&& let Some(normalized) = normalize_machine_id(machine_id)
{
return Some(normalized);
}
// 如果配置了全局 machineId作为默认值
if let Some(ref machine_id) = config.machine_id
&& let Some(normalized) = normalize_machine_id(machine_id)
{
return Some(normalized);
}
// 使用 refreshToken 生成
if let Some(ref refresh_token) = credentials.refresh_token
&& !refresh_token.is_empty()
{
return Some(sha256_hex(&format!("KotlinNativeAPI/{}", refresh_token)));
}
// 没有有效的凭证
None
}
/// SHA256 哈希实现(返回十六进制字符串)
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let result = hasher.finalize();
hex::encode(result)
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
#[test]
fn test_sha256_hex() {
let result = sha256_hex("test");
assert_eq!(result.len(), 64);
assert_eq!(
result,
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
);
}
#[test]
fn test_generate_with_custom_machine_id() {
let credentials = KiroCredentials::default();
let mut config = Config::default();
config.machine_id = Some("a".repeat(64));
let result = generate_from_credentials(&credentials, &config);
assert_eq!(result, Some("a".repeat(64)));
}
#[test]
fn test_generate_with_credential_machine_id_overrides_config() {
let mut credentials = KiroCredentials::default();
credentials.machine_id = Some("b".repeat(64));
let mut config = Config::default();
config.machine_id = Some("a".repeat(64));
let result = generate_from_credentials(&credentials, &config);
assert_eq!(result, Some("b".repeat(64)));
}
#[test]
fn test_generate_with_refresh_token() {
let mut credentials = KiroCredentials::default();
credentials.refresh_token = Some("test_refresh_token".to_string());
let config = Config::default();
let result = generate_from_credentials(&credentials, &config);
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().len(), 64);
}
#[test]
fn test_generate_without_credentials() {
let credentials = KiroCredentials::default();
let config = Config::default();
let result = generate_from_credentials(&credentials, &config);
assert!(result.is_none());
}
#[test]
fn test_normalize_uuid_format() {
// UUID 格式应该被转换为 64 字符
let uuid = "2582956e-cc88-4669-b546-07adbffcb894";
let result = normalize_machine_id(uuid);
assert!(result.is_some());
let normalized = result.unwrap();
assert_eq!(normalized.len(), 64);
// UUID 去掉连字符后重复一次
assert_eq!(
normalized,
"2582956ecc884669b54607adbffcb8942582956ecc884669b54607adbffcb894"
);
}
#[test]
fn test_normalize_64_char_hex() {
// 64 字符十六进制应该直接返回
let hex64 = "a".repeat(64);
let result = normalize_machine_id(&hex64);
assert_eq!(result, Some(hex64));
}
#[test]
fn test_normalize_invalid_format() {
// 无效格式应该返回 None
assert!(normalize_machine_id("invalid").is_none());
assert!(normalize_machine_id("too-short").is_none());
assert!(normalize_machine_id(&"g".repeat(64)).is_none()); // 非十六进制
}
#[test]
fn test_generate_with_uuid_machine_id() {
let mut credentials = KiroCredentials::default();
credentials.machine_id = Some("2582956e-cc88-4669-b546-07adbffcb894".to_string());
let config = Config::default();
let result = generate_from_credentials(&credentials, &config);
assert!(result.is_some());
assert_eq!(result.as_ref().unwrap().len(), 64);
}
}

13
src/kiro/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
//! Kiro API 客户端模块
pub mod affinity;
pub mod background_refresh;
pub mod cooldown;
pub mod fingerprint;
pub mod machine_id;
pub mod model;
pub mod parser;
pub mod provider;
pub mod rate_limiter;
pub mod token_manager;
pub mod web_portal;

View File

@@ -0,0 +1,4 @@
//! 共享类型模块
//!
//! 此模块已简化,移除了未使用的类型定义。
//! 如果将来需要扩展,可以在此添加新的共享类型。

View File

@@ -0,0 +1,849 @@
//! Kiro OAuth 凭证数据模型
//!
//! 支持从 Kiro IDE 的凭证文件加载,使用 Social 认证方式
//! 支持单凭据和多凭据配置格式
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use crate::http_client::ProxyConfig;
use crate::model::config::Config;
/// Kiro OAuth 凭证
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct KiroCredentials {
/// 凭据唯一标识符(自增 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
/// 访问令牌
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
/// 刷新令牌
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
/// Profile ARN
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_arn: Option<String>,
/// 过期时间 (RFC3339 格式)
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// 认证方式 (social / idc)
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_method: Option<String>,
/// OIDC Client ID (IdC 认证需要)
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
/// OIDC Client Secret (IdC 认证需要)
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
/// 凭据优先级(数字越小优先级越高,默认为 0
#[serde(default)]
#[serde(skip_serializing_if = "is_zero")]
pub priority: u32,
/// 凭据级 Region 配置(用于 Token 刷新及 API 请求默认值)
/// 未配置时回退到 config.json 的全局 region
/// 兼容旧配置中的 authRegion 字段
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(alias = "authRegion")]
pub region: Option<String>,
/// 凭据级 API Region用于 API 请求,可单独覆盖 region
#[serde(skip_serializing_if = "Option::is_none")]
pub api_region: Option<String>,
/// 凭据级 Machine ID 配置(可选)
/// 未配置时回退到 config.json 的 machineId都未配置时由 refreshToken 派生
#[serde(skip_serializing_if = "Option::is_none")]
pub machine_id: Option<String>,
/// 用户邮箱(从 Anthropic API 获取)
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
/// 订阅等级KIRO PRO+ / KIRO FREE 等)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub subscription_title: Option<String>,
/// 凭据级代理 URL可选
/// 支持 http/https/socks5 协议
/// 特殊值 "direct" 表示显式不使用代理(即使全局配置了代理)
/// 未配置时回退到全局代理配置
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_url: Option<String>,
/// 凭据级代理认证用户名(可选)
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_username: Option<String>,
/// 凭据级代理认证密码(可选)
#[serde(skip_serializing_if = "Option::is_none")]
pub proxy_password: Option<String>,
/// 凭据是否被禁用(默认为 false
#[serde(default)]
pub disabled: bool,
}
/// 判断是否为零(用于跳过序列化)
fn is_zero(value: &u32) -> bool {
*value == 0
}
fn canonicalize_auth_method_value(value: &str) -> &str {
if value.eq_ignore_ascii_case("builder-id") || value.eq_ignore_ascii_case("iam") {
"idc"
} else {
value
}
}
/// 凭据配置(支持单对象或数组格式)
///
/// 自动识别配置文件格式:
/// - 单对象格式(旧格式,向后兼容)
/// - 数组格式(新格式,支持多凭据)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum CredentialsConfig {
/// 单个凭据(旧格式)
Single(KiroCredentials),
/// 多凭据数组(新格式)
Multiple(Vec<KiroCredentials>),
}
impl CredentialsConfig {
/// 从文件加载凭据配置
///
/// - 如果文件不存在,返回空数组
/// - 如果文件内容为空,返回空数组
/// - 支持单对象或数组格式
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let path = path.as_ref();
// 文件不存在时返回空数组
if !path.exists() {
return Ok(CredentialsConfig::Multiple(vec![]));
}
let content = fs::read_to_string(path)?;
// 文件为空时返回空数组
if content.trim().is_empty() {
return Ok(CredentialsConfig::Multiple(vec![]));
}
let config = serde_json::from_str(&content)?;
Ok(config)
}
/// 转换为按优先级排序的凭据列表
pub fn into_sorted_credentials(self) -> Vec<KiroCredentials> {
match self {
CredentialsConfig::Single(mut cred) => {
cred.canonicalize_auth_method();
vec![cred]
}
CredentialsConfig::Multiple(mut creds) => {
// 按优先级排序(数字越小优先级越高)
creds.sort_by_key(|c| c.priority);
for cred in &mut creds {
cred.canonicalize_auth_method();
}
creds
}
}
}
/// 获取凭据数量
#[allow(dead_code)]
pub fn len(&self) -> usize {
match self {
CredentialsConfig::Single(_) => 1,
CredentialsConfig::Multiple(creds) => creds.len(),
}
}
/// 判断是否为空
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
match self {
CredentialsConfig::Single(_) => false,
CredentialsConfig::Multiple(creds) => creds.is_empty(),
}
}
/// 判断是否为多凭据格式(数组格式)
pub fn is_multiple(&self) -> bool {
matches!(self, CredentialsConfig::Multiple(_))
}
}
#[allow(dead_code)]
impl KiroCredentials {
/// 特殊值:显式不使用代理
pub const PROXY_DIRECT: &'static str = "direct";
/// 获取默认凭证文件路径
#[allow(dead_code)]
pub fn default_credentials_path() -> &'static str {
"credentials.json"
}
/// 获取有效的 Auth Region用于 Token 刷新)
/// 优先级:凭据.region > config.region
pub fn effective_auth_region<'a>(&'a self, config: &'a Config) -> &'a str {
self.region
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(&config.region)
}
/// 获取有效的 API Region用于 API 请求和额度查询)
/// 优先级:凭据.api_region > 凭据.region > config.api_region > config.region
pub fn effective_api_region<'a>(&'a self, config: &'a Config) -> &'a str {
self.api_region
.as_deref()
.filter(|s| !s.trim().is_empty())
.or_else(|| self.region.as_deref().filter(|s| !s.trim().is_empty()))
.unwrap_or(config.effective_api_region())
}
/// 获取有效的代理配置
/// 优先级:凭据代理 > 全局代理 > 无代理
/// 特殊值 "direct" 表示显式不使用代理(即使全局配置了代理)
pub fn effective_proxy(&self, global_proxy: Option<&ProxyConfig>) -> Option<ProxyConfig> {
match self.proxy_url.as_deref() {
Some(url) if url.eq_ignore_ascii_case(Self::PROXY_DIRECT) => None,
Some(url) => {
let mut proxy = ProxyConfig::new(url);
if let (Some(username), Some(password)) =
(&self.proxy_username, &self.proxy_password)
{
proxy = proxy.with_auth(username, password);
}
Some(proxy)
}
None => global_proxy.cloned(),
}
}
/// 从 JSON 字符串解析凭证
#[allow(dead_code)]
pub fn from_json(json_string: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json_string)
}
/// 从文件加载凭证
#[allow(dead_code)]
pub fn load<P: AsRef<Path>>(path: P) -> anyhow::Result<Self> {
let content = fs::read_to_string(path.as_ref())?;
if content.is_empty() {
anyhow::bail!("凭证文件为空: {:?}", path.as_ref());
}
let credentials = Self::from_json(&content)?;
Ok(credentials)
}
/// 序列化为格式化的 JSON 字符串
#[allow(dead_code)]
pub fn to_pretty_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn canonicalize_auth_method(&mut self) {
let auth_method = match &self.auth_method {
Some(m) => m,
None => return,
};
let canonical = canonicalize_auth_method_value(auth_method);
if canonical != auth_method {
self.auth_method = Some(canonical.to_string());
}
}
/// 检查凭据是否支持 Opus 模型
///
/// Free 账号不支持 Opus 模型,需要 PRO 或更高等级订阅
pub fn supports_opus(&self) -> bool {
match &self.subscription_title {
Some(title) => {
let title_upper = title.to_uppercase();
// 如果包含 FREE则不支持 Opus
!title_upper.contains("FREE")
}
// 如果还没有获取订阅信息,暂时允许(首次使用时会获取)
None => true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::config::Config;
#[test]
fn test_from_json() {
let json = r#"{
"accessToken": "test_token",
"refreshToken": "test_refresh",
"profileArn": "arn:aws:test",
"expiresAt": "2024-01-01T00:00:00Z",
"authMethod": "social"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.access_token, Some("test_token".to_string()));
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
assert_eq!(creds.profile_arn, Some("arn:aws:test".to_string()));
assert_eq!(creds.expires_at, Some("2024-01-01T00:00:00Z".to_string()));
assert_eq!(creds.auth_method, Some("social".to_string()));
}
#[test]
fn test_from_json_with_unknown_keys() {
let json = r#"{
"accessToken": "test_token",
"unknownField": "should be ignored"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.access_token, Some("test_token".to_string()));
}
#[test]
fn test_to_json() {
let creds = KiroCredentials {
id: None,
access_token: Some("token".to_string()),
refresh_token: None,
profile_arn: None,
expires_at: None,
auth_method: Some("social".to_string()),
client_id: None,
client_secret: None,
priority: 0,
region: None,
api_region: None,
machine_id: None,
email: None,
subscription_title: None,
proxy_url: None,
proxy_username: None,
proxy_password: None,
disabled: false,
};
let json = creds.to_pretty_json().unwrap();
assert!(json.contains("accessToken"));
assert!(json.contains("authMethod"));
assert!(!json.contains("refreshToken"));
// priority 为 0 时不序列化
assert!(!json.contains("priority"));
}
#[test]
fn test_default_credentials_path() {
assert_eq!(
KiroCredentials::default_credentials_path(),
"credentials.json"
);
}
#[test]
fn test_priority_default() {
let json = r#"{"refreshToken": "test"}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.priority, 0);
}
#[test]
fn test_priority_explicit() {
let json = r#"{"refreshToken": "test", "priority": 5}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.priority, 5);
}
#[test]
fn test_credentials_config_single() {
let json = r#"{"refreshToken": "test", "expiresAt": "2025-12-31T00:00:00Z"}"#;
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
assert!(matches!(config, CredentialsConfig::Single(_)));
assert_eq!(config.len(), 1);
}
#[test]
fn test_credentials_config_multiple() {
let json = r#"[
{"refreshToken": "test1", "priority": 1},
{"refreshToken": "test2", "priority": 0}
]"#;
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
assert!(matches!(config, CredentialsConfig::Multiple(_)));
assert_eq!(config.len(), 2);
}
#[test]
fn test_credentials_config_priority_sorting() {
let json = r#"[
{"refreshToken": "t1", "priority": 2},
{"refreshToken": "t2", "priority": 0},
{"refreshToken": "t3", "priority": 1}
]"#;
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
let list = config.into_sorted_credentials();
// 验证按优先级排序
assert_eq!(list[0].refresh_token, Some("t2".to_string())); // priority 0
assert_eq!(list[1].refresh_token, Some("t3".to_string())); // priority 1
assert_eq!(list[2].refresh_token, Some("t1".to_string())); // priority 2
}
// ============ Region 字段测试 ============
#[test]
fn test_region_field_parsing() {
// 测试解析包含 region 字段的 JSON
let json = r#"{
"refreshToken": "test_refresh",
"region": "us-east-1"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
assert_eq!(creds.region, Some("us-east-1".to_string()));
}
#[test]
fn test_region_field_missing_backward_compat() {
// 测试向后兼容:不包含 region 字段的旧格式 JSON
let json = r#"{
"refreshToken": "test_refresh",
"authMethod": "social"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
assert_eq!(creds.region, None);
}
#[test]
fn test_region_field_serialization() {
// 测试序列化时正确输出 region 字段
let creds = KiroCredentials {
id: None,
access_token: None,
refresh_token: Some("test".to_string()),
profile_arn: None,
expires_at: None,
auth_method: None,
client_id: None,
client_secret: None,
priority: 0,
region: Some("eu-west-1".to_string()),
api_region: None,
machine_id: None,
email: None,
subscription_title: None,
proxy_url: None,
proxy_username: None,
proxy_password: None,
disabled: false,
};
let json = creds.to_pretty_json().unwrap();
assert!(json.contains("region"));
assert!(json.contains("eu-west-1"));
}
#[test]
fn test_region_field_none_not_serialized() {
// 测试 region 为 None 时不序列化
let creds = KiroCredentials {
id: None,
access_token: None,
refresh_token: Some("test".to_string()),
profile_arn: None,
expires_at: None,
auth_method: None,
client_id: None,
client_secret: None,
priority: 0,
region: None,
api_region: None,
machine_id: None,
email: None,
subscription_title: None,
proxy_url: None,
proxy_username: None,
proxy_password: None,
disabled: false,
};
let json = creds.to_pretty_json().unwrap();
assert!(!json.contains("region"));
}
// ============ MachineId 字段测试 ============
#[test]
fn test_machine_id_field_parsing() {
let machine_id = "a".repeat(64);
let json = format!(
r#"{{
"refreshToken": "test_refresh",
"machineId": "{machine_id}"
}}"#
);
let creds = KiroCredentials::from_json(&json).unwrap();
assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
assert_eq!(creds.machine_id, Some(machine_id));
}
#[test]
fn test_machine_id_field_serialization() {
let creds = KiroCredentials {
refresh_token: Some("test".to_string()),
machine_id: Some("b".repeat(64)),
..Default::default()
};
let json = creds.to_pretty_json().unwrap();
assert!(json.contains("machineId"));
}
#[test]
fn test_machine_id_field_none_not_serialized() {
let creds = KiroCredentials {
refresh_token: Some("test".to_string()),
machine_id: None,
..Default::default()
};
let json = creds.to_pretty_json().unwrap();
assert!(!json.contains("machineId"));
}
#[test]
fn test_multiple_credentials_with_different_regions() {
// 测试多凭据场景下不同凭据使用各自的 region
let json = r#"[
{"refreshToken": "t1", "region": "us-east-1"},
{"refreshToken": "t2", "region": "eu-west-1"},
{"refreshToken": "t3"}
]"#;
let config: CredentialsConfig = serde_json::from_str(json).unwrap();
let list = config.into_sorted_credentials();
assert_eq!(list[0].region, Some("us-east-1".to_string()));
assert_eq!(list[1].region, Some("eu-west-1".to_string()));
assert_eq!(list[2].region, None);
}
#[test]
fn test_region_field_with_all_fields() {
// 测试包含所有字段的完整 JSON
let json = r#"{
"id": 1,
"accessToken": "access",
"refreshToken": "refresh",
"profileArn": "arn:aws:test",
"expiresAt": "2025-12-31T00:00:00Z",
"authMethod": "idc",
"clientId": "client123",
"clientSecret": "secret456",
"priority": 5,
"region": "ap-northeast-1"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.id, Some(1));
assert_eq!(creds.access_token, Some("access".to_string()));
assert_eq!(creds.refresh_token, Some("refresh".to_string()));
assert_eq!(creds.profile_arn, Some("arn:aws:test".to_string()));
assert_eq!(creds.expires_at, Some("2025-12-31T00:00:00Z".to_string()));
assert_eq!(creds.auth_method, Some("idc".to_string()));
assert_eq!(creds.client_id, Some("client123".to_string()));
assert_eq!(creds.client_secret, Some("secret456".to_string()));
assert_eq!(creds.priority, 5);
assert_eq!(creds.region, Some("ap-northeast-1".to_string()));
}
#[test]
fn test_region_roundtrip() {
// 测试序列化和反序列化的往返一致性
let original = KiroCredentials {
id: Some(42),
access_token: Some("token".to_string()),
refresh_token: Some("refresh".to_string()),
profile_arn: None,
expires_at: None,
auth_method: Some("social".to_string()),
client_id: None,
client_secret: None,
priority: 3,
region: Some("us-west-2".to_string()),
api_region: None,
machine_id: Some("c".repeat(64)),
email: None,
subscription_title: None,
proxy_url: None,
proxy_username: None,
proxy_password: None,
disabled: false,
};
let json = original.to_pretty_json().unwrap();
let parsed = KiroCredentials::from_json(&json).unwrap();
assert_eq!(parsed.id, original.id);
assert_eq!(parsed.access_token, original.access_token);
assert_eq!(parsed.refresh_token, original.refresh_token);
assert_eq!(parsed.priority, original.priority);
assert_eq!(parsed.region, original.region);
assert_eq!(parsed.machine_id, original.machine_id);
}
// ============ api_region 字段测试 ============
#[test]
fn test_api_region_field_parsing() {
let json = r#"{
"refreshToken": "test_refresh",
"apiRegion": "ap-southeast-1"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.api_region, Some("ap-southeast-1".to_string()));
}
#[test]
fn test_api_region_serialization() {
let creds = KiroCredentials {
refresh_token: Some("test".to_string()),
api_region: Some("us-west-2".to_string()),
..Default::default()
};
let json = creds.to_pretty_json().unwrap();
assert!(json.contains("apiRegion"));
assert!(json.contains("us-west-2"));
}
#[test]
fn test_api_region_none_not_serialized() {
let creds = KiroCredentials {
refresh_token: Some("test".to_string()),
api_region: None,
..Default::default()
};
let json = creds.to_pretty_json().unwrap();
assert!(!json.contains("apiRegion"));
}
#[test]
fn test_api_region_roundtrip() {
let original = KiroCredentials {
refresh_token: Some("refresh".to_string()),
region: Some("us-east-1".to_string()),
api_region: Some("ap-northeast-1".to_string()),
..Default::default()
};
let json = original.to_pretty_json().unwrap();
let parsed = KiroCredentials::from_json(&json).unwrap();
assert_eq!(parsed.region, original.region);
assert_eq!(parsed.api_region, original.api_region);
}
#[test]
fn test_backward_compat_no_api_region() {
// 旧格式 JSON 不包含 apiRegion应正常解析
let json = r#"{
"refreshToken": "test_refresh",
"region": "us-east-1"
}"#;
let creds = KiroCredentials::from_json(json).unwrap();
assert_eq!(creds.region, Some("us-east-1".to_string()));
assert_eq!(creds.api_region, None);
}
// ============ effective_auth_region / effective_api_region 优先级测试 ============
#[test]
fn test_effective_auth_region_credential_region_wins() {
// 凭据.region > config.region
let mut config = Config::default();
config.region = "config-region".to_string();
let creds = KiroCredentials {
region: Some("cred-region".to_string()),
..Default::default()
};
assert_eq!(creds.effective_auth_region(&config), "cred-region");
}
#[test]
fn test_effective_auth_region_fallback_to_config_region() {
let mut config = Config::default();
config.region = "config-region".to_string();
let creds = KiroCredentials::default();
assert_eq!(creds.effective_auth_region(&config), "config-region");
}
#[test]
fn test_effective_api_region_credential_api_region_highest() {
// 凭据.api_region > 凭据.region > config.api_region > config.region
let mut config = Config::default();
config.region = "config-region".to_string();
config.api_region = Some("config-api-region".to_string());
let creds = KiroCredentials {
region: Some("cred-region".to_string()),
api_region: Some("cred-api-region".to_string()),
..Default::default()
};
assert_eq!(creds.effective_api_region(&config), "cred-api-region");
}
#[test]
fn test_effective_api_region_fallback_to_credential_region() {
let mut config = Config::default();
config.region = "config-region".to_string();
config.api_region = Some("config-api-region".to_string());
let creds = KiroCredentials {
region: Some("cred-region".to_string()),
..Default::default()
};
assert_eq!(creds.effective_api_region(&config), "cred-region");
}
#[test]
fn test_effective_api_region_fallback_to_config_api_region() {
let mut config = Config::default();
config.region = "config-region".to_string();
config.api_region = Some("config-api-region".to_string());
let creds = KiroCredentials::default();
assert_eq!(creds.effective_api_region(&config), "config-api-region");
}
#[test]
fn test_effective_api_region_fallback_to_config_region() {
let mut config = Config::default();
config.region = "config-region".to_string();
let creds = KiroCredentials::default();
assert_eq!(creds.effective_api_region(&config), "config-region");
}
#[test]
fn test_region_and_api_region_independent() {
// region 用于 authapi_region 可单独覆盖 API 请求
let mut config = Config::default();
config.region = "default".to_string();
let creds = KiroCredentials {
region: Some("eu-central-1".to_string()),
api_region: Some("us-east-1".to_string()),
..Default::default()
};
assert_eq!(creds.effective_auth_region(&config), "eu-central-1");
assert_eq!(creds.effective_api_region(&config), "us-east-1");
}
// ============ 凭据级代理优先级测试 ============
#[test]
fn test_effective_proxy_credential_overrides_global() {
let global = ProxyConfig::new("http://global:8080");
let creds = KiroCredentials {
proxy_url: Some("socks5://cred:1080".to_string()),
..Default::default()
};
let result = creds.effective_proxy(Some(&global));
assert_eq!(result, Some(ProxyConfig::new("socks5://cred:1080")));
}
#[test]
fn test_effective_proxy_credential_with_auth() {
let global = ProxyConfig::new("http://global:8080");
let creds = KiroCredentials {
proxy_url: Some("http://proxy:3128".to_string()),
proxy_username: Some("user".to_string()),
proxy_password: Some("pass".to_string()),
..Default::default()
};
let result = creds.effective_proxy(Some(&global));
let expected = ProxyConfig::new("http://proxy:3128").with_auth("user", "pass");
assert_eq!(result, Some(expected));
}
#[test]
fn test_effective_proxy_direct_bypasses_global() {
let global = ProxyConfig::new("http://global:8080");
let creds = KiroCredentials {
proxy_url: Some("direct".to_string()),
..Default::default()
};
let result = creds.effective_proxy(Some(&global));
assert_eq!(result, None);
}
#[test]
fn test_effective_proxy_direct_case_insensitive() {
let global = ProxyConfig::new("http://global:8080");
let creds = KiroCredentials {
proxy_url: Some("DIRECT".to_string()),
..Default::default()
};
let result = creds.effective_proxy(Some(&global));
assert_eq!(result, None);
}
#[test]
fn test_effective_proxy_fallback_to_global() {
let global = ProxyConfig::new("http://global:8080");
let creds = KiroCredentials::default();
let result = creds.effective_proxy(Some(&global));
assert_eq!(result, Some(ProxyConfig::new("http://global:8080")));
}
#[test]
fn test_effective_proxy_none_when_no_proxy() {
let creds = KiroCredentials::default();
let result = creds.effective_proxy(None);
assert_eq!(result, None);
}
}

View File

@@ -0,0 +1,115 @@
//! 助手响应事件
//!
//! 处理 assistantResponseEvent 类型的事件
use serde::{Deserialize, Serialize};
use crate::kiro::parser::error::ParseResult;
use crate::kiro::parser::frame::Frame;
use super::base::EventPayload;
/// 助手响应事件
///
/// 包含 AI 助手的流式响应内容
///
/// # 设计说明
///
/// 此结构体只保留实际使用的 `content` 字段,其他 API 返回的字段
/// 通过 `#[serde(flatten)]` 捕获到 `extra` 中,确保反序列化不会失败。
///
/// # 示例
///
/// ```rust
/// use kiro_rs::kiro::model::events::AssistantResponseEvent;
///
/// let json = r#"{"content":"Hello, world!"}"#;
/// let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
/// assert_eq!(event.content, "Hello, world!");
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistantResponseEvent {
/// 响应内容片段
#[serde(default)]
pub content: String,
/// 捕获其他未使用的字段,确保反序列化兼容性
#[serde(flatten)]
#[serde(skip_serializing)]
#[allow(dead_code)]
extra: serde_json::Value,
}
impl EventPayload for AssistantResponseEvent {
fn from_frame(frame: &Frame) -> ParseResult<Self> {
frame.payload_as_json()
}
}
impl Default for AssistantResponseEvent {
fn default() -> Self {
Self {
content: String::new(),
extra: serde_json::Value::Null,
}
}
}
impl std::fmt::Display for AssistantResponseEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_simple() {
let json = r#"{"content":"Hello, world!"}"#;
let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.content, "Hello, world!");
}
#[test]
fn test_deserialize_with_extra_fields() {
// 确保包含额外字段时反序列化不会失败
let json = r#"{
"content": "Done",
"conversationId": "conv-123",
"messageId": "msg-456",
"messageStatus": "COMPLETED",
"followupPrompt": {
"content": "Would you like me to explain further?",
"userIntent": "EXPLAIN_CODE_SELECTION"
}
}"#;
let event: AssistantResponseEvent = serde_json::from_str(json).unwrap();
assert_eq!(event.content, "Done");
}
#[test]
fn test_serialize_minimal() {
let event = AssistantResponseEvent::default();
let event = AssistantResponseEvent {
content: "Test".to_string(),
..event
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"content\":\"Test\""));
// extra 字段不应该被序列化
assert!(!json.contains("extra"));
}
#[test]
fn test_display() {
let event = AssistantResponseEvent {
content: "test".to_string(),
..Default::default()
};
assert_eq!(format!("{}", event), "test");
}
}

View File

@@ -0,0 +1,186 @@
//! 事件基础定义
//!
//! 定义事件类型枚举、trait 和统一事件结构
use crate::kiro::parser::error::{ParseError, ParseResult};
use crate::kiro::parser::frame::Frame;
/// 事件类型枚举
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EventType {
/// 助手响应事件
AssistantResponse,
/// 工具使用事件
ToolUse,
/// 计费事件
Metering,
/// 上下文使用率事件
ContextUsage,
/// 未知事件类型
Unknown,
}
impl EventType {
/// 从事件类型字符串解析
pub fn from_str(s: &str) -> Self {
match s {
"assistantResponseEvent" => Self::AssistantResponse,
"toolUseEvent" => Self::ToolUse,
"meteringEvent" => Self::Metering,
"contextUsageEvent" => Self::ContextUsage,
_ => Self::Unknown,
}
}
/// 转换为事件类型字符串
pub fn as_str(&self) -> &'static str {
match self {
Self::AssistantResponse => "assistantResponseEvent",
Self::ToolUse => "toolUseEvent",
Self::Metering => "meteringEvent",
Self::ContextUsage => "contextUsageEvent",
Self::Unknown => "unknown",
}
}
}
impl std::fmt::Display for EventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// 事件 payload trait
///
/// 所有具体事件类型都需要实现此 trait
pub trait EventPayload: Sized {
/// 从帧解析事件负载
fn from_frame(frame: &Frame) -> ParseResult<Self>;
}
/// 统一事件枚举
///
/// 封装所有可能的事件类型
#[derive(Debug, Clone)]
pub enum Event {
/// 助手响应
AssistantResponse(super::AssistantResponseEvent),
/// 工具使用
ToolUse(super::ToolUseEvent),
/// 计费
Metering(()),
/// 上下文使用率
ContextUsage(super::ContextUsageEvent),
/// 未知事件 (保留原始帧数据)
Unknown {},
/// 服务端错误
Error {
/// 错误代码
error_code: String,
/// 错误消息
error_message: String,
},
/// 服务端异常
Exception {
/// 异常类型
exception_type: String,
/// 异常消息
message: String,
},
}
impl Event {
/// 从帧解析事件
pub fn from_frame(frame: Frame) -> ParseResult<Self> {
let message_type = frame.message_type().unwrap_or("event");
match message_type {
"event" => Self::parse_event(frame),
"error" => Self::parse_error(frame),
"exception" => Self::parse_exception(frame),
other => Err(ParseError::InvalidMessageType(other.to_string())),
}
}
/// 解析事件类型消息
fn parse_event(frame: Frame) -> ParseResult<Self> {
let event_type_str = frame.event_type().unwrap_or("unknown");
let event_type = EventType::from_str(event_type_str);
match event_type {
EventType::AssistantResponse => {
let payload = super::AssistantResponseEvent::from_frame(&frame)?;
Ok(Self::AssistantResponse(payload))
}
EventType::ToolUse => {
let payload = super::ToolUseEvent::from_frame(&frame)?;
Ok(Self::ToolUse(payload))
}
EventType::Metering => Ok(Self::Metering(())),
EventType::ContextUsage => {
let payload = super::ContextUsageEvent::from_frame(&frame)?;
Ok(Self::ContextUsage(payload))
}
EventType::Unknown => Ok(Self::Unknown {}),
}
}
/// 解析错误类型消息
fn parse_error(frame: Frame) -> ParseResult<Self> {
let error_code = frame
.headers
.error_code()
.unwrap_or("UnknownError")
.to_string();
let error_message = frame.payload_as_str();
Ok(Self::Error {
error_code,
error_message,
})
}
/// 解析异常类型消息
fn parse_exception(frame: Frame) -> ParseResult<Self> {
let exception_type = frame
.headers
.exception_type()
.unwrap_or("UnknownException")
.to_string();
let message = frame.payload_as_str();
Ok(Self::Exception {
exception_type,
message,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_type_from_str() {
assert_eq!(
EventType::from_str("assistantResponseEvent"),
EventType::AssistantResponse
);
assert_eq!(EventType::from_str("toolUseEvent"), EventType::ToolUse);
assert_eq!(EventType::from_str("meteringEvent"), EventType::Metering);
assert_eq!(
EventType::from_str("contextUsageEvent"),
EventType::ContextUsage
);
assert_eq!(EventType::from_str("unknown_type"), EventType::Unknown);
}
#[test]
fn test_event_type_as_str() {
assert_eq!(
EventType::AssistantResponse.as_str(),
"assistantResponseEvent"
);
assert_eq!(EventType::ToolUse.as_str(), "toolUseEvent");
}
}

View File

@@ -0,0 +1,40 @@
//! 上下文使用率事件
//!
//! 处理 contextUsageEvent 类型的事件
use serde::Deserialize;
use crate::kiro::parser::error::ParseResult;
use crate::kiro::parser::frame::Frame;
use super::base::EventPayload;
/// 上下文使用率事件
///
/// 包含当前上下文窗口的使用百分比
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextUsageEvent {
/// 上下文使用百分比 (0-100)
#[serde(default)]
pub context_usage_percentage: f64,
}
impl EventPayload for ContextUsageEvent {
fn from_frame(frame: &Frame) -> ParseResult<Self> {
frame.payload_as_json()
}
}
impl ContextUsageEvent {
/// 获取格式化的百分比字符串
pub fn formatted_percentage(&self) -> String {
format!("{:.2}%", self.context_usage_percentage)
}
}
impl std::fmt::Display for ContextUsageEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.formatted_percentage())
}
}

View File

@@ -0,0 +1,13 @@
//! 事件模型
//!
//! 定义 generateAssistantResponse 流式响应的事件类型
mod assistant;
mod base;
mod context_usage;
mod tool_use;
pub use assistant::AssistantResponseEvent;
pub use base::Event;
pub use context_usage::ContextUsageEvent;
pub use tool_use::ToolUseEvent;

View File

@@ -0,0 +1,52 @@
//! 工具使用事件
//!
//! 处理 toolUseEvent 类型的事件
use serde::Deserialize;
use crate::kiro::parser::error::ParseResult;
use crate::kiro::parser::frame::Frame;
use super::base::EventPayload;
/// 工具使用事件
///
/// 包含工具调用的流式数据
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolUseEvent {
/// 工具名称
pub name: String,
/// 工具调用 ID
pub tool_use_id: String,
/// 工具输入数据 (JSON 字符串,可能是流式的部分数据)
#[serde(default)]
pub input: String,
/// 是否是最后一个块
#[serde(default)]
pub stop: bool,
}
impl EventPayload for ToolUseEvent {
fn from_frame(frame: &Frame) -> ParseResult<Self> {
frame.payload_as_json()
}
}
impl std::fmt::Display for ToolUseEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.stop {
write!(
f,
"ToolUse[{}] (id={}, complete): {}",
self.name, self.tool_use_id, self.input
)
} else {
write!(
f,
"ToolUse[{}] (id={}, partial): {}",
self.name, self.tool_use_id, self.input
)
}
}
}

16
src/kiro/model/mod.rs Normal file
View File

@@ -0,0 +1,16 @@
//! Kiro 数据模型
//!
//! 包含 Kiro API 的所有数据类型定义:
//! - `common`: 共享类型(枚举和辅助结构体)
//! - `events`: 响应事件类型
//! - `requests`: 请求类型
//! - `credentials`: OAuth 凭证
//! - `token_refresh`: Token 刷新
//! - `usage_limits`: 使用额度查询
pub mod common;
pub mod credentials;
pub mod events;
pub mod requests;
pub mod token_refresh;
pub mod usage_limits;

View File

@@ -0,0 +1,408 @@
//! 对话类型定义
//!
//! 定义 Kiro API 中对话相关的类型,包括消息、历史记录等
use serde::{Deserialize, Serialize};
use super::tool::{Tool, ToolResult, ToolUseEntry};
/// 对话状态
///
/// Kiro API 请求中的核心结构,包含当前消息和历史记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConversationState {
/// 代理延续 ID
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_continuation_id: Option<String>,
/// 代理任务类型(通常为 "vibe"
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_task_type: Option<String>,
/// 聊天触发类型("MANUAL" 或 "AUTO"
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_trigger_type: Option<String>,
/// 当前消息
pub current_message: CurrentMessage,
/// 会话 ID
pub conversation_id: String,
/// 历史消息列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<Message>,
}
impl ConversationState {
/// 创建新的对话状态
pub fn new(conversation_id: impl Into<String>) -> Self {
Self {
agent_continuation_id: None,
agent_task_type: None,
chat_trigger_type: None,
current_message: CurrentMessage::default(),
conversation_id: conversation_id.into(),
history: Vec::new(),
}
}
/// 设置代理延续 ID
pub fn with_agent_continuation_id(mut self, id: impl Into<String>) -> Self {
self.agent_continuation_id = Some(id.into());
self
}
/// 设置代理任务类型
pub fn with_agent_task_type(mut self, task_type: impl Into<String>) -> Self {
self.agent_task_type = Some(task_type.into());
self
}
/// 设置聊天触发类型
pub fn with_chat_trigger_type(mut self, trigger_type: impl Into<String>) -> Self {
self.chat_trigger_type = Some(trigger_type.into());
self
}
/// 设置当前消息
pub fn with_current_message(mut self, message: CurrentMessage) -> Self {
self.current_message = message;
self
}
/// 添加历史消息
pub fn with_history(mut self, history: Vec<Message>) -> Self {
self.history = history;
self
}
}
/// 当前消息容器
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CurrentMessage {
/// 用户输入消息
pub user_input_message: UserInputMessage,
}
impl CurrentMessage {
/// 创建新的当前消息
pub fn new(user_input_message: UserInputMessage) -> Self {
Self { user_input_message }
}
}
/// 用户输入消息
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserInputMessage {
/// 用户输入消息上下文
pub user_input_message_context: UserInputMessageContext,
/// 消息内容
pub content: String,
/// 模型 ID
pub model_id: String,
/// 图片列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<KiroImage>,
/// 消息来源(通常为 "AI_EDITOR"
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
}
impl UserInputMessage {
/// 创建新的用户输入消息
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
user_input_message_context: UserInputMessageContext::default(),
content: content.into(),
model_id: model_id.into(),
images: Vec::new(),
origin: Some("AI_EDITOR".to_string()),
}
}
/// 设置消息上下文
pub fn with_context(mut self, context: UserInputMessageContext) -> Self {
self.user_input_message_context = context;
self
}
/// 添加图片
pub fn with_images(mut self, images: Vec<KiroImage>) -> Self {
self.images = images;
self
}
/// 设置来源
pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
self.origin = Some(origin.into());
self
}
}
/// 用户输入消息上下文
///
/// 包含工具定义和工具执行结果
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserInputMessageContext {
/// 工具执行结果列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_results: Vec<ToolResult>,
/// 可用工具列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<Tool>,
}
impl UserInputMessageContext {
/// 创建新的消息上下文
pub fn new() -> Self {
Self::default()
}
/// 设置工具列表
pub fn with_tools(mut self, tools: Vec<Tool>) -> Self {
self.tools = tools;
self
}
/// 设置工具结果
pub fn with_tool_results(mut self, results: Vec<ToolResult>) -> Self {
self.tool_results = results;
self
}
}
/// Kiro 图片
///
/// API 中使用的图片格式
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KiroImage {
/// 图片格式("jpeg", "png", "gif", "webp"
pub format: String,
/// 图片数据源
pub source: KiroImageSource,
}
impl KiroImage {
/// 从 base64 数据创建图片
pub fn from_base64(format: impl Into<String>, data: impl Into<String>) -> Self {
Self {
format: format.into(),
source: KiroImageSource { bytes: data.into() },
}
}
}
/// Kiro 图片数据源
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KiroImageSource {
/// base64 编码的图片数据
pub bytes: String,
}
/// 历史消息
///
/// 可以是用户消息或助手消息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Message {
/// 用户消息
User(HistoryUserMessage),
/// 助手消息
Assistant(HistoryAssistantMessage),
}
#[allow(dead_code)]
impl Message {
/// 创建用户消息
pub fn user(content: impl Into<String>, model_id: impl Into<String>) -> Self {
Self::User(HistoryUserMessage::new(content, model_id))
}
/// 创建助手消息
pub fn assistant(content: impl Into<String>) -> Self {
Self::Assistant(HistoryAssistantMessage::new(content))
}
/// 判断是否为用户消息
pub fn is_user(&self) -> bool {
matches!(self, Self::User(_))
}
/// 判断是否为助手消息
pub fn is_assistant(&self) -> bool {
matches!(self, Self::Assistant(_))
}
}
/// 历史用户消息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryUserMessage {
/// 用户输入消息
pub user_input_message: UserMessage,
}
impl HistoryUserMessage {
/// 创建新的历史用户消息
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
user_input_message: UserMessage::new(content, model_id),
}
}
}
/// 用户消息(历史记录中使用)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserMessage {
/// 消息内容
pub content: String,
/// 模型 ID
pub model_id: String,
/// 消息来源
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
/// 图片列表
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub images: Vec<KiroImage>,
/// 用户输入消息上下文
#[serde(default, skip_serializing_if = "is_default_context")]
pub user_input_message_context: UserInputMessageContext,
}
fn is_default_context(ctx: &UserInputMessageContext) -> bool {
ctx.tools.is_empty() && ctx.tool_results.is_empty()
}
impl UserMessage {
/// 创建新的用户消息
pub fn new(content: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
content: content.into(),
model_id: model_id.into(),
origin: Some("AI_EDITOR".to_string()),
images: Vec::new(),
user_input_message_context: UserInputMessageContext::default(),
}
}
/// 设置图片
pub fn with_images(mut self, images: Vec<KiroImage>) -> Self {
self.images = images;
self
}
/// 设置上下文
pub fn with_context(mut self, context: UserInputMessageContext) -> Self {
self.user_input_message_context = context;
self
}
}
/// 历史助手消息
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryAssistantMessage {
/// 助手响应消息
pub assistant_response_message: AssistantMessage,
}
impl HistoryAssistantMessage {
/// 创建新的历史助手消息
pub fn new(content: impl Into<String>) -> Self {
Self {
assistant_response_message: AssistantMessage::new(content),
}
}
}
/// 助手消息(历史记录中使用)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AssistantMessage {
/// 响应内容
pub content: String,
/// 工具使用列表
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_uses: Option<Vec<ToolUseEntry>>,
}
impl AssistantMessage {
/// 创建新的助手消息
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
tool_uses: None,
}
}
/// 设置工具使用
pub fn with_tool_uses(mut self, tool_uses: Vec<ToolUseEntry>) -> Self {
self.tool_uses = Some(tool_uses);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_conversation_state_new() {
let state = ConversationState::new("conv-123")
.with_agent_task_type("vibe")
.with_chat_trigger_type("MANUAL");
assert_eq!(state.conversation_id, "conv-123");
assert_eq!(state.agent_task_type, Some("vibe".to_string()));
assert_eq!(state.chat_trigger_type, Some("MANUAL".to_string()));
}
#[test]
fn test_user_input_message() {
let msg = UserInputMessage::new("Hello", "claude-3-5-sonnet").with_origin("AI_EDITOR");
assert_eq!(msg.content, "Hello");
assert_eq!(msg.model_id, "claude-3-5-sonnet");
assert_eq!(msg.origin, Some("AI_EDITOR".to_string()));
}
#[test]
fn test_message_enum() {
let user_msg = Message::user("Hello", "model-id");
assert!(user_msg.is_user());
assert!(!user_msg.is_assistant());
let assistant_msg = Message::assistant("Hi there!");
assert!(assistant_msg.is_assistant());
assert!(!assistant_msg.is_user());
}
#[test]
fn test_history_serialize() {
let history = vec![
Message::user("Hello", "claude-3-5-sonnet"),
Message::assistant("Hi! How can I help you?"),
];
let json = serde_json::to_string(&history).unwrap();
assert!(json.contains("userInputMessage"));
assert!(json.contains("assistantResponseMessage"));
}
#[test]
fn test_conversation_state_serialize() {
let state = ConversationState::new("conv-123")
.with_agent_task_type("vibe")
.with_current_message(CurrentMessage::new(UserInputMessage::new(
"Hello",
"claude-3-5-sonnet",
)));
let json = serde_json::to_string(&state).unwrap();
assert!(json.contains("\"conversationId\":\"conv-123\""));
assert!(json.contains("\"agentTaskType\":\"vibe\""));
assert!(json.contains("\"content\":\"Hello\""));
}
}

View File

@@ -0,0 +1,68 @@
//! Kiro 请求类型定义
//!
//! 定义 Kiro API 的主请求结构
use serde::{Deserialize, Serialize};
use super::conversation::ConversationState;
/// Kiro API 请求
///
/// 用于构建发送给 Kiro API 的请求
///
/// # 示例
///
/// ```rust
/// use kiro_rs::kiro::model::requests::{
/// KiroRequest, ConversationState, CurrentMessage, UserInputMessage, Tool
/// };
///
/// // 创建简单请求
/// let state = ConversationState::new("conv-123")
/// .with_agent_task_type("vibe")
/// .with_current_message(CurrentMessage::new(
/// UserInputMessage::new("Hello", "claude-3-5-sonnet")
/// ));
///
/// let request = KiroRequest::new(state);
/// let json = request.to_json().unwrap();
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KiroRequest {
/// 对话状态
pub conversation_state: ConversationState,
/// Profile ARN可选
#[serde(skip_serializing_if = "Option::is_none")]
pub profile_arn: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_kiro_request_deserialize() {
let json = r#"{
"conversationState": {
"conversationId": "conv-456",
"currentMessage": {
"userInputMessage": {
"content": "Test message",
"modelId": "claude-3-5-sonnet",
"userInputMessageContext": {}
}
}
}
}"#;
let request: KiroRequest = serde_json::from_str(json).unwrap();
assert_eq!(request.conversation_state.conversation_id, "conv-456");
assert_eq!(
request
.conversation_state
.current_message
.user_input_message
.content,
"Test message"
);
}
}

View File

@@ -0,0 +1,7 @@
//! 请求类型模块
//!
//! 包含 Kiro API 请求相关的类型定义
pub mod conversation;
pub mod kiro;
pub mod tool;

View File

@@ -0,0 +1,192 @@
//! 工具类型定义
//!
//! 定义 Kiro API 中工具相关的类型
use serde::{Deserialize, Serialize};
/// 工具定义
///
/// 用于在请求中定义可用的工具
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tool {
/// 工具规范
pub tool_specification: ToolSpecification,
}
/// 工具规范
///
/// 定义工具的名称、描述和输入模式
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolSpecification {
/// 工具名称
pub name: String,
/// 工具描述
pub description: String,
/// 输入模式JSON Schema
pub input_schema: InputSchema,
}
/// 输入模式
///
/// 包装 JSON Schema 定义
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputSchema {
/// JSON Schema 定义
pub json: serde_json::Value,
}
impl Default for InputSchema {
fn default() -> Self {
Self {
json: serde_json::json!({
"type": "object",
"properties": {}
}),
}
}
}
impl InputSchema {
/// 从 JSON 值创建
pub fn from_json(json: serde_json::Value) -> Self {
Self { json }
}
}
/// 工具执行结果
///
/// 用于返回工具执行的结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolResult {
/// 工具使用 ID与请求中的 tool_use_id 对应)
pub tool_use_id: String,
/// 结果内容(数组格式)
pub content: Vec<serde_json::Map<String, serde_json::Value>>,
/// 执行状态("success" 或 "error"
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
/// 是否为错误
#[serde(default, skip_serializing_if = "is_false")]
pub is_error: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl ToolResult {
/// 创建成功的工具结果
pub fn success(tool_use_id: impl Into<String>, content: impl Into<String>) -> Self {
let mut map = serde_json::Map::new();
map.insert(
"text".to_string(),
serde_json::Value::String(content.into()),
);
Self {
tool_use_id: tool_use_id.into(),
content: vec![map],
status: Some("success".to_string()),
is_error: false,
}
}
/// 创建错误的工具结果
pub fn error(tool_use_id: impl Into<String>, error_message: impl Into<String>) -> Self {
let mut map = serde_json::Map::new();
map.insert(
"text".to_string(),
serde_json::Value::String(error_message.into()),
);
Self {
tool_use_id: tool_use_id.into(),
content: vec![map],
status: Some("error".to_string()),
is_error: true,
}
}
}
/// 工具使用条目
///
/// 用于历史消息中记录工具调用
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolUseEntry {
/// 工具使用 ID
pub tool_use_id: String,
/// 工具名称
pub name: String,
/// 工具输入参数
pub input: serde_json::Value,
}
impl ToolUseEntry {
/// 创建新的工具使用条目
pub fn new(tool_use_id: impl Into<String>, name: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
name: name.into(),
input: serde_json::json!({}),
}
}
/// 设置输入参数
pub fn with_input(mut self, input: serde_json::Value) -> Self {
self.input = input;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_result_success() {
let result = ToolResult::success("tool-123", "Operation completed");
assert!(!result.is_error);
assert_eq!(result.status, Some("success".to_string()));
}
#[test]
fn test_tool_result_error() {
let result = ToolResult::error("tool-456", "File not found");
assert!(result.is_error);
assert_eq!(result.status, Some("error".to_string()));
}
#[test]
fn test_tool_result_serialize() {
let result = ToolResult::success("tool-789", "Done");
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"toolUseId\":\"tool-789\""));
assert!(json.contains("\"status\":\"success\""));
// is_error = false 应该被跳过
assert!(!json.contains("isError"));
}
#[test]
fn test_tool_use_entry() {
let entry = ToolUseEntry::new("use-123", "read_file")
.with_input(serde_json::json!({"path": "/test.txt"}));
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"toolUseId\":\"use-123\""));
assert!(json.contains("\"name\":\"read_file\""));
assert!(json.contains("\"path\":\"/test.txt\""));
}
#[test]
fn test_input_schema_default() {
let schema = InputSchema::default();
assert_eq!(schema.json["type"], "object");
}
}

View File

@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
/// 刷新 Token 的请求体 (Social 认证)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshRequest {
pub refresh_token: String,
}
/// 刷新 Token 的响应体 (Social 认证)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub profile_arn: Option<String>,
#[serde(default)]
pub expires_in: Option<i64>,
}
/// IdC Token 刷新请求体 (AWS SSO OIDC)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IdcRefreshRequest {
pub client_id: String,
pub client_secret: String,
pub refresh_token: String,
pub grant_type: String,
}
/// IdC Token 刷新响应体 (AWS SSO OIDC)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IdcRefreshResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
// #[serde(default)]
// pub token_type: Option<String>,
#[serde(default)]
pub expires_in: Option<i64>,
}

View File

@@ -0,0 +1,213 @@
//! 使用额度查询数据模型
//!
//! 包含 getUsageLimits API 的响应类型定义
use serde::Deserialize;
/// 使用额度查询响应
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageLimitsResponse {
/// 下次重置日期 (Unix 时间戳)
#[serde(default)]
#[allow(dead_code)]
pub next_date_reset: Option<f64>,
/// 订阅信息
#[serde(default)]
pub subscription_info: Option<SubscriptionInfo>,
/// 使用量明细列表
#[serde(default)]
pub usage_breakdown_list: Vec<UsageBreakdown>,
}
/// 订阅信息
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubscriptionInfo {
/// 订阅标题 (KIRO PRO+ / KIRO FREE 等)
#[serde(default)]
pub subscription_title: Option<String>,
}
/// 使用量明细
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageBreakdown {
/// 当前使用量
#[serde(default)]
#[allow(dead_code)]
pub current_usage: i64,
/// 当前使用量(精确值)
#[serde(default)]
pub current_usage_with_precision: f64,
/// 奖励额度列表(可能为 null
#[serde(default)]
pub bonuses: Option<Vec<Bonus>>,
/// 免费试用信息
#[serde(default)]
pub free_trial_info: Option<FreeTrialInfo>,
/// 下次重置日期 (Unix 时间戳)
#[serde(default)]
#[allow(dead_code)]
pub next_date_reset: Option<f64>,
/// 使用限额
#[serde(default)]
#[allow(dead_code)]
pub usage_limit: i64,
/// 使用限额(精确值)
#[serde(default)]
pub usage_limit_with_precision: f64,
}
/// 奖励额度
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Bonus {
/// 当前使用量
#[serde(default)]
pub current_usage: f64,
/// 使用限额
#[serde(default)]
pub usage_limit: f64,
/// 状态 (ACTIVE / EXPIRED)
#[serde(default)]
pub status: Option<String>,
}
impl Bonus {
/// 检查 bonus 是否处于激活状态(大小写不敏感)
pub fn is_active(&self) -> bool {
self.status
.as_deref()
.map(|s| s.eq_ignore_ascii_case("ACTIVE"))
.unwrap_or(false)
}
}
/// 免费试用信息
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FreeTrialInfo {
/// 当前使用量
#[serde(default)]
#[allow(dead_code)]
pub current_usage: i64,
/// 当前使用量(精确值)
#[serde(default)]
pub current_usage_with_precision: f64,
/// 免费试用过期时间 (Unix 时间戳)
#[serde(default)]
#[allow(dead_code)]
pub free_trial_expiry: Option<f64>,
/// 免费试用状态 (ACTIVE / EXPIRED)
#[serde(default)]
pub free_trial_status: Option<String>,
/// 使用限额
#[serde(default)]
#[allow(dead_code)]
pub usage_limit: i64,
/// 使用限额(精确值)
#[serde(default)]
pub usage_limit_with_precision: f64,
}
// ============ 便捷方法实现 ============
impl FreeTrialInfo {
/// 检查免费试用是否处于激活状态
pub fn is_active(&self) -> bool {
self.free_trial_status
.as_deref()
.map(|s| s == "ACTIVE")
.unwrap_or(false)
}
}
impl UsageLimitsResponse {
/// 获取订阅标题
pub fn subscription_title(&self) -> Option<&str> {
self.subscription_info
.as_ref()
.and_then(|info| info.subscription_title.as_deref())
}
/// 获取第一个使用量明细
fn primary_breakdown(&self) -> Option<&UsageBreakdown> {
self.usage_breakdown_list.first()
}
/// 获取总使用限额(精确值)
///
/// 累加基础额度、激活的免费试用额度和激活的奖励额度
#[allow(clippy::collapsible_if)]
pub fn usage_limit(&self) -> f64 {
let Some(breakdown) = self.primary_breakdown() else {
return 0.0;
};
let mut total = breakdown.usage_limit_with_precision;
// 累加激活的 free trial 额度
if let Some(trial) = &breakdown.free_trial_info {
if trial.is_active() {
total += trial.usage_limit_with_precision;
}
}
// 累加激活的 bonus 额度
if let Some(bonuses) = &breakdown.bonuses {
for bonus in bonuses {
if bonus.is_active() {
total += bonus.usage_limit;
}
}
}
total
}
/// 获取总当前使用量(精确值)
///
/// 累加基础使用量、激活的免费试用使用量和激活的奖励使用量
#[allow(clippy::collapsible_if)]
pub fn current_usage(&self) -> f64 {
let Some(breakdown) = self.primary_breakdown() else {
return 0.0;
};
let mut total = breakdown.current_usage_with_precision;
// 累加激活的 free trial 使用量
if let Some(trial) = &breakdown.free_trial_info {
if trial.is_active() {
total += trial.current_usage_with_precision;
}
}
// 累加激活的 bonus 使用量
if let Some(bonuses) = &breakdown.bonuses {
for bonus in bonuses {
if bonus.is_active() {
total += bonus.current_usage;
}
}
}
total
}
}

37
src/kiro/parser/crc.rs Normal file
View File

@@ -0,0 +1,37 @@
//! CRC32 校验实现
//!
//! AWS Event Stream 使用 CRC32 (ISO-HDLC/以太网/ZIP 标准)
use crc::{CRC_32_ISO_HDLC, Crc};
/// CRC32 计算器实例 (ISO-HDLC 标准,多项式 0xEDB88320)
const CRC32: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
/// 计算 CRC32 校验和 (ISO-HDLC 标准)
///
/// # Arguments
/// * `data` - 要计算校验和的数据
///
/// # Returns
/// CRC32 校验和值
pub fn crc32(data: &[u8]) -> u32 {
CRC32.checksum(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_crc32_empty() {
// 空数据的 CRC32 应该是 0
assert_eq!(crc32(&[]), 0);
}
#[test]
fn test_crc32_known_value() {
// "123456789" 的 CRC32 (ISO-HDLC) 值是 0xCBF43926
let data = b"123456789";
assert_eq!(crc32(data), 0xCBF43926);
}
}

476
src/kiro/parser/decoder.rs Normal file
View File

@@ -0,0 +1,476 @@
//! AWS Event Stream 流式解码器
//!
//! 使用状态机处理流式数据,支持断点续传和容错处理
//!
//! ## 状态机设计
//!
//! 参考 kiro-kt 项目的状态机设计,采用四态模型:
//!
//! ```text
//! ┌─────────────────┐
//! │ Ready │ (初始态,就绪接收数据)
//! └────────┬────────┘
//! │ feed() 提供数据
//! ↓
//! ┌─────────────────┐
//! │ Parsing │ decode() 尝试解析
//! └────────┬────────┘
//! │
//! ┌────┴────────────┐
//! ↓ ↓
//! [成功] [失败]
//! │ │
//! ↓ ├─> error_count++
//! ┌─────────┐ │
//! │ Ready │ ├─> error_count < max_errors?
//! └─────────┘ │ YES → Recovering → Ready
//! │ NO ↓
//! ┌────────────┐
//! │ Stopped │ (终止态)
//! └────────────┘
//! ```
use super::error::{ParseError, ParseResult};
use super::frame::{Frame, PRELUDE_SIZE, parse_frame};
use bytes::{Buf, BytesMut};
/// 默认最大缓冲区大小 (16 MB)
pub const DEFAULT_MAX_BUFFER_SIZE: usize = 16 * 1024 * 1024;
/// 默认最大连续错误数
pub const DEFAULT_MAX_ERRORS: usize = 5;
/// 默认初始缓冲区容量
pub const DEFAULT_BUFFER_CAPACITY: usize = 8192;
/// 解码器状态
///
/// 采用四态模型,参考 kiro-kt 的设计:
/// - Ready: 就绪状态,可以接收数据
/// - Parsing: 正在解析帧
/// - Recovering: 恢复中(尝试跳过损坏数据)
/// - Stopped: 已停止(错误过多,终止态)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecoderState {
/// 就绪,可以接收数据
Ready,
/// 正在解析帧
Parsing,
/// 恢复中(跳过损坏数据)
Recovering,
/// 已停止(错误过多)
Stopped,
}
/// 流式事件解码器
///
/// 用于从字节流中解析 AWS Event Stream 消息帧
///
/// # Example
///
/// ```rust,ignore
/// use kiro_rs::kiro::parser::EventStreamDecoder;
///
/// let mut decoder = EventStreamDecoder::new();
///
/// // 提供流数据
/// decoder.feed(chunk)?;
///
/// // 解码所有可用帧
/// for result in decoder.decode_iter() {
/// match result {
/// Ok(frame) => println!("Got frame: {:?}", frame.event_type()),
/// Err(e) => eprintln!("Parse error: {}", e),
/// }
/// }
/// ```
pub struct EventStreamDecoder {
/// 内部缓冲区
buffer: BytesMut,
/// 当前状态
state: DecoderState,
/// 已处理的帧数量
frames_decoded: usize,
/// 连续错误计数
error_count: usize,
/// 最大连续错误数
max_errors: usize,
/// 最大缓冲区大小
max_buffer_size: usize,
/// 跳过的字节数(用于调试)
bytes_skipped: usize,
}
impl Default for EventStreamDecoder {
fn default() -> Self {
Self::new()
}
}
impl EventStreamDecoder {
/// 创建新的解码器
pub fn new() -> Self {
Self::with_capacity(DEFAULT_BUFFER_CAPACITY)
}
/// 创建具有指定缓冲区大小的解码器
pub fn with_capacity(capacity: usize) -> Self {
Self {
buffer: BytesMut::with_capacity(capacity),
state: DecoderState::Ready,
frames_decoded: 0,
error_count: 0,
max_errors: DEFAULT_MAX_ERRORS,
max_buffer_size: DEFAULT_MAX_BUFFER_SIZE,
bytes_skipped: 0,
}
}
/// 创建具有自定义配置的解码器
#[allow(dead_code)]
pub fn with_config(capacity: usize, max_errors: usize, max_buffer_size: usize) -> Self {
Self {
buffer: BytesMut::with_capacity(capacity),
state: DecoderState::Ready,
frames_decoded: 0,
error_count: 0,
max_errors,
max_buffer_size,
bytes_skipped: 0,
}
}
/// 向解码器提供数据
///
/// # Returns
/// - `Ok(())` - 数据已添加到缓冲区
/// - `Err(BufferOverflow)` - 缓冲区已满
pub fn feed(&mut self, data: &[u8]) -> ParseResult<()> {
// 检查缓冲区大小限制
let new_size = self.buffer.len() + data.len();
if new_size > self.max_buffer_size {
return Err(ParseError::BufferOverflow {
size: new_size,
max: self.max_buffer_size,
});
}
self.buffer.extend_from_slice(data);
// 从 Recovering 状态恢复到 Ready
if self.state == DecoderState::Recovering {
self.state = DecoderState::Ready;
}
Ok(())
}
/// 尝试解码下一个帧
///
/// # Returns
/// - `Ok(Some(frame))` - 成功解码一个帧
/// - `Ok(None)` - 数据不足,需要更多数据
/// - `Err(e)` - 解码错误
pub fn decode(&mut self) -> ParseResult<Option<Frame>> {
// 如果已停止,直接返回错误
if self.state == DecoderState::Stopped {
return Err(ParseError::TooManyErrors {
count: self.error_count,
last_error: "解码器已停止".to_string(),
});
}
// 缓冲区为空,保持 Ready 状态
if self.buffer.is_empty() {
self.state = DecoderState::Ready;
return Ok(None);
}
// 转移到 Parsing 状态
self.state = DecoderState::Parsing;
match parse_frame(&self.buffer) {
Ok(Some((frame, consumed))) => {
// 成功解析
self.buffer.advance(consumed);
self.state = DecoderState::Ready;
self.frames_decoded += 1;
self.error_count = 0; // 重置连续错误计数
Ok(Some(frame))
}
Ok(None) => {
// 数据不足,回到 Ready 状态等待更多数据
self.state = DecoderState::Ready;
Ok(None)
}
Err(e) => {
self.error_count += 1;
let error_msg = e.to_string();
// 检查是否超过最大错误数
if self.error_count >= self.max_errors {
self.state = DecoderState::Stopped;
tracing::error!(
"解码器停止: 连续 {} 次错误,最后错误: {}",
self.error_count,
error_msg
);
return Err(ParseError::TooManyErrors {
count: self.error_count,
last_error: error_msg,
});
}
// 根据错误类型采用不同的恢复策略
self.try_recover(&e);
self.state = DecoderState::Recovering;
Err(e)
}
}
}
/// 创建解码迭代器
pub fn decode_iter(&mut self) -> DecodeIter<'_> {
DecodeIter { decoder: self }
}
/// 尝试容错恢复
///
/// 根据错误类型采用不同的恢复策略(参考 kiro-kt 的设计):
/// - Prelude 阶段错误CRC 失败、长度异常):跳过 1 字节,尝试找下一帧边界
/// - Data 阶段错误Message CRC 失败、Header 解析失败):跳过整个损坏帧
fn try_recover(&mut self, error: &ParseError) {
if self.buffer.is_empty() {
return;
}
match error {
// Prelude 阶段错误:可能是帧边界错位,逐字节扫描找下一个有效边界
ParseError::PreludeCrcMismatch { .. }
| ParseError::MessageTooSmall { .. }
| ParseError::MessageTooLarge { .. } => {
let skipped_byte = self.buffer[0];
self.buffer.advance(1);
self.bytes_skipped += 1;
tracing::warn!(
"Prelude 错误恢复: 跳过字节 0x{:02x} (累计跳过 {} 字节)",
skipped_byte,
self.bytes_skipped
);
}
// Data 阶段错误:帧边界正确但数据损坏,跳过整个帧
ParseError::MessageCrcMismatch { .. } | ParseError::HeaderParseFailed(_) => {
// 尝试读取 total_length 来跳过整帧
if self.buffer.len() >= PRELUDE_SIZE {
let total_length = u32::from_be_bytes([
self.buffer[0],
self.buffer[1],
self.buffer[2],
self.buffer[3],
]) as usize;
// 确保 total_length 合理且缓冲区有足够数据
if total_length >= 16 && total_length <= self.buffer.len() {
tracing::warn!("Data 错误恢复: 跳过损坏帧 ({} 字节)", total_length);
self.buffer.advance(total_length);
self.bytes_skipped += total_length;
return;
}
}
// 无法确定帧长度,回退到逐字节跳过
let skipped_byte = self.buffer[0];
self.buffer.advance(1);
self.bytes_skipped += 1;
tracing::warn!(
"Data 错误恢复 (回退): 跳过字节 0x{:02x} (累计跳过 {} 字节)",
skipped_byte,
self.bytes_skipped
);
}
// 其他错误:逐字节跳过
_ => {
let skipped_byte = self.buffer[0];
self.buffer.advance(1);
self.bytes_skipped += 1;
tracing::warn!(
"通用错误恢复: 跳过字节 0x{:02x} (累计跳过 {} 字节)",
skipped_byte,
self.bytes_skipped
);
}
}
}
// ==================== 生命周期管理方法 ====================
/// 重置解码器到初始状态
///
/// 清空缓冲区和所有计数器,恢复到 Ready 状态
#[allow(dead_code)]
pub fn reset(&mut self) {
self.buffer.clear();
self.state = DecoderState::Ready;
self.frames_decoded = 0;
self.error_count = 0;
self.bytes_skipped = 0;
}
/// 获取当前状态
#[allow(dead_code)]
pub fn state(&self) -> DecoderState {
self.state
}
/// 检查是否处于 Ready 状态
#[allow(dead_code)]
pub fn is_ready(&self) -> bool {
self.state == DecoderState::Ready
}
/// 检查是否处于 Stopped 状态
#[allow(dead_code)]
pub fn is_stopped(&self) -> bool {
self.state == DecoderState::Stopped
}
/// 检查是否处于 Recovering 状态
#[allow(dead_code)]
pub fn is_recovering(&self) -> bool {
self.state == DecoderState::Recovering
}
/// 获取已解码的帧数量
#[allow(dead_code)]
pub fn frames_decoded(&self) -> usize {
self.frames_decoded
}
/// 获取当前连续错误计数
#[allow(dead_code)]
pub fn error_count(&self) -> usize {
self.error_count
}
/// 获取跳过的字节数
#[allow(dead_code)]
pub fn bytes_skipped(&self) -> usize {
self.bytes_skipped
}
/// 获取缓冲区中待处理的字节数
#[allow(dead_code)]
pub fn buffer_len(&self) -> usize {
self.buffer.len()
}
/// 尝试从 Stopped 状态恢复
///
/// 重置错误计数并转移到 Ready 状态
/// 注意:缓冲区内容保留,可能仍包含损坏数据
#[allow(dead_code)]
pub fn try_resume(&mut self) {
if self.state == DecoderState::Stopped {
self.error_count = 0;
self.state = DecoderState::Ready;
tracing::info!("解码器从 Stopped 状态恢复");
}
}
}
/// 解码迭代器
pub struct DecodeIter<'a> {
decoder: &'a mut EventStreamDecoder,
}
impl<'a> Iterator for DecodeIter<'a> {
type Item = ParseResult<Frame>;
fn next(&mut self) -> Option<Self::Item> {
// 如果处于 Stopped 或 Recovering 状态,停止迭代
match self.decoder.state {
DecoderState::Stopped => return None,
DecoderState::Recovering => return None,
_ => {}
}
match self.decoder.decode() {
Ok(Some(frame)) => Some(Ok(frame)),
Ok(None) => None,
Err(e) => Some(Err(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decoder_new() {
let decoder = EventStreamDecoder::new();
assert_eq!(decoder.state(), DecoderState::Ready);
assert_eq!(decoder.frames_decoded(), 0);
assert_eq!(decoder.error_count(), 0);
}
#[test]
fn test_decoder_feed() {
let mut decoder = EventStreamDecoder::new();
assert!(decoder.feed(&[1, 2, 3, 4]).is_ok());
assert_eq!(decoder.buffer_len(), 4);
}
#[test]
fn test_decoder_buffer_overflow() {
let mut decoder = EventStreamDecoder::with_config(1024, 5, 100);
let result = decoder.feed(&[0u8; 101]);
assert!(matches!(result, Err(ParseError::BufferOverflow { .. })));
}
#[test]
fn test_decoder_insufficient_data() {
let mut decoder = EventStreamDecoder::new();
decoder.feed(&[0u8; 10]).unwrap();
let result = decoder.decode();
assert!(matches!(result, Ok(None)));
assert_eq!(decoder.state(), DecoderState::Ready);
}
#[test]
fn test_decoder_reset() {
let mut decoder = EventStreamDecoder::new();
decoder.feed(&[1, 2, 3, 4]).unwrap();
decoder.reset();
assert_eq!(decoder.state(), DecoderState::Ready);
assert_eq!(decoder.buffer_len(), 0);
assert_eq!(decoder.frames_decoded(), 0);
}
#[test]
fn test_decoder_state_transitions() {
let decoder = EventStreamDecoder::new();
// 初始状态
assert!(decoder.is_ready());
assert!(!decoder.is_stopped());
assert!(!decoder.is_recovering());
}
#[test]
fn test_decoder_try_resume() {
let mut decoder = EventStreamDecoder::new();
// 手动设置为 Stopped 状态进行测试
decoder.state = DecoderState::Stopped;
decoder.error_count = 5;
decoder.try_resume();
assert!(decoder.is_ready());
assert_eq!(decoder.error_count(), 0);
}
}

94
src/kiro/parser/error.rs Normal file
View File

@@ -0,0 +1,94 @@
//! AWS Event Stream 解析错误定义
use std::fmt;
/// 解析错误类型
#[derive(Debug)]
pub enum ParseError {
/// 数据不足,需要更多字节
Incomplete { needed: usize, available: usize },
/// Prelude CRC 校验失败
PreludeCrcMismatch { expected: u32, actual: u32 },
/// Message CRC 校验失败
MessageCrcMismatch { expected: u32, actual: u32 },
/// 无效的头部值类型
InvalidHeaderType(u8),
/// 头部解析错误
HeaderParseFailed(String),
/// 消息长度超限
MessageTooLarge { length: u32, max: u32 },
/// 消息长度过小
MessageTooSmall { length: u32, min: u32 },
/// 无效的消息类型
InvalidMessageType(String),
/// Payload 反序列化失败
PayloadDeserialize(serde_json::Error),
/// IO 错误
Io(std::io::Error),
/// 连续错误过多,解码器已停止
TooManyErrors { count: usize, last_error: String },
/// 缓冲区溢出
BufferOverflow { size: usize, max: usize },
}
impl std::error::Error for ParseError {}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Incomplete { needed, available } => {
write!(f, "数据不足: 需要 {} 字节, 当前 {} 字节", needed, available)
}
Self::PreludeCrcMismatch { expected, actual } => {
write!(
f,
"Prelude CRC 校验失败: 期望 0x{:08x}, 实际 0x{:08x}",
expected, actual
)
}
Self::MessageCrcMismatch { expected, actual } => {
write!(
f,
"Message CRC 校验失败: 期望 0x{:08x}, 实际 0x{:08x}",
expected, actual
)
}
Self::InvalidHeaderType(t) => write!(f, "无效的头部值类型: {}", t),
Self::HeaderParseFailed(msg) => write!(f, "头部解析失败: {}", msg),
Self::MessageTooLarge { length, max } => {
write!(f, "消息长度超限: {} 字节 (最大 {})", length, max)
}
Self::MessageTooSmall { length, min } => {
write!(f, "消息长度过小: {} 字节 (最小 {})", length, min)
}
Self::InvalidMessageType(t) => write!(f, "无效的消息类型: {}", t),
Self::PayloadDeserialize(e) => write!(f, "Payload 反序列化失败: {}", e),
Self::Io(e) => write!(f, "IO 错误: {}", e),
Self::TooManyErrors { count, last_error } => {
write!(
f,
"连续错误过多 ({} 次),解码器已停止: {}",
count, last_error
)
}
Self::BufferOverflow { size, max } => {
write!(f, "缓冲区溢出: {} 字节 (最大 {})", size, max)
}
}
}
}
impl From<std::io::Error> for ParseError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<serde_json::Error> for ParseError {
fn from(e: serde_json::Error) -> Self {
Self::PayloadDeserialize(e)
}
}
/// 解析结果类型
pub type ParseResult<T> = Result<T, ParseError>;

178
src/kiro/parser/frame.rs Normal file
View File

@@ -0,0 +1,178 @@
//! AWS Event Stream 消息帧解析
//!
//! ## 消息格式
//!
//! ```text
//! ┌──────────────┬──────────────┬──────────────┬──────────┬──────────┬───────────┐
//! │ Total Length │ Header Length│ Prelude CRC │ Headers │ Payload │ Msg CRC │
//! │ (4 bytes) │ (4 bytes) │ (4 bytes) │ (变长) │ (变长) │ (4 bytes) │
//! └──────────────┴──────────────┴──────────────┴──────────┴──────────┴───────────┘
//! ```
//!
//! - Total Length: 整个消息的总长度(包括自身 4 字节)
//! - Header Length: 头部数据的长度
//! - Prelude CRC: 前 8 字节Total Length + Header Length的 CRC32 校验
//! - Headers: 头部数据
//! - Payload: 载荷数据(通常是 JSON
//! - Message CRC: 整个消息(不含 Message CRC 自身)的 CRC32 校验
use super::crc::crc32;
use super::error::{ParseError, ParseResult};
use super::header::{Headers, parse_headers};
/// Prelude 固定大小 (12 字节)
pub const PRELUDE_SIZE: usize = 12;
/// 最小消息大小 (Prelude + Message CRC)
pub const MIN_MESSAGE_SIZE: usize = PRELUDE_SIZE + 4;
/// 最大消息大小限制 (16 MB)
pub const MAX_MESSAGE_SIZE: u32 = 16 * 1024 * 1024;
/// 解析后的消息帧
#[derive(Debug, Clone)]
pub struct Frame {
/// 消息头部
pub headers: Headers,
/// 消息负载
pub payload: Vec<u8>,
}
impl Frame {
/// 获取消息类型
pub fn message_type(&self) -> Option<&str> {
self.headers.message_type()
}
/// 获取事件类型
pub fn event_type(&self) -> Option<&str> {
self.headers.event_type()
}
/// 将 payload 解析为 JSON
pub fn payload_as_json<T: serde::de::DeserializeOwned>(&self) -> ParseResult<T> {
serde_json::from_slice(&self.payload).map_err(ParseError::PayloadDeserialize)
}
/// 将 payload 解析为字符串
pub fn payload_as_str(&self) -> String {
String::from_utf8_lossy(&self.payload).to_string()
}
}
/// 尝试从缓冲区解析一个完整的帧
///
/// 这是一个无状态的纯函数,每次调用独立解析。
/// 缓冲区管理由上层 `EventStreamDecoder` 负责。
///
/// # Arguments
/// * `buffer` - 输入缓冲区
///
/// # Returns
/// - `Ok(Some((frame, consumed)))` - 成功解析,返回帧和消费的字节数
/// - `Ok(None)` - 数据不足,需要更多数据
/// - `Err(e)` - 解析错误
pub fn parse_frame(buffer: &[u8]) -> ParseResult<Option<(Frame, usize)>> {
// 检查是否有足够的数据读取 prelude
if buffer.len() < PRELUDE_SIZE {
return Ok(None);
}
// 读取 prelude
let total_length = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]);
let header_length = u32::from_be_bytes([buffer[4], buffer[5], buffer[6], buffer[7]]);
let prelude_crc = u32::from_be_bytes([buffer[8], buffer[9], buffer[10], buffer[11]]);
// 验证消息长度范围
if total_length < MIN_MESSAGE_SIZE as u32 {
return Err(ParseError::MessageTooSmall {
length: total_length,
min: MIN_MESSAGE_SIZE as u32,
});
}
if total_length > MAX_MESSAGE_SIZE {
return Err(ParseError::MessageTooLarge {
length: total_length,
max: MAX_MESSAGE_SIZE,
});
}
let total_length = total_length as usize;
let header_length = header_length as usize;
// 检查是否有完整的消息
if buffer.len() < total_length {
return Ok(None);
}
// 验证 Prelude CRC
let actual_prelude_crc = crc32(&buffer[..8]);
if actual_prelude_crc != prelude_crc {
return Err(ParseError::PreludeCrcMismatch {
expected: prelude_crc,
actual: actual_prelude_crc,
});
}
// 读取 Message CRC
let message_crc = u32::from_be_bytes([
buffer[total_length - 4],
buffer[total_length - 3],
buffer[total_length - 2],
buffer[total_length - 1],
]);
// 验证 Message CRC (对整个消息不含最后4字节)
let actual_message_crc = crc32(&buffer[..total_length - 4]);
if actual_message_crc != message_crc {
return Err(ParseError::MessageCrcMismatch {
expected: message_crc,
actual: actual_message_crc,
});
}
// 解析头部
let headers_start = PRELUDE_SIZE;
let headers_end = headers_start + header_length;
// 验证头部边界
if headers_end > total_length - 4 {
return Err(ParseError::HeaderParseFailed(
"头部长度超出消息边界".to_string(),
));
}
let headers = parse_headers(&buffer[headers_start..headers_end], header_length)?;
// 提取 payload (去除最后4字节的 message_crc)
let payload_start = headers_end;
let payload_end = total_length - 4;
let payload = buffer[payload_start..payload_end].to_vec();
Ok(Some((Frame { headers, payload }, total_length)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frame_insufficient_data() {
let buffer = [0u8; 10]; // 小于 PRELUDE_SIZE
assert!(matches!(parse_frame(&buffer), Ok(None)));
}
#[test]
fn test_frame_message_too_small() {
// 构造一个 total_length = 10 的 prelude (小于最小值)
let mut buffer = vec![0u8; 16];
buffer[0..4].copy_from_slice(&10u32.to_be_bytes()); // total_length
buffer[4..8].copy_from_slice(&0u32.to_be_bytes()); // header_length
let prelude_crc = crc32(&buffer[0..8]);
buffer[8..12].copy_from_slice(&prelude_crc.to_be_bytes());
let result = parse_frame(&buffer);
assert!(matches!(result, Err(ParseError::MessageTooSmall { .. })));
}
}

317
src/kiro/parser/header.rs Normal file
View File

@@ -0,0 +1,317 @@
//! AWS Event Stream 头部解析
//!
//! 实现 AWS Event Stream 协议的头部解析功能
use super::error::{ParseError, ParseResult};
use std::collections::HashMap;
/// 头部值类型标识
///
/// AWS Event Stream 协议定义的 10 种值类型
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeaderValueType {
BoolTrue = 0,
BoolFalse = 1,
Byte = 2,
Short = 3,
Integer = 4,
Long = 5,
ByteArray = 6,
String = 7,
Timestamp = 8,
Uuid = 9,
}
impl TryFrom<u8> for HeaderValueType {
type Error = ParseError;
fn try_from(value: u8) -> ParseResult<Self> {
match value {
0 => Ok(Self::BoolTrue),
1 => Ok(Self::BoolFalse),
2 => Ok(Self::Byte),
3 => Ok(Self::Short),
4 => Ok(Self::Integer),
5 => Ok(Self::Long),
6 => Ok(Self::ByteArray),
7 => Ok(Self::String),
8 => Ok(Self::Timestamp),
9 => Ok(Self::Uuid),
_ => Err(ParseError::InvalidHeaderType(value)),
}
}
}
/// 头部值
///
/// 支持 AWS Event Stream 协议定义的所有值类型
#[derive(Debug, Clone, PartialEq)]
pub enum HeaderValue {
Bool(bool),
Byte(i8),
Short(i16),
Integer(i32),
Long(i64),
ByteArray(Vec<u8>),
String(String),
Timestamp(i64),
Uuid([u8; 16]),
}
impl HeaderValue {
/// 尝试获取字符串值
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
_ => None,
}
}
}
/// 消息头部集合
#[derive(Debug, Clone, Default)]
pub struct Headers {
inner: HashMap<String, HeaderValue>,
}
impl Headers {
/// 创建空的头部集合
pub fn new() -> Self {
Self {
inner: HashMap::new(),
}
}
/// 插入头部
pub fn insert(&mut self, name: String, value: HeaderValue) {
self.inner.insert(name, value);
}
/// 获取头部值
pub fn get(&self, name: &str) -> Option<&HeaderValue> {
self.inner.get(name)
}
/// 获取字符串类型的头部值
pub fn get_string(&self, name: &str) -> Option<&str> {
self.get(name).and_then(|v| v.as_str())
}
/// 获取消息类型 (:message-type)
pub fn message_type(&self) -> Option<&str> {
self.get_string(":message-type")
}
/// 获取事件类型 (:event-type)
pub fn event_type(&self) -> Option<&str> {
self.get_string(":event-type")
}
/// 获取异常类型 (:exception-type)
pub fn exception_type(&self) -> Option<&str> {
self.get_string(":exception-type")
}
/// 获取错误代码 (:error-code)
pub fn error_code(&self) -> Option<&str> {
self.get_string(":error-code")
}
}
/// 从字节流解析头部
///
/// # Arguments
/// * `data` - 头部数据切片
/// * `header_length` - 头部总长度
///
/// # Returns
/// 解析后的 Headers 结构
pub fn parse_headers(data: &[u8], header_length: usize) -> ParseResult<Headers> {
// 验证数据长度是否足够
if data.len() < header_length {
return Err(ParseError::Incomplete {
needed: header_length,
available: data.len(),
});
}
let mut headers = Headers::new();
let mut offset = 0;
while offset < header_length {
// 读取头部名称长度 (1 byte)
if offset >= data.len() {
break;
}
let name_len = data[offset] as usize;
offset += 1;
// 验证名称长度
if name_len == 0 {
return Err(ParseError::HeaderParseFailed(
"头部名称长度不能为 0".to_string(),
));
}
// 读取头部名称
if offset + name_len > data.len() {
return Err(ParseError::Incomplete {
needed: name_len,
available: data.len() - offset,
});
}
let name = String::from_utf8_lossy(&data[offset..offset + name_len]).to_string();
offset += name_len;
// 读取值类型 (1 byte)
if offset >= data.len() {
return Err(ParseError::Incomplete {
needed: 1,
available: 0,
});
}
let value_type = HeaderValueType::try_from(data[offset])?;
offset += 1;
// 根据类型解析值
let value = parse_header_value(&data[offset..], value_type, &mut offset)?;
headers.insert(name, value);
}
Ok(headers)
}
/// 解析头部值
fn parse_header_value(
data: &[u8],
value_type: HeaderValueType,
global_offset: &mut usize,
) -> ParseResult<HeaderValue> {
let mut local_offset = 0;
let result = match value_type {
HeaderValueType::BoolTrue => Ok(HeaderValue::Bool(true)),
HeaderValueType::BoolFalse => Ok(HeaderValue::Bool(false)),
HeaderValueType::Byte => {
ensure_bytes(data, 1)?;
let v = data[0] as i8;
local_offset = 1;
Ok(HeaderValue::Byte(v))
}
HeaderValueType::Short => {
ensure_bytes(data, 2)?;
let v = i16::from_be_bytes([data[0], data[1]]);
local_offset = 2;
Ok(HeaderValue::Short(v))
}
HeaderValueType::Integer => {
ensure_bytes(data, 4)?;
let v = i32::from_be_bytes([data[0], data[1], data[2], data[3]]);
local_offset = 4;
Ok(HeaderValue::Integer(v))
}
HeaderValueType::Long => {
ensure_bytes(data, 8)?;
let v = i64::from_be_bytes([
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
]);
local_offset = 8;
Ok(HeaderValue::Long(v))
}
HeaderValueType::Timestamp => {
ensure_bytes(data, 8)?;
let v = i64::from_be_bytes([
data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
]);
local_offset = 8;
Ok(HeaderValue::Timestamp(v))
}
HeaderValueType::ByteArray => {
ensure_bytes(data, 2)?;
let len = u16::from_be_bytes([data[0], data[1]]) as usize;
ensure_bytes(data, 2 + len)?;
let v = data[2..2 + len].to_vec();
local_offset = 2 + len;
Ok(HeaderValue::ByteArray(v))
}
HeaderValueType::String => {
ensure_bytes(data, 2)?;
let len = u16::from_be_bytes([data[0], data[1]]) as usize;
ensure_bytes(data, 2 + len)?;
let v = String::from_utf8_lossy(&data[2..2 + len]).to_string();
local_offset = 2 + len;
Ok(HeaderValue::String(v))
}
HeaderValueType::Uuid => {
ensure_bytes(data, 16)?;
let mut uuid = [0u8; 16];
uuid.copy_from_slice(&data[..16]);
local_offset = 16;
Ok(HeaderValue::Uuid(uuid))
}
};
*global_offset += local_offset;
result
}
/// 确保有足够的字节
fn ensure_bytes(data: &[u8], needed: usize) -> ParseResult<()> {
if data.len() < needed {
Err(ParseError::Incomplete {
needed,
available: data.len(),
})
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_header_value_type_conversion() {
assert_eq!(
HeaderValueType::try_from(0).unwrap(),
HeaderValueType::BoolTrue
);
assert_eq!(
HeaderValueType::try_from(7).unwrap(),
HeaderValueType::String
);
assert!(HeaderValueType::try_from(10).is_err());
}
#[test]
fn test_header_value_as_str() {
let value = HeaderValue::String("test".to_string());
assert_eq!(value.as_str(), Some("test"));
let value = HeaderValue::Bool(true);
assert_eq!(value.as_str(), None);
}
#[test]
fn test_headers_get_string() {
let mut headers = Headers::new();
headers.insert(
":message-type".to_string(),
HeaderValue::String("event".to_string()),
);
assert_eq!(headers.message_type(), Some("event"));
}
#[test]
fn test_parse_headers_string() {
// 构造一个简单的头部: name_len(1) + name + type(7=string) + value_len(2) + value
// 头部名: "x" (长度 1)
// 值类型: 7 (String)
// 值: "ab" (长度 2)
let data = [1u8, b'x', 7, 0, 2, b'a', b'b'];
let headers = parse_headers(&data, data.len()).unwrap();
assert_eq!(headers.get_string("x"), Some("ab"));
}
}

10
src/kiro/parser/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
//! AWS Event Stream 解析器
//!
//! 提供对 AWS Event Stream 协议的解析支持,
//! 用于处理 generateAssistantResponse 端点的流式响应
pub mod crc;
pub mod decoder;
pub mod error;
pub mod frame;
pub mod header;

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