93 Commits

Author SHA1 Message Date
fawney19
394cc536a9 feat: 添加 API 格式访问限制
扩展访问限制功能,支持 API Key 级别的 API 格式限制(OPENAI、CLAUDE、GEMINI)。

- AccessRestrictions 新增 allowed_api_formats 字段
- 新增 is_api_format_allowed() 方法检查格式权限
- models.py 添加 _filter_formats_by_restrictions() 函数过滤 API 格式
- 在所有模型列表和查询端点应用格式限制检查
- 添加 _build_empty_list_response() 统一空响应构建逻辑
2025-12-30 17:50:39 +08:00
fawney19
e20a09f15a feat: 添加模型列表访问限制功能
实现 API Key 和 User 级别的模型访问权限控制,支持按 Provider 和模型名称限制。

- 新增 AccessRestrictions 类处理访问限制合并逻辑(API Key 优先于 User)
- models_service 支持根据限制过滤模型列表
- models.py 在列表查询时构建并应用访问限制
- 优化缓存策略:仅无限制请求使用缓存,有限制的请求旁路缓存
- 修复 logger 配置:enqueue 改为 False 避免 macOS 信号量泄漏
2025-12-30 16:57:59 +08:00
fawney19
b89a4af0cf refactor: 统一 HTTP 客户端超时配置
将 HTTPClientPool 中硬编码的超时参数改为使用可配置的环境变量,提高系统的灵活性和可维护性。

- 添加 HTTP_READ_TIMEOUT 环境变量配置(默认 300 秒)
- 统一所有 HTTP 客户端创建逻辑使用配置化超时
- 改进变量命名清晰性(config -> default_config 或 client_config)
2025-12-30 15:06:55 +08:00
fawney19
a56854af43 feat: 为 GlobalModel 添加关联提供商查询 API
添加新的 API 端点 GET /api/admin/models/global/{global_model_id}/providers,用于获取 GlobalModel 的所有关联提供商(包括非活跃的)。

- 后端:实现 AdminGetGlobalModelProvidersAdapter 适配器
- 前端:使用新 API 替换原有的 ModelCatalog 获取方式
- 数据库:改进初始化时的错误提示和连接异常处理
2025-12-30 14:47:35 +08:00
fawney19
4a35d78c8d fix: 修正 README 中的图片文件扩展名 2025-12-30 09:44:45 +08:00
fawney19
26b281271e docs: 更新许可证说明、README 文档和模拟数据;调整模型版本至最新 2025-12-30 09:41:03 +08:00
fawney19
96094cfde2 fix: 调整仪表盘普通用户月度统计显示,添加月度费用字段 2025-12-29 18:28:37 +08:00
fawney19
7e26af5476 fix: 修复仪表盘缓存命中率和配额显示逻辑
- 本月缓存命中率改为使用本月数据计算(monthly_input_tokens + cache_read_tokens),而非全时数据
- 修复配额显示:有配额时显示实际金额,无配额时显示为 /bin/zsh 并标记为高警告状态
2025-12-29 18:12:33 +08:00
fawney19
c8dfb784bc fix: 修复仪表盘月份统计逻辑,改为自然月而非过去30天 2025-12-29 17:55:42 +08:00
fawney19
fd3a5a5afe refactor: 将模型测试功能从 ModelsTab 移到 KeyAllowedModelsDialog 2025-12-29 17:44:02 +08:00
fawney19
599b3d4c95 feat: 添加 Provider API Key 查看和复制功能
- 后端添加 GET /api/admin/endpoints/keys/{key_id}/reveal 接口
- 前端密钥列表添加眼睛按钮(显示/隐藏完整密钥)和复制按钮
- 关闭抽屉时自动清除已显示的密钥(安全考虑)

Fixes #53
2025-12-29 17:14:26 +08:00
fawney19
41719a00e7 refactor: 改进分布式任务锁的清理策略
实现两种锁清理模式:
- 单实例模式(默认):启动时使用 Lua 脚本原子性清理旧锁,解决 worker 重启时���锁残留问题
- 多实例模式:使用 NX 选项竞争锁,依赖 TTL 处理异常退出

可通过 SINGLE_INSTANCE_MODE 环境变量控制模式选择。
2025-12-28 21:34:43 +08:00
fawney19
b5c0f85dca refactor: 统一剪贴板复制功能到 useClipboard 组合式函数
将各个组件和视图中重复的剪贴板复制逻辑提取到 useClipboard 组合式函数。
增加 showToast 参数支持静默复制,减少代码重复,提高维护性。
2025-12-28 20:41:52 +08:00
fawney19
7d6d262ed3 feat: 增加用户密码修改时的确认验证
在编辑用户时,如果填写了新密码,需要进行密码确认,确保两次输入一致。
同时更新后端请求模型以支持密码字段。
2025-12-28 20:00:25 +08:00
fawney19
e21acd73eb fix: 修复模型映射中重复关联的问题
在批量分配模型和编辑模型映射时,需要检查不仅是主模型名是否已关联,
还要检查其映射名称是否已存在,防止同一个上游模型被重复关联。
2025-12-28 19:40:07 +08:00
fawney19
702f9bc5f1 fix: 修复缓存监控页面TTL分析时间段选择器点击无响应
为 Select 组件添加 v-model:open 绑定,解决 radix-vue Select 组件
在某些情况下点击无响应的问题。

Fixes #55
2025-12-28 19:14:49 +08:00
fawney19
d0ce798881 fix: TTL=0时启用Key随机轮换模式
- 当所有Key的cache_ttl_minutes都为0时,使用随机排序代替确定性哈希
- 将hashlib和random的import移到文件顶部
- 简化单Key场景的处理逻辑

Closes #57
2025-12-28 19:07:25 +08:00
fawney19
2b1d197047 Merge remote-tracking branch 'gitcode/master' into htmambo/master 2025-12-25 22:47:08 +08:00
fawney19
71bc2e6aab fix: 增加参数校验防止除零错误 2025-12-25 22:44:17 +08:00
fawney19
afb329934a fix: 修复端点健康统计时间分段计算的除零错误 2025-12-25 19:54:16 +08:00
elky0401
1313af45a3 !4 merge htmambo/master into master
refactor: 重构模型测试错误解析逻辑并修复用量统计变量引用

Created-by: elky0401
Commit-by: fawney19;hoping
Merged-by: elky0401
Description: feat: 引入统一的端点检查器以重构适配器并改进错误处理和用量统计。
refactor: 重构模型测试错误解析逻辑并修复用量统计变量引用

See merge request: elky0401/Aether!4
2025-12-25 19:39:33 +08:00
fawney19
dddb327885 refactor: 重构模型测试错误解析逻辑并修复用量统计变量引用
- 将 ModelsTab 和 ModelAliasesTab 中重复的错误解析逻辑提取到 errorParser.ts
- 添加 parseTestModelError 函数统一处理测试响应错误
- 为 testModel API 添加 TypeScript 类型定义 (TestModelRequest/TestModelResponse)
- 修复 endpoint_checker.py 中 usage_data 变量引用错误
2025-12-25 19:36:29 +08:00
hoping
26b4a37323 feat: 引入统一的端点检查器以重构适配器并改进错误处理和用量统计。 2025-12-25 00:02:56 +08:00
fawney19
9dad194130 fix: 修复 API Key 访问限制字段无法清除的问题
- 统一前端创建和更新 API Key 时的空数组处理逻辑
- 后端创建和更新接口都支持空数组转 NULL(表示不限制)
- 开启自动刷新时立即刷新一次数据
2025-12-24 22:35:30 +08:00
fawney19
03ad16ea8a fix: 修复迁移脚本在全新安装时报错及改进统计回填逻辑
迁移脚本修复:
- 移除 AUTOCOMMIT 模式,改为在同一事务中创建索引
- 分别检查每个索引是否存在,只创建缺失的索引
- 修复全新安装时 AUTOCOMMIT 连接看不到未提交表的问题 (#46)

统计回填改进:
- 分别检查 StatsDaily 和 StatsDailyModel 的缺失日期
- 只回填实际缺失的数据而非连续区间
- 添加失败统计计数和 rollback 错误日志
2025-12-24 21:50:05 +08:00
fawney19
2fa64b98e3 fix: deploy.sh 将 Dockerfile.app.local 纳入代码变化检测 2025-12-24 18:10:42 +08:00
fawney19
75d7e89cbb perf: 添加 gunicorn --preload 参数优化内存占用
Worker 进程共享只读内存(代码、常量),可减少约 30-40% 内存占用

Closes #44
2025-12-24 18:10:42 +08:00
fawney19
d73a443484 fix: 修复初次执行 migrate.sh 时 usage 表不存在的问题 (#43)
- 在 baseline 中直接创建 usage 表复合索引
- 在后续迁移中添加表存在性检查,避免 AUTOCOMMIT 连接看不到事务中的表
2025-12-24 18:10:42 +08:00
Hwwwww-dev
15a9b88fc8 feat: enhance extract_cache_creation_tokens function to support three formats[#41] (#42)
- Updated the function to prioritize nested format, followed by flat new format, and finally old format for cache creation tokens.
- Added fallback logic for cases where the preferred formats return zero.
- Expanded unit tests to cover new format scenarios and ensure proper functionality across all formats.

Co-authored-by: heweimin <heweimin@retaileye.ai>
2025-12-24 01:31:45 +08:00
fawney19
03eb7203ec fix(api): 同步 chat_handler_base 使用 aiter_bytes 支持自动解压 2025-12-24 01:13:35 +08:00
hank9999
e38cd6819b fix(api): 优化字节流迭代器以支持自动解压 gzip (#39) 2025-12-24 01:11:35 +08:00
fawney19
d44cfaddf6 fix: rename variable to avoid shadowing in model mapping cache stats
循环内部变量 provider_model_mappings 与外部列表同名,导致外部列表被覆盖为 None 引发 AttributeError
2025-12-23 00:38:37 +08:00
fawney19
65225710a8 refactor: use ConcurrencyDefaults for CACHE_RESERVATION_RATIO constant 2025-12-23 00:34:18 +08:00
fawney19
d7f5b16359 fix: rebuild app image when migration files change
deploy.sh was only running alembic upgrade on the old container when
migration files changed, but the migration files are baked into the
Docker image. Now it rebuilds the app image when migrations change.
2025-12-23 00:23:22 +08:00
fawney19
7185818724 fix: remove index_exists check to avoid transaction conflict in migration
- Remove index_exists function that used op.get_bind() within transaction
- Use IF NOT EXISTS / IF EXISTS SQL syntax instead
- Fixes CREATE INDEX CONCURRENTLY error in Docker migration
2025-12-23 00:21:03 +08:00
fawney19
868f3349e5 fix: use AUTOCOMMIT mode for CREATE INDEX CONCURRENTLY in migration
PostgreSQL 不允许在事务块内执行 CREATE INDEX CONCURRENTLY,
通过创建独立连接并设置 AUTOCOMMIT 隔离级别来解决此问题。
2025-12-23 00:18:11 +08:00
fawney19
d7384e69d9 fix: improve code quality and add type safety for Key updates
- Replace f-string logging with lazy formatting in keys.py (lines 256, 265)
- Add EndpointAPIKeyUpdate type interface for frontend type safety
- Use typed EndpointAPIKeyUpdate instead of any in KeyFormDialog.vue
2025-12-23 00:11:10 +08:00
fawney19
1d5c378343 feat: add TTFB timeout detection and improve stream handling
- Add stream first byte timeout (TTFB) detection to trigger failover
  when provider responds too slowly (configurable via STREAM_FIRST_BYTE_TIMEOUT)
- Add rate limit fail-open/fail-close strategy configuration
- Improve exception handling in stream prefetch with proper error classification
- Refactor UsageService with shared _prepare_usage_record method
- Add batch deletion for old usage records to avoid long transaction locks
- Update CLI adapters to use proper User-Agent headers for each CLI client
- Add composite indexes migration for usage table query optimization
- Fix streaming status display in frontend to show TTFB during streaming
- Remove sensitive JWT secret logging in auth service
2025-12-22 23:44:42 +08:00
fawney19
4e1aed9976 feat: add daily model statistics aggregation with stats_daily_model table 2025-12-20 02:39:10 +08:00
fawney19
e2e7996a54 feat: implement upstream model import and batch model assignment with UI components 2025-12-20 02:01:17 +08:00
fawney19
df9f9a9f4f feat: add internal model list query interface with configurable User-Agent headers 2025-12-19 23:40:42 +08:00
fawney19
7553b0da80 fix: 优化自动刷新交互和ESC关闭样式
- 自动刷新改为按钮切换模式,移除独立Switch开关
- 自动刷新间隔从30s改为10s
- ESC关闭弹窗后blur焦点,避免样式残留
2025-12-19 18:47:14 +08:00
fawney19
8f30bf0bef Merge pull request #32 from htmambo/master
个性化处理
2025-12-19 18:46:26 +08:00
hoping
8c12174521 个性化处理
1. 为所有抽屉和对话框添加 ESC 键关闭功能;
2. 为`使用记录`表格添加自动刷新开关;
3. 为后端 API 请求增加 User-Agent 头部;
4. 修改启动命令支持从.env中读取数据库和Redis配置。
2025-12-19 17:31:15 +08:00
fawney19
6aa1876955 feat: add Dockerfile.app.local for China mirror support 2025-12-19 16:20:02 +08:00
fawney19
7f07122aea refactor: separate frontend build from base image for faster incremental builds 2025-12-19 16:02:38 +08:00
fawney19
c2ddc6bd3c refactor: optimize Docker build with multi-stage and slim runtime base image 2025-12-19 15:51:21 +08:00
fawney19
af476ff21e feat: enhance error logging and upstream response tracking for provider failures 2025-12-19 15:29:48 +08:00
fawney19
3bbc1c6b66 feat: add provider compatibility error detection for intelligent failover
- Introduce ProviderCompatibilityException for unsupported parameter/feature errors
- Add COMPATIBILITY_ERROR_PATTERNS to detect provider-specific limitations
- Implement _is_compatibility_error() method in ErrorClassifier
- Prioritize compatibility error checking before client error validation
- Remove 'max_tokens' from CLIENT_ERROR_PATTERNS as it can indicate compatibility issues
- Enable automatic failover when provider doesn't support requested features
- Improve error classification accuracy with pattern matching for common compatibility issues
2025-12-19 13:28:26 +08:00
fawney19
c69a0a8506 refactor: remove stream smoothing config from system settings and improve base image caching
- Remove stream_smoothing configuration from SystemConfigService (moved to handler default)
- Remove stream smoothing UI controls from admin settings page
- Add AdminClearSingleAffinityAdapter for targeted cache invalidation
- Add clearSingleAffinity API endpoint to clear specific affinity cache entries
- Include global_model_id in affinity list response for UI deletion support
- Improve CI/CD workflow with hash-based base image change detection
- Add hash label to base image for reliable cache invalidation detection
- Use remote image inspection to determine if base image rebuild is needed
- Include Dockerfile.base in hash calculation for proper dependency tracking
2025-12-19 13:09:56 +08:00
fawney19
1fae202bde Merge pull request #30 from AAEE86/master
chore: Modify the order of API format enumeration
2025-12-19 12:34:22 +08:00
fawney19
b9a26c4550 fix: add SETUPTOOLS_SCM_PRETEND_VERSION for CI builds 2025-12-19 12:01:19 +08:00
AAEE86
e42bd35d48 chore: Modify the order of API format enumeration - Move CLAUDE_CLI before OPENAI 2025-12-19 11:44:10 +08:00
fawney19
f22a073fd9 fix: rebuild app image when base image changes during deployment
- Track BASE_REBUILT flag to detect base image rebuilds
- Force app image rebuild when base image is rebuilt
- Prevents stale app images built with outdated base images
- Ensures consistent deployment when base dependencies change
2025-12-19 11:32:43 +08:00
fawney19
5c7ad089d2 fix: disable nginx buffering for streaming responses
- Add X-Accel-Buffering: no header to prevent nginx from buffering streamed content
- Ensures immediate delivery of each chunk without proxy buffering delays
- Improves real-time streaming performance and responsiveness
- Applies to both production and local Dockerfiles
2025-12-19 11:26:15 +08:00
fawney19
97425ac68f refactor: make stream smoothing parameters configurable and add models cache invalidation
- Move stream smoothing parameters (chunk_size, delay_ms) to database config
- Remove hardcoded stream smoothing constants from StreamProcessor
- Simplify dynamic delay calculation by using config values directly
- Add invalidate_models_list_cache() function to clear /v1/models endpoint cache
- Call cache invalidation on model create, update, delete, and bulk operations
- Update admin UI to allow runtime configuration of smoothing parameters
- Improve model listing freshness when models are modified
2025-12-19 11:03:46 +08:00
fawney19
912f6643e2 tune: adjust stream smoothing parameters for better user experience
- Increase chunk size from 5 to 20 characters for fewer delays
- Reduce min delay from 15ms to 8ms for faster playback
- Reduce max delay from 24ms to 15ms for better responsiveness
- Adjust text thresholds to better differentiate content types
- Apply parameter tuning to both StreamProcessor and _LightweightSmoother
2025-12-19 09:51:09 +08:00
fawney19
6c0373fda6 refactor: simplify text splitting logic in stream processor
- Remove complex conditional logic for short/medium/long text differentiation
- Unify text splitting to always use consistent CHUNK_SIZE-based splitting
- Rely on dynamic delay calculation for output speed adjustment
- Reduce code complexity in both main smoother and lightweight smoother
2025-12-19 09:48:11 +08:00
fawney19
070121717d refactor: consolidate stream smoothing into StreamProcessor with intelligent timing
- Move StreamSmoother functionality directly into StreamProcessor for better integration
- Create ContentExtractor strategy pattern for format-agnostic content extraction
- Implement intelligent dynamic delay calculation based on text length
- Support three text length tiers: short (char-by-char), medium (chunked), long (chunked)
- Remove manual chunk_size and delay_ms configuration - now auto-calculated
- Simplify admin UI to single toggle switch with auto timing adjustment
- Extract format detection logic to reusable content_extractors module
- Improve code maintainability with cleaner architecture
2025-12-19 09:46:22 +08:00
fawney19
85fafeacb8 feat: add stream smoothing feature for improved user experience
- Implement StreamSmoother class to split large content chunks into smaller pieces with delay
- Support OpenAI, Claude, and Gemini API response formats for smooth playback
- Add stream smoothing configuration to system settings (enable, chunk size, delay)
- Create streamlined API for stream smoothing with StreamSmoothingConfig dataclass
- Add admin UI controls for configuring stream smoothing parameters
- Use batch configuration loading to minimize database queries
- Enable typing effect simulation for better user experience in streaming responses
2025-12-19 03:15:19 +08:00
fawney19
daf8b870f0 fix: include Dockerfile.base.local in dependency hash calculation
- Add Dockerfile.base.local to deps hash to detect Docker configuration changes
- Ensures deployment rebuilds when nginx proxy settings are modified
- Prevents stale Docker images from being reused after config changes
2025-12-19 02:38:46 +08:00
fawney19
880fb61c66 fix: disable gzip compression in nginx proxy configuration
- Add gzip off directive to prevent nginx from compressing proxied responses
- Ensures stream integrity for chunked transfer encoding
- Applies to both production and local Dockerfiles
2025-12-19 02:17:07 +08:00
fawney19
7e792dabfc refactor: use background task for client disconnection monitoring
- Replace time-based throttling with background task for disconnect checks
- Remove time.monotonic() and related throttling logic
- Prevent blocking of stream transmission during connection checks
- Properly clean up background task with try/finally block
- Improve throughput and responsiveness of stream processing
2025-12-19 01:59:56 +08:00
fawney19
cd06169b2f fix: detect OpenAI format stream completion via finish_reason
- Add detection of finish_reason in OpenAI API responses to mark stream completion
- Ensures OpenAI API streams are properly marked as complete even without explicit completion events
- Complements existing completion event detection for other API formats
2025-12-19 01:44:35 +08:00
fawney19
50ffd47546 fix: handle client disconnection after stream completion gracefully
- Check has_completion flag before marking client disconnection as failure
- Allow graceful termination if response already completed when client disconnects
- Change logging level to info for post-completion disconnections
- Prevent false error reporting when client closes connection after receiving full response
2025-12-19 01:36:20 +08:00
fawney19
5f0c1fb347 refactor: remove unused response normalizer module
- Delete unused ResponseNormalizer class and its initialization logic
- Remove response_normalizer and enable_response_normalization parameters from handlers
- Simplify chat adapter base initialization by removing normalizer setup
- Clean up unused imports in handler modules
2025-12-19 01:20:30 +08:00
fawney19
7b932d7afb refactor: optimize middleware with pure ASGI implementation and enhance security measures
- Replace BaseHTTPMiddleware with pure ASGI implementation in plugin middleware for better streaming response handling
- Add trusted proxy count configuration for client IP extraction in reverse proxy environments
- Implement audit log cleanup scheduler with configurable retention period
- Replace plaintext token logging with SHA256 hash fingerprints for security
- Fix database session lifecycle management in middleware
- Improve request tracing and error logging throughout the system
- Add comprehensive tests for pipeline architecture
2025-12-18 19:07:20 +08:00
fawney19
c7b971cfe7 Merge pull request #29 from fawney19/feature/proxy-support
feat: add HTTP/SOCKS5 proxy support for API endpoints
2025-12-18 16:18:25 +08:00
fawney19
293bb592dc fix: enhance proxy configuration with password preservation and UI improvements
- Add 'enabled' field to ProxyConfig for preserving config when disabled
- Mask proxy password in API responses (return '***' instead of actual password)
- Preserve existing password on update when new password not provided
- Add URL encoding for proxy credentials (handle special chars like @, :, /)
- Enhanced URL validation: block SOCKS4, require valid host, forbid embedded auth
- UI improvements: use Switch component, dynamic password placeholder
- Add confirmation dialog for orphaned credentials (URL empty but has username/password)
- Prevent browser password autofill with randomized IDs and CSS text-security
- Unify ProxyConfig type definition in types.ts
2025-12-18 16:14:37 +08:00
fawney19
3e50c157be feat: add HTTP/SOCKS5 proxy support for API endpoints
- Add proxy field to ProviderEndpoint database model with migration
- Add ProxyConfig Pydantic model for proxy URL validation
- Extend HTTP client pool with create_client_with_proxy method
- Integrate proxy configuration in chat_handler_base.py and cli_handler_base.py
- Update admin API endpoints to support proxy configuration CRUD
- Add proxy configuration UI in frontend EndpointFormDialog

Fixes #28
2025-12-18 14:46:47 +08:00
fawney19
21587449c8 fix: improve error classification and logging system
- Enhance error classifier to properly handle API key failures with fallback support
- Add error reason/code parsing for better AWS and multi-provider compatibility
- Improve error message structure detection for non-standard formats
- Refactor file logging with size-based rotation (100MB) instead of daily
- Optimize production logging by disabling backtrace and diagnose
- Clean up model validation and remove redundant configurations
2025-12-18 10:57:31 +08:00
fawney19
3d0ab353d3 refactor: migrate Pydantic Config to v2 ConfigDict 2025-12-18 02:20:53 +08:00
fawney19
b2a857c164 refactor: consolidate transaction management and remove legacy modules
- Remove unused context.py module (replaced by request.state)
- Remove provider_cache.py (no longer needed)
- Unify environment loading in config/settings.py instead of __init__.py
- Add deprecation warning for get_async_db() (consolidating on sync Session)
- Enhance database.py documentation with comprehensive transaction strategy
- Simplify audit logging to reuse request-level Session (no separate connections)
- Extract UsageService._build_usage_params() helper to reduce code duplication
- Update model and user cache implementations with refined transaction handling
- Remove unnecessary sessionmaker from pipeline
- Clean up audit service exception handling
2025-12-18 01:59:40 +08:00
fawney19
4d1d863916 refactor: improve authentication and user data handling
- Replace user cache queries with direct database queries to ensure data consistency
- Fix token_type parameter in verify_token calls (access token verification)
- Fix role-based permission check using dictionary ranking instead of string comparison
- Fix logout operation to use correct JWT claim name (user_id instead of sub)
- Simplify user authentication flow by removing unnecessary cache layer
- Optimize session initialization in main.py using create_session helper
- Remove unused imports and exception variables
2025-12-18 01:09:22 +08:00
fawney19
b579420690 refactor: optimize database session lifecycle and middleware architecture
- Improve database pool capacity logging with detailed configuration parameters
- Optimize database session dependency injection with middleware-managed lifecycle
- Simplify plugin middleware by delegating session creation to FastAPI dependencies
- Fix import path in auth routes (relative to absolute)
- Add safety checks for database session management across middleware exception handlers
- Ensure session cleanup only when not managed by middleware (avoid premature cleanup)
2025-12-18 00:35:46 +08:00
fawney19
9d5c84f9d3 refactor: add scheduling mode support and optimize system settings UI
- Add fixed_order and cache_affinity scheduling modes to CacheAwareScheduler
- Only apply cache affinity in cache_affinity mode; use fixed order otherwise
- Simplify Dialog components with title/description props
- Remove unnecessary button shadows in SystemSettings
- Optimize import dialog UI structure
- Update ModelAliasesTab shadow styling
- Fix fallback orchestrator type hints
- Add scheduling_mode configuration in system config
2025-12-17 19:15:08 +08:00
fawney19
53e6a82480 Merge pull request #25 from fawney19/fix/22-dark-mode-settings-bug
fix: 修复个人设置页面深色模式切换后刷新失效的问题
2025-12-17 18:11:53 +08:00
fawney19
bd11ebdbd5 fix: 修复个人设置页面深色模式切换后刷新失效的问题
- 前端使用 useDarkMode composable 统一主题切换逻辑
- 后端支持 system 主题值(之前只支持 auto)
- 主题以本地 localStorage 为准,避免刷新时被服务端旧值覆盖

Fixes #22
2025-12-17 18:02:19 +08:00
fawney19
1dac4cb156 refactor: optimize provider query and stats aggregation logic 2025-12-17 16:41:10 +08:00
fawney19
50abb55c94 fix(models): clear form state when loading model data for edit
Reset model selection, search query, and expanded provider state
when switching to edit mode to prevent stale UI state carrying over
from previous operations. Also ensure tieredPricing is properly set
or reset based on model data.
2025-12-16 18:42:58 +08:00
fawney19
73d3c9d3e4 ui(models): display model ID in global model form dialog
Show model ID below model name in the dropdown list for better clarity
when selecting models, with appropriate text styling for selected state.
2025-12-16 18:36:23 +08:00
fawney19
d24c3885ab feat(admin): add config and user data import/export functionality
Add comprehensive import/export endpoints for:
- Provider and model configuration (with key decryption for export)
- User data and API keys (preserving encrypted data)

Includes merge modes (skip/overwrite/error) for conflict handling,
10MB size limit for imports, and automatic cache invalidation.

Also fix optional field in GlobalModelResponse tiered_pricing.
2025-12-16 18:33:14 +08:00
fawney19
d696c575e6 refactor(migrations): add idempotency checks to migration scripts 2025-12-16 17:46:38 +08:00
fawney19
46ff5a1a50 refactor(models): enhance model management with official provider marking and extended metadata
- Add OFFICIAL_PROVIDERS set to mark first-party vendors in models.dev
- Implement official provider marking function with cache compatibility
- Extend model metadata with family, context_limit, output_limit fields
- Improve frontend model selection UI with wider panel and better search
- Add dark mode support for provider logos
- Optimize scrollbar styling for model lists
- Update deployment documentation with clearer migration steps
2025-12-16 17:28:40 +08:00
fawney19
edce43d45f fix(auth): make get_current_user and get_current_user_from_header async functions
将 get_current_user 和 get_current_user_from_header 函数声明为 async,
并更新 AuthService.verify_token 的调用为 await,以正确处理异步 Token 验证。
2025-12-16 13:42:26 +08:00
fawney19
33265b4b13 refactor(global-model): migrate model metadata to flexible config structure
将模型配置从多个固定字段(description, official_url, icon_url, default_supports_* 等)
统一为灵活的 config JSON 字段,提高扩展性。同时优化前端模型创建表单,支持从 models-dev
列表直接选择模型快速填充。

主要变更:
- 后端:模型表迁移,支持 config JSON 存储模型能力和元信息
- 前端:GlobalModelFormDialog 支持两种创建方式(列表选择/手动填写)
- API 类型更新,对齐新的数据结构
2025-12-16 12:21:21 +08:00
fawney19
a94aeca2d3 docs(deploy): add database migration step to deployment guide and create migration script 2025-12-16 09:21:24 +08:00
fawney19
c42ebdd0ee test(handler): add comprehensive stream processor unit tests 2025-12-16 02:40:26 +08:00
fawney19
f1e3c2ab11 feat(frontend-usage): enhance usage UI with first byte latency metrics
- Update usage records table to display first_byte_time_ms metrics
- Improve request timeline visualization for latency tracking
- Extend usage types for new timing information
2025-12-16 02:39:54 +08:00
fawney19
4e2ba0e57f feat(usage): add first_byte_time_ms tracking to usage statistics
- Enhance usage service to capture and store first byte latency metrics
- Update usage API routes to include new timing information
2025-12-16 02:39:36 +08:00
fawney19
a3df41d63d refactor(cli-handler): improve stream handling and response processing
- Refactor CLI handler base for better stream context management
- Optimize request/response handling for Claude, OpenAI, and Gemini CLI adapters
- Enhance telemetry tracking across CLI handlers
2025-12-16 02:39:20 +08:00
fawney19
ad1c8c394c refactor(handler): optimize stream processing and telemetry pipeline
- Enhance stream context for better token and latency tracking
- Refactor stream processor for improved performance metrics
- Improve telemetry integration with first_byte_time_ms support
- Add comprehensive stream context unit tests
2025-12-16 02:39:03 +08:00
fawney19
9b496abb73 feat(db): add first_byte_time_ms column to usage table 2025-12-16 02:38:43 +08:00
181 changed files with 15592 additions and 4288 deletions

View File

@@ -1,8 +1,16 @@
# ==================== 必须配置(启动前) ====================
# 以下配置项必须在项目启动前设置
# 数据库密码
# 数据库配置
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_NAME=aether
DB_PASSWORD=your_secure_password_here
# Redis 配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password_here
# JWT密钥使用 python generate_keys.py 生成)

View File

@@ -15,6 +15,8 @@ env:
REGISTRY: ghcr.io
BASE_IMAGE_NAME: fawney19/aether-base
APP_IMAGE_NAME: fawney19/aether
# Files that affect base image - used for hash calculation
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
jobs:
check-base-changes:
@@ -23,8 +25,13 @@ jobs:
base_changed: ${{ steps.check.outputs.base_changed }}
steps:
- uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
fetch-depth: 2
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check if base image needs rebuild
id: check
@@ -34,10 +41,26 @@ jobs:
exit 0
fi
# Check if base-related files changed
if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
# Calculate current hash of base-related files
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
echo "Current base files hash: $CURRENT_HASH"
# Try to get hash label from remote image config
# Pull the image config and extract labels
REMOTE_HASH=""
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
fi
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
# No remote image or no hash label, need to rebuild
echo "No remote base image or hash label found, need rebuild"
echo "base_changed=true" >> $GITHUB_OUTPUT
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
echo "base_changed=true" >> $GITHUB_OUTPUT
else
echo "Hash matches, no rebuild needed"
echo "base_changed=false" >> $GITHUB_OUTPUT
fi
@@ -61,6 +84,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Calculate base files hash
id: hash
run: |
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
echo "hash=$HASH" >> $GITHUB_OUTPUT
- name: Extract metadata for base image
id: meta
uses: docker/metadata-action@v5
@@ -69,6 +98,8 @@ jobs:
tags: |
type=raw,value=latest
type=sha,prefix=
labels: |
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
- name: Build and push base image
uses: docker/build-push-action@v5
@@ -117,7 +148,7 @@ jobs:
- name: Update Dockerfile.app to use registry base image
run: |
sed -i "s|FROM aether-base:latest|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest|g" Dockerfile.app
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
- name: Build and push app image
uses: docker/build-push-action@v5

View File

@@ -1,16 +1,134 @@
# 应用镜像:基于基础镜像,只复制代码(秒级构建)
# 运行镜像:从 base 提取产物到精简运行时
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
FROM aether-base:latest
# 用于 GitHub Actions CI官方源
FROM aether-base:latest AS builder
WORKDIR /app
# 复制前端源码并构建
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
WORKDIR /app
# 运行时依赖(无 gcc/nodejs/npm
RUN apt-get update && apt-get install -y \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 只复制需要的 Python 可执行文件
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# 构建前端(使用基础镜像中已安装的 node_modules
COPY frontend/ /tmp/frontend/
RUN cd /tmp/frontend && npm run build && \
cp -r dist/* /usr/share/nginx/html/ && \
rm -rf /tmp/frontend
# Nginx 配置模板
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

135
Dockerfile.app.local Normal file
View File

@@ -0,0 +1,135 @@
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
# 用于本地/国内服务器部署
FROM aether-base:latest AS builder
WORKDIR /app
# 复制前端源码并构建
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# ==================== 运行时镜像 ====================
FROM python:3.12-slim
WORKDIR /app
# 运行时依赖(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
nginx \
supervisor \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# 从 base 镜像复制 Python 包
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# 只复制需要的 Python 可执行文件
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
# 从 builder 阶段复制前端构建产物
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
# 复制后端代码
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# Nginx 配置模板
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' gzip off;' \
' add_header X-Accel-Buffering no;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app --preload -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

View File

@@ -1,122 +1,25 @@
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
# 构建镜像:编译环境 + 预编译的依赖
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
WORKDIR /app
# 系统依赖
# 构建工具
RUN apt-get update && apt-get install -y \
nginx \
supervisor \
libpq-dev \
gcc \
curl \
gettext-base \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Python 依赖(安装到系统,不用 -e 模式)
# Python 依赖
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
pip install --no-cache-dir .
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
# 前端依赖
COPY frontend/package*.json /tmp/frontend/
WORKDIR /tmp/frontend
RUN npm ci
# Nginx 配置模板
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
WORKDIR /app
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# 前端依赖(只安装,不构建)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm ci

View File

@@ -1,18 +1,15 @@
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
FROM python:3.12-slim
WORKDIR /app
# 系统依赖
# 构建工具(使用清华镜像源)
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
apt-get update && apt-get install -y \
nginx \
supervisor \
libpq-dev \
gcc \
curl \
gettext-base \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
@@ -20,107 +17,12 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.li
# pip 镜像源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
# Python 依赖(安装到系统,不用 -e 模式)
# Python 依赖
COPY pyproject.toml README.md ./
RUN mkdir -p src && touch src/__init__.py && \
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir .
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
pip cache purge
# 前端依赖
COPY frontend/package*.json /tmp/frontend/
WORKDIR /tmp/frontend
RUN npm config set registry https://registry.npmmirror.com && npm ci
# Nginx 配置模板
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' client_max_body_size 100M;' \
'' \
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
' expires 1y;' \
' add_header Cache-Control "public, no-transform";' \
' try_files $uri =404;' \
' }' \
'' \
' location ~ ^/(src|node_modules)/ {' \
' deny all;' \
' return 404;' \
' }' \
'' \
' location ~ ^/(dashboard|admin|login)(/|$) {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location / {' \
' try_files $uri $uri/ @backend;' \
' }' \
'' \
' location @backend {' \
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_set_header Connection "";' \
' proxy_set_header Accept $http_accept;' \
' proxy_set_header Content-Type $content_type;' \
' proxy_set_header Authorization $http_authorization;' \
' proxy_set_header X-Api-Key $http_x_api_key;' \
' proxy_buffering off;' \
' proxy_cache off;' \
' proxy_request_buffering off;' \
' chunked_transfer_encoding on;' \
' proxy_connect_timeout 600s;' \
' proxy_send_timeout 600s;' \
' proxy_read_timeout 600s;' \
' }' \
'}' > /etc/nginx/sites-available/default.template
# Supervisor 配置
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'logfile=/var/log/supervisor/supervisord.log' \
'pidfile=/var/run/supervisord.pid' \
'' \
'[program:nginx]' \
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/var/log/nginx/access.log' \
'stderr_logfile=/var/log/nginx/error.log' \
'' \
'[program:app]' \
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
'directory=/app' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'stderr_logfile=/dev/stderr' \
'stderr_logfile_maxbytes=0' \
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
# 创建目录
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
WORKDIR /app
# 环境变量
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONIOENCODING=utf-8 \
LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \
PORT=8084
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/health || exit 1
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
# 前端依赖(只安装,不构建,使用淘宝镜像源)
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci

15
LICENSE
View File

@@ -5,12 +5,17 @@ Aether 非商业开源许可证
特此授予任何获得本软件及其相关文档文件(以下简称"软件")副本的人免费使用、
复制、修改、合并、发布和分发本软件的权限,但须遵守以下条件:
1. 仅限非商业用途
本软件不得用于商业目的。商业目的包括但不限于:
1. 仅限非盈利用途
本软件不得用于盈利目的。盈利目的包括但不限于:
- 出售本软件或任何衍生作品
- 使用本软件提供付费服务
- 将本软件用于商业产品或服务
- 将本软件用于任何旨在获取商业利益或金钱报酬的活动
- 将本软件用于以盈利为目的的商业产品或服务
以下用途被明确允许:
- 个人学习和研究
- 教育机构的教学和研究
- 非盈利组织的内部使用
- 企业内部非盈利性质的使用(如内部工具、测试环境等)
2. 署名要求
上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。
@@ -22,7 +27,7 @@ Aether 非商业开源许可证
您不得以不同的条款将本软件再许可给他人。
5. 商业许可
如需商业使用,请联系版权持有人以获取单独的商业许可。
如需将本软件用于盈利目的,请联系版权持有人以获取单独的商业许可。
本软件按"原样"提供,不提供任何明示或暗示的保证,包括但不限于对适销性、
特定用途适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何

View File

@@ -60,8 +60,11 @@ python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署
docker-compose up -d
# 4. 更新
docker-compose pull && docker-compose up -d
# 4. 首次部署时, 初始化数据库
./migrate.sh
# 5. 更新
docker-compose pull && docker-compose up -d && ./migrate.sh
```
### Docker Compose本地构建镜像
@@ -140,7 +143,7 @@ cd frontend && npm install && npm run dev
- **模型级别**: 在模型管理中针对指定模型开启 1H缓存策略
- **密钥级别**: 在密钥管理中针对指定密钥使用 1H缓存策略
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能用支持 1H缓存的模型
> **注意**: 若对密钥设置强制 1H缓存, 则该密钥只能使用支持 1H缓存的模型, 匹配提供商Key, 将会导致这个Key无法同时用于Claude Code、Codex、GeminiCLI, 因为更推荐使用模型开启1H缓存.
### Q: 如何配置负载均衡?
@@ -159,4 +162,16 @@ cd frontend && npm install && npm run dev
## 许可证
本项目采用 [Aether 非商业开源许可证](LICENSE)。
本项目采用 [Aether 非商业开源许可证](LICENSE)。允许个人学习、教育研究、非盈利组织及企业内部非盈利性质的使用;禁止用于盈利目的。商业使用请联系获取商业许可。
## 联系作者
<p align="center">
<img src="docs/author/qq_qrcode.jpg" width="200" alt="QQ二维码">
</p>
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=fawney19/Aether&type=Date)](https://star-history.com/#fawney19/Aether&Date)

View File

@@ -20,10 +20,10 @@ depends_on = None
def upgrade() -> None:
# Create ENUM types
op.execute("CREATE TYPE userrole AS ENUM ('admin', 'user')")
# Create ENUM types (with IF NOT EXISTS for idempotency)
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
op.execute(
"CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier')"
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
)
# ==================== users ====================
@@ -35,7 +35,7 @@ def upgrade() -> None:
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column(
"role",
sa.Enum("admin", "user", name="userrole", create_type=False),
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
nullable=False,
server_default="user",
),
@@ -67,7 +67,7 @@ def upgrade() -> None:
sa.Column("website", sa.String(500), nullable=True),
sa.Column(
"billing_type",
sa.Enum(
postgresql.ENUM(
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
),
nullable=False,
@@ -394,6 +394,10 @@ def upgrade() -> None:
index=True,
),
)
# usage 表复合索引(优化常见查询)
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
# ==================== user_quotas ====================
op.create_table(

View File

@@ -26,16 +26,66 @@ branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def table_exists(bind, table_name: str) -> bool:
"""检查表是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = :table_name
)
"""
),
{"table_name": table_name},
)
return result.scalar()
def index_exists(bind, index_name: str) -> bool:
"""检查索引是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM pg_indexes
WHERE indexname = :index_name
)
"""
),
{"index_name": index_name},
)
return result.scalar()
def upgrade() -> None:
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
# 1. 添加 provider_model_aliases 字段
op.add_column(
'models',
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
)
# 2. 迁移 model_mappings 数据
bind = op.get_bind()
# 1. 添加 provider_model_aliases 字段(如果不存在)
if not column_exists(bind, "models", "provider_model_aliases"):
op.add_column(
'models',
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
)
# 2. 迁移 model_mappings 数据(如果表存在)
session = Session(bind=bind)
model_mappings_table = sa.table(
@@ -96,104 +146,118 @@ def upgrade() -> None:
# 查询所有活跃的 provider 级别 alias只迁移 is_active=True 且 mapping_type='alias' 的)
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
mappings = session.execute(
sa.select(
model_mappings_table.c.source_model,
model_mappings_table.c.target_global_model_id,
model_mappings_table.c.provider_id,
)
.where(
model_mappings_table.c.is_active.is_(True),
model_mappings_table.c.provider_id.isnot(None),
model_mappings_table.c.mapping_type == "alias",
)
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
).all()
# 按 (provider_id, target_global_model_id) 分组,收集别名
alias_groups: dict = {}
for source_model, target_global_model_id, provider_id in mappings:
if not isinstance(source_model, str):
continue
source_model = source_model.strip()
if not source_model:
continue
if not isinstance(provider_id, str) or not provider_id:
continue
if not isinstance(target_global_model_id, str) or not target_global_model_id:
continue
key = (provider_id, target_global_model_id)
if key not in alias_groups:
alias_groups[key] = []
priority = len(alias_groups[key]) + 1
alias_groups[key].append({"name": source_model, "priority": priority})
# 更新对应的 models 记录
for (provider_id, global_model_id), aliases in alias_groups.items():
model_row = session.execute(
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
# 仅当 model_mappings 表存在时执行迁移
if table_exists(bind, "model_mappings"):
mappings = session.execute(
sa.select(
model_mappings_table.c.source_model,
model_mappings_table.c.target_global_model_id,
model_mappings_table.c.provider_id,
)
.where(
models_table.c.provider_id == provider_id,
models_table.c.global_model_id == global_model_id,
model_mappings_table.c.is_active.is_(True),
model_mappings_table.c.provider_id.isnot(None),
model_mappings_table.c.mapping_type == "alias",
)
.limit(1)
).first()
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
).all()
if model_row:
model_id = model_row[0]
existing_aliases = normalize_alias_list(model_row[1])
# 按 (provider_id, target_global_model_id) 分组,收集别名
alias_groups: dict = {}
for source_model, target_global_model_id, provider_id in mappings:
if not isinstance(source_model, str):
continue
source_model = source_model.strip()
if not source_model:
continue
if not isinstance(provider_id, str) or not provider_id:
continue
if not isinstance(target_global_model_id, str) or not target_global_model_id:
continue
existing_names = {a["name"] for a in existing_aliases}
merged_aliases = list(existing_aliases)
for alias in aliases:
name = alias.get("name")
if not isinstance(name, str):
continue
name = name.strip()
if not name or name in existing_names:
continue
key = (provider_id, target_global_model_id)
if key not in alias_groups:
alias_groups[key] = []
priority = len(alias_groups[key]) + 1
alias_groups[key].append({"name": source_model, "priority": priority})
merged_aliases.append(
{
"name": name,
"priority": len(merged_aliases) + 1,
}
# 更新对应的 models 记录
for (provider_id, global_model_id), aliases in alias_groups.items():
model_row = session.execute(
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
.where(
models_table.c.provider_id == provider_id,
models_table.c.global_model_id == global_model_id,
)
existing_names.add(name)
.limit(1)
).first()
session.execute(
models_table.update()
.where(models_table.c.id == model_id)
.values(
provider_model_aliases=merged_aliases if merged_aliases else None,
updated_at=datetime.now(timezone.utc),
if model_row:
model_id = model_row[0]
existing_aliases = normalize_alias_list(model_row[1])
existing_names = {a["name"] for a in existing_aliases}
merged_aliases = list(existing_aliases)
for alias in aliases:
name = alias.get("name")
if not isinstance(name, str):
continue
name = name.strip()
if not name or name in existing_names:
continue
merged_aliases.append(
{
"name": name,
"priority": len(merged_aliases) + 1,
}
)
existing_names.add(name)
session.execute(
models_table.update()
.where(models_table.c.id == model_id)
.values(
provider_model_aliases=merged_aliases if merged_aliases else None,
updated_at=datetime.now(timezone.utc),
)
)
)
session.commit()
session.commit()
# 3. 删除 model_mappings 表
op.drop_table('model_mappings')
# 3. 删除 model_mappings 表
op.drop_table('model_mappings')
# 4. 添加索引优化别名解析性能
# provider_model_name 索引(支持精确匹配)
op.create_index(
"idx_model_provider_model_name",
"models",
["provider_model_name"],
unique=False,
postgresql_where=sa.text("is_active = true"),
)
# provider_model_name 索引(支持精确匹配,如果不存在
if not index_exists(bind, "idx_model_provider_model_name"):
op.create_index(
"idx_model_provider_model_name",
"models",
["provider_model_name"],
unique=False,
postgresql_where=sa.text("is_active = true"),
)
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL
if bind.dialect.name == "postgresql":
# 将 json 列转为 jsonbjsonb 性能更好且支持 GIN 索引)
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
op.execute(
"""
ALTER TABLE models
ALTER COLUMN provider_model_aliases TYPE jsonb
USING provider_model_aliases::jsonb
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'models'
AND column_name = 'provider_model_aliases'
AND data_type = 'json'
) THEN
ALTER TABLE models
ALTER COLUMN provider_model_aliases TYPE jsonb
USING provider_model_aliases::jsonb;
END IF;
END $$;
"""
)
# 创建 GIN 索引

View File

@@ -0,0 +1,47 @@
"""add first_byte_time_ms to usage table
Revision ID: 180e63a9c83a
Revises: e9b3d63f0cbf
Create Date: 2025-12-15 17:07:44.631032+00:00
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '180e63a9c83a'
down_revision = 'e9b3d63f0cbf'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None:
"""应用迁移:升级到新版本"""
bind = op.get_bind()
# 添加首字时间字段到 usage 表(如果不存在)
if not column_exists(bind, "usage", "first_byte_time_ms"):
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
# 删除首字时间字段
op.drop_column('usage', 'first_byte_time_ms')

View File

@@ -0,0 +1,110 @@
"""refactor global_model to use config json field
Revision ID: 1cc6942cf06f
Revises: 180e63a9c83a
Create Date: 2025-12-16 03:11:32.480976+00:00
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '1cc6942cf06f'
down_revision = '180e63a9c83a'
branch_labels = None
depends_on = None
def column_exists(bind, table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
result = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = :table_name AND column_name = :column_name
)
"""
),
{"table_name": table_name, "column_name": column_name},
)
return result.scalar()
def upgrade() -> None:
"""应用迁移:升级到新版本
1. 添加 config 列
2. 把旧数据迁移到 config
3. 删除旧列
"""
bind = op.get_bind()
# 检查是否已经迁移过config 列存在且旧列不存在)
has_config = column_exists(bind, "global_models", "config")
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
if has_config and not has_old_columns:
# 已完成迁移,跳过
return
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
if not has_config:
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
# 2. 迁移数据:把旧字段合并到 config JSON仅当旧列存在时
if has_old_columns:
op.execute("""
UPDATE global_models
SET config = jsonb_strip_nulls(jsonb_build_object(
'streaming', COALESCE(default_supports_streaming, true),
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
'description', description,
'icon_url', icon_url,
'official_url', official_url
))
""")
# 3. 删除旧列
op.drop_column('global_models', 'default_supports_streaming')
op.drop_column('global_models', 'default_supports_vision')
op.drop_column('global_models', 'default_supports_function_calling')
op.drop_column('global_models', 'default_supports_extended_thinking')
op.drop_column('global_models', 'default_supports_image_generation')
op.drop_column('global_models', 'description')
op.drop_column('global_models', 'icon_url')
op.drop_column('global_models', 'official_url')
def downgrade() -> None:
"""回滚迁移:降级到旧版本"""
# 1. 添加旧列
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
# 2. 从 config 恢复数据
op.execute("""
UPDATE global_models
SET
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
description = config->>'description',
icon_url = config->>'icon_url',
official_url = config->>'official_url'
""")
# 3. 删除 config 列
op.drop_column('global_models', 'config')

View File

@@ -0,0 +1,57 @@
"""add proxy field to provider_endpoints
Revision ID: f30f9936f6a2
Revises: 1cc6942cf06f
Create Date: 2025-12-18 06:31:58.451112+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'f30f9936f6a2'
down_revision = '1cc6942cf06f'
branch_labels = None
depends_on = None
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def get_column_type(table_name: str, column_name: str) -> str:
"""获取列的类型"""
bind = op.get_bind()
inspector = inspect(bind)
for col in inspector.get_columns(table_name):
if col['name'] == column_name:
return str(col['type']).upper()
return ''
def upgrade() -> None:
"""添加 proxy 字段到 provider_endpoints 表"""
if not column_exists('provider_endpoints', 'proxy'):
# 字段不存在,直接添加 JSONB 类型
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
else:
# 字段已存在,检查是否需要转换类型
col_type = get_column_type('provider_endpoints', 'proxy')
if 'JSONB' not in col_type:
# 如果是 JSON 类型,转换为 JSONB
op.execute(
'ALTER TABLE provider_endpoints '
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
)
def downgrade() -> None:
"""移除 proxy 字段"""
if column_exists('provider_endpoints', 'proxy'):
op.drop_column('provider_endpoints', 'proxy')

View File

@@ -0,0 +1,86 @@
"""add stats_daily_model table and rename provider_model_aliases
Revision ID: a1b2c3d4e5f6
Revises: f30f9936f6a2
Create Date: 2025-12-20 12:00:00.000000+00:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = 'f30f9936f6a2'
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
"""检查表是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
"""检查列是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
# 1. 创建 stats_daily_model 表
if not table_exists('stats_daily_model'):
op.create_table(
'stats_daily_model',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
sa.Column('model', sa.String(100), nullable=False),
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), onupdate=sa.func.now()),
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
)
# 创建索引
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
def index_exists(table_name: str, index_name: str) -> bool:
"""检查索引是否存在"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
return index_name in indexes
def downgrade() -> None:
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
# 恢复列名
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
# 删除表
if table_exists('stats_daily_model'):
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
op.drop_table('stats_daily_model')

View File

@@ -0,0 +1,65 @@
"""add usage table composite indexes for query optimization
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-12-20 15:00:00.000000+00:00
"""
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = 'b2c3d4e5f6g7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""为 usage 表添加复合索引以优化常见查询
注意:这些索引已经在 baseline 迁移中创建。
此迁移仅用于从旧版本升级的场景,新安装会跳过。
"""
conn = op.get_bind()
# 检查 usage 表是否存在
result = conn.execute(text(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
))
if not result.scalar():
# 表不存在,跳过
return
# 定义需要创建的索引
indexes = [
("idx_usage_user_created", "ON usage (user_id, created_at)"),
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
]
# 分别检查并创建每个索引
for index_name, index_def in indexes:
result = conn.execute(text(
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
))
if result.scalar():
continue # 索引已存在,跳过
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
def downgrade() -> None:
"""删除复合索引"""
conn = op.get_bind()
# 使用 IF EXISTS 避免索引不存在时报错
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
))
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_apikey_created"
))
conn.execute(text(
"DROP INDEX IF EXISTS idx_usage_user_created"
))

View File

@@ -21,15 +21,18 @@ HASH_FILE=".deps-hash"
CODE_HASH_FILE=".code-hash"
MIGRATION_HASH_FILE=".migration-hash"
# 计算依赖文件的哈希值
# 计算依赖文件的哈希值(包含 Dockerfile.base.local
calc_deps_hash() {
cat pyproject.toml frontend/package.json frontend/package-lock.json 2>/dev/null | md5sum | cut -d' ' -f1
cat pyproject.toml frontend/package.json frontend/package-lock.json Dockerfile.base.local 2>/dev/null | md5sum | cut -d' ' -f1
}
# 计算代码文件的哈希值
# 计算代码文件的哈希值(包含 Dockerfile.app.local
calc_code_hash() {
find src -type f -name "*.py" 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
find frontend/src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) 2>/dev/null | sort | xargs cat 2>/dev/null | md5sum | cut -d' ' -f1
{
cat Dockerfile.app.local 2>/dev/null
find src -type f -name "*.py" 2>/dev/null | sort | xargs cat 2>/dev/null
find frontend/src -type f \( -name "*.vue" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" \) 2>/dev/null | sort | xargs cat 2>/dev/null
} | md5sum | cut -d' ' -f1
}
# 计算迁移文件的哈希值
@@ -88,7 +91,7 @@ build_base() {
# 构建应用镜像
build_app() {
echo ">>> Building app image (code only)..."
docker build -f Dockerfile.app -t aether-app:latest .
docker build -f Dockerfile.app.local -t aether-app:latest .
save_code_hash
}
@@ -162,29 +165,46 @@ git pull
# 标记是否需要重启
NEED_RESTART=false
BASE_REBUILT=false
# 检查基础镜像是否存在,或依赖是否变化
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
echo ">>> Base image not found, building..."
build_base
BASE_REBUILT=true
NEED_RESTART=true
elif check_deps_changed; then
echo ">>> Dependencies changed, rebuilding base image..."
build_base
BASE_REBUILT=true
NEED_RESTART=true
else
echo ">>> Dependencies unchanged."
fi
# 检查代码是否变化
# 检查代码或迁移是否变化,或者 base 重建了app 依赖 base
# 注意:迁移文件打包在镜像中,所以迁移变化也需要重建 app 镜像
MIGRATION_CHANGED=false
if check_migration_changed; then
MIGRATION_CHANGED=true
fi
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
echo ">>> App image not found, building..."
build_app
NEED_RESTART=true
elif [ "$BASE_REBUILT" = true ]; then
echo ">>> Base image rebuilt, rebuilding app image..."
build_app
NEED_RESTART=true
elif check_code_changed; then
echo ">>> Code changed, rebuilding app image..."
build_app
NEED_RESTART=true
elif [ "$MIGRATION_CHANGED" = true ]; then
echo ">>> Migration files changed, rebuilding app image..."
build_app
NEED_RESTART=true
else
echo ">>> Code unchanged."
fi
@@ -197,9 +217,9 @@ else
echo ">>> No changes detected, skipping restart."
fi
# 检查迁移变化
if check_migration_changed; then
echo ">>> Migration files changed, running database migration..."
# 检查迁移变化(如果前面已经检测到变化并重建了镜像,这里直接运行迁移)
if [ "$MIGRATION_CHANGED" = true ]; then
echo ">>> Running database migration..."
sleep 3
run_migration
else

3
dev.sh
View File

@@ -8,7 +8,8 @@ source .env
set +a
# 构建 DATABASE_URL
export DATABASE_URL="postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether"
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
# 启动 uvicorn热重载模式
echo "🚀 启动本地开发服务器..."

View File

@@ -41,7 +41,7 @@ services:
app:
build:
context: .
dockerfile: Dockerfile.app
dockerfile: Dockerfile.app.local
image: aether-app:latest
container_name: aether-app
environment:

BIN
docs/author/qq_qrcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -1,5 +1,179 @@
import apiClient from './client'
// 配置导出数据结构
export interface ConfigExportData {
version: string
exported_at: string
global_models: GlobalModelExport[]
providers: ProviderExport[]
}
// 用户导出数据结构
export interface UsersExportData {
version: string
exported_at: string
users: UserExport[]
}
export interface UserExport {
email: string
username: string
password_hash: string
role: string
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_models?: string[] | null
model_capability_settings?: any
quota_usd?: number | null
used_usd?: number
total_usd?: number
is_active: boolean
api_keys: UserApiKeyExport[]
}
export interface UserApiKeyExport {
key_hash: string
key_encrypted?: string | null
name?: string | null
is_standalone: boolean
balance_used_usd?: number
current_balance_usd?: number | null
allowed_providers?: string[] | null
allowed_endpoints?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
rate_limit?: number
concurrent_limit?: number | null
force_capabilities?: any
is_active: boolean
auto_delete_on_expiry?: boolean
total_requests?: number
total_cost_usd?: number
}
export interface GlobalModelExport {
name: string
display_name: string
default_price_per_request?: number | null
default_tiered_pricing: any
supported_capabilities?: string[] | null
config?: any
is_active: boolean
}
export interface ProviderExport {
name: string
display_name: string
description?: string | null
website?: string | null
billing_type?: string | null
monthly_quota_usd?: number | null
quota_reset_day?: number
rpm_limit?: number | null
provider_priority?: number
is_active: boolean
rate_limit?: number | null
concurrent_limit?: number | null
config?: any
endpoints: EndpointExport[]
models: ModelExport[]
}
export interface EndpointExport {
api_format: string
base_url: string
headers?: any
timeout?: number
max_retries?: number
max_concurrent?: number | null
rate_limit?: number | null
is_active: boolean
custom_path?: string | null
config?: any
keys: KeyExport[]
}
export interface KeyExport {
api_key: string
name?: string | null
note?: string | null
rate_multiplier?: number
internal_priority?: number
global_priority?: number | null
max_concurrent?: number | null
rate_limit?: number | null
daily_limit?: number | null
monthly_limit?: number | null
allowed_models?: string[] | null
capabilities?: any
is_active: boolean
}
export interface ModelExport {
global_model_name: string | null
provider_model_name: string
provider_model_mappings?: any
price_per_request?: number | null
tiered_pricing?: any
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
is_active: boolean
config?: any
}
// Provider 模型查询响应
export interface ProviderModelsQueryResponse {
success: boolean
data: {
models: Array<{
id: string
object?: string
created?: number
owned_by?: string
display_name?: string
api_format?: string
}>
error?: string
}
provider: {
id: string
name: string
display_name: string
}
}
export interface ConfigImportRequest extends ConfigExportData {
merge_mode: 'skip' | 'overwrite' | 'error'
}
export interface UsersImportRequest extends UsersExportData {
merge_mode: 'skip' | 'overwrite' | 'error'
}
export interface UsersImportResponse {
message: string
stats: {
users: { created: number; updated: number; skipped: number }
api_keys: { created: number; skipped: number }
errors: string[]
}
}
export interface ConfigImportResponse {
message: string
stats: {
global_models: { created: number; updated: number; skipped: number }
providers: { created: number; updated: number; skipped: number }
endpoints: { created: number; updated: number; skipped: number }
keys: { created: number; updated: number; skipped: number }
models: { created: number; updated: number; skipped: number }
errors: string[]
}
}
// API密钥管理相关接口定义
export interface AdminApiKey {
id: string // UUID
@@ -173,5 +347,44 @@ export const adminApi = {
'/api/admin/system/api-formats'
)
return response.data
},
// 导出配置
async exportConfig(): Promise<ConfigExportData> {
const response = await apiClient.get<ConfigExportData>('/api/admin/system/config/export')
return response.data
},
// 导入配置
async importConfig(data: ConfigImportRequest): Promise<ConfigImportResponse> {
const response = await apiClient.post<ConfigImportResponse>(
'/api/admin/system/config/import',
data
)
return response.data
},
// 导出用户数据
async exportUsers(): Promise<UsersExportData> {
const response = await apiClient.get<UsersExportData>('/api/admin/system/users/export')
return response.data
},
// 导入用户数据
async importUsers(data: UsersImportRequest): Promise<UsersImportResponse> {
const response = await apiClient.post<UsersImportResponse>(
'/api/admin/system/users/import',
data
)
return response.data
},
// 查询 Provider 可用模型(从上游 API 获取)
async queryProviderModels(providerId: string, apiKeyId?: string): Promise<ProviderModelsQueryResponse> {
const response = await apiClient.post<ProviderModelsQueryResponse>(
'/api/admin/provider-query/models',
{ provider_id: providerId, api_key_id: apiKeyId }
)
return response.data
}
}

View File

@@ -66,6 +66,7 @@ export interface UserAffinity {
key_name: string | null
key_prefix: string | null // Provider Key 脱敏显示前4...后4
rate_multiplier: number
global_model_id: string | null // 原始的 global_model_id用于删除
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5
api_format: string | null // API 格式 (claude/openai)
@@ -119,6 +120,18 @@ export const cacheApi = {
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
},
/**
* 清除单条缓存亲和性
*
* @param affinityKey API Key ID
* @param endpointId Endpoint ID
* @param modelId GlobalModel ID
* @param apiFormat API 格式 (claude/openai)
*/
async clearSingleAffinity(affinityKey: string, endpointId: string, modelId: string, apiFormat: string): Promise<void> {
await api.delete(`/api/admin/monitoring/cache/affinity/${affinityKey}/${endpointId}/${modelId}/${apiFormat}`)
},
/**
* 清除所有缓存
*/

View File

@@ -87,6 +87,8 @@ export interface DashboardStatsResponse {
cache_stats?: CacheStats
users?: UserStats
token_breakdown?: TokenBreakdown
// 普通用户专用字段
monthly_cost?: number
}
export interface RecentRequestsResponse {

View File

@@ -1,5 +1,5 @@
import client from '../client'
import type { ProviderEndpoint } from './types'
import type { ProviderEndpoint, ProxyConfig } from './types'
/**
* 获取指定 Provider 的所有 Endpoints
@@ -38,6 +38,7 @@ export async function createEndpoint(
rate_limit?: number
is_active?: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
}
): Promise<ProviderEndpoint> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
@@ -63,6 +64,7 @@ export async function updateEndpoint(
rate_limit: number
is_active: boolean
config: Record<string, any>
proxy: ProxyConfig | null
}>
): Promise<ProviderEndpoint> {
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)

View File

@@ -4,7 +4,8 @@ import type {
GlobalModelUpdate,
GlobalModelResponse,
GlobalModelWithStats,
GlobalModelListResponse
GlobalModelListResponse,
ModelCatalogProviderDetail,
} from './types'
/**
@@ -83,3 +84,16 @@ export async function batchAssignToProviders(
)
return response.data
}
/**
* 获取 GlobalModel 的所有关联提供商(包括非活跃的)
*/
export async function getGlobalModelProviders(globalModelId: string): Promise<{
providers: ModelCatalogProviderDetail[]
total: number
}> {
const response = await client.get(
`/api/admin/models/global/${globalModelId}/providers`
)
return response.data
}

View File

@@ -110,6 +110,14 @@ export async function updateEndpointKey(
return response.data
}
/**
* 获取完整的 API Key用于查看和复制
*/
export async function revealEndpointKey(keyId: string): Promise<{ api_key: string }> {
const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`)
return response.data
}
/**
* 删除 Endpoint Key
*/

View File

@@ -5,6 +5,8 @@ import type {
ModelUpdate,
ModelCatalogResponse,
ProviderAvailableSourceModelsResponse,
UpstreamModel,
ImportFromUpstreamResponse,
} from './types'
/**
@@ -119,3 +121,40 @@ export async function batchAssignModelsToProvider(
)
return response.data
}
/**
* 查询提供商的上游模型列表
*/
export async function queryProviderUpstreamModels(
providerId: string
): Promise<{
success: boolean
data: {
models: UpstreamModel[]
error: string | null
}
provider: {
id: string
name: string
display_name: string
}
}> {
const response = await client.post('/api/admin/provider-query/models', {
provider_id: providerId,
})
return response.data
}
/**
* 从上游提供商导入模型
*/
export async function importModelsFromUpstream(
providerId: string,
modelIds: string[]
): Promise<ImportFromUpstreamResponse> {
const response = await client.post(
`/api/admin/providers/${providerId}/import-from-upstream`,
{ model_ids: modelIds }
)
return response.data
}

View File

@@ -58,3 +58,38 @@ export async function deleteProvider(providerId: string): Promise<{ message: str
return response.data
}
/**
* 测试模型连接性
*/
export interface TestModelRequest {
provider_id: string
model_name: string
api_key_id?: string
message?: string
api_format?: string
}
export interface TestModelResponse {
success: boolean
error?: string
data?: {
response?: {
status_code?: number
error?: string | { message?: string }
choices?: Array<{ message?: { content?: string } }>
}
content_preview?: string
}
provider?: {
id: string
name: string
display_name: string
}
model?: string
}
export async function testModel(data: TestModelRequest): Promise<TestModelResponse> {
const response = await client.post('/api/admin/provider-query/test-model', data)
return response.data
}

View File

@@ -1,3 +1,35 @@
// API 格式常量
export const API_FORMATS = {
CLAUDE: 'CLAUDE',
CLAUDE_CLI: 'CLAUDE_CLI',
OPENAI: 'OPENAI',
OPENAI_CLI: 'OPENAI_CLI',
GEMINI: 'GEMINI',
GEMINI_CLI: 'GEMINI_CLI',
} as const
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
// API 格式显示名称映射按品牌分组API 在前CLI 在后)
export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE]: 'Claude',
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
[API_FORMATS.OPENAI]: 'OpenAI',
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
[API_FORMATS.GEMINI]: 'Gemini',
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
}
/**
* 代理配置类型
*/
export interface ProxyConfig {
url: string
username?: string
password?: string
enabled?: boolean // 是否启用代理false 时保留配置但不使用)
}
export interface ProviderEndpoint {
id: string
provider_id: string
@@ -19,6 +51,7 @@ export interface ProviderEndpoint {
last_failure_at?: string
is_active: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
total_keys: number
active_keys: number
created_at: string
@@ -77,6 +110,24 @@ export interface EndpointAPIKey {
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
}
export interface EndpointAPIKeyUpdate {
name?: string
api_key?: string // 仅在需要更新时提供
rate_multiplier?: number
internal_priority?: number
global_priority?: number | null
max_concurrent?: number | null // null 表示切换为自适应模式
rate_limit?: number
daily_limit?: number
monthly_limit?: number
allowed_models?: string[] | null
capabilities?: Record<string, boolean> | null
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
note?: string
is_active?: boolean
}
export interface EndpointHealthDetail {
api_format: string
health_score: number
@@ -211,17 +262,21 @@ export interface ConcurrencyStatus {
key_max_concurrent?: number
}
export interface ProviderModelAlias {
export interface ProviderModelMapping {
name: string
priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
}
// 保留别名以保持向后兼容
export type ProviderModelAlias = ProviderModelMapping
export interface Model {
id: string
provider_id: string
global_model_id?: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的主模型名称
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
@@ -251,7 +306,7 @@ export interface Model {
export interface ModelCreate {
provider_model_name: string // Provider 侧的主模型名称
provider_model_aliases?: ProviderModelAlias[] // 模型名称别名列表(带优先级)
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
global_model_id: string // 关联的 GlobalModel ID必填
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
@@ -268,7 +323,7 @@ export interface ModelCreate {
export interface ModelUpdate {
provider_model_name?: string
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string
price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
@@ -407,67 +462,45 @@ export interface TieredPricingConfig {
export interface GlobalModelCreate {
name: string
display_name: string
description?: string
official_url?: string
icon_url?: string
// 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request?: number
// 阶梯计费配置(必填,固定价格用单阶梯表示)
default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[]
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any>
is_active?: boolean
}
export interface GlobalModelUpdate {
display_name?: string
description?: string
official_url?: string
icon_url?: string
is_active?: boolean
// 按次计费配置
default_price_per_request?: number | null // null 表示清空
// 阶梯计费配置
default_tiered_pricing?: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any> | null
}
export interface GlobalModelResponse {
id: string
name: string
display_name: string
description?: string
official_url?: string
icon_url?: string
is_active: boolean
// 按次计费配置
default_price_per_request?: number
// 阶梯计费配置(必填)
default_tiered_pricing: TieredPricingConfig
// 默认能力配置
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_streaming?: boolean
default_supports_extended_thinking?: boolean
default_supports_image_generation?: boolean
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式
config?: Record<string, any> | null
// 统计数据
provider_count?: number
alias_count?: number
usage_count?: number
created_at: string
updated_at?: string
@@ -483,3 +516,42 @@ export interface GlobalModelListResponse {
models: GlobalModelResponse[]
total: number
}
// ==================== 上游模型导入相关 ====================
/**
* 上游模型(从提供商 API 获取的原始模型)
*/
export interface UpstreamModel {
id: string
owned_by?: string
display_name?: string
api_format?: string
}
/**
* 导入成功的模型信息
*/
export interface ImportFromUpstreamSuccessItem {
model_id: string
global_model_id: string
global_model_name: string
provider_model_id: string
created_global_model: boolean
}
/**
* 导入失败的模型信息
*/
export interface ImportFromUpstreamErrorItem {
model_id: string
error: string
}
/**
* 从上游提供商导入模型响应
*/
export interface ImportFromUpstreamResponse {
success: ImportFromUpstreamSuccessItem[]
errors: ImportFromUpstreamErrorItem[]
}

View File

@@ -20,4 +20,5 @@ export {
updateGlobalModel,
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
} from './endpoints/global-models'

View File

@@ -0,0 +1,288 @@
/**
* Models.dev API 服务
* 通过后端代理获取 models.dev 数据(解决跨域问题)
*/
import api from './client'
// 缓存配置
const CACHE_KEY = 'models_dev_cache'
const CACHE_DURATION = 15 * 60 * 1000 // 15 分钟
// Models.dev API 数据结构
export interface ModelsDevCost {
input?: number
output?: number
reasoning?: number
cache_read?: number
}
export interface ModelsDevLimit {
context?: number
output?: number
}
export interface ModelsDevModel {
id: string
name: string
family?: string
reasoning?: boolean
tool_call?: boolean
structured_output?: boolean
temperature?: boolean
attachment?: boolean
knowledge?: string
release_date?: string
last_updated?: string
input?: string[] // 输入模态: text, image, audio, video, pdf
output?: string[] // 输出模态: text, image, audio
open_weights?: boolean
cost?: ModelsDevCost
limit?: ModelsDevLimit
deprecated?: boolean
}
export interface ModelsDevProvider {
id: string
env?: string[]
npm?: string
api?: string
name: string
doc?: string
models: Record<string, ModelsDevModel>
official?: boolean // 是否为官方提供商
}
export type ModelsDevData = Record<string, ModelsDevProvider>
// 扁平化的模型列表项(用于搜索和选择)
export interface ModelsDevModelItem {
providerId: string
providerName: string
modelId: string
modelName: string
family?: string
inputPrice?: number
outputPrice?: number
contextLimit?: number
outputLimit?: number
supportsVision?: boolean
supportsToolCall?: boolean
supportsReasoning?: boolean
supportsStructuredOutput?: boolean
supportsTemperature?: boolean
supportsAttachment?: boolean
openWeights?: boolean
deprecated?: boolean
official?: boolean // 是否来自官方提供商
// 用于 display_metadata 的额外字段
knowledgeCutoff?: string
releaseDate?: string
inputModalities?: string[]
outputModalities?: string[]
}
interface CacheData {
timestamp: number
data: ModelsDevData
}
// 内存缓存
let memoryCache: CacheData | null = null
function hasOfficialFlag(data: ModelsDevData): boolean {
return Object.values(data).some(provider => typeof provider?.official === 'boolean')
}
/**
* 获取 models.dev 数据(带缓存)
*/
export async function getModelsDevData(): Promise<ModelsDevData> {
// 1. 检查内存缓存
if (memoryCache && Date.now() - memoryCache.timestamp < CACHE_DURATION) {
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
if (hasOfficialFlag(memoryCache.data)) {
return memoryCache.data
}
memoryCache = null
}
// 2. 检查 localStorage 缓存
try {
const cached = localStorage.getItem(CACHE_KEY)
if (cached) {
const cacheData: CacheData = JSON.parse(cached)
if (Date.now() - cacheData.timestamp < CACHE_DURATION) {
// 兼容旧缓存:没有 official 字段时丢弃,强制刷新一次
if (hasOfficialFlag(cacheData.data)) {
memoryCache = cacheData
return cacheData.data
}
localStorage.removeItem(CACHE_KEY)
}
}
} catch {
// 缓存解析失败,忽略
}
// 3. 从后端代理获取新数据
const response = await api.get<ModelsDevData>('/api/admin/models/external')
const data = response.data
// 4. 更新缓存
const cacheData: CacheData = {
timestamp: Date.now(),
data,
}
memoryCache = cacheData
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
} catch {
// localStorage 写入失败,忽略
}
return data
}
// 模型列表缓存(避免重复转换)
let modelsListCache: ModelsDevModelItem[] | null = null
let modelsListCacheTimestamp: number | null = null
/**
* 获取扁平化的模型列表
* 数据只加载一次,通过参数过滤官方/全部
*/
export async function getModelsDevList(officialOnly: boolean = true): Promise<ModelsDevModelItem[]> {
const data = await getModelsDevData()
const currentTimestamp = memoryCache?.timestamp ?? 0
// 如果缓存为空或数据已刷新,构建一次
if (!modelsListCache || modelsListCacheTimestamp !== currentTimestamp) {
const items: ModelsDevModelItem[] = []
for (const [providerId, provider] of Object.entries(data)) {
if (!provider.models) continue
for (const [modelId, model] of Object.entries(provider.models)) {
items.push({
providerId,
providerName: provider.name,
modelId,
modelName: model.name || modelId,
family: model.family,
inputPrice: model.cost?.input,
outputPrice: model.cost?.output,
contextLimit: model.limit?.context,
outputLimit: model.limit?.output,
supportsVision: model.input?.includes('image'),
supportsToolCall: model.tool_call,
supportsReasoning: model.reasoning,
supportsStructuredOutput: model.structured_output,
supportsTemperature: model.temperature,
supportsAttachment: model.attachment,
openWeights: model.open_weights,
deprecated: model.deprecated,
official: provider.official,
// display_metadata 相关字段
knowledgeCutoff: model.knowledge,
releaseDate: model.release_date,
inputModalities: model.input,
outputModalities: model.output,
})
}
}
// 按 provider 名称和模型名称排序
items.sort((a, b) => {
const providerCompare = a.providerName.localeCompare(b.providerName)
if (providerCompare !== 0) return providerCompare
return a.modelName.localeCompare(b.modelName)
})
modelsListCache = items
modelsListCacheTimestamp = currentTimestamp
}
// 根据参数过滤
if (officialOnly) {
return modelsListCache.filter(m => m.official)
}
return modelsListCache
}
/**
* 搜索模型
* 搜索时包含所有提供商(包括第三方)
*/
export async function searchModelsDevModels(
query: string,
options?: {
limit?: number
excludeDeprecated?: boolean
}
): Promise<ModelsDevModelItem[]> {
// 搜索时包含全部提供商
const allModels = await getModelsDevList(false)
const { limit = 50, excludeDeprecated = true } = options || {}
const queryLower = query.toLowerCase()
const filtered = allModels.filter((model) => {
if (excludeDeprecated && model.deprecated) return false
// 搜索模型 ID、名称、provider 名称、family
return (
model.modelId.toLowerCase().includes(queryLower) ||
model.modelName.toLowerCase().includes(queryLower) ||
model.providerName.toLowerCase().includes(queryLower) ||
model.family?.toLowerCase().includes(queryLower)
)
})
// 排序:精确匹配优先
filtered.sort((a, b) => {
const aExact =
a.modelId.toLowerCase() === queryLower ||
a.modelName.toLowerCase() === queryLower
const bExact =
b.modelId.toLowerCase() === queryLower ||
b.modelName.toLowerCase() === queryLower
if (aExact && !bExact) return -1
if (!aExact && bExact) return 1
return 0
})
return filtered.slice(0, limit)
}
/**
* 获取特定模型详情
*/
export async function getModelsDevModel(
providerId: string,
modelId: string
): Promise<ModelsDevModel | null> {
const data = await getModelsDevData()
return data[providerId]?.models?.[modelId] || null
}
/**
* 获取 provider logo URL
*/
export function getProviderLogoUrl(providerId: string): string {
return `https://models.dev/logos/${providerId}.svg`
}
/**
* 清除缓存
*/
export function clearModelsDevCache(): void {
memoryCache = null
modelsListCache = null
modelsListCacheTimestamp = null
try {
localStorage.removeItem(CACHE_KEY)
} catch {
// 忽略错误
}
}

View File

@@ -9,20 +9,14 @@ export interface PublicGlobalModel {
id: string
name: string
display_name: string | null
description: string | null
icon_url: string | null
is_active: boolean
// 阶梯计费配置
default_tiered_pricing: TieredPricingConfig
default_price_per_request: number | null // 按次计费价格
// 能力
default_supports_vision: boolean
default_supports_function_calling: boolean
default_supports_streaming: boolean
default_supports_extended_thinking: boolean
default_supports_image_generation: boolean
// Key 能力支持
supported_capabilities: string[] | null
// 模型配置JSON
config: Record<string, any> | null
}
export interface PublicGlobalModelListResponse {

View File

@@ -299,7 +299,7 @@ function formatDuration(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60))
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60))
if (hours > 0) {
return `${hours}h${minutes > 0 ? minutes + 'm' : ''}`
return `${hours}h${minutes > 0 ? `${minutes}m` : ''}`
}
return `${minutes}m`
}

View File

@@ -34,11 +34,10 @@ const buttonClass = computed(() => {
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = {
default:
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
default: 'bg-primary text-white hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85',
outline:
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all',
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 backdrop-blur transition-all',
secondary:
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',

View File

@@ -2,7 +2,7 @@
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 overflow-y-auto"
class="fixed inset-0 overflow-y-auto pointer-events-none"
:style="{ zIndex: containerZIndex }"
>
<!-- 背景遮罩 -->
@@ -16,13 +16,13 @@
>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
/>
</Transition>
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0 pointer-events-none">
<!-- 对话框内容 -->
<Transition
enter-active-class="duration-300 ease-out"
@@ -34,7 +34,7 @@
>
<div
v-if="isOpen"
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border"
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border pointer-events-auto"
:style="{ zIndex: contentZIndex }"
:class="maxWidthClass"
@click.stop
@@ -92,6 +92,7 @@
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
// Props 定义
const props = defineProps<{
@@ -157,4 +158,16 @@ const maxWidthClass = computed(() => {
const containerZIndex = computed(() => props.zIndex || 60)
const backdropZIndex = computed(() => props.zIndex || 60)
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
// 添加 ESC 键监听
useEscapeKey(() => {
if (isOpen.value) {
handleClose()
return true // 阻止其他监听器(如父级抽屉的 ESC 监听器)
}
return false
}, {
disableOnInput: true,
once: false
})
</script>

View File

@@ -45,7 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
const contentClass = computed(() =>
cn(
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class

View File

@@ -4,11 +4,11 @@ import { log } from '@/utils/logger'
export function useClipboard() {
const { success, error: showError } = useToast()
async function copyToClipboard(text: string): Promise<boolean> {
async function copyToClipboard(text: string, showToast = true): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
success('已复制到剪贴板')
if (showToast) success('已复制到剪贴板')
return true
}
@@ -25,17 +25,17 @@ export function useClipboard() {
try {
const successful = document.execCommand('copy')
if (successful) {
success('已复制到剪贴板')
if (showToast) success('已复制到剪贴板')
return true
}
showError('复制失败,请手动复制')
if (showToast) showError('复制失败,请手动复制')
return false
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
log.error('复制失败:', err)
showError('复制失败,请手动选择文本进行复制')
if (showToast) showError('复制失败,请手动选择文本进行复制')
return false
}
}

View File

@@ -47,11 +47,11 @@ export function useConfirm() {
/**
* 便捷方法:危险操作确认(红色主题)
*/
const confirmDanger = (message: string, title?: string): Promise<boolean> => {
const confirmDanger = (message: string, title?: string, confirmText?: string): Promise<boolean> => {
return confirm({
message,
title: title || '危险操作',
confirmText: '删除',
confirmText: confirmText || '删除',
variant: 'danger'
})
}

View File

@@ -0,0 +1,83 @@
import { onMounted, onUnmounted, ref } from 'vue'
/**
* ESC 键监听 Composable简化版本直接使用独立监听器
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
*
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
* @param options - 配置选项
*/
export function useEscapeKey(
callback: () => void | boolean,
options: {
/** 是否在输入框获得焦点时禁用 ESC 键,默认 true */
disableOnInput?: boolean
/** 是否只监听一次,默认 false */
once?: boolean
} = {}
) {
const { disableOnInput = true, once = false } = options
const isActive = ref(true)
function handleKeyDown(event: KeyboardEvent) {
// 只处理 ESC 键
if (event.key !== 'Escape') return
// 检查组件是否还活跃
if (!isActive.value) return
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
if (disableOnInput) {
const activeElement = document.activeElement
const isInputElement = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'SELECT' ||
activeElement.contentEditable === 'true' ||
activeElement.getAttribute('role') === 'textbox' ||
activeElement.getAttribute('role') === 'combobox'
)
// 如果焦点在输入框中,不处理 ESC 键
if (isInputElement) return
}
// 执行回调,如果返回 true 则阻止其他监听器
const handled = callback()
if (handled === true) {
event.stopImmediatePropagation()
}
// 移除当前元素的焦点,避免残留样式
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// 如果只监听一次,则移除监听器
if (once) {
removeEventListener()
}
}
function addEventListener() {
document.addEventListener('keydown', handleKeyDown)
}
function removeEventListener() {
document.removeEventListener('keydown', handleKeyDown)
}
onMounted(() => {
addEventListener()
})
onUnmounted(() => {
isActive.value = false
removeEventListener()
})
return {
addEventListener,
removeEventListener
}
}

View File

@@ -132,7 +132,7 @@
type="number"
min="1"
max="10000"
placeholder="100"
placeholder="留空不限制"
class="h-10"
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
/>
@@ -376,7 +376,7 @@ const form = ref<StandaloneKeyFormData>({
initial_balance_usd: 10,
expire_days: undefined,
never_expire: true,
rate_limit: 100,
rate_limit: undefined,
auto_delete_on_expiry: false,
allowed_providers: [],
allowed_api_formats: [],
@@ -389,7 +389,7 @@ function resetForm() {
initial_balance_usd: 10,
expire_days: undefined,
never_expire: true,
rate_limit: 100,
rate_limit: undefined,
auto_delete_on_expiry: false,
allowed_providers: [],
allowed_api_formats: [],
@@ -408,7 +408,7 @@ function loadKeyData() {
initial_balance_usd: props.apiKey.initial_balance_usd,
expire_days: props.apiKey.expire_days,
never_expire: props.apiKey.never_expire,
rate_limit: props.apiKey.rate_limit || 100,
rate_limit: props.apiKey.rate_limit,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
allowed_providers: props.apiKey.allowed_providers || [],
allowed_api_formats: props.apiKey.allowed_api_formats || [],

View File

@@ -2,174 +2,304 @@
<Dialog
:model-value="open"
:title="isEditMode ? '编辑模型' : '创建统一模型'"
:description="isEditMode ? '修改模型配置和价格信息' : '添加一个新的全局模型定义'"
:description="isEditMode ? '修改模型配置和价格信息' : ''"
:icon="isEditMode ? SquarePen : Layers"
size="xl"
size="3xl"
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-5 max-h-[70vh] overflow-y-auto pr-1"
@submit.prevent="handleSubmit"
<div
class="flex gap-4"
:class="isEditMode ? '' : 'h-[500px]'"
>
<!-- 基本信息 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
基本信息
</h4>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="model-name"
class="text-xs"
>模型名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
<p
v-if="!isEditMode"
class="text-xs text-muted-foreground"
>
创建后不可修改
</p>
</div>
<div class="space-y-1.5">
<Label
for="model-display-name"
class="text-xs"
>显示名称 *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
</div>
<div class="space-y-1.5">
<Label
for="model-description"
class="text-xs"
>描述</Label>
<Input
id="model-description"
v-model="form.description"
placeholder="简短描述此模型的特点"
/>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_streaming"
type="checkbox"
class="rounded"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式输出</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_vision"
type="checkbox"
class="rounded"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉理解</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_function_calling"
type="checkbox"
class="rounded"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具调用</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_extended_thinking"
type="checkbox"
class="rounded"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>深度思考</span>
</label>
<label class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm">
<input
v-model="form.default_supports_image_generation"
type="checkbox"
class="rounded"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>图像生成</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
<section
v-if="availableCapabilities.length > 0"
class="space-y-2"
<!-- 左侧模型选择仅创建模式 -->
<div
v-if="!isEditMode"
class="w-[260px] shrink-0 flex flex-col h-full"
>
<h4 class="font-medium text-sm">
Key 能力支持
</h4>
<div class="flex flex-wrap gap-2">
<label
v-for="cap in availableCapabilities"
:key="cap.name"
class="flex items-center gap-2 px-3 py-1.5 rounded-md border border-border bg-muted/30 cursor-pointer text-sm"
>
<input
type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)"
class="rounded"
@change="toggleCapability(cap.name)"
>
<span>{{ cap.display_name }}</span>
</label>
</div>
</section>
<!-- 价格配置 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/>
<!-- 按次计费 -->
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费 ($/)</Label>
<!-- 搜索框 -->
<div class="relative mb-3">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
:model-value="form.default_price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-32"
placeholder="留空不启用"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
v-model="searchQuery"
type="text"
placeholder="搜索模型、提供商..."
class="pl-8 h-8 text-sm"
/>
<span class="text-xs text-muted-foreground">每次请求固定费用,可与 Token 计费叠加</span>
</div>
</section>
</form>
<!-- 模型列表两级结构 -->
<div class="flex-1 overflow-y-auto border rounded-lg min-h-0 scrollbar-thin">
<div
v-if="loading"
class="flex items-center justify-center h-32"
>
<Loader2 class="w-5 h-5 animate-spin text-muted-foreground" />
</div>
<template v-else>
<!-- 提供商分组 -->
<div
v-for="group in groupedModels"
:key="group.providerId"
class="border-b last:border-b-0"
>
<!-- 提供商标题行 -->
<div
class="flex items-center gap-2 px-2.5 py-2 cursor-pointer hover:bg-muted text-sm"
@click="toggleProvider(group.providerId)"
>
<ChevronRight
class="w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0"
:class="expandedProvider === group.providerId ? 'rotate-90' : ''"
/>
<img
:src="getProviderLogoUrl(group.providerId)"
:alt="group.providerName"
class="w-4 h-4 rounded shrink-0 dark:invert dark:brightness-90"
@error="handleLogoError"
>
<span class="truncate font-medium text-xs flex-1">{{ group.providerName }}</span>
<span class="text-[10px] text-muted-foreground shrink-0">{{ group.models.length }}</span>
</div>
<!-- 模型列表 -->
<div
v-if="expandedProvider === group.providerId"
class="bg-muted/30"
>
<div
v-for="model in group.models"
:key="model.modelId"
class="flex flex-col gap-0.5 pl-7 pr-2.5 py-1.5 cursor-pointer text-xs border-t"
:class="selectedModel?.modelId === model.modelId && selectedModel?.providerId === model.providerId
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'"
@click="selectModel(model)"
>
<span class="truncate font-medium">{{ model.modelName }}</span>
<span
class="truncate text-[10px]"
:class="selectedModel?.modelId === model.modelId && selectedModel?.providerId === model.providerId
? 'text-primary-foreground/70'
: 'text-muted-foreground'"
>{{ model.modelId }}</span>
</div>
</div>
</div>
<div
v-if="groupedModels.length === 0"
class="text-center py-8 text-sm text-muted-foreground"
>
{{ searchQuery ? '未找到模型' : '加载中...' }}
</div>
</template>
</div>
</div>
<!-- 右侧表单 -->
<div
class="flex-1 overflow-y-auto h-full scrollbar-thin"
:class="isEditMode ? 'max-h-[70vh]' : ''"
>
<form
class="space-y-5"
@submit.prevent="handleSubmit"
>
<!-- 基本信息 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
基本信息
</h4>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label
for="model-name"
class="text-xs"
>模型名称 *</Label>
<Input
id="model-name"
v-model="form.name"
placeholder="claude-3-5-sonnet-20241022"
:disabled="isEditMode"
required
/>
</div>
<div class="space-y-1.5">
<Label
for="model-display-name"
class="text-xs"
>显示名称 *</Label>
<Input
id="model-display-name"
v-model="form.display_name"
placeholder="Claude 3.5 Sonnet"
required
/>
</div>
</div>
<div class="space-y-1.5">
<Label
for="model-description"
class="text-xs"
>描述</Label>
<Input
id="model-description"
:model-value="form.config?.description || ''"
placeholder="简短描述此模型的特点"
@update:model-value="(v) => setConfigField('description', v || undefined)"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1.5">
<Label
for="model-family"
class="text-xs"
>模型系列</Label>
<Input
id="model-family"
:model-value="form.config?.family || ''"
placeholder=" GPT-4Claude 3"
@update:model-value="(v) => setConfigField('family', v || undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-context-limit"
class="text-xs"
>上下文限制</Label>
<Input
id="model-context-limit"
type="number"
:model-value="form.config?.context_limit ?? ''"
placeholder=" 128000"
@update:model-value="(v) => setConfigField('context_limit', v ? Number(v) : undefined)"
/>
</div>
<div class="space-y-1.5">
<Label
for="model-output-limit"
class="text-xs"
>输出限制</Label>
<Input
id="model-output-limit"
type="number"
:model-value="form.config?.output_limit ?? ''"
placeholder=" 8192"
@update:model-value="(v) => setConfigField('output_limit', v ? Number(v) : undefined)"
/>
</div>
</div>
</section>
<!-- 能力配置 -->
<section class="space-y-2">
<h4 class="font-medium text-sm">
默认能力
</h4>
<div class="flex flex-wrap gap-2">
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.streaming !== false"
class="rounded"
@change="setConfigField('streaming', ($event.target as HTMLInputElement).checked)"
>
<Zap class="w-3.5 h-3.5 text-muted-foreground" />
<span>流式</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.vision === true"
class="rounded"
@change="setConfigField('vision', ($event.target as HTMLInputElement).checked)"
>
<Eye class="w-3.5 h-3.5 text-muted-foreground" />
<span>视觉</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.function_calling === true"
class="rounded"
@change="setConfigField('function_calling', ($event.target as HTMLInputElement).checked)"
>
<Wrench class="w-3.5 h-3.5 text-muted-foreground" />
<span>工具</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.extended_thinking === true"
class="rounded"
@change="setConfigField('extended_thinking', ($event.target as HTMLInputElement).checked)"
>
<Brain class="w-3.5 h-3.5 text-muted-foreground" />
<span>思考</span>
</label>
<label class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm">
<input
type="checkbox"
:checked="form.config?.image_generation === true"
class="rounded"
@change="setConfigField('image_generation', ($event.target as HTMLInputElement).checked)"
>
<Image class="w-3.5 h-3.5 text-muted-foreground" />
<span>生图</span>
</label>
</div>
</section>
<!-- Key 能力配置 -->
<section
v-if="availableCapabilities.length > 0"
class="space-y-2"
>
<h4 class="font-medium text-sm">
Key 能力支持
</h4>
<div class="flex flex-wrap gap-2">
<label
v-for="cap in availableCapabilities"
:key="cap.name"
class="flex items-center gap-2 px-2.5 py-1 rounded-md border bg-muted/30 cursor-pointer text-sm"
>
<input
type="checkbox"
:checked="form.supported_capabilities?.includes(cap.name)"
class="rounded"
@change="toggleCapability(cap.name)"
>
<span>{{ cap.display_name }}</span>
</label>
</div>
</section>
<!-- 价格配置 -->
<section class="space-y-3">
<h4 class="font-medium text-sm">
价格配置
</h4>
<TieredPricingEditor
ref="tieredPricingEditorRef"
v-model="tieredPricing"
:show-cache1h="form.supported_capabilities?.includes('cache_1h')"
/>
<div class="flex items-center gap-3 pt-2 border-t">
<Label class="text-xs whitespace-nowrap">按次计费</Label>
<Input
:model-value="form.default_price_per_request ?? ''"
type="number"
step="0.001"
min="0"
class="w-24"
placeholder="$/"
@update:model-value="(v) => form.default_price_per_request = parseNumberInput(v, { allowFloat: true })"
/>
<span class="text-xs text-muted-foreground">可与 Token 计费叠加</span>
</div>
</section>
</form>
</div>
</div>
<template #footer>
<Button
@@ -180,7 +310,7 @@
取消
</Button>
<Button
:disabled="submitting"
:disabled="submitting || !form.name || !form.display_name"
@click="handleSubmit"
>
<Loader2
@@ -189,19 +319,35 @@
/>
{{ isEditMode ? '保存' : '创建' }}
</Button>
<Button
v-if="selectedModel && !isEditMode"
type="button"
variant="ghost"
@click="clearSelection"
>
清空
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen } from 'lucide-vue-next'
import { ref, computed, onMounted, watch } from 'vue'
import {
Eye, Wrench, Brain, Zap, Image, Loader2, Layers, SquarePen,
Search, ChevronRight
} from 'lucide-vue-next'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
import { parseNumberInput } from '@/utils/form'
import { log } from '@/utils/logger'
import TieredPricingEditor from './TieredPricingEditor.vue'
import {
getModelsDevList,
getProviderLogoUrl,
type ModelsDevModelItem,
} from '@/api/models-dev'
import {
createGlobalModel,
updateGlobalModel,
@@ -226,42 +372,147 @@ const { success, error: showError } = useToast()
const submitting = ref(false)
const tieredPricingEditorRef = ref<InstanceType<typeof TieredPricingEditor> | null>(null)
// 阶梯计费配置(统一使用,固定价格就是单阶梯)
// 模型列表相关
const loading = ref(false)
const searchQuery = ref('')
const allModelsCache = ref<ModelsDevModelItem[]>([]) // 全部模型(缓存)
const selectedModel = ref<ModelsDevModelItem | null>(null)
const expandedProvider = ref<string | null>(null)
// 当前显示的模型列表:有搜索词时用全部,否则只用官方
const allModels = computed(() => {
if (searchQuery.value) {
return allModelsCache.value
}
return allModelsCache.value.filter(m => m.official)
})
// 按提供商分组的模型
interface ProviderGroup {
providerId: string
providerName: string
models: ModelsDevModelItem[]
}
const groupedModels = computed(() => {
let models = allModels.value.filter(m => !m.deprecated)
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
models = models.filter(model => {
const searchableText = `${model.providerId} ${model.providerName} ${model.modelId} ${model.modelName} ${model.family || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 按提供商分组
const groups = new Map<string, ProviderGroup>()
for (const model of models) {
if (!groups.has(model.providerId)) {
groups.set(model.providerId, {
providerId: model.providerId,
providerName: model.providerName,
models: []
})
}
groups.get(model.providerId)!.models.push(model)
}
// 转换为数组并排序
const result = Array.from(groups.values())
// 如果有搜索词,把提供商名称/ID匹配的排在前面
if (searchQuery.value) {
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result.sort((a, b) => {
const aText = `${a.providerId} ${a.providerName}`.toLowerCase()
const bText = `${b.providerId} ${b.providerName}`.toLowerCase()
const aProviderMatch = keywords.some(k => aText.includes(k))
const bProviderMatch = keywords.some(k => bText.includes(k))
if (aProviderMatch && !bProviderMatch) return -1
if (!aProviderMatch && bProviderMatch) return 1
return a.providerName.localeCompare(b.providerName)
})
} else {
result.sort((a, b) => a.providerName.localeCompare(b.providerName))
}
return result
})
// 搜索时如果只有一个提供商,自动展开
watch(groupedModels, (groups) => {
if (searchQuery.value && groups.length === 1) {
expandedProvider.value = groups[0].providerId
}
})
// 切换提供商展开状态
function toggleProvider(providerId: string) {
expandedProvider.value = expandedProvider.value === providerId ? null : providerId
}
// 阶梯计费配置
const tieredPricing = ref<TieredPricingConfig | null>(null)
interface FormData {
name: string
display_name: string
description?: string
default_price_per_request?: number
default_supports_streaming?: boolean
default_supports_image_generation?: boolean
default_supports_vision?: boolean
default_supports_function_calling?: boolean
default_supports_extended_thinking?: boolean
supported_capabilities?: string[]
config?: Record<string, any>
is_active?: boolean
}
const defaultForm = (): FormData => ({
name: '',
display_name: '',
description: '',
default_price_per_request: undefined,
default_supports_streaming: true,
default_supports_image_generation: false,
default_supports_vision: false,
default_supports_function_calling: false,
default_supports_extended_thinking: false,
supported_capabilities: [],
config: { streaming: true },
is_active: true,
})
const form = ref<FormData>(defaultForm())
const KEEP_FALSE_CONFIG_KEYS = new Set(['streaming'])
// 设置 config 字段
function setConfigField(key: string, value: any) {
if (!form.value.config) {
form.value.config = {}
}
if (value === undefined || value === '' || (value === false && !KEEP_FALSE_CONFIG_KEYS.has(key))) {
delete form.value.config[key]
} else {
form.value.config[key] = value
}
}
// Key 能力选项
const availableCapabilities = ref<CapabilityDefinition[]>([])
// 加载模型列表
async function loadModels() {
if (allModelsCache.value.length > 0) return
loading.value = true
try {
// 只加载一次全部模型,过滤在 computed 中完成
allModelsCache.value = await getModelsDevList(false)
} catch (err) {
log.error('Failed to load models:', err)
} finally {
loading.value = false
}
}
// 打开对话框时加载数据
watch(() => props.open, (isOpen) => {
if (isOpen && !props.model) {
loadModels()
}
})
// 加载可用能力列表
async function loadCapabilities() {
try {
@@ -284,38 +535,92 @@ function toggleCapability(capName: string) {
}
}
// 组件挂载时加载能力列表
onMounted(() => {
loadCapabilities()
})
// 选择模型并填充表单
function selectModel(model: ModelsDevModelItem) {
selectedModel.value = model
expandedProvider.value = model.providerId
form.value.name = model.modelId
form.value.display_name = model.modelName
// 构建 config
const config: Record<string, any> = {
streaming: true,
}
if (model.supportsVision) config.vision = true
if (model.supportsToolCall) config.function_calling = true
if (model.supportsReasoning) config.extended_thinking = true
if (model.supportsStructuredOutput) config.structured_output = true
if (model.supportsTemperature !== false) config.temperature = model.supportsTemperature
if (model.supportsAttachment) config.attachment = true
if (model.openWeights) config.open_weights = true
if (model.contextLimit) config.context_limit = model.contextLimit
if (model.outputLimit) config.output_limit = model.outputLimit
if (model.knowledgeCutoff) config.knowledge_cutoff = model.knowledgeCutoff
if (model.family) config.family = model.family
if (model.releaseDate) config.release_date = model.releaseDate
if (model.inputModalities?.length) config.input_modalities = model.inputModalities
if (model.outputModalities?.length) config.output_modalities = model.outputModalities
form.value.config = config
if (model.inputPrice !== undefined || model.outputPrice !== undefined) {
tieredPricing.value = {
tiers: [{
up_to: null,
input_price_per_1m: model.inputPrice || 0,
output_price_per_1m: model.outputPrice || 0,
}]
}
} else {
tieredPricing.value = null
}
}
// 清除选择(手动填写)
function clearSelection() {
selectedModel.value = null
form.value = defaultForm()
tieredPricing.value = null
}
// Logo 加载失败处理
function handleLogoError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
// 重置表单
function resetForm() {
form.value = defaultForm()
tieredPricing.value = null
searchQuery.value = ''
selectedModel.value = null
expandedProvider.value = null
}
// 加载模型数据(编辑模式)
function loadModelData() {
if (!props.model) return
// 先重置创建模式的残留状态
selectedModel.value = null
searchQuery.value = ''
expandedProvider.value = null
form.value = {
name: props.model.name,
display_name: props.model.display_name,
description: props.model.description,
default_price_per_request: props.model.default_price_per_request,
default_supports_streaming: props.model.default_supports_streaming,
default_supports_image_generation: props.model.default_supports_image_generation,
default_supports_vision: props.model.default_supports_vision,
default_supports_function_calling: props.model.default_supports_function_calling,
default_supports_extended_thinking: props.model.default_supports_extended_thinking,
supported_capabilities: [...(props.model.supported_capabilities || [])],
config: props.model.config ? { ...props.model.config } : { streaming: true },
is_active: props.model.is_active,
}
// 加载阶梯计费配置(深拷贝)
if (props.model.default_tiered_pricing) {
tieredPricing.value = JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
}
// 确保 tieredPricing 也被正确设置或重置
tieredPricing.value = props.model.default_tiered_pricing
? JSON.parse(JSON.stringify(props.model.default_tiered_pricing))
: null
}
// 使用 useFormDialog 统一处理对话框逻辑
@@ -339,24 +644,22 @@ async function handleSubmit() {
return
}
// 获取包含自动计算缓存价格的最终数据
const finalTiers = tieredPricingEditorRef.value?.getFinalTiers()
const finalTieredPricing = finalTiers ? { tiers: finalTiers } : tieredPricing.value
// 清理空的 config
const cleanConfig = form.value.config && Object.keys(form.value.config).length > 0
? form.value.config
: undefined
submitting.value = true
try {
if (isEditMode.value && props.model) {
const updateData: GlobalModelUpdate = {
display_name: form.value.display_name,
description: form.value.description,
// 使用 null 而不是 undefined 来显式清空字段
config: cleanConfig || null,
default_price_per_request: form.value.default_price_per_request ?? null,
default_tiered_pricing: finalTieredPricing,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : null,
is_active: form.value.is_active,
}
@@ -366,14 +669,9 @@ async function handleSubmit() {
const createData: GlobalModelCreate = {
name: form.value.name!,
display_name: form.value.display_name!,
description: form.value.description,
default_price_per_request: form.value.default_price_per_request || undefined,
config: cleanConfig,
default_price_per_request: form.value.default_price_per_request ?? undefined,
default_tiered_pricing: finalTieredPricing,
default_supports_streaming: form.value.default_supports_streaming,
default_supports_image_generation: form.value.default_supports_image_generation,
default_supports_vision: form.value.default_supports_vision,
default_supports_function_calling: form.value.default_supports_function_calling,
default_supports_extended_thinking: form.value.default_supports_extended_thinking,
supported_capabilities: form.value.supported_capabilities?.length ? form.value.supported_capabilities : undefined,
is_active: form.value.is_active,
}

View File

@@ -38,12 +38,12 @@
>
<Copy class="w-3 h-3" />
</button>
<template v-if="model.description">
<template v-if="model.config?.description">
<span class="shrink-0">·</span>
<span
class="text-xs truncate"
:title="model.description"
>{{ model.description }}</span>
:title="model.config?.description"
>{{ model.config?.description }}</span>
</template>
</div>
</div>
@@ -143,10 +143,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'"
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -160,10 +160,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'"
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -177,10 +177,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'"
:variant="model.config?.vision === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
{{ model.config?.vision === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -194,10 +194,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'"
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -211,10 +211,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
</Badge>
</div>
</div>
@@ -396,11 +396,11 @@
</div>
<div class="p-3 rounded-lg border bg-muted/20">
<div class="flex items-center justify-between">
<Label class="text-xs text-muted-foreground">别名数量</Label>
<Tag class="w-4 h-4 text-muted-foreground" />
<Label class="text-xs text-muted-foreground">调用次数</Label>
<BarChart3 class="w-4 h-4 text-muted-foreground" />
</div>
<p class="text-2xl font-bold mt-1">
{{ model.alias_count || 0 }}
{{ model.usage_count || 0 }}
</p>
</div>
</div>
@@ -455,105 +455,153 @@
<template v-else-if="providers.length > 0">
<!-- 桌面端表格 -->
<Table class="hidden sm:table">
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">
Provider
</TableHead>
<TableHead class="w-[120px] h-10 font-semibold">
能力
</TableHead>
<TableHead class="w-[180px] h-10 font-semibold">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="h-10 font-semibold">
Provider
</TableHead>
<TableHead class="w-[120px] h-10 font-semibold">
能力
</TableHead>
<TableHead class="w-[180px] h-10 font-semibold">
价格 ($/M)
</TableHead>
<TableHead class="w-[80px] h-10 font-semibold text-center">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers"
:key="provider.id"
class="border-b border-border/40 hover:bg-muted/30 transition-colors"
class="p-4 space-y-3"
>
<TableCell class="py-3">
<div class="flex items-center gap-2">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
:title="provider.is_active ? '活跃' : '停用'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div>
</TableCell>
<TableCell class="py-3">
<div class="flex gap-0.5">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
title="流式输出"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
title="工具调用"
/>
</div>
</TableCell>
<TableCell class="py-3">
<div class="text-xs font-mono space-y-0.5">
<!-- Token 计费输入/输出 -->
<div v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0">
<span class="text-muted-foreground">输入/输出:</span>
<span class="ml-1">${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}</span>
<!-- 阶梯标记 -->
<span
v-if="(provider.tier_count || 1) > 1"
class="ml-1 text-muted-foreground"
title="阶梯计费"
>[阶梯]</span>
</div>
<!-- 缓存价格 -->
<div
v-if="(provider.cache_creation_price_per_1m || 0) > 0 || (provider.cache_read_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>缓存:</span>
<span class="ml-1">${{ (provider.cache_creation_price_per_1m || 0).toFixed(2) }}/${{ (provider.cache_read_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 1h 缓存价格 -->
<div
v-if="(provider.cache_1h_creation_price_per_1m || 0) > 0"
class="text-muted-foreground"
>
<span>1h 缓存:</span>
<span class="ml-1">${{ (provider.cache_1h_creation_price_per_1m || 0).toFixed(2) }}</span>
</div>
<!-- 按次计费 -->
<div v-if="(provider.price_per_request || 0) > 0">
<span class="text-muted-foreground">按次:</span>
<span class="ml-1">${{ (provider.price_per_request || 0).toFixed(3) }}/</span>
</div>
<!-- 无定价 -->
<span
v-if="!(provider.input_price_per_1m || 0) && !(provider.output_price_per_1m || 0) && !(provider.price_per_request || 0)"
class="text-muted-foreground"
>-</span>
</div>
</TableCell>
<TableCell class="py-3 text-center">
<div class="flex items-center justify-center gap-1">
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
title="编辑此关联"
@click="$emit('editProvider', provider)"
>
<Edit class="w-3.5 h-3.5" />
@@ -562,7 +610,6 @@
variant="ghost"
size="icon"
class="h-7 w-7"
:title="provider.is_active ? '停用此关联' : '启用此关联'"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
@@ -571,82 +618,35 @@
variant="ghost"
size="icon"
class="h-7 w-7"
title="删除此关联"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
<!-- 移动端卡片列表 -->
<div class="sm:hidden divide-y divide-border/40">
<div
v-for="provider in providers"
:key="provider.id"
class="p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<span
class="w-2 h-2 rounded-full shrink-0"
:class="provider.is_active ? 'bg-green-500' : 'bg-gray-300'"
/>
<span class="font-medium truncate">{{ provider.display_name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('editProvider', provider)"
<div class="flex items-center gap-3 text-xs">
<div class="flex gap-1">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('toggleProviderStatus', provider)"
>
<Power class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click="$emit('deleteProvider', provider)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs">
<div class="flex gap-1">
<Zap
v-if="provider.supports_streaming"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Eye
v-if="provider.supports_vision"
class="w-3.5 h-3.5 text-muted-foreground"
/>
<Wrench
v-if="provider.supports_function_calling"
class="w-3.5 h-3.5 text-muted-foreground"
/>
</div>
<div
v-if="(provider.input_price_per_1m || 0) > 0 || (provider.output_price_per_1m || 0) > 0"
class="text-muted-foreground font-mono"
>
${{ (provider.input_price_per_1m || 0).toFixed(1) }}/${{ (provider.output_price_per_1m || 0).toFixed(1) }}
</div>
</div>
</div>
</div>
</template>
@@ -695,9 +695,12 @@ import {
Loader2,
RefreshCw,
Copy,
Layers
Layers,
BarChart3
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
@@ -729,6 +732,7 @@ const emit = defineEmits<{
'refreshProviders': []
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props {
model: GlobalModelResponse | null
@@ -761,16 +765,6 @@ function handleClose() {
}
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
// 格式化日期
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
@@ -832,6 +826,16 @@ watch(() => props.open, (newOpen) => {
detailTab.value = 'basic'
}
})
// 添加 ESC 键监听
useEscapeKey(() => {
if (props.open) {
handleClose()
}
}, {
disableOnInput: true,
once: false
})
</script>
<style scoped>

View File

@@ -31,29 +31,46 @@
<!-- 左右对比布局 -->
<div class="flex gap-2 items-stretch">
<!-- 左侧可添加的模型 -->
<!-- 左侧可添加的模型分组折叠 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
可添加
</p>
<Button
v-if="availableModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllLeft"
>
{{ isAllLeftSelected ? '取消全选' : '全选' }}
</Button>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-medium shrink-0">
可添加
</p>
<div class="flex-1 relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="搜索模型..."
class="pl-7 h-7 text-xs"
/>
</div>
<Badge
variant="secondary"
class="text-xs"
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="刷新上游模型"
:disabled="fetchingUpstreamModels"
@click="fetchUpstreamModels(true)"
>
{{ availableModels.length }}
</Badge>
<RefreshCw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<button
v-else-if="!fetchingUpstreamModels"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="从提供商获取模型"
@click="fetchUpstreamModels"
>
<Zap class="w-3.5 h-3.5" />
</button>
<Loader2
v-else
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
/>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
@@ -63,7 +80,7 @@
<Loader2 class="w-6 h-6 animate-spin text-primary" />
</div>
<div
v-else-if="availableModels.length === 0"
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Layers class="w-10 h-10 mb-2 opacity-30" />
@@ -73,37 +90,142 @@
</div>
<div
v-else
class="p-2 space-y-1"
class="p-2 space-y-2"
>
<!-- 全局模型折叠组 -->
<div
v-for="model in availableModels"
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
:class="selectedLeftIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50 cursor-pointer'"
@click="toggleLeftSelection(model.id)"
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
class="border rounded-lg overflow-hidden"
>
<Checkbox
:checked="selectedLeftIds.includes(model.id)"
@update:checked="toggleLeftSelection(model.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
<button
type="button"
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
@click="toggleGroupCollapse('global')"
>
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
全局模型
</span>
<span class="text-xs text-muted-foreground">
({{ availableGlobalModels.length }})
</span>
</button>
<button
v-if="availableGlobalModels.length > 0"
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="selectAllGlobalModels"
>
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
</button>
</div>
<Badge
:variant="model.is_active ? 'outline' : 'secondary'"
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
<div
v-show="!collapsedGroups.has('global')"
class="p-2 space-y-1 border-t"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
<div
v-if="availableGlobalModels.length === 0"
class="py-4 text-center text-xs text-muted-foreground"
>
所有全局模型均已关联
</div>
<div
v-for="model in availableGlobalModels"
v-else
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedGlobalModelIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleGlobalModelSelection(model.id)"
>
<Checkbox
:checked="selectedGlobalModelIds.includes(model.id)"
@update:checked="toggleGlobalModelSelection(model.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ model.display_name }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.name }}
</p>
</div>
<Badge
:variant="model.is_active ? 'outline' : 'secondary'"
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
class="text-xs shrink-0"
>
{{ model.is_active ? '活跃' : '停用' }}
</Badge>
</div>
</div>
</div>
<!-- 从提供商获取的模型折叠组 -->
<div
v-for="group in upstreamModelGroups"
:key="group.api_format"
class="border rounded-lg overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
<button
type="button"
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
@click="toggleGroupCollapse(group.api_format)"
>
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
</span>
<span class="text-xs text-muted-foreground">
({{ group.models.length }})
</span>
</button>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="selectAllUpstreamModels(group.api_format)"
>
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has(group.api_format)"
class="p-2 space-y-1 border-t"
>
<div
v-for="model in group.models"
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
:class="selectedUpstreamModelIds.includes(model.id)
? 'border-primary bg-primary/10'
: 'hover:bg-muted/50'"
@click="toggleUpstreamModelSelection(model.id)"
>
<Checkbox
:checked="selectedUpstreamModelIds.includes(model.id)"
@update:checked="toggleUpstreamModelSelection(model.id)"
@click.stop
/>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ model.id }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -115,8 +237,8 @@
variant="outline"
size="sm"
class="w-9 h-8"
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
:disabled="selectedLeftIds.length === 0 || submittingAdd"
:class="totalSelectedCount > 0 && !submittingAdd ? 'border-primary' : ''"
:disabled="totalSelectedCount === 0 || submittingAdd"
title="添加选中"
@click="batchAddSelected"
>
@@ -127,7 +249,7 @@
<ChevronRight
v-else
class="w-6 h-6 stroke-[3]"
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''"
:class="totalSelectedCount > 0 && !submittingAdd ? 'text-primary' : ''"
/>
</Button>
<Button
@@ -154,26 +276,18 @@
<!-- 右侧已添加的模型 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="existingModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ isAllRightSelected ? '取消全选' : '全选' }}
</Button>
</div>
<Badge
variant="secondary"
class="text-xs"
<p class="text-sm font-medium">
已添加
</p>
<Button
v-if="existingModels.length > 0"
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
@click="toggleSelectAllRight"
>
{{ existingModels.length }}
</Badge>
{{ isAllRightSelected ? '取消' : '全选' }}
</Button>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
@@ -238,11 +352,12 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
import { Layers, Loader2, ChevronRight, ChevronLeft, ChevronDown, Zap, RefreshCw, Search } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Input from '@/components/ui/input.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import {
@@ -253,8 +368,13 @@ import {
getProviderModels,
batchAssignModelsToProvider,
deleteModel,
importModelsFromUpstream,
API_FORMAT_LABELS,
type Model
} from '@/api/endpoints'
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
const props = defineProps<{
open: boolean
@@ -274,17 +394,27 @@ const { error: showError, success } = useToast()
const loadingGlobalModels = ref(false)
const submittingAdd = ref(false)
const submittingRemove = ref(false)
const fetchingUpstreamModels = ref(false)
const upstreamModelsLoaded = ref(false)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
const existingModels = ref<Model[]>([])
const upstreamModels = ref<UpstreamModel[]>([])
// 选择状态
const selectedLeftIds = ref<string[]>([])
const selectedGlobalModelIds = ref<string[]>([])
const selectedUpstreamModelIds = ref<string[]>([])
const selectedRightIds = ref<string[]>([])
// 计算可添加的模型(排除已关联的)
const availableModels = computed(() => {
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 搜索状态
const searchQuery = ref('')
// 计算可添加的全局模型(排除已关联的)
const availableGlobalModelsBase = computed(() => {
const existingGlobalModelIds = new Set(
existingModels.value
.filter(m => m.global_model_id)
@@ -293,31 +423,129 @@ const availableModels = computed(() => {
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
})
// 全选状态
const isAllLeftSelected = computed(() =>
availableModels.value.length > 0 &&
selectedLeftIds.value.length === availableModels.value.length
)
// 搜索过滤后的全局模型
const availableGlobalModels = computed(() => {
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
const query = searchQuery.value.toLowerCase()
return availableGlobalModelsBase.value.filter(m =>
m.name.toLowerCase().includes(query) ||
m.display_name.toLowerCase().includes(query)
)
})
// 计算可添加的上游模型(排除已关联的,包括主模型名和映射名称)
const availableUpstreamModelsBase = computed(() => {
const existingModelNames = new Set<string>()
for (const m of existingModels.value) {
// 主模型名
existingModelNames.add(m.provider_model_name)
// 映射名称
for (const mapping of m.provider_model_mappings ?? []) {
if (mapping.name) existingModelNames.add(mapping.name)
}
}
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
})
// 搜索过滤后的上游模型
const availableUpstreamModels = computed(() => {
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
const query = searchQuery.value.toLowerCase()
return availableUpstreamModelsBase.value.filter(m =>
m.id.toLowerCase().includes(query) ||
(m.owned_by && m.owned_by.toLowerCase().includes(query))
)
})
// 按 API 格式分组的上游模型
const upstreamModelGroups = computed(() => {
const groups: Record<string, UpstreamModel[]> = {}
for (const model of availableUpstreamModels.value) {
const format = model.api_format || 'unknown'
if (!groups[format]) {
groups[format] = []
}
groups[format].push(model)
}
// 按 API_FORMAT_LABELS 的顺序排序
const order = Object.keys(API_FORMAT_LABELS)
return Object.entries(groups)
.map(([api_format, models]) => ({ api_format, models }))
.sort((a, b) => {
const aIndex = order.indexOf(a.api_format)
const bIndex = order.indexOf(b.api_format)
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
// 总可添加数量
const totalAvailableCount = computed(() => {
return availableGlobalModels.value.length + availableUpstreamModels.value.length
})
// 总选中数量
const totalSelectedCount = computed(() => {
return selectedGlobalModelIds.value.length + selectedUpstreamModelIds.value.length
})
// 全选状态
const isAllRightSelected = computed(() =>
existingModels.value.length > 0 &&
selectedRightIds.value.length === existingModels.value.length
)
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (availableGlobalModels.value.length === 0) return false
return availableGlobalModels.value.every(m => selectedGlobalModelIds.value.includes(m.id))
})
// 检查某个上游组是否全选
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
if (!group || group.models.length === 0) return false
return group.models.every(m => selectedUpstreamModelIds.value.includes(m.id))
}
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
// 重置状态
selectedLeftIds.value = []
selectedGlobalModelIds.value = []
selectedUpstreamModelIds.value = []
selectedRightIds.value = []
upstreamModels.value = []
upstreamModelsLoaded.value = false
collapsedGroups.value = new Set()
searchQuery.value = ''
}
})
// 加载数据
async function loadData() {
await Promise.all([loadGlobalModels(), loadExistingModels()])
// 默认折叠全局模型组
collapsedGroups.value = new Set(['global'])
// 检查缓存,如果有缓存数据则直接使用
const cachedModels = getCachedModels(props.providerId)
if (cachedModels) {
upstreamModels.value = cachedModels
upstreamModelsLoaded.value = true
// 折叠所有上游模型组
for (const model of cachedModels) {
if (model.api_format) {
collapsedGroups.value.add(model.api_format)
}
}
}
}
// 加载全局模型列表
@@ -342,13 +570,91 @@ async function loadExistingModels() {
}
}
// 切换左侧选择
function toggleLeftSelection(id: string) {
const index = selectedLeftIds.value.indexOf(id)
if (index === -1) {
selectedLeftIds.value.push(id)
// 从提供商获取模型
async function fetchUpstreamModels(forceRefresh = false) {
if (forceRefresh) {
clearCache(props.providerId)
}
try {
fetchingUpstreamModels.value = true
const result = await fetchCachedModels(props.providerId, forceRefresh)
if (result) {
if (result.error) {
showError(result.error, '错误')
} else {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 折叠所有上游模型组
const allGroups = new Set(collapsedGroups.value)
for (const model of result.models) {
if (model.api_format) {
allGroups.add(model.api_format)
}
}
collapsedGroups.value = allGroups
}
}
} finally {
fetchingUpstreamModels.value = false
}
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
collapsedGroups.value.delete(group)
} else {
selectedLeftIds.value.splice(index, 1)
collapsedGroups.value.add(group)
}
// 触发响应式更新
collapsedGroups.value = new Set(collapsedGroups.value)
}
// 切换全局模型选择
function toggleGlobalModelSelection(id: string) {
const index = selectedGlobalModelIds.value.indexOf(id)
if (index === -1) {
selectedGlobalModelIds.value.push(id)
} else {
selectedGlobalModelIds.value.splice(index, 1)
}
}
// 切换上游模型选择
function toggleUpstreamModelSelection(id: string) {
const index = selectedUpstreamModelIds.value.indexOf(id)
if (index === -1) {
selectedUpstreamModelIds.value.push(id)
} else {
selectedUpstreamModelIds.value.splice(index, 1)
}
}
// 全选全局模型
function selectAllGlobalModels() {
const allIds = availableGlobalModels.value.map(m => m.id)
const allSelected = allIds.every(id => selectedGlobalModelIds.value.includes(id))
if (allSelected) {
selectedGlobalModelIds.value = selectedGlobalModelIds.value.filter(id => !allIds.includes(id))
} else {
const newIds = allIds.filter(id => !selectedGlobalModelIds.value.includes(id))
selectedGlobalModelIds.value.push(...newIds)
}
}
// 全选某个 API 格式的上游模型
function selectAllUpstreamModels(apiFormat: string) {
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
if (!group) return
const allIds = group.models.map(m => m.id)
const allSelected = allIds.every(id => selectedUpstreamModelIds.value.includes(id))
if (allSelected) {
selectedUpstreamModelIds.value = selectedUpstreamModelIds.value.filter(id => !allIds.includes(id))
} else {
const newIds = allIds.filter(id => !selectedUpstreamModelIds.value.includes(id))
selectedUpstreamModelIds.value.push(...newIds)
}
}
@@ -362,15 +668,6 @@ function toggleRightSelection(id: string) {
}
}
// 全选/取消全选左侧
function toggleSelectAllLeft() {
if (isAllLeftSelected.value) {
selectedLeftIds.value = []
} else {
selectedLeftIds.value = availableModels.value.map(m => m.id)
}
}
// 全选/取消全选右侧
function toggleSelectAllRight() {
if (isAllRightSelected.value) {
@@ -382,22 +679,41 @@ function toggleSelectAllRight() {
// 批量添加选中的模型
async function batchAddSelected() {
if (selectedLeftIds.value.length === 0) return
if (totalSelectedCount.value === 0) return
try {
submittingAdd.value = true
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
let totalSuccess = 0
const allErrors: string[] = []
if (result.success.length > 0) {
success(`成功添加 ${result.success.length} 个模型`)
// 处理全局模型
if (selectedGlobalModelIds.value.length > 0) {
const result = await batchAssignModelsToProvider(props.providerId, selectedGlobalModelIds.value)
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
}
if (result.errors.length > 0) {
const errorMessages = result.errors.map(e => e.error).join(', ')
showError(`部分模型添加失败: ${errorMessages}`, '警告')
// 处理上游模型(调用 import-from-upstream API
if (selectedUpstreamModelIds.value.length > 0) {
const result = await importModelsFromUpstream(props.providerId, selectedUpstreamModelIds.value)
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
}
selectedLeftIds.value = []
if (totalSuccess > 0) {
success(`成功添加 ${totalSuccess} 个模型`)
}
if (allErrors.length > 0) {
showError(`部分模型添加失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
}
selectedGlobalModelIds.value = []
selectedUpstreamModelIds.value = []
await loadExistingModels()
emit('changed')
} catch (err: any) {

View File

@@ -9,7 +9,7 @@
>
<form
class="space-y-6"
@submit.prevent="handleSubmit"
@submit.prevent="handleSubmit()"
>
<!-- API 配置 -->
<div class="space-y-4">
@@ -132,6 +132,79 @@
</div>
</div>
</div>
<!-- 代理配置 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">
代理配置
</h3>
<div class="flex items-center gap-2">
<Switch v-model="proxyEnabled" />
<span class="text-sm text-muted-foreground">启用代理</span>
</div>
</div>
<div
v-if="proxyEnabled"
class="space-y-4 rounded-lg border p-4"
>
<div class="space-y-2">
<Label for="proxy_url">代理 URL *</Label>
<Input
id="proxy_url"
v-model="form.proxy_url"
placeholder="http://host:port 或 socks5://host:port"
required
:class="proxyUrlError ? 'border-red-500' : ''"
/>
<p
v-if="proxyUrlError"
class="text-xs text-red-500"
>
{{ proxyUrlError }}
</p>
<p
v-else
class="text-xs text-muted-foreground"
>
支持 HTTPHTTPSSOCKS5 代理
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="proxy_user">用户名可选</Label>
<Input
:id="`proxy_user_${formId}`"
:name="`proxy_user_${formId}`"
v-model="form.proxy_username"
placeholder="代理认证用户名"
autocomplete="off"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
/>
</div>
<div class="space-y-2">
<Label :for="`proxy_pass_${formId}`">密码可选</Label>
<Input
:id="`proxy_pass_${formId}`"
:name="`proxy_pass_${formId}`"
v-model="form.proxy_password"
type="text"
:placeholder="passwordPlaceholder"
autocomplete="off"
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
/>
</div>
</div>
</div>
</div>
</form>
<template #footer>
@@ -145,12 +218,24 @@
</Button>
<Button
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
@click="handleSubmit"
@click="handleSubmit()"
>
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
</Button>
</template>
</Dialog>
<!-- 确认清空凭据对话框 -->
<AlertDialog
v-model="showClearCredentialsDialog"
title="清空代理凭据"
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
type="warning"
confirm-text="清空并保存"
cancel-text="返回编辑"
@confirm="confirmClearCredentials"
@cancel="showClearCredentialsDialog = false"
/>
</template>
<script setup lang="ts">
@@ -165,7 +250,9 @@ import {
SelectValue,
SelectContent,
SelectItem,
Switch,
} from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue'
import { Link, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
@@ -194,6 +281,11 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
const selectOpen = ref(false)
const proxyEnabled = ref(false)
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
// 生成随机 ID 防止浏览器自动填充
const formId = Math.random().toString(36).substring(2, 10)
// 内部状态
const internalOpen = computed(() => props.modelValue)
@@ -207,7 +299,11 @@ const form = ref({
max_retries: 3,
max_concurrent: undefined as number | undefined,
rate_limit: undefined as number | undefined,
is_active: true
is_active: true,
// 代理配置
proxy_url: '',
proxy_username: '',
proxy_password: '',
})
// API 格式列表
@@ -237,6 +333,53 @@ const defaultPathPlaceholder = computed(() => {
return `留空使用默认路径:${defaultPath.value}`
})
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
const hasExistingPassword = computed(() => {
if (!props.endpoint?.proxy) return false
const proxy = props.endpoint.proxy as { password?: string }
return proxy?.password === MASKED_PASSWORD
})
// 密码输入框的 placeholder
const passwordPlaceholder = computed(() => {
if (hasExistingPassword.value) {
return '已保存密码,留空保持不变'
}
return '代理认证密码'
})
// 代理 URL 验证
const proxyUrlError = computed(() => {
// 只有启用代理且填写了 URL 时才验证
if (!proxyEnabled.value || !form.value.proxy_url) {
return ''
}
const url = form.value.proxy_url.trim()
// 检查禁止的特殊字符
if (/[\n\r]/.test(url)) {
return '代理 URL 包含非法字符'
}
// 验证协议(不支持 SOCKS4
if (!/^(http|https|socks5):\/\//i.test(url)) {
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
}
try {
const parsed = new URL(url)
if (!parsed.host) {
return '代理 URL 必须包含有效的 host'
}
// 禁止 URL 中内嵌认证信息
if (parsed.username || parsed.password) {
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
}
} catch {
return '代理 URL 格式无效'
}
return ''
})
// 组件挂载时加载API格式
onMounted(() => {
loadApiFormats()
@@ -252,14 +395,23 @@ function resetForm() {
max_retries: 3,
max_concurrent: undefined,
rate_limit: undefined,
is_active: true
is_active: true,
proxy_url: '',
proxy_username: '',
proxy_password: '',
}
proxyEnabled.value = false
}
// 原始密码占位符(后端返回的脱敏标记)
const MASKED_PASSWORD = '***'
// 加载端点数据(编辑模式)
function loadEndpointData() {
if (!props.endpoint) return
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
form.value = {
api_format: props.endpoint.api_format,
base_url: props.endpoint.base_url,
@@ -268,8 +420,15 @@ function loadEndpointData() {
max_retries: props.endpoint.max_retries,
max_concurrent: props.endpoint.max_concurrent || undefined,
rate_limit: props.endpoint.rate_limit || undefined,
is_active: props.endpoint.is_active
is_active: props.endpoint.is_active,
proxy_url: proxy?.url || '',
proxy_username: proxy?.username || '',
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
}
// 根据 enabled 字段或 url 存在判断是否启用代理
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
}
// 使用 useFormDialog 统一处理对话框逻辑
@@ -282,12 +441,47 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
// 构建代理配置
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
// - 无 URL 时返回 null
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
if (!form.value.proxy_url) {
// 没填 URL无代理配置
return null
}
return {
url: form.value.proxy_url,
username: form.value.proxy_username || undefined,
password: form.value.proxy_password || undefined,
enabled: proxyEnabled.value, // 开关状态决定是否启用
}
}
// 提交表单
const handleSubmit = async () => {
const handleSubmit = async (skipCredentialCheck = false) => {
if (!props.provider && !props.endpoint) return
// 只在开关开启且填写了 URL 时验证
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
showError(proxyUrlError.value, '代理配置错误')
return
}
// 检查:开关开启但没有 URL却有用户名或密码
const hasOrphanedCredentials = proxyEnabled.value
&& !form.value.proxy_url
&& (form.value.proxy_username || form.value.proxy_password)
if (hasOrphanedCredentials && !skipCredentialCheck) {
// 弹出确认对话框
showClearCredentialsDialog.value = true
return
}
loading.value = true
try {
const proxyConfig = buildProxyConfig()
if (isEditMode.value && props.endpoint) {
// 更新端点
await updateEndpoint(props.endpoint.id, {
@@ -297,7 +491,8 @@ const handleSubmit = async () => {
max_retries: form.value.max_retries,
max_concurrent: form.value.max_concurrent,
rate_limit: form.value.rate_limit,
is_active: form.value.is_active
is_active: form.value.is_active,
proxy: proxyConfig,
})
success('端点已更新', '保存成功')
@@ -313,7 +508,8 @@ const handleSubmit = async () => {
max_retries: form.value.max_retries,
max_concurrent: form.value.max_concurrent,
rate_limit: form.value.rate_limit,
is_active: form.value.is_active
is_active: form.value.is_active,
proxy: proxyConfig,
})
success('端点创建成功', '成功')
@@ -329,4 +525,12 @@ const handleSubmit = async () => {
loading.value = false
}
}
// 确认清空凭据并继续保存
const confirmClearCredentials = () => {
form.value.proxy_username = ''
form.value.proxy_password = ''
showClearCredentialsDialog.value = false
handleSubmit(true) // 跳过凭据检查,直接提交
}
</script>

View File

@@ -116,6 +116,19 @@
{{ model.global_model_name }}
</div>
</div>
<!-- 测试按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试模型连接"
:disabled="testingModelName === model.global_model_name"
@click.stop="testModelConnection(model)"
>
<Loader2 v-if="testingModelName === model.global_model_name" class="w-3.5 h-3.5 animate-spin" />
<Play v-else class="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
@@ -148,16 +161,17 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Box, Loader2, Settings2 } from 'lucide-vue-next'
import { Box, Loader2, Settings2, Play } from 'lucide-vue-next'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { parseApiError, parseTestModelError } from '@/utils/errorParser'
import {
updateEndpointKey,
getProviderAvailableSourceModels,
testModel,
type EndpointAPIKey,
type ProviderAvailableSourceModel
} from '@/api/endpoints'
@@ -181,6 +195,7 @@ const loadingModels = ref(false)
const availableModels = ref<ProviderAvailableSourceModel[]>([])
const selectedModels = ref<string[]>([])
const initialModels = ref<string[]>([])
const testingModelName = ref<string | null>(null)
// 监听对话框打开
watch(() => props.open, (open) => {
@@ -268,6 +283,32 @@ function clearModels() {
selectedModels.value = []
}
// 测试模型连接
async function testModelConnection(model: ProviderAvailableSourceModel) {
if (!props.providerId || !props.apiKey || testingModelName.value) return
testingModelName.value = model.global_model_name
try {
const result = await testModel({
provider_id: props.providerId,
model_name: model.provider_model_name,
api_key_id: props.apiKey.id,
message: "hello"
})
if (result.success) {
success(`模型 "${model.display_name}" 测试成功`)
} else {
showError(`模型测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`模型测试失败: ${errorMsg}`)
} finally {
testingModelName.value = null
}
}
function areArraysEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort()

View File

@@ -260,6 +260,7 @@ import {
updateEndpointKey,
getAllCapabilities,
type EndpointAPIKey,
type EndpointAPIKeyUpdate,
type ProviderEndpoint,
type CapabilityDefinition
} from '@/api/endpoints'
@@ -386,10 +387,11 @@ function loadKeyData() {
api_key: '',
rate_multiplier: props.editingKey.rate_multiplier || 1.0,
internal_priority: props.editingKey.internal_priority ?? 50,
max_concurrent: props.editingKey.max_concurrent || undefined,
rate_limit: props.editingKey.rate_limit || undefined,
daily_limit: props.editingKey.daily_limit || undefined,
monthly_limit: props.editingKey.monthly_limit || undefined,
// 保留原始的 null/undefined 状态null 表示自适应模式
max_concurrent: props.editingKey.max_concurrent ?? undefined,
rate_limit: props.editingKey.rate_limit ?? undefined,
daily_limit: props.editingKey.daily_limit ?? undefined,
monthly_limit: props.editingKey.monthly_limit ?? undefined,
cache_ttl_minutes: props.editingKey.cache_ttl_minutes ?? 5,
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
@@ -439,12 +441,17 @@ async function handleSave() {
saving.value = true
try {
if (props.editingKey) {
// 更新
const updateData: any = {
// 更新模式
// 注意max_concurrent 需要显式发送 null 来切换到自适应模式
// undefined 会在 JSON 中被忽略,所以用 null 表示"清空/自适应"
const updateData: EndpointAPIKeyUpdate = {
name: form.value.name,
rate_multiplier: form.value.rate_multiplier,
internal_priority: form.value.internal_priority,
max_concurrent: form.value.max_concurrent,
// 显式使用 null 表示自适应模式,这样后端能区分"未提供"和"设置为 null"
// 注意:只有 max_concurrent 需要这种处理,因为它有"自适应模式"的概念
// 其他限制字段rate_limit 等)不支持"清空"操作undefined 会被 JSON 忽略即不更新
max_concurrent: form.value.max_concurrent === undefined ? null : form.value.max_concurrent,
rate_limit: form.value.rate_limit,
daily_limit: form.value.daily_limit,
monthly_limit: form.value.monthly_limit,

View File

@@ -18,7 +18,7 @@
</p>
</div>
<!-- 别名列表 -->
<!-- 映射列表 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">名称映射</Label>
@@ -92,7 +92,7 @@
</div>
</div>
<!-- 别名输入框 -->
<!-- 映射输入框 -->
<Input
v-model="alias.name"
placeholder="映射名称,如 Claude-Sonnet-4.5"
@@ -117,8 +117,12 @@
class="text-center py-6 text-muted-foreground border rounded-lg border-dashed"
>
<Tag class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">未配置映射</p>
<p class="text-xs mt-1">将只使用主模型名称</p>
<p class="text-sm">
未配置映射
</p>
<p class="text-xs mt-1">
将只使用主模型名称
</p>
</div>
</div>
</div>
@@ -180,9 +184,9 @@ const editingPriorityIndex = ref<number | null>(null)
// 监听 open 变化
watch(() => props.open, (newOpen) => {
if (newOpen && props.model) {
// 加载现有别名配置
if (props.model.provider_model_aliases && Array.isArray(props.model.provider_model_aliases)) {
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_aliases))
// 加载现有映射配置
if (props.model.provider_model_mappings && Array.isArray(props.model.provider_model_mappings)) {
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
} else {
aliases.value = []
}
@@ -193,16 +197,16 @@ watch(() => props.open, (newOpen) => {
}
})
// 添加别名
// 添加映射
function addAlias() {
// 新别名优先级为当前最大优先级 + 1或者默认为 1
// 新映射优先级为当前最大优先级 + 1或者默认为 1
const maxPriority = aliases.value.length > 0
? Math.max(...aliases.value.map(a => a.priority))
: 0
aliases.value.push({ name: '', priority: maxPriority + 1 })
}
// 移除别名
// 移除映射
function removeAlias(index: number) {
aliases.value.splice(index, 1)
}
@@ -240,7 +244,7 @@ function handleDrop(targetIndex: number) {
const items = [...aliases.value]
const draggedItem = items[dragIndex]
// 记录每个别名的原始优先级(在修改前)
// 记录每个映射的原始优先级(在修改前)
const originalPriorityMap = new Map<number, number>()
items.forEach((alias, idx) => {
originalPriorityMap.set(idx, alias.priority)
@@ -251,7 +255,7 @@ function handleDrop(targetIndex: number) {
items.splice(targetIndex, 0, draggedItem)
// 按新顺序为每个组分配新的优先级
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
// 同组的映射保持相同的优先级(被拖动的映射单独成组)
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
let currentPriority = 1
@@ -259,12 +263,12 @@ function handleDrop(targetIndex: number) {
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
items.forEach((alias, newIdx) => {
// 找到这个别名在原数组中的索引
// 找到这个映射在原数组中的索引
const originalIdx = aliases.value.findIndex(a => a === alias)
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
if (alias === draggedItem) {
// 被拖动的别名是独立的新组,获得当前优先级
// 被拖动的映射是独立的新组,获得当前优先级
alias.priority = currentPriority
currentPriority++
} else {
@@ -314,11 +318,11 @@ async function handleSubmit() {
submitting.value = true
try {
// 过滤掉空的别名
// 过滤掉空的映射
const validAliases = aliases.value.filter(a => a.name.trim())
await updateModel(props.providerId, props.model.id, {
provider_model_aliases: validAliases.length > 0 ? validAliases : null
provider_model_mappings: validAliases.length > 0 ? validAliases : null
})
showSuccess('映射配置已保存')

View File

@@ -0,0 +1,796 @@
<template>
<Dialog
:model-value="open"
:title="editingGroup ? '编辑模型映射' : '添加模型映射'"
:description="editingGroup ? '修改映射配置' : '为模型添加新的名称映射'"
:icon="Tag"
size="4xl"
@update:model-value="$emit('update:open', $event)"
>
<div class="space-y-4">
<!-- 第一行目标模型 | 作用域 -->
<div class="flex gap-4">
<!-- 目标模型 -->
<div class="flex-1 space-y-1.5">
<Label class="text-xs">目标模型</Label>
<Select
v-model:open="modelSelectOpen"
:model-value="formData.modelId"
:disabled="!!editingGroup"
@update:model-value="handleModelChange"
>
<SelectTrigger class="h-9">
<SelectValue placeholder="请选择模型" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="model in models"
:key="model.id"
:value="model.id"
>
{{ model.global_model_display_name || model.provider_model_name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 作用域 -->
<div class="flex-1 space-y-1.5">
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
<div
v-if="providerApiFormats.length > 0"
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
>
<button
v-for="format in providerApiFormats"
:key="format"
type="button"
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
:class="[
formData.apiFormats.includes(format)
? 'bg-primary text-primary-foreground'
: 'bg-background border border-border hover:bg-muted'
]"
@click="toggleApiFormat(format)"
>
{{ API_FORMAT_LABELS[format] || format }}
</button>
</div>
<div
v-else
class="h-9 flex items-center text-xs text-muted-foreground"
>
无可用格式
</div>
</div>
</div>
<!-- 第二行两栏布局 -->
<div class="flex gap-4 items-stretch">
<!-- 左侧上游模型列表 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium shrink-0">
上游模型
</span>
<div class="flex-1 relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
v-model="upstreamModelSearch"
placeholder="搜索模型..."
class="pl-7 h-7 text-xs"
/>
</div>
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="刷新列表"
:disabled="refreshingUpstreamModels"
@click="refreshUpstreamModels"
>
<RefreshCw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': refreshingUpstreamModels }"
/>
</button>
<button
v-else-if="!fetchingUpstreamModels"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="获取上游模型列表"
@click="fetchUpstreamModels"
>
<Zap class="w-3.5 h-3.5" />
</button>
<Loader2
v-else
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
/>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<template v-if="upstreamModelsLoaded">
<div
v-if="groupedAvailableUpstreamModels.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Zap class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
</p>
</div>
<div
v-else
class="p-2 space-y-2"
>
<!-- 按分组显示可折叠 -->
<div
v-for="group in groupedAvailableUpstreamModels"
:key="group.api_format"
class="border rounded-lg overflow-hidden"
>
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
<button
type="button"
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
@click="toggleGroupCollapse(group.api_format)"
>
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
</span>
<span class="text-xs text-muted-foreground">
({{ group.models.length }})
</span>
</button>
</div>
<div
v-show="!collapsedGroups.has(group.api_format)"
class="p-2 space-y-1 border-t"
>
<div
v-for="model in group.models"
:key="model.id"
class="flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
:title="model.id"
>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">
{{ model.id }}
</p>
<p class="text-xs text-muted-foreground truncate font-mono">
{{ model.owned_by || model.id }}
</p>
</div>
<button
type="button"
class="p-1 hover:bg-primary/10 rounded transition-colors shrink-0"
title="添加到映射"
@click="addUpstreamModel(model.id)"
>
<ChevronRight class="w-4 h-4 text-muted-foreground hover:text-primary" />
</button>
</div>
</div>
</div>
</div>
</template>
<!-- 未加载状态 -->
<div
v-else
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Zap class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
点击右上角按钮
</p>
<p class="text-xs mt-1">
从上游获取可用模型
</p>
</div>
</div>
</div>
<!-- 右侧映射名称列表 -->
<div class="flex-1 space-y-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium">
映射名称
</p>
<button
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors"
title="手动添加"
@click="addAliasItem"
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="border rounded-lg h-80 overflow-y-auto">
<div
v-if="formData.aliases.length === 0"
class="flex flex-col items-center justify-center h-full text-muted-foreground"
>
<Tag class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
从左侧选择模型
</p>
<p class="text-xs mt-1">
或点击上方"手动添加"
</p>
</div>
<div
v-else
class="p-2 space-y-1"
>
<div
v-for="(alias, index) in formData.aliases"
:key="`alias-${index}`"
class="group flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
:class="[
draggedIndex === index ? 'bg-primary/5' : '',
dragOverIndex === index ? 'bg-primary/10 border-primary' : ''
]"
draggable="true"
@dragstart="handleDragStart(index, $event)"
@dragend="handleDragEnd"
@dragover.prevent="handleDragOver(index)"
@dragleave="handleDragLeave"
@drop="handleDrop(index)"
>
<!-- 删除按钮 -->
<button
type="button"
class="p-1 hover:bg-destructive/10 rounded transition-colors shrink-0"
title="移除"
@click="removeAliasItem(index)"
>
<ChevronLeft class="w-4 h-4 text-muted-foreground hover:text-destructive" />
</button>
<!-- 优先级 -->
<div class="shrink-0">
<input
v-if="editingPriorityIndex === index"
type="number"
min="1"
:value="alias.priority"
class="w-7 h-6 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
autofocus
@blur="finishEditPriority(index, $event)"
@keydown.enter="($event.target as HTMLInputElement).blur()"
@keydown.escape="cancelEditPriority"
>
<div
v-else
class="w-6 h-6 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
title="点击编辑优先级"
@click.stop="startEditPriority(index)"
>
{{ alias.priority }}
</div>
</div>
<!-- 名称显示/编辑 -->
<div class="flex-1 min-w-0">
<Input
v-if="alias.isEditing"
v-model="alias.name"
placeholder="输入映射名称"
class="h-7 text-xs"
autofocus
@blur="alias.isEditing = false"
@keydown.enter="alias.isEditing = false"
/>
<p
v-else
class="font-medium text-sm truncate cursor-pointer hover:text-primary"
title="点击编辑"
@click="alias.isEditing = true"
>
{{ alias.name || '点击输入名称' }}
</p>
</div>
<!-- 拖拽手柄 -->
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
<GripVertical class="w-4 h-4" />
</div>
</div>
</div>
<!-- 拖拽提示 -->
<div
v-if="formData.aliases.length > 1"
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center"
>
拖拽调整优先级顺序
</div>
</div>
</div>
</div>
</div>
<template #footer>
<Button
variant="outline"
@click="$emit('update:open', false)"
>
取消
</Button>
<Button
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
@click="handleSubmit"
>
<Loader2
v-if="submitting"
class="w-4 h-4 mr-2 animate-spin"
/>
{{ editingGroup ? '保存' : '添加' }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Tag, Loader2, GripVertical, Zap, Search, RefreshCw, ChevronDown, ChevronRight, ChevronLeft, Plus } from 'lucide-vue-next'
import {
Button,
Input,
Label,
Dialog,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { useToast } from '@/composables/useToast'
import {
API_FORMAT_LABELS,
type Model,
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
interface FormAlias {
name: string
priority: number
isEditing?: boolean
}
export interface AliasGroup {
model: Model
apiFormatsKey: string
apiFormats: string[]
aliases: ProviderModelAlias[]
}
const props = defineProps<{
open: boolean
providerId: string
providerApiFormats: string[]
models: Model[]
editingGroup?: AliasGroup | null
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'saved': []
}>()
const { error: showError, success: showSuccess } = useToast()
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
// 状态
const submitting = ref(false)
const modelSelectOpen = ref(false)
// 拖拽状态
const draggedIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
// 优先级编辑状态
const editingPriorityIndex = ref<number | null>(null)
// 快速添加(上游模型)状态
const fetchingUpstreamModels = ref(false)
const refreshingUpstreamModels = ref(false)
const upstreamModelsLoaded = ref(false)
const upstreamModels = ref<UpstreamModel[]>([])
const upstreamModelSearch = ref('')
// 分组折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 表单数据
const formData = ref<{
modelId: string
apiFormats: string[]
aliases: FormAlias[]
}>({
modelId: '',
apiFormats: [],
aliases: []
})
// 检查是否有有效的映射
const hasValidAliases = computed(() => {
return formData.value.aliases.some(a => a.name.trim())
})
// 过滤和排序后的上游模型列表
const filteredUpstreamModels = computed(() => {
const searchText = upstreamModelSearch.value.toLowerCase().trim()
let result = [...upstreamModels.value]
result.sort((a, b) => a.id.localeCompare(b.id))
if (searchText) {
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => {
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
return result
})
// 按 API 格式分组的上游模型列表
interface UpstreamModelGroup {
api_format: string
models: Array<{ id: string; owned_by?: string; api_format?: string }>
}
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
// 收集当前表单已添加的名称
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
// 收集所有已存在的映射名称(包括主模型名和映射名称)
for (const m of props.models) {
addedNames.add(m.provider_model_name)
for (const mapping of m.provider_model_mappings ?? []) {
if (mapping.name) addedNames.add(mapping.name)
}
}
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
const groups = new Map<string, UpstreamModelGroup>()
for (const model of availableModels) {
const format = model.api_format || 'UNKNOWN'
if (!groups.has(format)) {
groups.set(format, { api_format: format, models: [] })
}
groups.get(format)!.models.push(model)
}
const order = Object.keys(API_FORMAT_LABELS)
return Array.from(groups.values()).sort((a, b) => {
const aIndex = order.indexOf(a.api_format)
const bIndex = order.indexOf(b.api_format)
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
// 监听打开状态
watch(() => props.open, (isOpen) => {
if (isOpen) {
initForm()
}
})
// 初始化表单
function initForm() {
if (props.editingGroup) {
formData.value = {
modelId: props.editingGroup.model.id,
apiFormats: [...props.editingGroup.apiFormats],
aliases: props.editingGroup.aliases.map(a => ({ name: a.name, priority: a.priority }))
}
} else {
formData.value = {
modelId: '',
apiFormats: [],
aliases: []
}
}
// 重置状态
editingPriorityIndex.value = null
draggedIndex.value = null
dragOverIndex.value = null
upstreamModelSearch.value = ''
collapsedGroups.value = new Set()
// 检查缓存,如果有缓存数据则直接使用
const cachedModels = getCachedModels(props.providerId)
if (cachedModels) {
upstreamModels.value = cachedModels
upstreamModelsLoaded.value = true
// 默认折叠所有分组
for (const model of cachedModels) {
if (model.api_format) {
collapsedGroups.value.add(model.api_format)
}
}
} else {
upstreamModelsLoaded.value = false
upstreamModels.value = []
}
}
// 处理模型选择变更
function handleModelChange(value: string) {
formData.value.modelId = value
const selectedModel = props.models.find(m => m.id === value)
if (selectedModel) {
upstreamModelSearch.value = selectedModel.provider_model_name
}
}
// 切换 API 格式
function toggleApiFormat(format: string) {
const index = formData.value.apiFormats.indexOf(format)
if (index >= 0) {
formData.value.apiFormats.splice(index, 1)
} else {
formData.value.apiFormats.push(format)
}
}
// 切换分组折叠状态
function toggleGroupCollapse(apiFormat: string) {
if (collapsedGroups.value.has(apiFormat)) {
collapsedGroups.value.delete(apiFormat)
} else {
collapsedGroups.value.add(apiFormat)
}
}
// 添加映射项
function addAliasItem() {
const maxPriority = formData.value.aliases.length > 0
? Math.max(...formData.value.aliases.map(a => a.priority))
: 0
formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true })
}
// 删除映射项
function removeAliasItem(index: number) {
formData.value.aliases.splice(index, 1)
}
// ===== 拖拽排序 =====
function handleDragStart(index: number, event: DragEvent) {
draggedIndex.value = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleDragEnd() {
draggedIndex.value = null
dragOverIndex.value = null
}
function handleDragOver(index: number) {
if (draggedIndex.value !== null && draggedIndex.value !== index) {
dragOverIndex.value = index
}
}
function handleDragLeave() {
dragOverIndex.value = null
}
function handleDrop(targetIndex: number) {
const dragIndex = draggedIndex.value
if (dragIndex === null || dragIndex === targetIndex) {
dragOverIndex.value = null
return
}
const items = [...formData.value.aliases]
const draggedItem = items[dragIndex]
const originalPriorityMap = new Map<number, number>()
items.forEach((alias, idx) => {
originalPriorityMap.set(idx, alias.priority)
})
items.splice(dragIndex, 1)
items.splice(targetIndex, 0, draggedItem)
const groupNewPriority = new Map<number, number>()
let currentPriority = 1
items.forEach((alias) => {
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
if (alias === draggedItem) {
alias.priority = currentPriority
currentPriority++
} else {
if (groupNewPriority.has(originalPriority)) {
alias.priority = groupNewPriority.get(originalPriority)!
} else {
groupNewPriority.set(originalPriority, currentPriority)
alias.priority = currentPriority
currentPriority++
}
}
})
formData.value.aliases = items
draggedIndex.value = null
dragOverIndex.value = null
}
// ===== 优先级编辑 =====
function startEditPriority(index: number) {
editingPriorityIndex.value = index
}
function finishEditPriority(index: number, event: FocusEvent) {
const input = event.target as HTMLInputElement
const newPriority = parseInt(input.value) || 1
formData.value.aliases[index].priority = Math.max(1, newPriority)
editingPriorityIndex.value = null
}
function cancelEditPriority() {
editingPriorityIndex.value = null
}
// ===== 快速添加(上游模型)=====
async function fetchUpstreamModels() {
if (!props.providerId) return
upstreamModelSearch.value = ''
fetchingUpstreamModels.value = true
try {
const result = await fetchCachedModels(props.providerId)
if (result) {
if (result.error) {
showError(result.error, '错误')
} else {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 默认折叠所有分组
for (const model of result.models) {
if (model.api_format) {
collapsedGroups.value.add(model.api_format)
}
}
}
}
} finally {
fetchingUpstreamModels.value = false
}
}
function addUpstreamModel(modelId: string) {
if (formData.value.aliases.some(a => a.name === modelId)) {
return
}
const maxPriority = formData.value.aliases.length > 0
? Math.max(...formData.value.aliases.map(a => a.priority))
: 0
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
}
async function refreshUpstreamModels() {
if (!props.providerId || refreshingUpstreamModels.value) return
refreshingUpstreamModels.value = true
clearCache(props.providerId)
try {
const result = await fetchCachedModels(props.providerId, true)
if (result) {
if (result.error) {
showError(result.error, '错误')
} else {
upstreamModels.value = result.models
}
}
} finally {
refreshingUpstreamModels.value = false
}
}
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
if (!formats || formats.length === 0) return ''
return [...formats].sort().join(',')
}
// 提交表单
async function handleSubmit() {
if (submitting.value) return
if (!formData.value.modelId || formData.value.aliases.length === 0) return
const validAliases = formData.value.aliases.filter(a => a.name.trim())
if (validAliases.length === 0) {
showError('请至少添加一个有效的映射名称', '错误')
return
}
submitting.value = true
try {
const targetModel = props.models.find(m => m.id === formData.value.modelId)
if (!targetModel) {
showError('模型不存在', '错误')
return
}
const currentAliases = targetModel.provider_model_mappings || []
let newAliases: ProviderModelAlias[]
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
name: a.name.trim(),
priority: a.priority,
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
})
if (props.editingGroup) {
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
})
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
return
}
newAliases = [
...filteredAliases,
...validAliases.map(buildAlias)
]
} else {
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
if (duplicates.length > 0) {
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
return
}
newAliases = [
...currentAliases,
...validAliases.map(buildAlias)
]
}
await updateModel(props.providerId, targetModel.id, {
provider_model_mappings: newAliases
})
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')
emit('update:open', false)
emit('saved')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
} finally {
submitting.value = false
}
}
</script>

View File

@@ -312,8 +312,41 @@
<template #footer>
<div class="flex items-center justify-between w-full">
<div class="text-xs text-muted-foreground">
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
<div class="flex items-center gap-4">
<div class="text-xs text-muted-foreground">
当前模式: <span class="font-medium">{{ activeMainTab === 'provider' ? '提供商优先' : 'Key 优先' }}</span>
</div>
<div class="flex items-center gap-2 pl-4 border-l border-border">
<span class="text-xs text-muted-foreground">调度:</span>
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md">
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded transition-all"
:class="[
schedulingMode === 'fixed_order'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
title="严格按优先级顺序,不考虑缓存"
@click="schedulingMode = 'fixed_order'"
>
固定顺序
</button>
<button
type="button"
class="px-2 py-1 text-xs font-medium rounded transition-all"
:class="[
schedulingMode === 'cache_affinity'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
title="优先使用已缓存的Provider利用Prompt Cache"
@click="schedulingMode = 'cache_affinity'"
>
缓存亲和
</button>
</div>
</div>
</div>
<div class="flex gap-2">
<Button
@@ -410,6 +443,9 @@ const saving = ref(false)
// Key 优先级编辑状态
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
// 调度模式状态
const schedulingMode = ref<'fixed_order' | 'cache_affinity'>('cache_affinity')
// 可用的 API 格式
const availableFormats = computed(() => {
return Object.keys(keysByFormat.value).sort()
@@ -433,11 +469,18 @@ watch(internalOpen, async (open) => {
// 加载当前的优先级模式配置
async function loadCurrentPriorityMode() {
try {
const response = await adminApi.getSystemConfig('provider_priority_mode')
const currentMode = response.value || 'provider'
const [priorityResponse, schedulingResponse] = await Promise.all([
adminApi.getSystemConfig('provider_priority_mode'),
adminApi.getSystemConfig('scheduling_mode')
])
const currentMode = priorityResponse.value || 'provider'
activeMainTab.value = currentMode === 'global_key' ? 'key' : 'provider'
const currentSchedulingMode = schedulingResponse.value || 'cache_affinity'
schedulingMode.value = currentSchedulingMode === 'fixed_order' ? 'fixed_order' : 'cache_affinity'
} catch {
activeMainTab.value = 'provider'
schedulingMode.value = 'cache_affinity'
}
}
@@ -611,11 +654,19 @@ async function save() {
const newMode = activeMainTab.value === 'key' ? 'global_key' : 'provider'
await adminApi.updateSystemConfig(
'provider_priority_mode',
newMode,
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)'
)
// 保存优先级模式和调度模式
await Promise.all([
adminApi.updateSystemConfig(
'provider_priority_mode',
newMode,
'Provider/Key 优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)'
),
adminApi.updateSystemConfig(
'scheduling_mode',
schedulingMode.value,
'调度模式fixed_order(固定顺序模式) 或 cache_affinity(缓存亲和模式)'
)
])
const providerUpdates = sortedProviders.value.map((provider, index) =>
updateProvider(provider.id, { provider_priority: index + 1 })

View File

@@ -337,8 +337,40 @@
{{ key.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<div class="text-[10px] font-mono text-muted-foreground truncate">
{{ key.api_key_masked }}
<div class="flex items-center gap-1">
<span class="text-[10px] font-mono text-muted-foreground truncate max-w-[180px]">
{{ revealedKeys.has(key.id) ? revealedKeys.get(key.id) : key.api_key_masked }}
</span>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
:title="revealedKeys.has(key.id) ? '隐藏密钥' : '显示密钥'"
:disabled="revealingKeyId === key.id"
@click.stop="toggleKeyReveal(key)"
>
<Loader2
v-if="revealingKeyId === key.id"
class="w-3 h-3 animate-spin"
/>
<EyeOff
v-else-if="revealedKeys.has(key.id)"
class="w-3 h-3"
/>
<Eye
v-else
class="w-3 h-3"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-5 w-5 shrink-0"
title="复制密钥"
@click.stop="copyFullKey(key)"
>
<Copy class="w-3 h-3" />
</Button>
</div>
</div>
<div class="flex items-center gap-1.5 ml-auto shrink-0">
@@ -483,9 +515,9 @@
<span
v-if="key.max_concurrent || key.is_adaptive"
class="text-muted-foreground"
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'}` : '固定并发限制'"
:title="key.is_adaptive ? `自适应并发限制(学习值: ${key.learned_max_concurrent ?? '未学习'}` : `固定并发限制: ${key.max_concurrent}`"
>
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.learned_max_concurrent || key.max_concurrent || 3 }}
{{ key.is_adaptive ? '自适应' : '固定' }}并发: {{ key.is_adaptive ? (key.learned_max_concurrent ?? '学习中') : key.max_concurrent }}
</span>
</div>
</div>
@@ -526,7 +558,15 @@
@edit-model="handleEditModel"
@delete-model="handleDeleteModel"
@batch-assign="handleBatchAssign"
@manage-alias="handleManageAlias"
/>
<!-- 模型名称映射 -->
<ModelAliasesTab
v-if="provider"
ref="modelAliasesTabRef"
:key="`aliases-${provider.id}`"
:provider="provider"
@refresh="handleRelatedDataRefresh"
/>
</div>
</template>
@@ -629,16 +669,6 @@
@update:open="batchAssignDialogOpen = $event"
@changed="handleBatchAssignChanged"
/>
<!-- 模型别名管理对话框 -->
<ModelAliasDialog
v-if="open && provider"
:open="aliasDialogOpen"
:provider-id="provider.id"
:model="aliasEditingModel"
@update:open="aliasDialogOpen = $event"
@saved="handleAliasSaved"
/>
</template>
<script setup lang="ts">
@@ -656,19 +686,23 @@ import {
Power,
Layers,
GripVertical,
Copy
Copy,
Eye,
EyeOff
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
import Card from '@/components/ui/card.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProvider, getProviderEndpoints } from '@/api/endpoints'
import {
KeyFormDialog,
KeyAllowedModelsDialog,
ModelsTab,
BatchAssignModelsDialog,
ModelAliasDialog
ModelAliasesTab,
BatchAssignModelsDialog
} from '@/features/providers/components'
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
import ProviderModelFormDialog from '@/features/providers/components/ProviderModelFormDialog.vue'
@@ -681,6 +715,7 @@ import {
updateEndpoint,
updateEndpointKey,
batchUpdateKeyPriority,
revealEndpointKey,
type ProviderEndpoint,
type EndpointAPIKey,
type Model
@@ -707,6 +742,7 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
const loading = ref(false)
const provider = ref<any>(null)
@@ -730,6 +766,10 @@ const recoveringEndpointId = ref<string | null>(null)
const togglingEndpointId = ref<string | null>(null)
const togglingKeyId = ref<string | null>(null)
// 密钥显示状态key_id -> 完整密钥
const revealedKeys = ref<Map<string, string>>(new Map())
const revealingKeyId = ref<string | null>(null)
// 模型相关状态
const modelFormDialogOpen = ref(false)
const editingModel = ref<Model | null>(null)
@@ -737,9 +777,8 @@ const deleteModelConfirmOpen = ref(false)
const modelToDelete = ref<Model | null>(null)
const batchAssignDialogOpen = ref(false)
// 别名管理相关状态
const aliasDialogOpen = ref(false)
const aliasEditingModel = ref<Model | null>(null)
// ModelAliasesTab 组件引用
const modelAliasesTabRef = ref<InstanceType<typeof ModelAliasesTab> | null>(null)
// 拖动排序相关状态
const dragState = ref({
@@ -763,7 +802,8 @@ const hasBlockingDialogOpen = computed(() =>
modelFormDialogOpen.value ||
deleteModelConfirmOpen.value ||
batchAssignDialogOpen.value ||
aliasDialogOpen.value
// 检测 ModelAliasesTab 子组件的 Dialog 是否打开
modelAliasesTabRef.value?.dialogOpen
)
// 监听 providerId 变化
@@ -792,7 +832,6 @@ watch(() => props.open, (newOpen) => {
keyAllowedModelsDialogOpen.value = false
deleteKeyConfirmOpen.value = false
batchAssignDialogOpen.value = false
aliasDialogOpen.value = false
// 重置临时数据
endpointToEdit.value = null
@@ -800,6 +839,9 @@ watch(() => props.open, (newOpen) => {
currentEndpoint.value = null
editingKey.value = null
keyToDelete.value = null
// 清除已显示的密钥(安全考虑)
revealedKeys.value.clear()
}
})
@@ -888,6 +930,43 @@ function handleConfigKeyModels(key: EndpointAPIKey) {
keyAllowedModelsDialogOpen.value = true
}
// 切换密钥显示/隐藏
async function toggleKeyReveal(key: EndpointAPIKey) {
if (revealedKeys.value.has(key.id)) {
// 已显示,隐藏它
revealedKeys.value.delete(key.id)
return
}
// 未显示,调用 API 获取完整密钥
revealingKeyId.value = key.id
try {
const result = await revealEndpointKey(key.id)
revealedKeys.value.set(key.id, result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
} finally {
revealingKeyId.value = null
}
}
// 复制完整密钥
async function copyFullKey(key: EndpointAPIKey) {
// 如果已经显示了,直接复制
if (revealedKeys.value.has(key.id)) {
copyToClipboard(revealedKeys.value.get(key.id)!)
return
}
// 否则先获取再复制
try {
const result = await revealEndpointKey(key.id)
copyToClipboard(result.api_key)
} catch (err: any) {
showError(err.response?.data?.detail || '获取密钥失败', '错误')
}
}
function handleDeleteKey(key: EndpointAPIKey) {
keyToDelete.value = key
deleteKeyConfirmOpen.value = true
@@ -1030,19 +1109,6 @@ async function handleBatchAssignChanged() {
emit('refresh')
}
// 处理管理映射 - 打开别名对话框
function handleManageAlias(model: Model) {
aliasEditingModel.value = model
aliasDialogOpen.value = true
}
// 处理别名保存完成
async function handleAliasSaved() {
aliasEditingModel.value = null
await loadProvider()
emit('refresh')
}
// 处理模型保存完成
async function handleModelSaved() {
editingModel.value = null
@@ -1265,16 +1331,6 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400'
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
}
// 加载 Provider 信息
async function loadProvider() {
if (!props.providerId) return
@@ -1318,6 +1374,16 @@ async function loadEndpoints() {
showError(err.response?.data?.detail || '加载端点失败', '错误')
}
}
// 添加 ESC 键监听
useEscapeKey(() => {
if (props.open) {
handleClose()
}
}, {
disableOnInput: true,
once: false
})
</script>
<style scoped>

View File

@@ -10,3 +10,4 @@ export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vu
export { default as ModelAliasDialog } from './ModelAliasDialog.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
export { default as ModelAliasesTab } from './provider-tabs/ModelAliasesTab.vue'

View File

@@ -0,0 +1,417 @@
<template>
<Card class="overflow-hidden">
<!-- 标题头部 -->
<div class="p-4 border-b border-border/60">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold flex items-center gap-2">
模型名称映射
</h3>
<Button
variant="outline"
size="sm"
class="h-8"
@click="openAddDialog"
>
<Plus class="w-3.5 h-3.5 mr-1.5" />
添加映射
</Button>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
<!-- 分组映射列表 -->
<div
v-else-if="aliasGroups.length > 0"
class="divide-y divide-border/40"
>
<div
v-for="group in aliasGroups"
:key="`${group.model.id}-${group.apiFormatsKey}`"
class="transition-colors"
>
<!-- 分组头部可点击展开 -->
<div
class="flex items-center justify-between px-4 py-3 hover:bg-muted/20 cursor-pointer"
@click="toggleAliasGroupExpand(`${group.model.id}-${group.apiFormatsKey}`)"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- 展开/收起图标 -->
<ChevronRight
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform"
:class="{ 'rotate-90': expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`) }"
/>
<!-- 模型名称 -->
<span class="font-semibold text-sm truncate">
{{ group.model.global_model_display_name || group.model.provider_model_name }}
</span>
<!-- 作用域标签 -->
<div class="flex items-center gap-1 shrink-0">
<Badge
v-if="group.apiFormats.length === 0"
variant="outline"
class="text-xs"
>
全部
</Badge>
<Badge
v-for="format in group.apiFormats"
v-else
:key="format"
variant="outline"
class="text-xs"
>
{{ API_FORMAT_LABELS[format] || format }}
</Badge>
</div>
<!-- 映射数量 -->
<span class="text-xs text-muted-foreground shrink-0">
({{ group.aliases.length }} 个映射)
</span>
</div>
<!-- 操作按钮 -->
<div
class="flex items-center gap-1.5 ml-4 shrink-0"
@click.stop
>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="编辑映射组"
@click="editGroup(group)"
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
title="删除映射组"
@click="deleteGroup(group)"
>
<Trash2 class="w-3.5 h-3.5" />
</Button>
</div>
</div>
<!-- 展开的映射列表 -->
<div
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
class="bg-muted/30 border-t border-border/30"
>
<div class="px-4 py-2 space-y-1">
<div
v-for="mapping in group.aliases"
:key="mapping.name"
class="flex items-center justify-between gap-2 py-1"
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<!-- 优先级标签 -->
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
{{ mapping.priority }}
</span>
<!-- 映射名称 -->
<span class="font-mono text-sm truncate">
{{ mapping.name }}
</span>
</div>
<!-- 测试按钮 -->
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0"
title="测试映射"
:disabled="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`"
@click="testMapping(group, mapping)"
>
<Loader2 v-if="testingMapping === `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`" class="w-3 h-3 animate-spin" />
<Play v-else class="w-3 h-3" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="p-8 text-center text-muted-foreground"
>
<Tag class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">
暂无模型映射
</p>
<p class="text-xs mt-1">
点击上方"添加映射"按钮为模型创建名称映射
</p>
</div>
</Card>
<!-- 添加/编辑映射对话框 -->
<ModelMappingDialog
v-model:open="dialogOpen"
:provider-id="provider.id"
:provider-api-formats="providerApiFormats"
:models="models"
:editing-group="editingGroup"
@saved="onDialogSaved"
/>
<!-- 删除确认对话框 -->
<AlertDialog
v-model="deleteConfirmOpen"
title="删除映射组"
:description="deleteConfirmDescription"
confirm-text="删除"
cancel-text="取消"
type="danger"
@confirm="confirmDelete"
@cancel="deleteConfirmOpen = false"
/>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Tag, Plus, Edit, Trash2, ChevronRight, Loader2, Play } from 'lucide-vue-next'
import { Card, Button, Badge } from '@/components/ui'
import AlertDialog from '@/components/common/AlertDialog.vue'
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
import { useToast } from '@/composables/useToast'
import {
getProviderModels,
testModel,
API_FORMAT_LABELS,
type Model,
type ProviderModelAlias
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { parseTestModelError } from '@/utils/errorParser'
const props = defineProps<{
provider: any
}>()
const emit = defineEmits<{
'refresh': []
}>()
const { error: showError, success: showSuccess } = useToast()
// 状态
const loading = ref(false)
const models = ref<Model[]>([])
const dialogOpen = ref(false)
const deleteConfirmOpen = ref(false)
const editingGroup = ref<AliasGroup | null>(null)
const deletingGroup = ref<AliasGroup | null>(null)
const testingMapping = ref<string | null>(null)
// 列表展开状态
const expandedAliasGroups = ref<Set<string>>(new Set())
// 获取 Provider 支持的 API 格式
const providerApiFormats = computed(() => {
const formats = props.provider?.api_formats
if (Array.isArray(formats) && formats.length > 0) {
const order = Object.keys(API_FORMAT_LABELS)
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
}
return []
})
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
if (!formats || formats.length === 0) return ''
return [...formats].sort().join(',')
}
// 按"模型+作用域"分组的映射列表
const aliasGroups = computed<AliasGroup[]>(() => {
const groups: AliasGroup[] = []
const groupMap = new Map<string, AliasGroup>()
for (const model of models.value) {
if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue
for (const alias of model.provider_model_mappings) {
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
const groupKey = `${model.id}|${apiFormatsKey}`
if (!groupMap.has(groupKey)) {
const group: AliasGroup = {
model,
apiFormatsKey,
apiFormats: alias.api_formats || [],
aliases: []
}
groupMap.set(groupKey, group)
groups.push(group)
}
groupMap.get(groupKey)!.aliases.push(alias)
}
}
for (const group of groups) {
group.aliases.sort((a, b) => a.priority - b.priority)
}
return groups.sort((a, b) => {
const nameA = (a.model.global_model_display_name || a.model.provider_model_name || '').toLowerCase()
const nameB = (b.model.global_model_display_name || b.model.provider_model_name || '').toLowerCase()
if (nameA !== nameB) return nameA.localeCompare(nameB)
return a.apiFormatsKey.localeCompare(b.apiFormatsKey)
})
})
// 加载模型
async function loadModels() {
try {
loading.value = true
models.value = await getProviderModels(props.provider.id)
} catch (err: any) {
showError(err.response?.data?.detail || '加载失败', '错误')
} finally {
loading.value = false
}
}
// 删除确认描述
const deleteConfirmDescription = computed(() => {
if (!deletingGroup.value) return ''
const { model, aliases, apiFormats } = deletingGroup.value
const modelName = model.global_model_display_name || model.provider_model_name
const scopeText = apiFormats.length === 0 ? '全部' : apiFormats.map(f => API_FORMAT_LABELS[f] || f).join(', ')
const aliasNames = aliases.map(a => a.name).join(', ')
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称${aliasNames}`
})
// 切换映射组展开状态
function toggleAliasGroupExpand(groupKey: string) {
if (expandedAliasGroups.value.has(groupKey)) {
expandedAliasGroups.value.delete(groupKey)
} else {
expandedAliasGroups.value.add(groupKey)
}
}
// 打开添加对话框
function openAddDialog() {
editingGroup.value = null
dialogOpen.value = true
}
// 编辑分组
function editGroup(group: AliasGroup) {
editingGroup.value = group
dialogOpen.value = true
}
// 删除分组
function deleteGroup(group: AliasGroup) {
deletingGroup.value = group
deleteConfirmOpen.value = true
}
// 确认删除
async function confirmDelete() {
if (!deletingGroup.value) return
const { model, aliases, apiFormatsKey } = deletingGroup.value
try {
const currentAliases = model.provider_model_mappings || []
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
const currentKey = getApiFormatsKey(a.api_formats)
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
})
await updateModel(props.provider.id, model.id, {
provider_model_mappings: newAliases.length > 0 ? newAliases : null
})
showSuccess('映射组已删除')
deleteConfirmOpen.value = false
deletingGroup.value = null
await loadModels()
emit('refresh')
} catch (err: any) {
showError(err.response?.data?.detail || '删除失败', '错误')
}
}
// 对话框保存后回调
async function onDialogSaved() {
await loadModels()
emit('refresh')
}
// 测试模型映射
async function testMapping(group: any, mapping: any) {
const testingKey = `${group.model.id}-${group.apiFormatsKey}-${mapping.name}`
testingMapping.value = testingKey
try {
// 根据分组的 API 格式来确定应该使用的格式
let apiFormat = null
if (group.apiFormats.length === 1) {
apiFormat = group.apiFormats[0]
} else if (group.apiFormats.length === 0) {
// 如果没有指定格式,但分组显示为"全部",则使用模型的默认格式
apiFormat = group.model.effective_api_format || group.model.api_format
}
const result = await testModel({
provider_id: props.provider.id,
model_name: mapping.name, // 使用映射名称进行测试
message: "hello",
api_format: apiFormat
})
if (result.success) {
showSuccess(`映射 "${mapping.name}" 测试成功`)
// 如果有响应内容,可以显示更多信息
if (result.data?.response?.choices?.[0]?.message?.content) {
const content = result.data.response.choices[0].message.content
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
} else if (result.data?.content_preview) {
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
}
} else {
showError(`映射测试失败: ${parseTestModelError(result)}`)
}
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
showError(`映射测试失败: ${errorMsg}`)
} finally {
testingMapping.value = null
}
}
// 监听 provider 变化
watch(() => props.provider?.id, (newId) => {
if (newId) {
loadModels()
}
}, { immediate: true })
onMounted(() => {
if (props.provider?.id) {
loadModels()
}
})
// 暴露给父组件,用于检测是否有弹窗打开
defineExpose({
dialogOpen: computed(() => dialogOpen.value || deleteConfirmOpen.value)
})
</script>

View File

@@ -165,15 +165,6 @@
>
<Edit class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="管理映射"
@click="openAliasDialog(model)"
>
<Tag class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -218,10 +209,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image, Tag } from 'lucide-vue-next'
import { Box, Edit, Trash2, Layers, Eye, Wrench, Zap, Brain, Power, Copy, Image } from 'lucide-vue-next'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import { getProviderModels, type Model } from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
@@ -233,10 +225,10 @@ const emit = defineEmits<{
'editModel': [model: Model]
'deleteModel': [model: Model]
'batchAssign': []
'manageAlias': [model: Model]
}>()
const { error: showError, success: showSuccess } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
@@ -254,12 +246,7 @@ const sortedModels = computed(() => {
// 复制模型 ID 到剪贴板
async function copyModelId(modelId: string) {
try {
await navigator.clipboard.writeText(modelId)
showSuccess('已复制到剪贴板')
} catch {
showError('复制失败', '错误')
}
await copyToClipboard(modelId)
}
// 加载模型
@@ -373,11 +360,6 @@ function openBatchAssignDialog() {
emit('batchAssign')
}
// 打开别名管理对话框
function openAliasDialog(model: Model) {
emit('manageAlias', model)
}
// 切换模型启用状态
async function toggleModelActive(model: Model) {
if (togglingModelId.value) return

View File

@@ -0,0 +1,112 @@
/**
* 上游模型缓存 - 共享缓存,避免重复请求
*/
import { ref } from 'vue'
import { adminApi } from '@/api/admin'
import type { UpstreamModel } from '@/api/endpoints/types'
// 扩展类型,包含可能的额外字段
export type { UpstreamModel }
interface CacheEntry {
models: UpstreamModel[]
timestamp: number
}
type FetchResult = { models: UpstreamModel[]; error?: string }
// 全局缓存(模块级别,所有组件共享)
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
// 进行中的请求(用于去重并发请求)
const pendingRequests = new Map<string, Promise<FetchResult>>()
// 请求状态
const loadingMap = ref<Map<string, boolean>>(new Map())
export function useUpstreamModelsCache() {
/**
* 获取上游模型列表
* @param providerId 提供商ID
* @param forceRefresh 是否强制刷新
* @returns 模型列表或 null如果请求失败
*/
async function fetchModels(
providerId: string,
forceRefresh = false
): Promise<FetchResult> {
// 检查缓存
if (!forceRefresh) {
const cached = cache.get(providerId)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return { models: cached.models }
}
}
// 检查是否有进行中的请求(非强制刷新时复用)
if (!forceRefresh && pendingRequests.has(providerId)) {
return pendingRequests.get(providerId)!
}
// 创建新请求
const requestPromise = (async (): Promise<FetchResult> => {
try {
loadingMap.value.set(providerId, true)
const response = await adminApi.queryProviderModels(providerId)
if (response.success && response.data?.models) {
// 存入缓存
cache.set(providerId, {
models: response.data.models,
timestamp: Date.now()
})
return { models: response.data.models }
} else {
return { models: [], error: response.data?.error || '获取上游模型失败' }
}
} catch (err: any) {
return { models: [], error: err.response?.data?.detail || '获取上游模型失败' }
} finally {
loadingMap.value.set(providerId, false)
pendingRequests.delete(providerId)
}
})()
pendingRequests.set(providerId, requestPromise)
return requestPromise
}
/**
* 获取缓存的模型(不发起请求)
*/
function getCachedModels(providerId: string): UpstreamModel[] | null {
const cached = cache.get(providerId)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.models
}
return null
}
/**
* 清除指定提供商的缓存
*/
function clearCache(providerId: string) {
cache.delete(providerId)
}
/**
* 检查是否正在加载
*/
function isLoading(providerId: string): boolean {
return loadingMap.value.get(providerId) || false
}
return {
fetchModels,
getCachedModels,
clearCache,
isLoading,
loadingMap
}
}

View File

@@ -479,10 +479,25 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
return groups
})
// 计算链路总耗时(从第一个节点开始到最后一个节点结束
// 计算链路总耗时(使用成功候选的 latency_ms 字段
// 优先使用 latency_ms因为它与 Usage.response_time_ms 使用相同的时间基准
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
const totalTraceLatency = computed(() => {
if (!timeline.value || timeline.value.length === 0) return 0
// 查找成功的候选,使用其 latency_ms
const successCandidate = timeline.value.find(c => c.status === 'success')
if (successCandidate?.latency_ms != null) {
return successCandidate.latency_ms
}
// 如果没有成功的候选,查找失败但有 latency_ms 的候选
const failedWithLatency = timeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
if (failedWithLatency?.latency_ms != null) {
return failedWithLatency.latency_ms
}
// 回退:使用 finished_at - started_at 计算
let earliestStart: number | null = null
let latestEnd: number | null = null

View File

@@ -25,7 +25,7 @@
</h3>
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
<span>{{ detail?.model || '-' }}</span>
<template v-if="detail?.target_model">
<template v-if="detail?.target_model && detail.target_model !== detail.model">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
@@ -472,6 +472,8 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Separator from '@/components/ui/separator.vue'
@@ -504,6 +506,7 @@ const copiedStates = ref<Record<string, boolean>>({})
const viewMode = ref<'compare' | 'formatted' | 'raw'>('compare')
const currentExpandDepth = ref(1)
const dataSource = ref<'client' | 'provider'>('client')
const { copyToClipboard } = useClipboard()
const historicalPricing = ref<{
input_price: string
output_price: string
@@ -783,7 +786,7 @@ function copyJsonToClipboard(tabName: string) {
}
if (data) {
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
copyToClipboard(JSON.stringify(data, null, 2), false)
copiedStates.value[tabName] = true
setTimeout(() => {
copiedStates.value[tabName] = false
@@ -897,6 +900,16 @@ const providerHeadersWithDiff = computed(() => {
return result
})
// 添加 ESC 键监听
useEscapeKey(() => {
if (props.isOpen) {
handleClose()
}
}, {
disableOnInput: true,
once: false
})
</script>
<style scoped>

View File

@@ -136,11 +136,20 @@
<!-- 分隔线 -->
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="$emit('refresh')"
/>
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:class="autoRefresh ? 'text-primary' : ''"
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新每10秒刷新'"
@click="$emit('update:autoRefresh', !autoRefresh)"
>
<RefreshCcw
class="w-3.5 h-3.5"
:class="autoRefresh ? 'animate-spin' : ''"
/>
</Button>
</template>
<Table>
@@ -177,8 +186,9 @@
费用
</TableHead>
<TableHead class="h-12 font-semibold w-[70px] text-right">
<div class="inline-block max-w-[2rem] leading-tight">
响应时间
<div class="flex flex-col items-end text-xs gap-0.5">
<span>首字</span>
<span class="text-muted-foreground font-normal">总耗时</span>
</div>
</TableHead>
</TableRow>
@@ -356,15 +366,48 @@
</div>
</TableCell>
<TableCell class="text-right py-4 w-[70px]">
<span
v-if="record.status === 'pending' || record.status === 'streaming'"
class="text-primary tabular-nums"
<!-- pending 状态只显示增长的总时间 -->
<div
v-if="record.status === 'pending'"
class="flex flex-col items-end text-xs gap-0.5"
>
{{ getElapsedTime(record) }}
</span>
<span v-else-if="record.response_time_ms">
{{ (record.response_time_ms / 1000).toFixed(2) }}s
</span>
<span class="text-muted-foreground">-</span>
<span class="text-primary tabular-nums">
{{ getElapsedTime(record) }}
</span>
</div>
<!-- streaming 状态首字固定 + 总时间增长 -->
<div
v-else-if="record.status === 'streaming'"
class="flex flex-col items-end text-xs gap-0.5"
>
<span
v-if="record.first_byte_time_ms != null"
class="tabular-nums"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span class="text-primary tabular-nums">
{{ getElapsedTime(record) }}
</span>
</div>
<!-- 已完成状态首字 + 总耗时 -->
<div
v-else-if="record.response_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5"
>
<span
v-if="record.first_byte_time_ms != null"
class="tabular-nums"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span class="text-muted-foreground tabular-nums">{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
</div>
<span
v-else
class="text-muted-foreground"
@@ -394,6 +437,7 @@ import { ref, computed, onUnmounted, watch } from 'vue'
import {
TableCard,
Badge,
Button,
Select,
SelectTrigger,
SelectValue,
@@ -406,8 +450,8 @@ import {
TableHead,
TableCell,
Pagination,
RefreshButton,
} from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { formatDateTime } from '../composables'
import { useRowClick } from '@/composables/useRowClick'
@@ -439,6 +483,8 @@ const props = defineProps<{
pageSize: number
totalRecords: number
pageSizeOptions: number[]
// 自动刷新
autoRefresh: boolean
}>()
const emit = defineEmits<{
@@ -449,6 +495,7 @@ const emit = defineEmits<{
'update:filterStatus': [value: string]
'update:currentPage': [value: number]
'update:pageSize': [value: number]
'update:autoRefresh': [value: boolean]
'refresh': []
'showDetail': [id: string]
}>()

View File

@@ -78,6 +78,7 @@ export interface UsageRecord {
cost: number
actual_cost?: number
response_time_ms?: number
first_byte_time_ms?: number // 首字时间 (TTFB)
is_stream: boolean
status_code?: number
error_message?: string

View File

@@ -86,6 +86,34 @@
</p>
</div>
<div
v-if="isEditMode && form.password.length > 0"
class="space-y-2"
>
<Label class="text-sm font-medium">
确认新密码 <span class="text-muted-foreground">*</span>
</Label>
<Input
:id="`pwd-confirm-${formNonce}`"
v-model="form.confirmPassword"
type="password"
autocomplete="new-password"
data-form-type="other"
data-lpignore="true"
:name="`confirm-${formNonce}`"
required
minlength="6"
placeholder="再次输入新密码"
class="h-10"
/>
<p
v-if="form.confirmPassword.length > 0 && form.password !== form.confirmPassword"
class="text-xs text-destructive"
>
两次输入的密码不一致
</p>
</div>
<div class="space-y-2">
<Label
for="form-email"
@@ -423,6 +451,7 @@ const apiFormats = ref<Array<{ value: string; label: string }>>([])
const form = ref({
username: '',
password: '',
confirmPassword: '',
email: '',
quota: 10,
role: 'user' as 'admin' | 'user',
@@ -443,6 +472,7 @@ function resetForm() {
form.value = {
username: '',
password: '',
confirmPassword: '',
email: '',
quota: 10,
role: 'user',
@@ -461,6 +491,7 @@ function loadUserData() {
form.value = {
username: props.user.username,
password: '',
confirmPassword: '',
email: props.user.email || '',
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
role: props.user.role,
@@ -486,7 +517,9 @@ const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0
const hasEmail = form.value.email.trim().length > 0
const hasPassword = isEditMode.value || form.value.password.length >= 6
return hasUsername && hasEmail && hasPassword
// 编辑模式下如果填写了密码,必须确认密码一致
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
return hasUsername && hasEmail && hasPassword && passwordConfirmed
})
// 加载访问控制选项

View File

@@ -5,7 +5,7 @@
import type { User, LoginResponse } from '@/api/auth'
import type { DashboardStatsResponse, RecentRequest, ProviderStatus, DailyStatsResponse } from '@/api/dashboard'
import type { User as AdminUser, ApiKey } from '@/api/users'
import type { User as AdminUser } from '@/api/users'
import type { AdminApiKeysResponse } from '@/api/admin'
import type { Profile, UsageResponse } from '@/api/me'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
@@ -185,18 +185,20 @@ export const MOCK_DASHBOARD_STATS: DashboardStatsResponse = {
output: 700000,
cache_creation: 50000,
cache_read: 200000
}
},
// 普通用户专用字段
monthly_cost: 45.67
}
export const MOCK_RECENT_REQUESTS: RecentRequest[] = [
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-20250514', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-4o', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-20250514', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-2.0-flash', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-20250514', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-4o-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-3-5-20241022', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-2.5-pro', tokens: 12345, time: '25 分钟前' }
{ id: 'req-001', user: 'alice', model: 'claude-sonnet-4-5-20250929', tokens: 15234, time: '2 分钟前' },
{ id: 'req-002', user: 'bob', model: 'gpt-5.1', tokens: 8765, time: '5 分钟前' },
{ id: 'req-003', user: 'charlie', model: 'claude-opus-4-5-20251101', tokens: 32100, time: '8 分钟前' },
{ id: 'req-004', user: 'diana', model: 'gemini-3-pro-preview', tokens: 4521, time: '12 分钟前' },
{ id: 'req-005', user: 'eve', model: 'claude-sonnet-4-5-20250929', tokens: 9876, time: '15 分钟前' },
{ id: 'req-006', user: 'frank', model: 'gpt-5.1-codex-mini', tokens: 2345, time: '18 分钟前' },
{ id: 'req-007', user: 'grace', model: 'claude-haiku-4-5-20251001', tokens: 6789, time: '22 分钟前' },
{ id: 'req-008', user: 'henry', model: 'gemini-3-pro-preview', tokens: 12345, time: '25 分钟前' }
]
export const MOCK_PROVIDER_STATUS: ProviderStatus[] = [
@@ -231,11 +233,11 @@ function generateDailyStats(): DailyStatsResponse {
unique_models: 8 + Math.floor(Math.random() * 5),
unique_providers: 4 + Math.floor(Math.random() * 3),
model_breakdown: [
{ model: 'claude-sonnet-4-20250514', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-4o', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-20250514', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-2.0-flash', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-3-5-20241022', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
{ model: 'claude-sonnet-4-5-20250929', requests: Math.floor(baseRequests * 0.35), tokens: Math.floor(baseTokens * 0.35), cost: Number((baseCost * 0.35).toFixed(2)) },
{ model: 'gpt-5.1', requests: Math.floor(baseRequests * 0.25), tokens: Math.floor(baseTokens * 0.25), cost: Number((baseCost * 0.25).toFixed(2)) },
{ model: 'claude-opus-4-5-20251101', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.20).toFixed(2)) },
{ model: 'gemini-3-pro-preview', requests: Math.floor(baseRequests * 0.15), tokens: Math.floor(baseTokens * 0.15), cost: Number((baseCost * 0.10).toFixed(2)) },
{ model: 'claude-haiku-4-5-20251001', requests: Math.floor(baseRequests * 0.10), tokens: Math.floor(baseTokens * 0.10), cost: Number((baseCost * 0.10).toFixed(2)) }
]
})
}
@@ -243,11 +245,11 @@ function generateDailyStats(): DailyStatsResponse {
return {
daily_stats: dailyStats,
model_summary: [
{ model: 'claude-sonnet-4-20250514', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-4o', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-20250514', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-2.0-flash', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-3-5-20241022', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
{ model: 'claude-sonnet-4-5-20250929', requests: 2456, tokens: 8500000, cost: 125.45, avg_response_time: 1.2, cost_per_request: 0.051, tokens_per_request: 3461 },
{ model: 'gpt-5.1', requests: 1823, tokens: 6200000, cost: 98.32, avg_response_time: 0.9, cost_per_request: 0.054, tokens_per_request: 3401 },
{ model: 'claude-opus-4-5-20251101', requests: 987, tokens: 4100000, cost: 156.78, avg_response_time: 2.1, cost_per_request: 0.159, tokens_per_request: 4154 },
{ model: 'gemini-3-pro-preview', requests: 1234, tokens: 3800000, cost: 28.56, avg_response_time: 0.6, cost_per_request: 0.023, tokens_per_request: 3079 },
{ model: 'claude-haiku-4-5-20251001', requests: 2100, tokens: 5200000, cost: 32.10, avg_response_time: 0.5, cost_per_request: 0.015, tokens_per_request: 2476 }
],
period: {
start_date: dailyStats[0].date,
@@ -336,7 +338,7 @@ export const MOCK_ALL_USERS: AdminUser[] = [
// ========== API Key 数据 ==========
export const MOCK_USER_API_KEYS: ApiKey[] = [
export const MOCK_USER_API_KEYS = [
{
id: 'key-uuid-001',
key_display: 'sk-ae...x7f9',
@@ -346,7 +348,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 1234,
total_cost_usd: 45.67
total_cost_usd: 45.67,
force_capabilities: null
},
{
id: 'key-uuid-002',
@@ -357,7 +360,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: true,
is_standalone: false,
total_requests: 5678,
total_cost_usd: 123.45
total_cost_usd: 123.45,
force_capabilities: { cache_1h: true }
},
{
id: 'key-uuid-003',
@@ -367,7 +371,8 @@ export const MOCK_USER_API_KEYS: ApiKey[] = [
is_active: false,
is_standalone: false,
total_requests: 100,
total_cost_usd: 2.34
total_cost_usd: 2.34,
force_capabilities: null
}
]
@@ -611,41 +616,42 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
id: 'gm-001',
name: 'claude-haiku-4-5-20251001',
display_name: 'claude-haiku-4-5',
description: 'Anthropic 最快速的 Claude 4 系列模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.00, output_price_per_1m: 5.00, cache_creation_price_per_1m: 1.25, cache_read_price_per_1m: 0.1 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'Anthropic 最快速的 Claude 4 系列模型'
},
provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-002',
name: 'claude-opus-4-5-20251101',
display_name: 'claude-opus-4-5',
description: 'Anthropic 最强大的模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 5.00, output_price_per_1m: 25.00, cache_creation_price_per_1m: 6.25, cache_read_price_per_1m: 0.5 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'Anthropic 最强大的模型'
},
provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-003',
name: 'claude-sonnet-4-5-20250929',
display_name: 'claude-sonnet-4-5',
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文',
is_active: true,
default_tiered_pricing: {
tiers: [
@@ -677,116 +683,124 @@ export const MOCK_GLOBAL_MODELS: GlobalModelResponse[] = [
}
]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'Anthropic 平衡型模型,支持 1h 缓存和 CLI 1M 上下文'
},
supported_capabilities: ['cache_1h', 'cli_1m'],
provider_count: 3,
alias_count: 2,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-004',
name: 'gemini-3-pro-image-preview',
display_name: 'gemini-3-pro-image-preview',
description: 'Google Gemini 3 Pro 图像生成预览版',
is_active: true,
default_price_per_request: 0.300,
default_tiered_pricing: {
tiers: []
},
default_supports_vision: true,
default_supports_function_calling: false,
default_supports_streaming: true,
default_supports_image_generation: true,
config: {
streaming: true,
vision: true,
function_calling: false,
image_generation: true,
description: 'Google Gemini 3 Pro 图像生成预览版'
},
provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-005',
name: 'gemini-3-pro-preview',
display_name: 'gemini-3-pro-preview',
description: 'Google Gemini 3 Pro 预览版',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 2.00, output_price_per_1m: 12.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'Google Gemini 3 Pro 预览版'
},
provider_count: 1,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-006',
name: 'gpt-5.1',
display_name: 'gpt-5.1',
description: 'OpenAI GPT-5.1 模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 模型'
},
provider_count: 2,
alias_count: 1,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-007',
name: 'gpt-5.1-codex',
display_name: 'gpt-5.1-codex',
description: 'OpenAI GPT-5.1 Codex 代码专用模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex 代码专用模型'
},
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-008',
name: 'gpt-5.1-codex-max',
display_name: 'gpt-5.1-codex-max',
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex Max 代码专用增强版'
},
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
},
{
id: 'gm-009',
name: 'gpt-5.1-codex-mini',
display_name: 'gpt-5.1-codex-mini',
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型',
is_active: true,
default_tiered_pricing: {
tiers: [{ up_to: null, input_price_per_1m: 1.25, output_price_per_1m: 10.00 }]
},
default_supports_vision: true,
default_supports_function_calling: true,
default_supports_streaming: true,
default_supports_extended_thinking: true,
config: {
streaming: true,
vision: true,
function_calling: true,
extended_thinking: true,
description: 'OpenAI GPT-5.1 Codex Mini 轻量代码模型'
},
provider_count: 2,
alias_count: 0,
created_at: '2024-01-01T00:00:00Z'
}
]
@@ -804,16 +818,16 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
quota_usd: 100,
used_usd: 45.32,
summary_by_model: [
{ model: 'claude-sonnet-4-20250514', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-4o', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-3-5-20241022', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-2.0-flash', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
{ model: 'claude-haiku-4-5-20251001', requests: 289, input_tokens: 420000, output_tokens: 170000, total_tokens: 590000, total_cost_usd: 8.50, actual_total_cost_usd: 6.30 },
{ model: 'gemini-3-pro-preview', requests: 177, input_tokens: 250000, output_tokens: 100000, total_tokens: 350000, total_cost_usd: 6.37, actual_total_cost_usd: 4.33 }
],
records: [
{
id: 'usage-001',
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-5-20250929',
input_tokens: 1500,
output_tokens: 800,
total_tokens: 2300,
@@ -828,7 +842,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
{
id: 'usage-002',
provider: 'openai',
model: 'gpt-4o',
model: 'gpt-5.1',
input_tokens: 2000,
output_tokens: 500,
total_tokens: 2500,

View File

@@ -403,12 +403,12 @@ function getUsageRecords() {
return cachedUsageRecords
}
// Mock 别名数据
// Mock 映射数据
const MOCK_ALIASES = [
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt4o', target_global_model_id: 'gm-004', target_global_model_name: 'gpt-4o', target_global_model_display_name: 'GPT-4o', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-flash', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-2.0-flash', target_global_model_display_name: 'Gemini 2.0 Flash', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-003', target_global_model_name: 'claude-sonnet-4-5-20250929', target_global_model_display_name: 'Claude Sonnet 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-5-20251101', target_global_model_display_name: 'Claude Opus 4.5', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-003', source_model: 'gpt5', target_global_model_id: 'gm-006', target_global_model_name: 'gpt-5.1', target_global_model_display_name: 'GPT-5.1', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
{ id: 'alias-004', source_model: 'gemini-pro', target_global_model_id: 'gm-005', target_global_model_name: 'gemini-3-pro-preview', target_global_model_display_name: 'Gemini 3 Pro Preview', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' }
]
// Mock Endpoint Keys
@@ -1000,17 +1000,11 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
id: m.id,
name: m.name,
display_name: m.display_name,
description: m.description,
icon_url: null,
is_active: m.is_active,
default_tiered_pricing: m.default_tiered_pricing,
default_price_per_request: null,
default_supports_vision: m.default_supports_vision,
default_supports_function_calling: m.default_supports_function_calling,
default_supports_streaming: m.default_supports_streaming,
default_supports_extended_thinking: m.default_supports_extended_thinking || false,
default_supports_image_generation: false,
supported_capabilities: null
default_price_per_request: m.default_price_per_request,
supported_capabilities: m.supported_capabilities,
config: m.config
})),
total: MOCK_GLOBAL_MODELS.length
})
@@ -1688,7 +1682,7 @@ registerDynamicRoute('GET', '/api/admin/models/mappings/:mappingId', async (_con
requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
}
return createMockResponse(alias)
})
@@ -1699,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co
requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
}
const body = JSON.parse(config.data || '{}')
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
@@ -1711,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_
requireAdmin()
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
if (!alias) {
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
}
return createMockResponse({ message: '删除成功(演示模式)' })
})
@@ -2178,10 +2172,10 @@ function generateIntervalTimelineData(
// 模型列表(用于按模型区分颜色)
const models = [
'claude-sonnet-4-20250514',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-opus-4-20250514'
'claude-sonnet-4-5-20250929',
'claude-haiku-4-5-20251001',
'claude-opus-4-5-20251101',
'gpt-5.1'
]
// 生成模拟的请求间隔数据

View File

@@ -14,7 +14,7 @@ export const useUsersStore = defineStore('users', () => {
try {
users.value = await usersApi.getAllUsers()
} catch (err: any) {
error.value = err.response?.data?.detail || '获取用户列表失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取用户列表失败'
} finally {
loading.value = false
}
@@ -29,7 +29,7 @@ export const useUsersStore = defineStore('users', () => {
users.value.push(newUser)
return newUser
} catch (err: any) {
error.value = err.response?.data?.detail || '创建用户失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建用户失败'
throw err
} finally {
loading.value = false
@@ -52,7 +52,7 @@ export const useUsersStore = defineStore('users', () => {
}
return updatedUser
} catch (err: any) {
error.value = err.response?.data?.detail || '更新用户失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '更新用户失败'
throw err
} finally {
loading.value = false
@@ -67,7 +67,7 @@ export const useUsersStore = defineStore('users', () => {
await usersApi.deleteUser(userId)
users.value = users.value.filter(u => u.id !== userId)
} catch (err: any) {
error.value = err.response?.data?.detail || '删除用户失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除用户失败'
throw err
} finally {
loading.value = false
@@ -78,7 +78,7 @@ export const useUsersStore = defineStore('users', () => {
try {
return await usersApi.getUserApiKeys(userId)
} catch (err: any) {
error.value = err.response?.data?.detail || '获取 API Keys 失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '获取 API Keys 失败'
throw err
}
}
@@ -87,7 +87,7 @@ export const useUsersStore = defineStore('users', () => {
try {
return await usersApi.createApiKey(userId, name)
} catch (err: any) {
error.value = err.response?.data?.detail || '创建 API Key 失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '创建 API Key 失败'
throw err
}
}
@@ -96,7 +96,7 @@ export const useUsersStore = defineStore('users', () => {
try {
await usersApi.deleteApiKey(userId, keyId)
} catch (err: any) {
error.value = err.response?.data?.detail || '删除 API Key 失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '删除 API Key 失败'
throw err
}
}
@@ -110,7 +110,7 @@ export const useUsersStore = defineStore('users', () => {
// 刷新用户列表以获取最新数据
await fetchUsers()
} catch (err: any) {
error.value = err.response?.data?.detail || '重置配额失败'
error.value = err.response?.data?.error?.message || err.response?.data?.detail || '重置配额失败'
throw err
} finally {
loading.value = false

View File

@@ -1169,4 +1169,26 @@ body[theme-mode='dark'] .literary-annotation {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--border));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}

View File

@@ -198,3 +198,49 @@ export function parseApiErrorShort(err: unknown, defaultMessage: string = '操
const lines = fullError.split('\n')
return lines[0] || defaultMessage
}
/**
* 解析模型测试响应的错误信息
* @param result 测试响应结果
* @returns 格式化的错误信息
*/
export function parseTestModelError(result: {
error?: string
data?: {
response?: {
status_code?: number
error?: string | { message?: string }
}
}
}): string {
let errorMsg = result.error || '测试失败'
// 检查HTTP状态码错误
if (result.data?.response?.status_code) {
const status = result.data.response.status_code
if (status === 403) {
errorMsg = '认证失败: API密钥无效或客户端类型不被允许'
} else if (status === 401) {
errorMsg = '认证失败: API密钥无效或已过期'
} else if (status === 404) {
errorMsg = '模型不存在: 请检查模型名称是否正确'
} else if (status === 429) {
errorMsg = '请求频率过高: 请稍后重试'
} else if (status >= 500) {
errorMsg = `服务器错误: HTTP ${status}`
} else {
errorMsg = `请求失败: HTTP ${status}`
}
}
// 尝试从错误响应中提取更多信息
if (result.data?.response?.error) {
if (typeof result.data.response.error === 'string') {
errorMsg = result.data.response.error
} else if (result.data.response.error?.message) {
errorMsg = result.data.response.error.message
}
}
return errorMsg
}

View File

@@ -650,6 +650,7 @@
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import {
@@ -693,6 +694,7 @@ import { log } from '@/utils/logger'
const { success, error } = useToast()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const apiKeys = ref<AdminApiKey[]>([])
const loading = ref(false)
@@ -751,15 +753,13 @@ const expiringSoonCount = computed(() => apiKeys.value.filter(key => isExpiringS
const filteredApiKeys = computed(() => {
let result = apiKeys.value
// 搜索筛选
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(key =>
(key.name && key.name.toLowerCase().includes(query)) ||
(key.key_display && key.key_display.toLowerCase().includes(query)) ||
(key.username && key.username.toLowerCase().includes(query)) ||
(key.user_email && key.user_email.toLowerCase().includes(query))
)
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(key => {
const searchableText = `${key.name || ''} ${key.key_display || ''} ${key.username || ''} ${key.user_email || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 状态筛选
@@ -929,20 +929,14 @@ function selectKey() {
}
async function copyKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value)
success('API Key 已复制到剪贴板')
} catch {
error('复制失败,请手动复制')
}
await copyToClipboard(newKeyValue.value)
}
async function copyKeyPrefix(apiKey: AdminApiKey) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
await copyToClipboard(response.key)
} catch (err) {
log.error('复制密钥失败:', err)
error('复制失败,请重试')
@@ -1048,9 +1042,10 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
auto_delete_on_expiry: data.auto_delete_on_expiry,
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
// 空数组表示清除限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
}
await adminApi.updateApiKey(data.id, updateData)
success('API Key 更新成功')
@@ -1066,9 +1061,10 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
rate_limit: data.rate_limit,
expire_days: data.never_expire ? null : (data.expire_days || null),
auto_delete_on_expiry: data.auto_delete_on_expiry,
allowed_providers: data.allowed_providers.length > 0 ? data.allowed_providers : undefined,
allowed_api_formats: data.allowed_api_formats.length > 0 ? data.allowed_api_formats : undefined,
allowed_models: data.allowed_models.length > 0 ? data.allowed_models : undefined
// 空数组表示不设置限制(允许全部),后端会将空数组存为 NULL
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
}
const response = await adminApi.createStandaloneApiKey(createData)
newKeyValue.value = response.key

View File

@@ -46,6 +46,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1)
const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000))
const analysisHoursSelectOpen = ref(false)
// ==================== 模型映射缓存 ====================
@@ -142,32 +143,37 @@ async function resetAffinitySearch() {
await fetchAffinityList()
}
async function clearUserCache(identifier: string, displayName?: string) {
const target = identifier?.trim()
if (!target) {
showError('无法识别标识符')
async function clearSingleAffinity(item: UserAffinity) {
const affinityKey = item.affinity_key?.trim()
const endpointId = item.endpoint_id?.trim()
const modelId = item.global_model_id?.trim()
const apiFormat = item.api_format?.trim()
if (!affinityKey || !endpointId || !modelId || !apiFormat) {
showError('缓存记录信息不完整,无法删除')
return
}
const label = displayName || target
const label = item.user_api_key_name || affinityKey
const modelLabel = item.model_display_name || item.model_name || modelId
const confirmed = await showConfirm({
title: '确认清除',
message: `确定要清除 ${label} 的缓存吗?`,
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
confirmText: '确认清除',
variant: 'destructive'
})
if (!confirmed) return
clearingRowAffinityKey.value = target
clearingRowAffinityKey.value = affinityKey
try {
await cacheApi.clearUserCache(target)
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
showSuccess('清除成功')
await fetchCacheStats()
await fetchAffinityList(tableKeyword.value.trim() || undefined)
} catch (error) {
showError('清除失败')
log.error('清除用户缓存失败', error)
log.error('清除单条缓存失败', error)
} finally {
clearingRowAffinityKey.value = null
}
@@ -618,7 +624,7 @@ onBeforeUnmount(() => {
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
:disabled="clearingRowAffinityKey === item.affinity_key"
title="清除缓存"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
@click="clearSingleAffinity(item)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -668,7 +674,7 @@ onBeforeUnmount(() => {
variant="ghost"
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
:disabled="clearingRowAffinityKey === item.affinity_key"
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
@click="clearSingleAffinity(item)"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
@@ -935,7 +941,10 @@ onBeforeUnmount(() => {
:key="`${index}-${aliasIndex}`"
>
<TableCell>
<Badge variant="outline" class="text-xs">
<Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }}
</Badge>
</TableCell>
@@ -981,7 +990,10 @@ onBeforeUnmount(() => {
class="p-4 space-y-2"
>
<div class="flex items-center justify-between">
<Badge variant="outline" class="text-xs">
<Badge
variant="outline"
class="text-xs"
>
{{ mapping.provider_name }}
</Badge>
<div class="flex items-center gap-2">
@@ -1045,7 +1057,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-muted-foreground hidden sm:inline">分析用户请求间隔推荐合适的缓存 TTL</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<Select v-model="analysisHours">
<Select v-model="analysisHours" v-model:open="analysisHoursSelectOpen">
<SelectTrigger class="w-24 sm:w-28 h-8">
<SelectValue placeholder="时间段" />
</SelectTrigger>

View File

@@ -111,9 +111,6 @@
<TableHead class="w-[80px] text-center">
提供商
</TableHead>
<TableHead class="w-[70px] text-center">
别名/映射
</TableHead>
<TableHead class="w-[80px] text-center">
调用次数
</TableHead>
@@ -128,7 +125,7 @@
<TableBody>
<TableRow v-if="loading">
<TableCell
colspan="8"
colspan="7"
class="text-center py-8"
>
<Loader2 class="w-6 h-6 animate-spin mx-auto" />
@@ -136,7 +133,7 @@
</TableRow>
<TableRow v-else-if="filteredGlobalModels.length === 0">
<TableCell
colspan="8"
colspan="7"
class="text-center py-8 text-muted-foreground"
>
没有找到匹配的模型
@@ -171,27 +168,27 @@
<div class="space-y-1 w-fit">
<div class="flex flex-wrap gap-1">
<Zap
v-if="model.default_supports_streaming"
v-if="model.config?.streaming !== false"
class="w-4 h-4 text-muted-foreground"
title="流式输出"
/>
<Image
v-if="model.default_supports_image_generation"
v-if="model.config?.image_generation === true"
class="w-4 h-4 text-muted-foreground"
title="图像生成"
/>
<Eye
v-if="model.default_supports_vision"
v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground"
title="视觉理解"
/>
<Wrench
v-if="model.default_supports_function_calling"
v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground"
title="工具调用"
/>
<Brain
v-if="model.default_supports_extended_thinking"
v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground"
title="深度思考"
/>
@@ -244,11 +241,6 @@
{{ model.provider_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<Badge variant="secondary">
{{ model.alias_count || 0 }}
</Badge>
</TableCell>
<TableCell class="text-center">
<span class="text-sm font-mono">{{ formatUsageCount(model.usage_count || 0) }}</span>
</TableCell>
@@ -369,23 +361,23 @@
<!-- 第二行能力图标 -->
<div class="flex flex-wrap gap-1.5">
<Zap
v-if="model.default_supports_streaming"
v-if="model.config?.streaming !== false"
class="w-4 h-4 text-muted-foreground"
/>
<Image
v-if="model.default_supports_image_generation"
v-if="model.config?.image_generation === true"
class="w-4 h-4 text-muted-foreground"
/>
<Eye
v-if="model.default_supports_vision"
v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground"
/>
<Wrench
v-if="model.default_supports_function_calling"
v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground"
/>
<Brain
v-if="model.default_supports_extended_thinking"
v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground"
/>
</div>
@@ -393,7 +385,6 @@
<!-- 第三行统计信息 -->
<div class="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提供商 {{ model.provider_count || 0 }}</span>
<span>别名 {{ model.alias_count || 0 }}</span>
<span>调用 {{ formatUsageCount(model.usage_count || 0) }}</span>
<span
v-if="getFirstTierPrice(model, 'input') || getFirstTierPrice(model, 'output')"
@@ -722,6 +713,7 @@ import ProviderModelFormDialog from '@/features/providers/components/ProviderMod
import type { Model } from '@/api/endpoints'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { useRowClick } from '@/composables/useRowClick'
import { parseApiError } from '@/utils/errorParser'
import {
@@ -745,6 +737,7 @@ import {
updateGlobalModel,
deleteGlobalModel,
batchAssignToProviders,
getGlobalModelProviders,
type GlobalModelResponse,
} from '@/api/global-models'
import { log } from '@/utils/logger'
@@ -752,6 +745,7 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
import { getAllCapabilities, type CapabilityDefinition } from '@/api/endpoints'
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
@@ -1011,30 +1005,30 @@ async function batchRemoveSelectedProviders() {
const filteredGlobalModels = computed(() => {
let result = globalModels.value
// 搜索
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(m =>
m.name.toLowerCase().includes(query) ||
m.display_name?.toLowerCase().includes(query)
)
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => {
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 能力筛选
if (capabilityFilters.value.streaming) {
result = result.filter(m => m.default_supports_streaming)
result = result.filter(m => m.config?.streaming !== false)
}
if (capabilityFilters.value.imageGeneration) {
result = result.filter(m => m.default_supports_image_generation)
result = result.filter(m => m.config?.image_generation === true)
}
if (capabilityFilters.value.vision) {
result = result.filter(m => m.default_supports_vision)
result = result.filter(m => m.config?.vision === true)
}
if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.default_supports_function_calling)
result = result.filter(m => m.config?.function_calling === true)
}
if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.default_supports_extended_thinking)
result = result.filter(m => m.config?.extended_thinking === true)
}
return result
@@ -1075,16 +1069,6 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
selectModel(model)
}
// 复制到剪贴板
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
success('已复制')
} catch {
showError('复制失败')
}
}
async function selectModel(model: GlobalModelResponse) {
selectedModel.value = model
detailTab.value = 'basic'
@@ -1097,42 +1081,32 @@ async function selectModel(model: GlobalModelResponse) {
async function loadModelProviders(_globalModelId: string) {
loadingModelProviders.value = true
try {
// 使用 ModelCatalog API 获取详细的关联提供商信息
const { getModelCatalog } = await import('@/api/endpoints')
const catalogResponse = await getModelCatalog()
// 使用新的 API 获取所有关联提供商(包括非活跃的)
const response = await getGlobalModelProviders(_globalModelId)
// 查找当前 GlobalModel 对应的 catalog item
const catalogItem = catalogResponse.models.find(
m => m.global_model_name === selectedModel.value?.name
)
if (catalogItem) {
// 转换为展示格式,包含完整的模型实现信息
selectedModelProviders.value = catalogItem.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
identifier: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
// 价格信息
input_price_per_1m: p.input_price_per_1m,
output_price_per_1m: p.output_price_per_1m,
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
cache_read_price_per_1m: p.cache_read_price_per_1m,
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
price_per_request: p.price_per_request,
effective_tiered_pricing: p.effective_tiered_pricing,
tier_count: p.tier_count,
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
}))
} else {
selectedModelProviders.value = []
}
// 转换为展示格式
selectedModelProviders.value = response.providers.map(p => ({
id: p.provider_id,
model_id: p.model_id,
display_name: p.provider_display_name || p.provider_name,
identifier: p.provider_name,
provider_type: 'API',
target_model: p.target_model,
is_active: p.is_active,
// 价格信息
input_price_per_1m: p.input_price_per_1m,
output_price_per_1m: p.output_price_per_1m,
cache_creation_price_per_1m: p.cache_creation_price_per_1m,
cache_read_price_per_1m: p.cache_read_price_per_1m,
cache_1h_creation_price_per_1m: p.cache_1h_creation_price_per_1m,
price_per_request: p.price_per_request,
effective_tiered_pricing: p.effective_tiered_pricing,
tier_count: p.tier_count,
// 能力信息
supports_vision: p.supports_vision,
supports_function_calling: p.supports_function_calling,
supports_streaming: p.supports_streaming
}))
} catch (err: any) {
log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误')

View File

@@ -505,13 +505,13 @@ const priorityModeConfig = computed(() => {
const filteredProviders = computed(() => {
let result = [...providers.value]
// 搜索筛选
// 搜索筛选(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase()
result = result.filter(p =>
p.display_name.toLowerCase().includes(query) ||
p.name.toLowerCase().includes(query)
)
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(p => {
const searchableText = `${p.display_name} ${p.name}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 排序
@@ -723,9 +723,19 @@ async function handleDeleteProvider(provider: ProviderWithEndpointsSummary) {
// 切换提供商状态
async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
try {
await updateProvider(provider.id, { is_active: !provider.is_active })
provider.is_active = !provider.is_active
showSuccess(provider.is_active ? '提供商已启用' : '提供商已停用')
const newStatus = !provider.is_active
await updateProvider(provider.id, { is_active: newStatus })
// 更新抽屉内部的 provider 对象
provider.is_active = newStatus
// 同时更新主页面 providers 数组中的对象,实现无感更新
const targetProvider = providers.value.find(p => p.id === provider.id)
if (targetProvider) {
targetProvider.is_active = newStatus
}
showSuccess(newStatus ? '提供商已启用' : '提供商已停用')
} catch (err: any) {
showError(err.response?.data?.detail || '操作失败', '错误')
}

View File

@@ -7,6 +7,7 @@
<template #actions>
<Button
:disabled="loading"
class="shadow-none hover:shadow-none"
@click="saveSystemConfig"
>
{{ loading ? '保存中...' : '保存所有配置' }}
@@ -15,6 +16,94 @@
</PageHeader>
<div class="mt-6 space-y-6">
<!-- 配置导出/导入 -->
<CardSection
title="配置管理"
description="导出或导入提供商和模型配置,便于备份或迁移"
>
<div class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
导出当前所有提供商端点API Key 和模型配置到 JSON 文件
</p>
<Button
variant="outline"
:disabled="exportLoading"
@click="handleExportConfig"
>
<Download class="w-4 h-4 mr-2" />
{{ exportLoading ? '导出中...' : '导出配置' }}
</Button>
</div>
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
JSON 文件导入配置支持跳过覆盖或报错三种冲突处理模式
</p>
<div class="flex items-center gap-2">
<input
ref="configFileInput"
type="file"
accept=".json"
class="hidden"
@change="handleConfigFileSelect"
>
<Button
variant="outline"
:disabled="importLoading"
@click="triggerConfigFileSelect"
>
<Upload class="w-4 h-4 mr-2" />
{{ importLoading ? '导入中...' : '导入配置' }}
</Button>
</div>
</div>
</div>
</CardSection>
<!-- 用户数据导出/导入 -->
<CardSection
title="用户数据管理"
description="导出或导入用户及其 API Keys 数据(不含管理员)"
>
<div class="flex flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
导出所有普通用户及其 API Keys JSON 文件
</p>
<Button
variant="outline"
:disabled="exportUsersLoading"
@click="handleExportUsers"
>
<Download class="w-4 h-4 mr-2" />
{{ exportUsersLoading ? '导出中...' : '导出用户数据' }}
</Button>
</div>
<div class="flex-1 min-w-[200px]">
<p class="text-sm text-muted-foreground mb-3">
JSON 文件导入用户数据需相同 ENCRYPTION_KEY
</p>
<div class="flex items-center gap-2">
<input
ref="usersFileInput"
type="file"
accept=".json"
class="hidden"
@change="handleUsersFileSelect"
>
<Button
variant="outline"
:disabled="importUsersLoading"
@click="triggerUsersFileSelect"
>
<Upload class="w-4 h-4 mr-2" />
{{ importUsersLoading ? '导入中...' : '导入用户数据' }}
</Button>
</div>
</div>
</div>
</CardSection>
<!-- 基础配置 -->
<CardSection
title="基础配置"
@@ -96,32 +185,13 @@
</div>
</CardSection>
<!-- API Key 管理配置 -->
<!-- 独立余额 Key 过期管理 -->
<CardSection
title="API Key 管理"
description="API Key 相关配置"
title="独立余额 Key 过期管理"
description="独立余额 Key 的过期处理策略(普通用户 Key 不会过期)"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<Label
for="api-key-expire"
class="block text-sm font-medium"
>
API密钥过期天数
</Label>
<Input
id="api-key-expire"
v-model.number="systemConfig.api_key_expire_days"
type="number"
placeholder="0"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
0 表示永不过期
</p>
</div>
<div class="flex items-center h-full pt-6">
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="auto-delete-expired-keys"
@@ -135,7 +205,7 @@
自动删除过期 Key
</Label>
<p class="text-xs text-muted-foreground">
关闭时仅禁用过期 Key
关闭时仅禁用过期 Key不会物理删除
</p>
</div>
</div>
@@ -359,6 +429,25 @@
避免单次操作过大影响性能
</p>
</div>
<div>
<Label
for="audit-log-retention-days"
class="block text-sm font-medium"
>
审计日志保留天数
</Label>
<Input
id="audit-log-retention-days"
v-model.number="systemConfig.audit_log_retention_days"
type="number"
placeholder="30"
class="mt-1"
/>
<p class="mt-1 text-xs text-muted-foreground">
超过后删除审计日志记录
</p>
</div>
</div>
<!-- 清理策略说明 -->
@@ -371,15 +460,318 @@
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储节省空间</p>
<p>3. <strong>统计阶段</strong>: 仅保留 tokens成本等统计信息</p>
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
<p>5. <strong>审计日志</strong>: 独立清理记录用户登录操作等安全事件</p>
</div>
</div>
</CardSection>
</div>
<!-- 导入配置对话框 -->
<Dialog
v-model:open="importDialogOpen"
title="导入配置"
description="选择冲突处理模式并确认导入"
>
<div class="space-y-4">
<div
v-if="importPreview"
class="p-3 bg-muted rounded-lg text-sm"
>
<p class="font-medium mb-2">
配置预览
</p>
<ul class="space-y-1 text-muted-foreground">
<li>全局模型: {{ importPreview.global_models?.length || 0 }} </li>
<li>提供商: {{ importPreview.providers?.length || 0 }} </li>
<li>
端点: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + (p.endpoints?.length || 0), 0) }}
</li>
<li>
API Keys: {{ importPreview.providers?.reduce((sum: number, p: any) => sum + p.endpoints?.reduce((s: number, e: any) => s + (e.keys?.length || 0), 0), 0) }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select
v-model="mergeMode"
v-model:open="mergeModeSelectOpen"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有配置
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入配置替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="mergeMode === 'skip'">
已存在的配置将被保留仅导入新配置
</template>
<template v-else-if="mergeMode === 'overwrite'">
已存在的配置将被导入的配置覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意相同的 API Keys 会自动跳过不会创建重复记录
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="importDialogOpen = false; mergeModeSelectOpen = false"
>
取消
</Button>
<Button
:disabled="importLoading"
@click="confirmImport"
>
{{ importLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog>
<!-- 导入结果对话框 -->
<Dialog
v-model:open="importResultDialogOpen"
title="导入完成"
>
<div
v-if="importResult"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
全局模型
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.global_models.created }},
更新: {{ importResult.stats.global_models.updated }},
跳过: {{ importResult.stats.global_models.skipped }}
</p>
</div>
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
提供商
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.providers.created }},
更新: {{ importResult.stats.providers.updated }},
跳过: {{ importResult.stats.providers.skipped }}
</p>
</div>
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
端点
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.endpoints.created }},
更新: {{ importResult.stats.endpoints.updated }},
跳过: {{ importResult.stats.endpoints.skipped }}
</p>
</div>
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
API Keys
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.keys.created }},
跳过: {{ importResult.stats.keys.skipped }}
</p>
</div>
<div class="p-3 bg-muted rounded-lg col-span-2">
<p class="font-medium">
模型配置
</p>
<p class="text-muted-foreground">
创建: {{ importResult.stats.models.created }},
更新: {{ importResult.stats.models.updated }},
跳过: {{ importResult.stats.models.skipped }}
</p>
</div>
</div>
<div
v-if="importResult.stats.errors.length > 0"
class="p-3 bg-destructive/10 rounded-lg"
>
<p class="font-medium text-destructive mb-2">
警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="importResultDialogOpen = false">
确定
</Button>
</template>
</Dialog>
<!-- 用户数据导入对话框 -->
<Dialog
v-model:open="importUsersDialogOpen"
title="导入用户数据"
description="选择冲突处理模式并确认导入"
>
<div class="space-y-4">
<div
v-if="importUsersPreview"
class="p-3 bg-muted rounded-lg text-sm"
>
<p class="font-medium mb-2">
数据预览
</p>
<ul class="space-y-1 text-muted-foreground">
<li>用户: {{ importUsersPreview.users?.length || 0 }} </li>
<li>
API Keys: {{ importUsersPreview.users?.reduce((sum: number, u: any) => sum + (u.api_keys?.length || 0), 0) }}
</li>
</ul>
</div>
<div>
<Label class="block text-sm font-medium mb-2">冲突处理模式</Label>
<Select
v-model="usersMergeMode"
v-model:open="usersMergeModeSelectOpen"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
跳过 - 保留现有用户
</SelectItem>
<SelectItem value="overwrite">
覆盖 - 用导入数据替换
</SelectItem>
<SelectItem value="error">
报错 - 遇到冲突时中止
</SelectItem>
</SelectContent>
</Select>
<p class="mt-1 text-xs text-muted-foreground">
<template v-if="usersMergeMode === 'skip'">
已存在的用户将被保留仅导入新用户
</template>
<template v-else-if="usersMergeMode === 'overwrite'">
已存在的用户将被导入的数据覆盖
</template>
<template v-else>
如果发现任何冲突导入将中止并回滚
</template>
</p>
</div>
<p class="text-xs text-muted-foreground">
注意用户 API Keys 需要目标系统使用相同的 ENCRYPTION_KEY 环境变量才能正常工作
</p>
</div>
<template #footer>
<Button
variant="outline"
@click="importUsersDialogOpen = false; usersMergeModeSelectOpen = false"
>
取消
</Button>
<Button
:disabled="importUsersLoading"
@click="confirmImportUsers"
>
{{ importUsersLoading ? '导入中...' : '确认导入' }}
</Button>
</template>
</Dialog>
<!-- 用户数据导入结果对话框 -->
<Dialog
v-model:open="importUsersResultDialogOpen"
title="用户数据导入完成"
>
<div
v-if="importUsersResult"
class="space-y-4"
>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
用户
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.users.created }},
更新: {{ importUsersResult.stats.users.updated }},
跳过: {{ importUsersResult.stats.users.skipped }}
</p>
</div>
<div class="p-3 bg-muted rounded-lg">
<p class="font-medium">
API Keys
</p>
<p class="text-muted-foreground">
创建: {{ importUsersResult.stats.api_keys.created }},
跳过: {{ importUsersResult.stats.api_keys.skipped }}
</p>
</div>
</div>
<div
v-if="importUsersResult.stats.errors.length > 0"
class="p-3 bg-destructive/10 rounded-lg"
>
<p class="font-medium text-destructive mb-2">
警告信息
</p>
<ul class="text-sm text-destructive space-y-1">
<li
v-for="(err, index) in importUsersResult.stats.errors"
:key="index"
>
{{ err }}
</li>
</ul>
</div>
</div>
<template #footer>
<Button @click="importUsersResultDialogOpen = false">
确定
</Button>
</template>
</Dialog>
</PageContainer>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Download, Upload } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Label from '@/components/ui/label.vue'
@@ -389,9 +781,12 @@ import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
import SelectContent from '@/components/ui/select-content.vue'
import SelectItem from '@/components/ui/select-item.vue'
import {
Dialog,
} from '@/components/ui'
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
import { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin'
import { adminApi, type ConfigExportData, type ConfigImportResponse, type UsersExportData, type UsersImportResponse } from '@/api/admin'
import { log } from '@/utils/logger'
const { success, error } = useToast()
@@ -403,8 +798,7 @@ interface SystemConfig {
// 用户注册
enable_registration: boolean
require_email_verification: boolean
// API Key 管理
api_key_expire_days: number
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 日志记录
request_log_level: string
@@ -418,11 +812,34 @@ interface SystemConfig {
header_retention_days: number
log_retention_days: number
cleanup_batch_size: number
audit_log_retention_days: number
}
const loading = ref(false)
const logLevelSelectOpen = ref(false)
// 导出/导入相关
const exportLoading = ref(false)
const importLoading = ref(false)
const importDialogOpen = ref(false)
const importResultDialogOpen = ref(false)
const configFileInput = ref<HTMLInputElement | null>(null)
const importPreview = ref<ConfigExportData | null>(null)
const importResult = ref<ConfigImportResponse | null>(null)
const mergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const mergeModeSelectOpen = ref(false)
// 用户数据导出/导入相关
const exportUsersLoading = ref(false)
const importUsersLoading = ref(false)
const importUsersDialogOpen = ref(false)
const importUsersResultDialogOpen = ref(false)
const usersFileInput = ref<HTMLInputElement | null>(null)
const importUsersPreview = ref<UsersExportData | null>(null)
const importUsersResult = ref<UsersImportResponse | null>(null)
const usersMergeMode = ref<'skip' | 'overwrite' | 'error'>('skip')
const usersMergeModeSelectOpen = ref(false)
const systemConfig = ref<SystemConfig>({
// 基础配置
default_user_quota_usd: 10.0,
@@ -430,8 +847,7 @@ const systemConfig = ref<SystemConfig>({
// 用户注册
enable_registration: false,
require_email_verification: false,
// API Key 管理
api_key_expire_days: 0,
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 日志记录
request_log_level: 'basic',
@@ -445,6 +861,7 @@ const systemConfig = ref<SystemConfig>({
header_retention_days: 90,
log_retention_days: 365,
cleanup_batch_size: 1000,
audit_log_retention_days: 30,
})
// 计算属性KB 和 字节 之间的转换
@@ -486,8 +903,7 @@ async function loadSystemConfig() {
// 用户注册
'enable_registration',
'require_email_verification',
// API Key 管理
'api_key_expire_days',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 日志记录
'request_log_level',
@@ -501,6 +917,7 @@ async function loadSystemConfig() {
'header_retention_days',
'log_retention_days',
'cleanup_batch_size',
'audit_log_retention_days',
]
for (const key of configs) {
@@ -545,12 +962,7 @@ async function saveSystemConfig() {
value: systemConfig.value.require_email_verification,
description: '是否需要邮箱验证'
},
// API Key 管理
{
key: 'api_key_expire_days',
value: systemConfig.value.api_key_expire_days,
description: 'API密钥过期天数'
},
// 独立余额 Key 过期管理
{
key: 'auto_delete_expired_keys',
value: systemConfig.value.auto_delete_expired_keys,
@@ -608,6 +1020,11 @@ async function saveSystemConfig() {
value: systemConfig.value.cleanup_batch_size,
description: '每批次清理的记录数'
},
{
key: 'audit_log_retention_days',
value: systemConfig.value.audit_log_retention_days,
description: '审计日志保留天数'
},
]
const promises = configItems.map(item =>
@@ -623,4 +1040,185 @@ async function saveSystemConfig() {
loading.value = false
}
}
// 导出配置
async function handleExportConfig() {
exportLoading.value = true
try {
const data = await adminApi.exportConfig()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `aether-config-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
success('配置已导出')
} catch (err) {
error('导出配置失败')
log.error('导出配置失败:', err)
} finally {
exportLoading.value = false
}
}
// 触发文件选择
function triggerConfigFileSelect() {
configFileInput.value?.click()
}
// 文件大小限制 (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024
// 处理文件选择
function handleConfigFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
error('文件大小不能超过 10MB')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as ConfigExportData
// 验证版本
if (data.version !== '1.0') {
error(`不支持的配置版本: ${data.version}`)
return
}
importPreview.value = data
mergeMode.value = 'skip'
importDialogOpen.value = true
} catch (err) {
error('解析配置文件失败,请确保是有效的 JSON 文件')
log.error('解析配置文件失败:', err)
}
}
reader.readAsText(file)
// 重置 input 以便能再次选择同一文件
input.value = ''
}
// 确认导入
async function confirmImport() {
if (!importPreview.value) return
importLoading.value = true
try {
const result = await adminApi.importConfig({
...importPreview.value,
merge_mode: mergeMode.value
})
importResult.value = result
importDialogOpen.value = false
mergeModeSelectOpen.value = false
importResultDialogOpen.value = true
success('配置导入成功')
} catch (err: any) {
error(err.response?.data?.detail || '导入配置失败')
log.error('导入配置失败:', err)
} finally {
importLoading.value = false
}
}
// 导出用户数据
async function handleExportUsers() {
exportUsersLoading.value = true
try {
const data = await adminApi.exportUsers()
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `aether-users-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
success('用户数据已导出')
} catch (err) {
error('导出用户数据失败')
log.error('导出用户数据失败:', err)
} finally {
exportUsersLoading.value = false
}
}
// 触发用户数据文件选择
function triggerUsersFileSelect() {
usersFileInput.value?.click()
}
// 处理用户数据文件选择
function handleUsersFileSelect(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (file.size > MAX_FILE_SIZE) {
error('文件大小不能超过 10MB')
input.value = ''
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content) as UsersExportData
// 验证版本
if (data.version !== '1.0') {
error(`不支持的配置版本: ${data.version}`)
return
}
importUsersPreview.value = data
usersMergeMode.value = 'skip'
importUsersDialogOpen.value = true
} catch (err) {
error('解析用户数据文件失败,请确保是有效的 JSON 文件')
log.error('解析用户数据文件失败:', err)
}
}
reader.readAsText(file)
// 重置 input 以便能再次选择同一文件
input.value = ''
}
// 确认导入用户数据
async function confirmImportUsers() {
if (!importUsersPreview.value) return
importUsersLoading.value = true
try {
const result = await adminApi.importUsers({
...importUsersPreview.value,
merge_mode: usersMergeMode.value
})
importUsersResult.value = result
importUsersDialogOpen.value = false
usersMergeModeSelectOpen.value = false
importUsersResultDialogOpen.value = true
success('用户数据导入成功')
} catch (err: any) {
error(err.response?.data?.detail || '导入用户数据失败')
log.error('导入用户数据失败:', err)
} finally {
importUsersLoading.value = false
}
}
</script>

View File

@@ -701,6 +701,7 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage'
import { adminApi } from '@/api/admin'
@@ -748,6 +749,7 @@ import { log } from '@/utils/logger'
const { success, error } = useToast()
const { confirmDanger, confirmWarning } = useConfirm()
const { copyToClipboard } = useClipboard()
const usersStore = useUsersStore()
// 用户表单对话框状态
@@ -791,11 +793,13 @@ const filteredUsers = computed(() => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(
u => u.username.toLowerCase().includes(query) || u.email?.toLowerCase().includes(query)
)
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
filtered = filtered.filter(u => {
const searchableText = `${u.username} ${u.email || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
if (filterRole.value !== 'all') {
@@ -873,7 +877,8 @@ async function toggleUserStatus(user: any) {
const action = user.is_active ? '禁用' : '启用'
const confirmed = await confirmDanger(
`确定要${action}用户 ${user.username} 吗?`,
`${action}用户`
`${action}用户`,
action
)
if (!confirmed) return
@@ -882,7 +887,7 @@ async function toggleUserStatus(user: any) {
await usersStore.updateUser(user.id, { is_active: !user.is_active })
success(`用户已${action}`)
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', `${action}用户失败`)
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', `${action}用户失败`)
}
}
@@ -953,7 +958,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
closeUserFormDialog()
} catch (err: any) {
const title = data.id ? '更新用户失败' : '创建用户失败'
error(err.response?.data?.detail || '未知错误', title)
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', title)
} finally {
userFormDialogRef.value?.setSaving(false)
}
@@ -987,7 +992,7 @@ async function createApiKey() {
showNewApiKeyDialog.value = true
await loadUserApiKeys(selectedUser.value.id)
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '创建 API Key 失败')
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '创建 API Key 失败')
} finally {
creatingApiKey.value = false
}
@@ -998,12 +1003,7 @@ function selectApiKey() {
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newApiKey.value)
success('API Key已复制到剪贴板')
} catch {
error('复制失败,请手动复制')
}
await copyToClipboard(newApiKey.value)
}
async function closeNewApiKeyDialog() {
@@ -1024,7 +1024,7 @@ async function deleteApiKey(apiKey: any) {
await loadUserApiKeys(selectedUser.value.id)
success('API Key已删除')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '删除 API Key 失败')
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除 API Key 失败')
}
}
@@ -1032,11 +1032,10 @@ async function copyFullKey(apiKey: any) {
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
await navigator.clipboard.writeText(response.key)
success('完整密钥已复制到剪贴板')
await copyToClipboard(response.key)
} catch (err: any) {
log.error('复制密钥失败:', err)
error(err.response?.data?.detail || '未知错误', '复制密钥失败')
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '复制密钥失败')
}
}
@@ -1052,7 +1051,7 @@ async function resetQuota(user: any) {
await usersStore.resetUserQuota(user.id)
success('配额已重置')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '重置配额失败')
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '重置配额失败')
}
}
@@ -1068,7 +1067,7 @@ async function deleteUser(user: any) {
await usersStore.deleteUser(user.id)
success('用户已删除')
} catch (err: any) {
error(err.response?.data?.detail || '未知错误', '删除用户失败')
error(err.response?.data?.error?.message || err.response?.data?.detail || '未知错误', '删除用户失败')
}
}
</script>

View File

@@ -102,9 +102,9 @@
<!-- Main Content -->
<main class="relative z-10">
<!-- Fixed Logo Container -->
<div class="fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
<div class="mt-4 fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
<div
class="transform-gpu logo-container"
class="mt-16 transform-gpu logo-container"
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
:style="fixedLogoStyle"
>
@@ -151,7 +151,7 @@
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20"
>
<div class="max-w-4xl mx-auto text-center">
<div class="h-80 w-full mb-16" />
<div class="h-80 w-full mb-16 mt-8" />
<h1
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
:style="getTitleStyle(SECTIONS.HOME)"
@@ -166,7 +166,7 @@
整合 Claude CodeCodex CLIGemini CLI 等多个 AI 编程助手
</p>
<button
class="mt-16 transition-all duration-700 cursor-pointer hover:scale-110"
class="mt-8 transition-all duration-700 cursor-pointer hover:scale-110"
:style="getScrollIndicatorStyle(SECTIONS.HOME)"
@click="scrollToSection(SECTIONS.CLAUDE)"
>

View File

@@ -103,7 +103,7 @@
</div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
<Clock class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<Clock class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
平均响应
@@ -114,7 +114,7 @@
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-kraft/30">
<AlertTriangle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<AlertTriangle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
错误率
@@ -128,7 +128,7 @@
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
<Shuffle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<Shuffle class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
转移次数
@@ -142,13 +142,13 @@
v-if="costStats"
class="relative p-3 sm:p-4 border-manilla/40"
>
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
实际成
月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatCurrency(costStats.total_actual_cost) }}
{{ formatCurrency(costStats.total_cost) }}
</p>
<Badge
v-if="costStats.cost_savings > 0"
@@ -162,14 +162,14 @@
</div>
</div>
<!-- 普通用户缓存统计 -->
<!-- 普通用户月度统计 -->
<div
v-else-if="!isAdmin && cacheStats && cacheStats.total_cache_tokens > 0"
v-else-if="!isAdmin && (hasCacheData || (userMonthlyCost !== null && userMonthlyCost > 0))"
class="mt-6"
>
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-medium text-foreground">
本月缓存使用
本月统计
</h3>
<Badge
variant="outline"
@@ -178,9 +178,17 @@
Monthly
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Card class="relative p-3 sm:p-4 border-book-cloth/30">
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<div
:class="[
'grid gap-2 sm:gap-3',
hasCacheData ? 'grid-cols-2 xl:grid-cols-4' : 'grid-cols-1 max-w-xs'
]"
>
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/30"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存命中率
@@ -190,8 +198,11 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-kraft/30">
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-kraft/30"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存读取
@@ -201,8 +212,11 @@
</p>
</div>
</Card>
<Card class="relative p-3 sm:p-4 border-book-cloth/25">
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-kraft" />
<Card
v-if="cacheStats"
class="relative p-3 sm:p-4 border-book-cloth/25"
>
<Database class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
缓存创建
@@ -213,19 +227,16 @@
</div>
</Card>
<Card
v-if="tokenBreakdown"
v-if="userMonthlyCost !== null"
class="relative p-3 sm:p-4 border-manilla/40"
>
<Hash class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-book-cloth" />
<DollarSign class="absolute top-3 right-3 h-3.5 w-3.5 sm:h-4 sm:w-4 text-muted-foreground" />
<div class="pr-6">
<p class="text-[9px] sm:text-[10px] font-semibold uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground">
总Token
本月费用
</p>
<p class="mt-1.5 sm:mt-2 text-lg sm:text-xl font-semibold text-foreground">
{{ formatTokens((tokenBreakdown.input || 0) + (tokenBreakdown.output || 0)) }}
</p>
<p class="mt-0.5 sm:mt-1 text-[9px] sm:text-[10px] text-muted-foreground">
输入 {{ formatTokens(tokenBreakdown.input || 0) }} / 输出 {{ formatTokens(tokenBreakdown.output || 0) }}
{{ formatCurrency(userMonthlyCost) }}
</p>
</div>
</Card>
@@ -254,16 +265,16 @@
<Card class="overflow-hidden p-4 flex flex-col flex-1 min-h-0 h-full max-h-[280px] sm:max-h-none">
<div
v-if="loadingAnnouncements"
class="py-8 text-center"
class="flex-1 flex items-center justify-center"
>
<Loader2 class="h-5 w-5 animate-spin mx-auto text-muted-foreground" />
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
<div
v-else-if="announcements.length === 0"
class="py-8 text-center"
class="flex-1 flex flex-col items-center justify-center"
>
<Bell class="h-8 w-8 mx-auto text-muted-foreground/40" />
<Bell class="h-8 w-8 text-muted-foreground/40" />
<p class="mt-2 text-xs text-muted-foreground">
暂无公告
</p>
@@ -793,9 +804,8 @@ const statCardGlows = [
'bg-kraft/30'
]
const getStatIconColor = (index: number): string => {
const colors = ['text-book-cloth', 'text-kraft', 'text-book-cloth', 'text-kraft']
return colors[index % colors.length]
const getStatIconColor = (_index: number): string => {
return 'text-muted-foreground'
}
// 统计数据
@@ -832,6 +842,12 @@ const cacheStats = ref<{
total_cache_tokens: number
} | null>(null)
const userMonthlyCost = ref<number | null>(null)
const hasCacheData = computed(() =>
cacheStats.value && cacheStats.value.total_cache_tokens > 0
)
const tokenBreakdown = ref<{
input: number
output: number
@@ -1087,6 +1103,7 @@ async function loadDashboardData() {
} else {
if (statsData.cache_stats) cacheStats.value = statsData.cache_stats
if (statsData.token_breakdown) tokenBreakdown.value = statsData.token_breakdown
if (statsData.monthly_cost !== undefined) userMonthlyCost.value = statsData.monthly_cost
}
} finally {
loading.value = false

View File

@@ -65,6 +65,7 @@
:page-size="pageSize"
:total-records="totalRecords"
:page-size-options="pageSizeOptions"
:auto-refresh="globalAutoRefresh"
@update:selected-period="handlePeriodChange"
@update:filter-user="handleFilterUserChange"
@update:filter-model="handleFilterModelChange"
@@ -72,6 +73,7 @@
@update:filter-status="handleFilterStatusChange"
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData"
@export="exportData"
@show-detail="showRequestDetail"
@@ -214,7 +216,10 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
// 自动刷新定时器
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次用于活跃请求
const GLOBAL_AUTO_REFRESH_INTERVAL = 10000 // 10秒刷新一次全局自动刷新
const globalAutoRefresh = ref(false) // 全局自动刷新开关
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
async function pollActiveRequests() {
@@ -278,9 +283,35 @@ watch(hasActiveRequests, (hasActive) => {
}
}, { immediate: true })
// 启动全局自动刷新
function startGlobalAutoRefresh() {
if (globalAutoRefreshTimer) return
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
}
// 停止全局自动刷新
function stopGlobalAutoRefresh() {
if (globalAutoRefreshTimer) {
clearInterval(globalAutoRefreshTimer)
globalAutoRefreshTimer = null
}
}
// 处理自动刷新开关变化
function handleAutoRefreshChange(value: boolean) {
globalAutoRefresh.value = value
if (value) {
refreshData() // 立即刷新一次
startGlobalAutoRefresh()
} else {
stopGlobalAutoRefresh()
}
}
// 组件卸载时清理定时器
onUnmounted(() => {
stopAutoRefresh()
stopGlobalAutoRefresh()
})
// 用户页面的前端分页

View File

@@ -226,8 +226,8 @@
<div
v-for="announcement in announcements"
:key="announcement.id"
class="p-4 space-y-2 cursor-pointer transition-colors"
:class="[
'p-4 space-y-2 cursor-pointer transition-colors',
announcement.is_read ? 'hover:bg-muted/30' : 'bg-primary/5 hover:bg-primary/10'
]"
@click="viewAnnouncementDetail(announcement)"

View File

@@ -165,17 +165,17 @@
<TableCell class="py-4">
<div class="flex gap-1.5">
<Eye
v-if="model.default_supports_vision"
v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground"
title="Vision"
/>
<Wrench
v-if="model.default_supports_function_calling"
v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground"
title="Tool Use"
/>
<Brain
v-if="model.default_supports_extended_thinking"
v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground"
title="Extended Thinking"
/>
@@ -253,15 +253,15 @@
<!-- 第二行能力图标 -->
<div class="flex gap-1.5">
<Eye
v-if="model.default_supports_vision"
v-if="model.config?.vision === true"
class="w-4 h-4 text-muted-foreground"
/>
<Wrench
v-if="model.default_supports_function_calling"
v-if="model.config?.function_calling === true"
class="w-4 h-4 text-muted-foreground"
/>
<Brain
v-if="model.default_supports_extended_thinking"
v-if="model.config?.extended_thinking === true"
class="w-4 h-4 text-muted-foreground"
/>
</div>
@@ -342,6 +342,7 @@ import {
Plus,
} from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import {
Card,
Table,
@@ -370,6 +371,7 @@ import { useRowClick } from '@/composables/useRowClick'
import { log } from '@/utils/logger'
const { success, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
// 状态
const loading = ref(false)
@@ -474,24 +476,24 @@ async function toggleCapability(modelName: string, capName: string) {
const filteredModels = computed(() => {
let result = models.value
// 搜索
// 搜索(支持空格分隔的多关键词 AND 搜索)
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(m =>
m.name.toLowerCase().includes(query) ||
m.display_name?.toLowerCase().includes(query)
)
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
result = result.filter(m => {
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
return keywords.every(keyword => searchableText.includes(keyword))
})
}
// 能力筛选
if (capabilityFilters.value.vision) {
result = result.filter(m => m.default_supports_vision)
result = result.filter(m => m.config?.vision === true)
}
if (capabilityFilters.value.toolUse) {
result = result.filter(m => m.default_supports_function_calling)
result = result.filter(m => m.config?.function_calling === true)
}
if (capabilityFilters.value.extendedThinking) {
result = result.filter(m => m.default_supports_extended_thinking)
result = result.filter(m => m.config?.extended_thinking === true)
}
return result
@@ -565,16 +567,6 @@ function hasTieredPricing(model: PublicGlobalModel): boolean {
return (tiered?.tiers?.length || 0) > 1
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
success('已复制')
} catch (err) {
log.error('复制失败:', err)
showError('复制失败')
}
}
onMounted(() => {
refreshData()
})

View File

@@ -62,6 +62,7 @@
<Button
type="submit"
:disabled="savingProfile"
class="shadow-none hover:shadow-none"
>
{{ savingProfile ? '保存中...' : '保存修改' }}
</Button>
@@ -107,6 +108,7 @@
<Button
type="submit"
:disabled="changingPassword"
class="shadow-none hover:shadow-none"
>
{{ changingPassword ? '修改中...' : '修改密码' }}
</Button>
@@ -320,6 +322,7 @@
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { meApi, type Profile } from '@/api/me'
import { useDarkMode, type ThemeMode } from '@/composables/useDarkMode'
import Card from '@/components/ui/card.vue'
import Button from '@/components/ui/button.vue'
import Badge from '@/components/ui/badge.vue'
@@ -338,6 +341,7 @@ import { log } from '@/utils/logger'
const authStore = useAuthStore()
const { success, error: showError } = useToast()
const { setThemeMode } = useDarkMode()
const profile = ref<Profile | null>(null)
@@ -375,20 +379,8 @@ function handleThemeChange(value: string) {
themeSelectOpen.value = false
updatePreferences()
// 应用主题
if (value === 'dark') {
document.documentElement.classList.add('dark')
} else if (value === 'light') {
document.documentElement.classList.remove('dark')
} else {
// system: 跟随系统
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (prefersDark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 使用 useDarkMode 统一切换主题
setThemeMode(value as ThemeMode)
}
function handleLanguageChange(value: string) {
@@ -418,10 +410,16 @@ async function loadProfile() {
async function loadPreferences() {
try {
const prefs = await meApi.getPreferences()
// 主题以本地 localStorage 为准useDarkMode 在应用启动时已初始化)
// 这样可以避免刷新页面时主题被服务端旧值覆盖
const { themeMode: currentThemeMode } = useDarkMode()
const localTheme = currentThemeMode.value
preferencesForm.value = {
avatar_url: prefs.avatar_url || '',
bio: prefs.bio || '',
theme: prefs.theme || 'light',
theme: localTheme, // 使用本地主题,而非服务端返回值
language: prefs.language || 'zh-CN',
timezone: prefs.timezone || 'Asia/Shanghai',
notifications: {
@@ -431,11 +429,12 @@ async function loadPreferences() {
}
}
// 应用主题
if (preferencesForm.value.theme === 'dark') {
document.documentElement.classList.add('dark')
} else if (preferencesForm.value.theme === 'light') {
document.documentElement.classList.remove('dark')
// 如果本地主题和服务端不一致,同步到服务端(静默更新,不提示用户)
const serverTheme = prefs.theme || 'light'
if (localTheme !== serverTheme) {
meApi.updatePreferences({ theme: localTheme }).catch(() => {
// 静默失败,不影响用户体验
})
}
} catch (error) {
log.error('加载偏好设置失败:', error)

View File

@@ -38,10 +38,10 @@
</button>
</div>
<p
v-if="model.description"
v-if="model.config?.description"
class="text-xs text-muted-foreground"
>
{{ model.description }}
{{ model.config?.description }}
</p>
</div>
<Button
@@ -73,10 +73,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_streaming ?? false ? 'default' : 'secondary'"
:variant="model.config?.streaming !== false ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_streaming ?? false ? '支持' : '不支持' }}
{{ model.config?.streaming !== false ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -90,10 +90,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_image_generation ?? false ? 'default' : 'secondary'"
:variant="model.config?.image_generation === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_image_generation ?? false ? '支持' : '不支持' }}
{{ model.config?.image_generation === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -107,10 +107,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_vision ?? false ? 'default' : 'secondary'"
:variant="model.config?.vision === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_vision ?? false ? '支持' : '不支持' }}
{{ model.config?.vision === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -124,10 +124,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_function_calling ?? false ? 'default' : 'secondary'"
:variant="model.config?.function_calling === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_function_calling ?? false ? '支持' : '不支持' }}
{{ model.config?.function_calling === true ? '支持' : '不支持' }}
</Badge>
</div>
<div class="flex items-center gap-2 p-3 rounded-lg border">
@@ -141,10 +141,10 @@
</p>
</div>
<Badge
:variant="model.default_supports_extended_thinking ?? false ? 'default' : 'secondary'"
:variant="model.config?.extended_thinking === true ? 'default' : 'secondary'"
class="text-xs"
>
{{ model.default_supports_extended_thinking ?? false ? '支持' : '不支持' }}
{{ model.config?.extended_thinking === true ? '支持' : '不支持' }}
</Badge>
</div>
</div>
@@ -350,7 +350,9 @@ import {
Layers,
Image as ImageIcon
} from 'lucide-vue-next'
import { useEscapeKey } from '@/composables/useEscapeKey'
import { useToast } from '@/composables/useToast'
import { useClipboard } from '@/composables/useClipboard'
import Card from '@/components/ui/card.vue'
import Badge from '@/components/ui/badge.vue'
import Button from '@/components/ui/button.vue'
@@ -374,6 +376,7 @@ const emit = defineEmits<{
}>()
const { success: showSuccess, error: showError } = useToast()
const { copyToClipboard } = useClipboard()
interface Props {
model: PublicGlobalModel | null
@@ -407,15 +410,6 @@ function handleClose() {
emit('update:open', false)
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
showSuccess('已复制')
} catch {
showError('复制失败')
}
}
function getFirstTierPrice(
tieredPricing: TieredPricingConfig | undefined | null,
priceKey: 'input_price_per_1m' | 'output_price_per_1m' | 'cache_creation_price_per_1m' | 'cache_read_price_per_1m'
@@ -453,6 +447,16 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
if (!tieredPricing?.tiers?.length) return '-'
return get1hCachePrice(tieredPricing.tiers[0])
}
// 添加 ESC 键监听
useEscapeKey(() => {
if (props.open) {
handleClose()
}
}, {
disableOnInput: true,
once: false
})
</script>
<style scoped>

12
migrate.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# 数据库迁移脚本 - 在 Docker 容器内执行 Alembic 迁移
set -e
CONTAINER_NAME="aether-app"
echo "Running database migrations in container: $CONTAINER_NAME"
docker exec $CONTAINER_NAME alembic upgrade head
echo "Database migration completed successfully"

View File

@@ -13,7 +13,7 @@ authors = [
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"License :: Other/Proprietary License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",

View File

@@ -3,10 +3,8 @@
A proxy server that enables AI models to work with multiple API providers.
"""
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# 注意: dotenv 加载已统一移至 src/config/settings.py
# 不要在此处重复加载
try:
from src._version import __version__

View File

@@ -7,6 +7,7 @@ from .api_keys import router as api_keys_router
from .endpoints import router as endpoints_router
from .models import router as models_router
from .monitoring import router as monitoring_router
from .provider_query import router as provider_query_router
from .provider_strategy import router as provider_strategy_router
from .providers import router as providers_router
from .security import router as security_router
@@ -26,5 +27,6 @@ router.include_router(provider_strategy_router)
router.include_router(adaptive_router)
router.include_router(models_router)
router.include_router(security_router)
router.include_router(provider_query_router)
__all__ = ["router"]

View File

@@ -223,7 +223,7 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
allowed_providers=self.key_data.allowed_providers,
allowed_api_formats=self.key_data.allowed_api_formats,
allowed_models=self.key_data.allowed_models,
rate_limit=self.key_data.rate_limit or 100,
rate_limit=self.key_data.rate_limit, # None 表示不限制
expire_days=self.key_data.expire_days,
initial_balance_usd=self.key_data.initial_balance_usd,
is_standalone=True, # 标记为独立Key

View File

@@ -80,6 +80,17 @@ async def get_keys_grouped_by_format(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/keys/{key_id}/reveal")
async def reveal_endpoint_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> dict:
"""获取完整的 API Key用于查看和复制"""
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/keys/{key_id}")
async def delete_endpoint_key(
key_id: str,
@@ -246,6 +257,15 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if "api_key" in update_data:
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
# 特殊处理 max_concurrent需要区分"未提供"和"显式设置为 null"
# 当 max_concurrent 被显式设置时(在 model_fields_set 中),即使值为 None 也应该更新
if "max_concurrent" in self.key_data.model_fields_set:
update_data["max_concurrent"] = self.key_data.max_concurrent
# 切换到自适应模式时,清空学习到的并发限制,让系统重新学习
if self.key_data.max_concurrent is None:
update_data["learned_max_concurrent"] = None
logger.info("Key %s 切换为自适应并发模式", self.key_id)
for field, value in update_data.items():
setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc)
@@ -253,7 +273,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
db.commit()
db.refresh(key)
logger.info(f"[OK] 更新 Key: ID={self.key_id}, Updates={list(update_data.keys())}")
logger.info("[OK] 更新 Key: ID=%s, Updates=%s", self.key_id, list(update_data.keys()))
try:
decrypted_key = crypto_service.decrypt(key.api_key)
@@ -284,6 +304,30 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
return EndpointAPIKeyResponse(**response_dict)
@dataclass
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
"""获取完整的 API Key用于查看和复制"""
key_id: str
async def handle(self, context): # type: ignore[override]
db = context.db
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
if not key:
raise NotFoundException(f"Key {self.key_id} 不存在")
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception as e:
logger.error(f"解密 Key 失败: ID={self.key_id}, Error={e}")
raise InvalidRequestException(
"无法解密 API Key可能是加密密钥已更改。请重新添加该密钥。"
)
logger.info(f"[REVEAL] 查看完整 Key: ID={self.key_id}, Name={key.name}")
return {"api_key": decrypted_key}
@dataclass
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
key_id: str

View File

@@ -5,7 +5,7 @@ ProviderEndpoint CRUD 管理 API
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import and_, func
@@ -27,6 +27,16 @@ router = APIRouter(tags=["Endpoint Management"])
pipeline = ApiRequestPipeline()
def mask_proxy_password(proxy_config: Optional[dict]) -> Optional[dict]:
"""对代理配置中的密码进行脱敏处理"""
if not proxy_config:
return None
masked = dict(proxy_config)
if masked.get("password"):
masked["password"] = "***"
return masked
@router.get("/providers/{provider_id}/endpoints", response_model=List[ProviderEndpointResponse])
async def list_provider_endpoints(
provider_id: str,
@@ -153,6 +163,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
"api_format": endpoint.api_format,
"total_keys": total_keys_map.get(endpoint.id, 0),
"active_keys": active_keys_map.get(endpoint.id, 0),
"proxy": mask_proxy_password(endpoint.proxy),
}
endpoint_dict.pop("_sa_instance_state", None)
result.append(ProviderEndpointResponse(**endpoint_dict))
@@ -202,6 +213,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
rate_limit=self.endpoint_data.rate_limit,
is_active=True,
config=self.endpoint_data.config,
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
created_at=now,
updated_at=now,
)
@@ -215,12 +227,13 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
endpoint_dict = {
k: v
for k, v in new_endpoint.__dict__.items()
if k not in {"api_format", "_sa_instance_state"}
if k not in {"api_format", "_sa_instance_state", "proxy"}
}
return ProviderEndpointResponse(
**endpoint_dict,
provider_name=provider.name,
api_format=new_endpoint.api_format,
proxy=mask_proxy_password(new_endpoint.proxy),
total_keys=0,
active_keys=0,
)
@@ -259,12 +272,13 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter):
endpoint_dict = {
k: v
for k, v in endpoint_obj.__dict__.items()
if k not in {"api_format", "_sa_instance_state"}
if k not in {"api_format", "_sa_instance_state", "proxy"}
}
return ProviderEndpointResponse(
**endpoint_dict,
provider_name=provider.name,
api_format=endpoint_obj.api_format,
proxy=mask_proxy_password(endpoint_obj.proxy),
total_keys=total_keys,
active_keys=active_keys,
)
@@ -284,6 +298,17 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
update_data = self.endpoint_data.model_dump(exclude_unset=True)
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
if "proxy" in update_data:
if update_data["proxy"] is not None:
new_proxy = dict(update_data["proxy"])
# 只有当密码字段未提供时才保留原密码(空字符串视为显式清除)
if "password" not in new_proxy and endpoint.proxy:
old_password = endpoint.proxy.get("password")
if old_password:
new_proxy["password"] = old_password
update_data["proxy"] = new_proxy
# proxy 为 None 时保留,用于清除代理配置
for field, value in update_data.items():
setattr(endpoint, field, value)
endpoint.updated_at = datetime.now(timezone.utc)
@@ -311,12 +336,13 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
endpoint_dict = {
k: v
for k, v in endpoint.__dict__.items()
if k not in {"api_format", "_sa_instance_state"}
if k not in {"api_format", "_sa_instance_state", "proxy"}
}
return ProviderEndpointResponse(
**endpoint_dict,
provider_name=provider.name if provider else "Unknown",
api_format=endpoint.api_format,
proxy=mask_proxy_password(endpoint.proxy),
total_keys=total_keys,
active_keys=active_keys,
)

View File

@@ -5,6 +5,7 @@
from fastapi import APIRouter
from .catalog import router as catalog_router
from .external import router as external_router
from .global_models import router as global_models_router
router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"])
@@ -12,3 +13,4 @@ router = APIRouter(prefix="/api/admin/models", tags=["Admin - Model Management"]
# 挂载子路由
router.include_router(catalog_router)
router.include_router(global_models_router)
router.include_router(external_router)

View File

@@ -72,10 +72,12 @@ class AdminGetModelCatalogAdapter(AdminApiAdapter):
for gm in global_models:
gm_id = gm.id
provider_entries: List[ModelCatalogProviderDetail] = []
# 从 config JSON 读取能力标志
gm_config = gm.config or {}
capability_flags = {
"supports_vision": gm.default_supports_vision or False,
"supports_function_calling": gm.default_supports_function_calling or False,
"supports_streaming": gm.default_supports_streaming or False,
"supports_vision": gm_config.get("vision", False),
"supports_function_calling": gm_config.get("function_calling", False),
"supports_streaming": gm_config.get("streaming", True),
}
# 遍历该 GlobalModel 的所有关联提供商
@@ -140,7 +142,7 @@ class AdminGetModelCatalogAdapter(AdminApiAdapter):
ModelCatalogItem(
global_model_name=gm.name,
display_name=gm.display_name,
description=gm.description,
description=gm_config.get("description"),
providers=provider_entries,
price_range=price_range,
total_providers=len(provider_entries),

View File

@@ -0,0 +1,141 @@
"""
models.dev 外部模型数据代理
"""
import json
from typing import Any, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from src.clients import get_redis_client
from src.core.logger import logger
from src.models.database import User
from src.utils.auth_utils import require_admin
router = APIRouter()
CACHE_KEY = "aether:external:models_dev"
CACHE_TTL = 15 * 60 # 15 分钟
# 标记官方/一手提供商,前端可据此过滤第三方转售商
OFFICIAL_PROVIDERS = {
"anthropic", # Claude 官方
"openai", # OpenAI 官方
"google", # Gemini 官方
"google-vertex", # Google Vertex AI
"azure", # Azure OpenAI
"amazon-bedrock", # AWS Bedrock
"xai", # Grok 官方
"meta", # Llama 官方
"deepseek", # DeepSeek 官方
"mistral", # Mistral 官方
"cohere", # Cohere 官方
"zhipuai", # 智谱 AI 官方
"alibaba", # 阿里云(通义千问)
"minimax", # MiniMax 官方
"moonshot", # 月之暗面Kimi
"baichuan", # 百川智能
"ai21", # AI21 Labs
}
async def _get_cached_data() -> Optional[dict[str, Any]]:
"""从 Redis 获取缓存数据"""
redis = await get_redis_client()
if redis is None:
return None
try:
cached = await redis.get(CACHE_KEY)
if cached:
result: dict[str, Any] = json.loads(cached)
return result
except Exception as e:
logger.warning(f"读取 models.dev 缓存失败: {e}")
return None
async def _set_cached_data(data: dict) -> None:
"""将数据写入 Redis 缓存"""
redis = await get_redis_client()
if redis is None:
return
try:
await redis.setex(CACHE_KEY, CACHE_TTL, json.dumps(data, ensure_ascii=False))
except Exception as e:
logger.warning(f"写入 models.dev 缓存失败: {e}")
def _mark_official_providers(data: dict[str, Any]) -> dict[str, Any]:
"""为每个提供商标记是否为官方"""
result = {}
for provider_id, provider_data in data.items():
result[provider_id] = {
**provider_data,
"official": provider_id in OFFICIAL_PROVIDERS,
}
return result
@router.get("/external")
async def get_external_models(_: User = Depends(require_admin)) -> JSONResponse:
"""
获取 models.dev 的模型数据(代理请求,解决跨域问题)
数据缓存 15 分钟(使用 Redis多 worker 共享)
每个提供商会标记 official 字段,前端可据此过滤
"""
# 检查缓存
cached = await _get_cached_data()
if cached is not None:
# 兼容旧缓存:如果没有 official 字段则补全并回写
try:
needs_mark = False
for provider_data in cached.values():
if not isinstance(provider_data, dict) or "official" not in provider_data:
needs_mark = True
break
if needs_mark:
marked_cached = _mark_official_providers(cached)
await _set_cached_data(marked_cached)
return JSONResponse(content=marked_cached)
except Exception as e:
logger.warning(f"处理 models.dev 缓存数据失败,将直接返回原缓存: {e}")
return JSONResponse(content=cached)
# 从 models.dev 获取数据
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get("https://models.dev/api.json")
response.raise_for_status()
data = response.json()
# 标记官方提供商
marked_data = _mark_official_providers(data)
# 写入缓存
await _set_cached_data(marked_data)
return JSONResponse(content=marked_data)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="请求 models.dev 超时")
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502, detail=f"models.dev 返回错误: {e.response.status_code}"
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"获取外部模型数据失败: {str(e)}")
@router.delete("/external/cache")
async def clear_external_models_cache(_: User = Depends(require_admin)) -> dict:
"""清除 models.dev 缓存"""
redis = await get_redis_client()
if redis is None:
return {"cleared": False, "message": "Redis 未启用"}
try:
await redis.delete(CACHE_KEY)
return {"cleared": True}
except Exception as e:
raise HTTPException(status_code=500, detail=f"清除缓存失败: {str(e)}")

View File

@@ -5,7 +5,7 @@ GlobalModel Admin API
"""
from dataclasses import dataclass
from typing import List, Optional
from typing import Optional
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -19,9 +19,11 @@ from src.models.pydantic_models import (
BatchAssignToProvidersResponse,
GlobalModelCreate,
GlobalModelListResponse,
GlobalModelProvidersResponse,
GlobalModelResponse,
GlobalModelUpdate,
GlobalModelWithStats,
ModelCatalogProviderDetail,
)
from src.services.model.global_model import GlobalModelService
@@ -108,6 +110,17 @@ async def batch_assign_to_providers(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{global_model_id}/providers", response_model=GlobalModelProvidersResponse)
async def get_global_model_providers(
request: Request,
global_model_id: str,
db: Session = Depends(get_db),
) -> GlobalModelProvidersResponse:
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
adapter = AdminGetGlobalModelProvidersAdapter(global_model_id=global_model_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ========== Adapters ==========
@@ -187,21 +200,15 @@ class AdminCreateGlobalModelAdapter(AdminApiAdapter):
db=context.db,
name=self.payload.name,
display_name=self.payload.display_name,
description=self.payload.description,
official_url=self.payload.official_url,
icon_url=self.payload.icon_url,
is_active=self.payload.is_active,
# 按次计费配置
default_price_per_request=self.payload.default_price_per_request,
# 阶梯计费配置
default_tiered_pricing=tiered_pricing_dict,
# 默认能力配置
default_supports_vision=self.payload.default_supports_vision,
default_supports_function_calling=self.payload.default_supports_function_calling,
default_supports_streaming=self.payload.default_supports_streaming,
default_supports_extended_thinking=self.payload.default_supports_extended_thinking,
# Key 能力配置
supported_capabilities=self.payload.supported_capabilities,
# 模型配置JSON
config=self.payload.config,
)
logger.info(f"GlobalModel 已创建: id={global_model.id} name={global_model.name}")
@@ -281,3 +288,61 @@ class AdminBatchAssignToProvidersAdapter(AdminApiAdapter):
logger.info(f"批量为 Provider 添加 GlobalModel: global_model_id={self.global_model_id} success={len(result['success'])} errors={len(result['errors'])}")
return BatchAssignToProvidersResponse(**result)
@dataclass
class AdminGetGlobalModelProvidersAdapter(AdminApiAdapter):
"""获取 GlobalModel 的所有关联提供商(包括非活跃的)"""
global_model_id: str
async def handle(self, context): # type: ignore[override]
from sqlalchemy.orm import joinedload
from src.models.database import Model
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
# 获取所有关联的 Model包括非活跃的
models = (
context.db.query(Model)
.options(joinedload(Model.provider), joinedload(Model.global_model))
.filter(Model.global_model_id == global_model.id)
.all()
)
provider_entries = []
for model in models:
provider = model.provider
if not provider:
continue
effective_tiered = model.get_effective_tiered_pricing()
tier_count = len(effective_tiered.get("tiers", [])) if effective_tiered else 1
provider_entries.append(
ModelCatalogProviderDetail(
provider_id=provider.id,
provider_name=provider.name,
provider_display_name=provider.display_name,
model_id=model.id,
target_model=model.provider_model_name,
input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
cache_read_price_per_1m=model.get_effective_cache_read_price(),
cache_1h_creation_price_per_1m=model.get_effective_1h_cache_creation_price(),
price_per_request=model.get_effective_price_per_request(),
effective_tiered_pricing=effective_tiered,
tier_count=tier_count,
supports_vision=model.get_effective_supports_vision(),
supports_function_calling=model.get_effective_supports_function_calling(),
supports_streaming=model.get_effective_supports_streaming(),
is_active=bool(model.is_active),
)
)
return GlobalModelProvidersResponse(
providers=provider_entries,
total=len(provider_entries),
)

View File

@@ -21,7 +21,8 @@ from src.core.logger import logger
from src.database import get_db
from src.models.database import ApiKey, User
from src.services.cache.affinity_manager import get_affinity_manager
from src.services.cache.aware_scheduler import get_cache_aware_scheduler
from src.services.cache.aware_scheduler import CacheAwareScheduler, get_cache_aware_scheduler
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/api/admin/monitoring/cache", tags=["Admin - Monitoring: Cache"])
pipeline = ApiRequestPipeline()
@@ -185,6 +186,30 @@ async def clear_user_cache(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}")
async def clear_single_affinity(
affinity_key: str,
endpoint_id: str,
model_id: str,
api_format: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
Clear a single cache affinity entry
Parameters:
- affinity_key: API Key ID
- endpoint_id: Endpoint ID
- model_id: Model ID (GlobalModel ID)
- api_format: API format (claude/openai)
"""
adapter = AdminClearSingleAffinityAdapter(
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("")
async def clear_all_cache(
request: Request,
@@ -250,7 +275,22 @@ class AdminCacheStatsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
try:
redis_client = get_redis_client_sync()
scheduler = await get_cache_aware_scheduler(redis_client)
# 读取系统配置,确保监控接口与编排器使用一致的模式
priority_mode = SystemConfigService.get_config(
context.db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
)
scheduling_mode = SystemConfigService.get_config(
context.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
scheduler = await get_cache_aware_scheduler(
redis_client,
priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
)
stats = await scheduler.get_stats()
logger.info("缓存统计信息查询成功")
context.add_audit_metadata(
@@ -270,7 +310,22 @@ class AdminCacheMetricsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> PlainTextResponse:
try:
redis_client = get_redis_client_sync()
scheduler = await get_cache_aware_scheduler(redis_client)
# 读取系统配置,确保监控接口与编排器使用一致的模式
priority_mode = SystemConfigService.get_config(
context.db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
)
scheduling_mode = SystemConfigService.get_config(
context.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
scheduler = await get_cache_aware_scheduler(
redis_client,
priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
)
stats = await scheduler.get_stats()
payload = self._format_prometheus(stats)
context.add_audit_metadata(
@@ -624,6 +679,7 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
"key_name": key.name if key else None,
"key_prefix": provider_key_masked,
"rate_multiplier": key.rate_multiplier if key else 1.0,
"global_model_id": affinity.get("model_name"), # 原始的 global_model_id
"model_name": (
global_model_map.get(affinity.get("model_name")).name
if affinity.get("model_name") and global_model_map.get(affinity.get("model_name"))
@@ -786,6 +842,65 @@ class AdminClearUserCacheAdapter(AdminApiAdapter):
raise HTTPException(status_code=500, detail=f"清除失败: {exc}")
@dataclass
class AdminClearSingleAffinityAdapter(AdminApiAdapter):
affinity_key: str
endpoint_id: str
model_id: str
api_format: str
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
db = context.db
try:
redis_client = get_redis_client_sync()
affinity_mgr = await get_affinity_manager(redis_client)
# 直接获取指定的亲和性记录(无需遍历全部)
existing_affinity = await affinity_mgr.get_affinity(
self.affinity_key, self.api_format, self.model_id
)
if not existing_affinity:
raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录")
# 验证 endpoint_id 是否匹配
if existing_affinity.endpoint_id != self.endpoint_id:
raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录")
# 失效单条记录
await affinity_mgr.invalidate_affinity(
self.affinity_key, self.api_format, self.model_id, endpoint_id=self.endpoint_id
)
# 获取用于日志的信息
api_key = db.query(ApiKey).filter(ApiKey.id == self.affinity_key).first()
api_key_name = api_key.name if api_key else None
logger.info(
f"已清除单条缓存亲和性: affinity_key={self.affinity_key[:8]}..., "
f"endpoint_id={self.endpoint_id[:8]}..., model_id={self.model_id[:8]}..."
)
context.add_audit_metadata(
action="cache_clear_single",
affinity_key=self.affinity_key,
endpoint_id=self.endpoint_id,
model_id=self.model_id,
)
return {
"status": "ok",
"message": f"已清除缓存亲和性: {api_key_name or self.affinity_key[:8]}",
"affinity_key": self.affinity_key,
"endpoint_id": self.endpoint_id,
"model_id": self.model_id,
}
except HTTPException:
raise
except Exception as exc:
logger.exception(f"清除单条缓存亲和性失败: {exc}")
raise HTTPException(status_code=500, detail=f"清除失败: {exc}")
class AdminClearAllCacheAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
try:
@@ -832,7 +947,7 @@ class AdminClearProviderCacheAdapter(AdminApiAdapter):
class AdminCacheConfigAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
from src.services.cache.affinity_manager import CacheAffinityManager
from src.services.cache.aware_scheduler import CacheAwareScheduler
from src.config.constants import ConcurrencyDefaults
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
# 获取动态预留管理器的配置
@@ -843,7 +958,7 @@ class AdminCacheConfigAdapter(AdminApiAdapter):
"status": "ok",
"data": {
"cache_ttl_seconds": CacheAffinityManager.DEFAULT_CACHE_TTL,
"cache_reservation_ratio": CacheAwareScheduler.CACHE_RESERVATION_RATIO,
"cache_reservation_ratio": ConcurrencyDefaults.CACHE_RESERVATION_RATIO,
"dynamic_reservation": {
"enabled": True,
"config": reservation_stats["config"],
@@ -866,7 +981,7 @@ class AdminCacheConfigAdapter(AdminApiAdapter):
context.add_audit_metadata(
action="cache_config",
cache_ttl_seconds=CacheAffinityManager.DEFAULT_CACHE_TTL,
cache_reservation_ratio=CacheAwareScheduler.CACHE_RESERVATION_RATIO,
cache_reservation_ratio=ConcurrencyDefaults.CACHE_RESERVATION_RATIO,
dynamic_reservation_enabled=True,
)
return response
@@ -1052,14 +1167,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
provider.display_name or provider.name
)
continue
# 检查是否在别名列表中
if model.provider_model_aliases:
alias_names = [
# 检查是否在映射列表中
if model.provider_model_mappings:
mapping_list = [
a.get("name")
for a in model.provider_model_aliases
for a in model.provider_model_mappings
if isinstance(a, dict)
]
if mapping_name in alias_names:
if mapping_name in mapping_list:
provider_names.append(
provider.display_name or provider.name
)
@@ -1121,19 +1236,19 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
try:
cached_data = json.loads(cached_str)
provider_model_name = cached_data.get("provider_model_name")
provider_model_aliases = cached_data.get("provider_model_aliases", [])
cached_model_mappings = cached_data.get("provider_model_mappings", [])
# 获取 Provider 和 GlobalModel 信息
provider = provider_map.get(provider_id)
global_model = global_model_map.get(global_model_id)
if provider and global_model:
# 提取别名名称
alias_names = []
if provider_model_aliases:
for alias_entry in provider_model_aliases:
if isinstance(alias_entry, dict) and alias_entry.get("name"):
alias_names.append(alias_entry["name"])
# 提取映射名称
mapping_names = []
if cached_model_mappings:
for mapping_entry in cached_model_mappings:
if isinstance(mapping_entry, dict) and mapping_entry.get("name"):
mapping_names.append(mapping_entry["name"])
# provider_model_name 为空时跳过
if not provider_model_name:
@@ -1141,14 +1256,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
# 只显示有实际映射的条目:
# 1. 全局模型名 != Provider 模型名(模型名称映射)
# 2. 或者有别名配置
# 2. 或者有映射配置
has_name_mapping = global_model.name != provider_model_name
has_aliases = len(alias_names) > 0
has_mappings = len(mapping_names) > 0
if has_name_mapping or has_aliases:
# 构建用于展示的别名列表
# 如果只有名称映射没有别名,则用 global_model_name 作为"请求名称"
display_aliases = alias_names if alias_names else [global_model.name]
if has_name_mapping or has_mappings:
# 构建用于展示的映射列表
# 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称"
display_mappings = mapping_names if mapping_names else [global_model.name]
provider_model_mappings.append({
"provider_id": provider_id,
@@ -1157,7 +1272,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
"global_model_name": global_model.name,
"global_model_display_name": global_model.display_name,
"provider_model_name": provider_model_name,
"aliases": display_aliases,
"aliases": display_mappings,
"ttl": ttl if ttl > 0 else None,
"hit_count": hit_count,
})

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
@@ -52,8 +52,7 @@ class CandidateResponse(BaseModel):
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class RequestTraceResponse(BaseModel):

View File

@@ -1,46 +1,30 @@
"""
Provider Query API 端点
用于查询提供商的余额、使用记录等信息
用于查询提供商的模型列表等信息
"""
from datetime import datetime
import asyncio
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, joinedload
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.database.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User
# 初始化适配器注册
from src.plugins.provider_query import init # noqa
from src.plugins.provider_query import get_query_registry
from src.plugins.provider_query.base import QueryCapability
from src.models.database import Provider, ProviderEndpoint, User
from src.utils.auth_utils import get_current_user
router = APIRouter(prefix="/provider-query", tags=["Provider Query"])
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
# ============ Request/Response Models ============
class BalanceQueryRequest(BaseModel):
"""余额查询请求"""
provider_id: str
api_key_id: Optional[str] = None # 如果不指定,使用提供商的第一个可用 API Key
class UsageSummaryQueryRequest(BaseModel):
"""使用汇总查询请求"""
provider_id: str
api_key_id: Optional[str] = None
period: str = "month" # day, week, month, year
class ModelsQueryRequest(BaseModel):
"""模型列表查询请求"""
@@ -48,316 +32,185 @@ class ModelsQueryRequest(BaseModel):
api_key_id: Optional[str] = None
class TestModelRequest(BaseModel):
"""模型测试请求"""
provider_id: str
model_name: str
api_key_id: Optional[str] = None
stream: bool = False
message: Optional[str] = "你好"
api_format: Optional[str] = None # 指定使用的API格式如果不指定则使用端点的默认格式
# ============ API Endpoints ============
@router.get("/adapters")
async def list_adapters(
current_user: User = Depends(get_current_user),
):
"""
获取所有可用的查询适配器
def _get_adapter_for_format(api_format: str):
"""根据 API 格式获取对应的 Adapter 类"""
# 先检查 Chat Adapter 注册表
adapter_class = get_adapter_class(api_format)
if adapter_class:
return adapter_class
Returns:
适配器列表
"""
registry = get_query_registry()
adapters = registry.list_adapters()
# 再检查 CLI Adapter 注册表
cli_adapter_class = get_cli_adapter_class(api_format)
if cli_adapter_class:
return cli_adapter_class
return {"success": True, "data": adapters}
@router.get("/capabilities/{provider_id}")
async def get_provider_capabilities(
provider_id: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取提供商支持的查询能力
Args:
provider_id: 提供商 ID
Returns:
支持的查询能力列表
"""
# 获取提供商
from sqlalchemy import select
result = await db.execute(select(Provider).where(Provider.id == provider_id))
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
registry = get_query_registry()
capabilities = registry.get_capabilities_for_provider(provider.name)
if capabilities is None:
return {
"success": True,
"data": {
"provider_id": provider_id,
"provider_name": provider.name,
"capabilities": [],
"has_adapter": False,
"message": "No query adapter available for this provider",
},
}
return {
"success": True,
"data": {
"provider_id": provider_id,
"provider_name": provider.name,
"capabilities": [c.name for c in capabilities],
"has_adapter": True,
},
}
@router.post("/balance")
async def query_balance(
request: BalanceQueryRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
查询提供商余额
Args:
request: 查询请求
Returns:
余额信息
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 获取提供商及其端点
result = await db.execute(
select(Provider)
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys))
.where(Provider.id == request.provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# 获取 API Key
api_key_value = None
endpoint_config = None
if request.api_key_id:
# 查找指定的 API Key
for endpoint in provider.endpoints:
for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id:
api_key_value = api_key.api_key
endpoint_config = {
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=404, detail="API Key not found")
else:
# 使用第一个可用的 API Key
for endpoint in provider.endpoints:
if endpoint.is_active and endpoint.api_keys:
for api_key in endpoint.api_keys:
if api_key.is_active:
api_key_value = api_key.api_key
endpoint_config = {
"base_url": endpoint.base_url,
"api_format": endpoint.api_format if endpoint.api_format else None,
}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询余额
registry = get_query_registry()
query_result = await registry.query_provider_balance(
provider_type=provider.name, api_key=api_key_value, endpoint_config=endpoint_config
)
if not query_result.success:
logger.warning(f"Balance query failed for provider {provider.name}: {query_result.error}")
return {
"success": query_result.success,
"data": query_result.to_dict(),
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
}
@router.post("/usage-summary")
async def query_usage_summary(
request: UsageSummaryQueryRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
查询提供商使用汇总
Args:
request: 查询请求
Returns:
使用汇总信息
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 获取提供商及其端点
result = await db.execute(
select(Provider)
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys))
.where(Provider.id == request.provider_id)
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# 获取 API Key逻辑同上
api_key_value = None
endpoint_config = None
if request.api_key_id:
for endpoint in provider.endpoints:
for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=404, detail="API Key not found")
else:
for endpoint in provider.endpoints:
if endpoint.is_active and endpoint.api_keys:
for api_key in endpoint.api_keys:
if api_key.is_active:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not api_key_value:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询使用汇总
registry = get_query_registry()
query_result = await registry.query_provider_usage(
provider_type=provider.name,
api_key=api_key_value,
period=request.period,
endpoint_config=endpoint_config,
)
return {
"success": query_result.success,
"data": query_result.to_dict(),
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
}
return None
@router.post("/models")
async def query_available_models(
request: ModelsQueryRequest,
db: AsyncSession = Depends(get_db),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
查询提供商可用模型
遍历所有活跃端点,根据端点的 API 格式选择正确的 Adapter 进行请求:
- OPENAI/OPENAI_CLI: 使用 OpenAIChatAdapter.fetch_models
- CLAUDE/CLAUDE_CLI: 使用 ClaudeChatAdapter.fetch_models
- GEMINI/GEMINI_CLI: 使用 GeminiChatAdapter.fetch_models
Args:
request: 查询请求
Returns:
模型列表
所有端点的模型列表(合并)
"""
from sqlalchemy import select
from sqlalchemy.orm import selectinload
# 获取提供商及其端点
result = await db.execute(
select(Provider)
.options(selectinload(Provider.endpoints).selectinload(ProviderEndpoint.api_keys))
.where(Provider.id == request.provider_id)
provider = (
db.query(Provider)
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
.filter(Provider.id == request.provider_id)
.first()
)
provider = result.scalar_one_or_none()
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
# 获取 API Key
api_key_value = None
endpoint_config = None
# 收集所有活跃端点的配置
endpoint_configs: list[dict] = []
if request.api_key_id:
# 指定了特定的 API Key只使用该 Key 对应的端点
for endpoint in provider.endpoints:
for api_key in endpoint.api_keys:
if api_key.id == request.api_key_id:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
})
break
if api_key_value:
if endpoint_configs:
break
if not api_key_value:
if not endpoint_configs:
raise HTTPException(status_code=404, detail="API Key not found")
else:
# 遍历所有活跃端点,每个端点取第一个可用的 Key
for endpoint in provider.endpoints:
if endpoint.is_active and endpoint.api_keys:
for api_key in endpoint.api_keys:
if api_key.is_active:
api_key_value = api_key.api_key
endpoint_config = {"base_url": endpoint.base_url}
break
if api_key_value:
break
if not endpoint.is_active or not endpoint.api_keys:
continue
if not api_key_value:
# 找第一个可用的 Key
for api_key in endpoint.api_keys:
if api_key.is_active:
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"Failed to decrypt API key: {e}")
continue # 尝试下一个 Key
endpoint_configs.append({
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
})
break # 只取第一个可用的 Key
if not endpoint_configs:
raise HTTPException(status_code=400, detail="No active API Key found for this provider")
# 查询模型
registry = get_query_registry()
adapter = registry.get_adapter_for_provider(provider.name)
# 并发请求所有端点的模型列表
all_models: list = []
errors: list[str] = []
if not adapter:
raise HTTPException(
status_code=400, detail=f"No query adapter available for provider: {provider.name}"
async def fetch_endpoint_models(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str]]:
base_url = config["base_url"]
if not base_url:
return [], None
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config.get("extra_headers")
try:
# 获取对应的 Adapter 类并调用 fetch_models
adapter_class = _get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}"
models, error = await adapter_class.fetch_models(
client, base_url, api_key_value, extra_headers
)
# 确保所有模型都有 api_format 字段
for m in models:
if "api_format" not in m:
m["api_format"] = api_format
return models, error
except Exception as e:
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
return [], f"{api_format}: {str(e)}"
# 限制并发请求数量,避免触发上游速率限制
MAX_CONCURRENT_REQUESTS = 5
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
async def fetch_with_semaphore(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str]]:
async with semaphore:
return await fetch_endpoint_models(client, config)
async with httpx.AsyncClient(timeout=30.0) as client:
results = await asyncio.gather(
*[fetch_with_semaphore(client, c) for c in endpoint_configs]
)
for models, error in results:
all_models.extend(models)
if error:
errors.append(error)
query_result = await adapter.query_available_models(
api_key=api_key_value, endpoint_config=endpoint_config
)
# 按 model id + api_format 去重(保留第一个)
seen_keys: set[str] = set()
unique_models: list = []
for model in all_models:
model_id = model.get("id")
api_format = model.get("api_format", "")
unique_key = f"{model_id}:{api_format}"
if model_id and unique_key not in seen_keys:
seen_keys.add(unique_key)
unique_models.append(model)
error = "; ".join(errors) if errors else None
if not unique_models and not error:
error = "No models returned from any endpoint"
return {
"success": query_result.success,
"data": query_result.to_dict(),
"success": len(unique_models) > 0,
"data": {"models": unique_models, "error": error},
"provider": {
"id": provider.id,
"name": provider.name,
@@ -366,45 +219,226 @@ async def query_available_models(
}
@router.delete("/cache/{provider_id}")
async def clear_query_cache(
provider_id: str,
api_key_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
@router.post("/test-model")
async def test_model(
request: TestModelRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
清除查询缓存
测试模型连接性
向指定提供商的指定模型发送测试请求,验证模型是否可用
Args:
provider_id: 提供商 ID
api_key_id: 可选,指定清除某个 API Key 的缓存
request: 测试请求
Returns:
清除结果
测试结果
"""
from sqlalchemy import select
# 获取提供商
result = await db.execute(select(Provider).where(Provider.id == provider_id))
provider = result.scalar_one_or_none()
# 获取提供商及其端点
provider = (
db.query(Provider)
.options(joinedload(Provider.endpoints).joinedload(ProviderEndpoint.api_keys))
.filter(Provider.id == request.provider_id)
.first()
)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
registry = get_query_registry()
adapter = registry.get_adapter_for_provider(provider.name)
# 找到合适的端点和API Key
endpoint_config = None
endpoint = None
api_key = None
if adapter:
if api_key_id:
# 获取 API Key 值来清除缓存
from sqlalchemy.orm import selectinload
if request.api_key_id:
# 使用指定的API Key
for ep in provider.endpoints:
for key in ep.api_keys:
if key.id == request.api_key_id and key.is_active and ep.is_active:
endpoint = ep
api_key = key
break
if endpoint:
break
else:
# 使用第一个可用的端点和密钥
for ep in provider.endpoints:
if not ep.is_active or not ep.api_keys:
continue
for key in ep.api_keys:
if key.is_active:
endpoint = ep
api_key = key
break
if endpoint:
break
result = await db.execute(select(ProviderAPIKey).where(ProviderAPIKey.id == api_key_id))
api_key = result.scalar_one_or_none()
if api_key:
adapter.clear_cache(api_key.api_key)
else:
adapter.clear_cache()
if not endpoint or not api_key:
raise HTTPException(status_code=404, detail="No active endpoint or API key found")
return {"success": True, "message": "Cache cleared successfully"}
try:
api_key_value = crypto_service.decrypt(api_key.api_key)
except Exception as e:
logger.error(f"[test-model] Failed to decrypt API key: {e}")
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
# 构建请求配置
endpoint_config = {
"api_key": api_key_value,
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
"base_url": endpoint.base_url,
"api_format": endpoint.api_format,
"extra_headers": endpoint.headers,
"timeout": endpoint.timeout or 30.0,
}
try:
# 获取对应的 Adapter 类
adapter_class = _get_adapter_for_format(endpoint.api_format)
if not adapter_class:
return {
"success": False,
"error": f"Unknown API format: {endpoint.api_format}",
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
"model": request.model_name,
}
logger.debug(f"[test-model] 使用 Adapter: {adapter_class.__name__}")
logger.debug(f"[test-model] 端点 API Format: {endpoint.api_format}")
# 如果请求指定了 api_format优先使用它
target_api_format = request.api_format or endpoint.api_format
if request.api_format and request.api_format != endpoint.api_format:
logger.debug(f"[test-model] 请求指定 API Format: {request.api_format}")
# 重新获取适配器
adapter_class = _get_adapter_for_format(request.api_format)
if not adapter_class:
return {
"success": False,
"error": f"Unknown API format: {request.api_format}",
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
"model": request.model_name,
}
logger.debug(f"[test-model] 重新选择 Adapter: {adapter_class.__name__}")
# 准备测试请求数据
check_request = {
"model": request.model_name,
"messages": [
{"role": "user", "content": request.message or "Hello! This is a test message."}
],
"max_tokens": 30,
"temperature": 0.7,
}
# 发送测试请求
async with httpx.AsyncClient(timeout=endpoint_config["timeout"]) as client:
# 非流式测试
logger.debug(f"[test-model] 开始非流式测试...")
response = await adapter_class.check_endpoint(
client,
endpoint_config["base_url"],
endpoint_config["api_key"],
check_request,
endpoint_config.get("extra_headers"),
# 用量计算参数(现在强制记录)
db=db,
user=current_user,
provider_name=provider.name,
provider_id=provider.id,
api_key_id=endpoint_config.get("api_key_id"),
model_name=request.model_name,
)
# 记录提供商返回信息
logger.debug(f"[test-model] 非流式测试结果:")
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
response_data = response.get('response', {})
response_body = response_data.get('response_body', {})
logger.debug(f"[test-model] Response Data: {response_data}")
logger.debug(f"[test-model] Response Body: {response_body}")
# 尝试解析 response_body (通常是 JSON 字符串)
parsed_body = response_body
import json
if isinstance(response_body, str):
try:
parsed_body = json.loads(response_body)
except json.JSONDecodeError:
pass
if isinstance(parsed_body, dict) and 'error' in parsed_body:
error_obj = parsed_body['error']
# 兼容 error 可能是字典或字符串的情况
if isinstance(error_obj, dict):
logger.debug(f"[test-model] Error Message: {error_obj.get('message')}")
raise HTTPException(status_code=500, detail=error_obj.get('message'))
else:
logger.debug(f"[test-model] Error: {error_obj}")
raise HTTPException(status_code=500, detail=error_obj)
elif 'error' in response:
logger.debug(f"[test-model] Error: {response['error']}")
raise HTTPException(status_code=500, detail=response['error'])
else:
# 如果有选择或消息,记录内容预览
if isinstance(response_data, dict):
if 'choices' in response_data and response_data['choices']:
choice = response_data['choices'][0]
if 'message' in choice:
content = choice['message'].get('content', '')
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
elif 'content' in response_data and response_data['content']:
content = str(response_data['content'])
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
# 检查测试是否成功基于HTTP状态码
status_code = response.get('status_code', 0)
is_success = status_code == 200 and 'error' not in response
return {
"success": is_success,
"data": {
"stream": False,
"response": response,
},
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
"model": request.model_name,
"endpoint": {
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
},
}
except Exception as e:
logger.error(f"[test-model] Error testing model {request.model_name}: {e}")
return {
"success": False,
"error": str(e),
"provider": {
"id": provider.id,
"name": provider.name,
"display_name": provider.display_name,
},
"model": request.model_name,
"endpoint": {
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
} if endpoint else None,
}

View File

@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session, joinedload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
@@ -21,16 +22,18 @@ from src.models.api import (
from src.models.pydantic_models import (
BatchAssignModelsToProviderRequest,
BatchAssignModelsToProviderResponse,
ImportFromUpstreamRequest,
ImportFromUpstreamResponse,
ImportFromUpstreamSuccessItem,
ImportFromUpstreamErrorItem,
ProviderAvailableSourceModel,
ProviderAvailableSourceModelsResponse,
)
from src.models.database import (
GlobalModel,
Model,
Provider,
)
from src.models.pydantic_models import (
ProviderAvailableSourceModel,
ProviderAvailableSourceModelsResponse,
)
from src.services.model.service import ModelService
router = APIRouter(tags=["Model Management"])
@@ -157,6 +160,28 @@ async def batch_assign_global_models_to_provider(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post(
"/{provider_id}/import-from-upstream",
response_model=ImportFromUpstreamResponse,
)
async def import_models_from_upstream(
provider_id: str,
payload: ImportFromUpstreamRequest,
request: Request,
db: Session = Depends(get_db),
) -> ImportFromUpstreamResponse:
"""
从上游提供商导入模型
流程:
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
2. 如不存在,自动创建新的 GlobalModel使用默认配置
3. 创建 Model 关联到当前 Provider
"""
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- Adapters --------
@@ -419,4 +444,135 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
f"Batch assigned {len(success)} GlobalModels to provider {provider.name} by {context.user.username}"
)
# 清除 /v1/models 列表缓存
if success:
await invalidate_models_list_cache()
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
@dataclass
class AdminImportFromUpstreamAdapter(AdminApiAdapter):
"""从上游提供商导入模型"""
provider_id: str
payload: ImportFromUpstreamRequest
async def handle(self, context): # type: ignore[override]
db = context.db
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
if not provider:
raise NotFoundException("Provider not found", "provider")
success: list[ImportFromUpstreamSuccessItem] = []
errors: list[ImportFromUpstreamErrorItem] = []
# 默认阶梯计费配置(免费)
default_tiered_pricing = {
"tiers": [
{
"up_to": None,
"input_price_per_1m": 0.0,
"output_price_per_1m": 0.0,
}
]
}
for model_id in self.payload.model_ids:
# 输入验证:检查 model_id 长度
if not model_id or len(model_id) > 100:
errors.append(
ImportFromUpstreamErrorItem(
model_id=model_id[:50] + "..." if model_id and len(model_id) > 50 else model_id or "<empty>",
error="Invalid model_id: must be 1-100 characters",
)
)
continue
try:
# 使用 savepoint 确保单个模型导入的原子性
savepoint = db.begin_nested()
try:
# 1. 检查是否已存在同名的 GlobalModel
global_model = (
db.query(GlobalModel).filter(GlobalModel.name == model_id).first()
)
created_global_model = False
if not global_model:
# 2. 创建新的 GlobalModel
global_model = GlobalModel(
name=model_id,
display_name=model_id,
default_tiered_pricing=default_tiered_pricing,
is_active=True,
)
db.add(global_model)
db.flush()
created_global_model = True
logger.info(
f"Created new GlobalModel: {model_id} during upstream import"
)
# 3. 检查是否已存在关联
existing = (
db.query(Model)
.filter(
Model.provider_id == self.provider_id,
Model.global_model_id == global_model.id,
)
.first()
)
if existing:
# 已存在关联,提交 savepoint 并记录成功
savepoint.commit()
success.append(
ImportFromUpstreamSuccessItem(
model_id=model_id,
global_model_id=global_model.id,
global_model_name=global_model.name,
provider_model_id=existing.id,
created_global_model=created_global_model,
)
)
continue
# 4. 创建新的 Model 记录
new_model = Model(
provider_id=self.provider_id,
global_model_id=global_model.id,
provider_model_name=global_model.name,
is_active=True,
)
db.add(new_model)
db.flush()
# 提交 savepoint
savepoint.commit()
success.append(
ImportFromUpstreamSuccessItem(
model_id=model_id,
global_model_id=global_model.id,
global_model_name=global_model.name,
provider_model_id=new_model.id,
created_global_model=created_global_model,
)
)
except Exception as e:
# 回滚到 savepoint
savepoint.rollback()
raise e
except Exception as e:
logger.error(f"Error importing model {model_id}: {e}")
errors.append(ImportFromUpstreamErrorItem(model_id=model_id, error=str(e)))
db.commit()
logger.info(
f"Imported {len(success)} models from upstream to provider {provider.name} by {context.user.username}"
)
# 清除 /v1/models 列表缓存
if success:
await invalidate_models_list_cache()
return ImportFromUpstreamResponse(success=success, errors=errors)

View File

@@ -91,6 +91,34 @@ async def get_api_formats(request: Request, db: Session = Depends(get_db)):
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/config/export")
async def export_config(request: Request, db: Session = Depends(get_db)):
"""导出提供商和模型配置(管理员)"""
adapter = AdminExportConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/config/import")
async def import_config(request: Request, db: Session = Depends(get_db)):
"""导入提供商和模型配置(管理员)"""
adapter = AdminImportConfigAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/users/export")
async def export_users(request: Request, db: Session = Depends(get_db)):
"""导出用户数据(管理员)"""
adapter = AdminExportUsersAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/users/import")
async def import_users(request: Request, db: Session = Depends(get_db)):
"""导入用户数据(管理员)"""
adapter = AdminImportUsersAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# -------- 系统设置适配器 --------
@@ -310,3 +338,749 @@ class AdminGetApiFormatsAdapter(AdminApiAdapter):
)
return {"formats": formats}
class AdminExportConfigAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""导出提供商和模型配置(解密数据)"""
from datetime import datetime, timezone
from src.core.crypto import crypto_service
from src.models.database import GlobalModel, Model, ProviderAPIKey, ProviderEndpoint
db = context.db
# 导出 GlobalModels
global_models = db.query(GlobalModel).all()
global_models_data = []
for gm in global_models:
global_models_data.append(
{
"name": gm.name,
"display_name": gm.display_name,
"default_price_per_request": gm.default_price_per_request,
"default_tiered_pricing": gm.default_tiered_pricing,
"supported_capabilities": gm.supported_capabilities,
"config": gm.config,
"is_active": gm.is_active,
}
)
# 导出 Providers 及其关联数据
providers = db.query(Provider).all()
providers_data = []
for provider in providers:
# 导出 Endpoints
endpoints = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.provider_id == provider.id)
.all()
)
endpoints_data = []
for ep in endpoints:
# 导出 Endpoint Keys
keys = (
db.query(ProviderAPIKey).filter(ProviderAPIKey.endpoint_id == ep.id).all()
)
keys_data = []
for key in keys:
# 解密 API Key
try:
decrypted_key = crypto_service.decrypt(key.api_key)
except Exception:
decrypted_key = ""
keys_data.append(
{
"api_key": decrypted_key,
"name": key.name,
"note": key.note,
"rate_multiplier": key.rate_multiplier,
"internal_priority": key.internal_priority,
"global_priority": key.global_priority,
"max_concurrent": key.max_concurrent,
"rate_limit": key.rate_limit,
"daily_limit": key.daily_limit,
"monthly_limit": key.monthly_limit,
"allowed_models": key.allowed_models,
"capabilities": key.capabilities,
"is_active": key.is_active,
}
)
endpoints_data.append(
{
"api_format": ep.api_format,
"base_url": ep.base_url,
"headers": ep.headers,
"timeout": ep.timeout,
"max_retries": ep.max_retries,
"max_concurrent": ep.max_concurrent,
"rate_limit": ep.rate_limit,
"is_active": ep.is_active,
"custom_path": ep.custom_path,
"config": ep.config,
"keys": keys_data,
}
)
# 导出 Provider Models
models = db.query(Model).filter(Model.provider_id == provider.id).all()
models_data = []
for model in models:
# 获取关联的 GlobalModel 名称
global_model = (
db.query(GlobalModel).filter(GlobalModel.id == model.global_model_id).first()
)
models_data.append(
{
"global_model_name": global_model.name if global_model else None,
"provider_model_name": model.provider_model_name,
"provider_model_mappings": model.provider_model_mappings,
"price_per_request": model.price_per_request,
"tiered_pricing": model.tiered_pricing,
"supports_vision": model.supports_vision,
"supports_function_calling": model.supports_function_calling,
"supports_streaming": model.supports_streaming,
"supports_extended_thinking": model.supports_extended_thinking,
"supports_image_generation": model.supports_image_generation,
"is_active": model.is_active,
"config": model.config,
}
)
providers_data.append(
{
"name": provider.name,
"display_name": provider.display_name,
"description": provider.description,
"website": provider.website,
"billing_type": provider.billing_type.value if provider.billing_type else None,
"monthly_quota_usd": provider.monthly_quota_usd,
"quota_reset_day": provider.quota_reset_day,
"rpm_limit": provider.rpm_limit,
"provider_priority": provider.provider_priority,
"is_active": provider.is_active,
"rate_limit": provider.rate_limit,
"concurrent_limit": provider.concurrent_limit,
"config": provider.config,
"endpoints": endpoints_data,
"models": models_data,
}
)
return {
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"global_models": global_models_data,
"providers": providers_data,
}
MAX_IMPORT_SIZE = 10 * 1024 * 1024 # 10MB
class AdminImportConfigAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""导入提供商和模型配置"""
import uuid
from datetime import datetime, timezone
from src.core.crypto import crypto_service
from src.core.enums import ProviderBillingType
from src.models.database import GlobalModel, Model, ProviderAPIKey, ProviderEndpoint
# 检查请求体大小
if context.raw_body and len(context.raw_body) > MAX_IMPORT_SIZE:
raise InvalidRequestException("请求体大小不能超过 10MB")
db = context.db
payload = context.ensure_json_body()
# 验证配置版本
version = payload.get("version")
if version != "1.0":
raise InvalidRequestException(f"不支持的配置版本: {version}")
# 获取导入选项
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
global_models_data = payload.get("global_models", [])
providers_data = payload.get("providers", [])
stats = {
"global_models": {"created": 0, "updated": 0, "skipped": 0},
"providers": {"created": 0, "updated": 0, "skipped": 0},
"endpoints": {"created": 0, "updated": 0, "skipped": 0},
"keys": {"created": 0, "updated": 0, "skipped": 0},
"models": {"created": 0, "updated": 0, "skipped": 0},
"errors": [],
}
try:
# 导入 GlobalModels
global_model_map = {} # name -> id 映射
for gm_data in global_models_data:
existing = (
db.query(GlobalModel).filter(GlobalModel.name == gm_data["name"]).first()
)
if existing:
global_model_map[gm_data["name"]] = existing.id
if merge_mode == "skip":
stats["global_models"]["skipped"] += 1
continue
elif merge_mode == "error":
raise InvalidRequestException(
f"GlobalModel '{gm_data['name']}' 已存在"
)
elif merge_mode == "overwrite":
# 更新现有记录
existing.display_name = gm_data.get(
"display_name", existing.display_name
)
existing.default_price_per_request = gm_data.get(
"default_price_per_request"
)
existing.default_tiered_pricing = gm_data.get(
"default_tiered_pricing", existing.default_tiered_pricing
)
existing.supported_capabilities = gm_data.get(
"supported_capabilities"
)
existing.config = gm_data.get("config")
existing.is_active = gm_data.get("is_active", True)
existing.updated_at = datetime.now(timezone.utc)
stats["global_models"]["updated"] += 1
else:
# 创建新记录
new_gm = GlobalModel(
id=str(uuid.uuid4()),
name=gm_data["name"],
display_name=gm_data.get("display_name", gm_data["name"]),
default_price_per_request=gm_data.get("default_price_per_request"),
default_tiered_pricing=gm_data.get(
"default_tiered_pricing",
{"tiers": [{"up_to": None, "input_price_per_1m": 0, "output_price_per_1m": 0}]},
),
supported_capabilities=gm_data.get("supported_capabilities"),
config=gm_data.get("config"),
is_active=gm_data.get("is_active", True),
)
db.add(new_gm)
db.flush()
global_model_map[gm_data["name"]] = new_gm.id
stats["global_models"]["created"] += 1
# 导入 Providers
for prov_data in providers_data:
existing_provider = (
db.query(Provider).filter(Provider.name == prov_data["name"]).first()
)
if existing_provider:
provider_id = existing_provider.id
if merge_mode == "skip":
stats["providers"]["skipped"] += 1
# 仍然需要处理 endpoints 和 models如果存在
elif merge_mode == "error":
raise InvalidRequestException(
f"Provider '{prov_data['name']}' 已存在"
)
elif merge_mode == "overwrite":
# 更新现有记录
existing_provider.display_name = prov_data.get(
"display_name", existing_provider.display_name
)
existing_provider.description = prov_data.get("description")
existing_provider.website = prov_data.get("website")
if prov_data.get("billing_type"):
existing_provider.billing_type = ProviderBillingType(
prov_data["billing_type"]
)
existing_provider.monthly_quota_usd = prov_data.get(
"monthly_quota_usd"
)
existing_provider.quota_reset_day = prov_data.get(
"quota_reset_day", 30
)
existing_provider.rpm_limit = prov_data.get("rpm_limit")
existing_provider.provider_priority = prov_data.get(
"provider_priority", 100
)
existing_provider.is_active = prov_data.get("is_active", True)
existing_provider.rate_limit = prov_data.get("rate_limit")
existing_provider.concurrent_limit = prov_data.get(
"concurrent_limit"
)
existing_provider.config = prov_data.get("config")
existing_provider.updated_at = datetime.now(timezone.utc)
stats["providers"]["updated"] += 1
else:
# 创建新 Provider
billing_type = ProviderBillingType.PAY_AS_YOU_GO
if prov_data.get("billing_type"):
billing_type = ProviderBillingType(prov_data["billing_type"])
new_provider = Provider(
id=str(uuid.uuid4()),
name=prov_data["name"],
display_name=prov_data.get("display_name", prov_data["name"]),
description=prov_data.get("description"),
website=prov_data.get("website"),
billing_type=billing_type,
monthly_quota_usd=prov_data.get("monthly_quota_usd"),
quota_reset_day=prov_data.get("quota_reset_day", 30),
rpm_limit=prov_data.get("rpm_limit"),
provider_priority=prov_data.get("provider_priority", 100),
is_active=prov_data.get("is_active", True),
rate_limit=prov_data.get("rate_limit"),
concurrent_limit=prov_data.get("concurrent_limit"),
config=prov_data.get("config"),
)
db.add(new_provider)
db.flush()
provider_id = new_provider.id
stats["providers"]["created"] += 1
# 导入 Endpoints
for ep_data in prov_data.get("endpoints", []):
existing_ep = (
db.query(ProviderEndpoint)
.filter(
ProviderEndpoint.provider_id == provider_id,
ProviderEndpoint.api_format == ep_data["api_format"],
)
.first()
)
if existing_ep:
endpoint_id = existing_ep.id
if merge_mode == "skip":
stats["endpoints"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"Endpoint '{ep_data['api_format']}' 已存在于 Provider '{prov_data['name']}'"
)
elif merge_mode == "overwrite":
existing_ep.base_url = ep_data.get(
"base_url", existing_ep.base_url
)
existing_ep.headers = ep_data.get("headers")
existing_ep.timeout = ep_data.get("timeout", 300)
existing_ep.max_retries = ep_data.get("max_retries", 3)
existing_ep.max_concurrent = ep_data.get("max_concurrent")
existing_ep.rate_limit = ep_data.get("rate_limit")
existing_ep.is_active = ep_data.get("is_active", True)
existing_ep.custom_path = ep_data.get("custom_path")
existing_ep.config = ep_data.get("config")
existing_ep.updated_at = datetime.now(timezone.utc)
stats["endpoints"]["updated"] += 1
else:
new_ep = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider_id,
api_format=ep_data["api_format"],
base_url=ep_data["base_url"],
headers=ep_data.get("headers"),
timeout=ep_data.get("timeout", 300),
max_retries=ep_data.get("max_retries", 3),
max_concurrent=ep_data.get("max_concurrent"),
rate_limit=ep_data.get("rate_limit"),
is_active=ep_data.get("is_active", True),
custom_path=ep_data.get("custom_path"),
config=ep_data.get("config"),
)
db.add(new_ep)
db.flush()
endpoint_id = new_ep.id
stats["endpoints"]["created"] += 1
# 导入 Keys
# 获取当前 endpoint 下所有已有的 keys用于去重
existing_keys = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.endpoint_id == endpoint_id)
.all()
)
# 解密已有 keys 用于比对
existing_key_values = set()
for ek in existing_keys:
try:
decrypted = crypto_service.decrypt(ek.api_key)
existing_key_values.add(decrypted)
except Exception:
pass
for key_data in ep_data.get("keys", []):
if not key_data.get("api_key"):
stats["errors"].append(
f"跳过空 API Key (Endpoint: {ep_data['api_format']})"
)
continue
# 检查是否已存在相同的 Key通过明文比对
if key_data["api_key"] in existing_key_values:
stats["keys"]["skipped"] += 1
continue
encrypted_key = crypto_service.encrypt(key_data["api_key"])
new_key = ProviderAPIKey(
id=str(uuid.uuid4()),
endpoint_id=endpoint_id,
api_key=encrypted_key,
name=key_data.get("name"),
note=key_data.get("note"),
rate_multiplier=key_data.get("rate_multiplier", 1.0),
internal_priority=key_data.get("internal_priority", 100),
global_priority=key_data.get("global_priority"),
max_concurrent=key_data.get("max_concurrent"),
rate_limit=key_data.get("rate_limit"),
daily_limit=key_data.get("daily_limit"),
monthly_limit=key_data.get("monthly_limit"),
allowed_models=key_data.get("allowed_models"),
capabilities=key_data.get("capabilities"),
is_active=key_data.get("is_active", True),
)
db.add(new_key)
# 添加到已有集合,防止同一批导入中重复
existing_key_values.add(key_data["api_key"])
stats["keys"]["created"] += 1
# 导入 Models
for model_data in prov_data.get("models", []):
global_model_name = model_data.get("global_model_name")
if not global_model_name:
stats["errors"].append(
f"跳过无 global_model_name 的模型 (Provider: {prov_data['name']})"
)
continue
global_model_id = global_model_map.get(global_model_name)
if not global_model_id:
# 尝试从数据库查找
existing_gm = (
db.query(GlobalModel)
.filter(GlobalModel.name == global_model_name)
.first()
)
if existing_gm:
global_model_id = existing_gm.id
else:
stats["errors"].append(
f"GlobalModel '{global_model_name}' 不存在,跳过模型"
)
continue
existing_model = (
db.query(Model)
.filter(
Model.provider_id == provider_id,
Model.provider_model_name == model_data["provider_model_name"],
)
.first()
)
if existing_model:
if merge_mode == "skip":
stats["models"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"Model '{model_data['provider_model_name']}' 已存在于 Provider '{prov_data['name']}'"
)
elif merge_mode == "overwrite":
existing_model.global_model_id = global_model_id
existing_model.provider_model_mappings = model_data.get(
"provider_model_mappings"
)
existing_model.price_per_request = model_data.get(
"price_per_request"
)
existing_model.tiered_pricing = model_data.get(
"tiered_pricing"
)
existing_model.supports_vision = model_data.get(
"supports_vision"
)
existing_model.supports_function_calling = model_data.get(
"supports_function_calling"
)
existing_model.supports_streaming = model_data.get(
"supports_streaming"
)
existing_model.supports_extended_thinking = model_data.get(
"supports_extended_thinking"
)
existing_model.supports_image_generation = model_data.get(
"supports_image_generation"
)
existing_model.is_active = model_data.get("is_active", True)
existing_model.config = model_data.get("config")
existing_model.updated_at = datetime.now(timezone.utc)
stats["models"]["updated"] += 1
else:
new_model = Model(
id=str(uuid.uuid4()),
provider_id=provider_id,
global_model_id=global_model_id,
provider_model_name=model_data["provider_model_name"],
provider_model_mappings=model_data.get(
"provider_model_mappings"
),
price_per_request=model_data.get("price_per_request"),
tiered_pricing=model_data.get("tiered_pricing"),
supports_vision=model_data.get("supports_vision"),
supports_function_calling=model_data.get(
"supports_function_calling"
),
supports_streaming=model_data.get("supports_streaming"),
supports_extended_thinking=model_data.get(
"supports_extended_thinking"
),
supports_image_generation=model_data.get(
"supports_image_generation"
),
is_active=model_data.get("is_active", True),
config=model_data.get("config"),
)
db.add(new_model)
stats["models"]["created"] += 1
db.commit()
# 失效缓存
from src.services.cache.invalidation import get_cache_invalidation_service
cache_service = get_cache_invalidation_service()
cache_service.clear_all_caches()
return {
"message": "配置导入成功",
"stats": stats,
}
except InvalidRequestException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise InvalidRequestException(f"导入失败: {str(e)}")
class AdminExportUsersAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""导出用户数据(保留加密数据,排除管理员)"""
from datetime import datetime, timezone
from src.core.enums import UserRole
from src.models.database import ApiKey, User
db = context.db
# 导出 Users排除管理员
users = db.query(User).filter(
User.is_deleted.is_(False),
User.role != UserRole.ADMIN
).all()
users_data = []
for user in users:
# 导出用户的 API Keys保留加密数据
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
api_keys_data = []
for key in api_keys:
api_keys_data.append(
{
"key_hash": key.key_hash,
"key_encrypted": key.key_encrypted,
"name": key.name,
"is_standalone": key.is_standalone,
"balance_used_usd": key.balance_used_usd,
"current_balance_usd": key.current_balance_usd,
"allowed_providers": key.allowed_providers,
"allowed_endpoints": key.allowed_endpoints,
"allowed_api_formats": key.allowed_api_formats,
"allowed_models": key.allowed_models,
"rate_limit": key.rate_limit,
"concurrent_limit": key.concurrent_limit,
"force_capabilities": key.force_capabilities,
"is_active": key.is_active,
"auto_delete_on_expiry": key.auto_delete_on_expiry,
"total_requests": key.total_requests,
"total_cost_usd": key.total_cost_usd,
}
)
users_data.append(
{
"email": user.email,
"username": user.username,
"password_hash": user.password_hash,
"role": user.role.value if user.role else "user",
"allowed_providers": user.allowed_providers,
"allowed_endpoints": user.allowed_endpoints,
"allowed_models": user.allowed_models,
"model_capability_settings": user.model_capability_settings,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": user.total_usd,
"is_active": user.is_active,
"api_keys": api_keys_data,
}
)
return {
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"users": users_data,
}
class AdminImportUsersAdapter(AdminApiAdapter):
async def handle(self, context): # type: ignore[override]
"""导入用户数据"""
import uuid
from datetime import datetime, timezone
from src.core.enums import UserRole
from src.models.database import ApiKey, User
# 检查请求体大小
if context.raw_body and len(context.raw_body) > MAX_IMPORT_SIZE:
raise InvalidRequestException("请求体大小不能超过 10MB")
db = context.db
payload = context.ensure_json_body()
# 验证配置版本
version = payload.get("version")
if version != "1.0":
raise InvalidRequestException(f"不支持的配置版本: {version}")
# 获取导入选项
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
users_data = payload.get("users", [])
stats = {
"users": {"created": 0, "updated": 0, "skipped": 0},
"api_keys": {"created": 0, "skipped": 0},
"errors": [],
}
try:
for user_data in users_data:
# 跳过管理员角色的导入(不区分大小写)
role_str = str(user_data.get("role", "")).lower()
if role_str == "admin":
stats["errors"].append(f"跳过管理员用户: {user_data.get('email')}")
stats["users"]["skipped"] += 1
continue
existing_user = (
db.query(User).filter(User.email == user_data["email"]).first()
)
if existing_user:
user_id = existing_user.id
if merge_mode == "skip":
stats["users"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"用户 '{user_data['email']}' 已存在"
)
elif merge_mode == "overwrite":
# 更新现有用户
existing_user.username = user_data.get(
"username", existing_user.username
)
if user_data.get("password_hash"):
existing_user.password_hash = user_data["password_hash"]
if user_data.get("role"):
existing_user.role = UserRole(user_data["role"])
existing_user.allowed_providers = user_data.get("allowed_providers")
existing_user.allowed_endpoints = user_data.get("allowed_endpoints")
existing_user.allowed_models = user_data.get("allowed_models")
existing_user.model_capability_settings = user_data.get(
"model_capability_settings"
)
existing_user.quota_usd = user_data.get("quota_usd")
existing_user.used_usd = user_data.get("used_usd", 0.0)
existing_user.total_usd = user_data.get("total_usd", 0.0)
existing_user.is_active = user_data.get("is_active", True)
existing_user.updated_at = datetime.now(timezone.utc)
stats["users"]["updated"] += 1
else:
# 创建新用户
role = UserRole.USER
if user_data.get("role"):
role = UserRole(user_data["role"])
new_user = User(
id=str(uuid.uuid4()),
email=user_data["email"],
username=user_data.get("username", user_data["email"].split("@")[0]),
password_hash=user_data.get("password_hash", ""),
role=role,
allowed_providers=user_data.get("allowed_providers"),
allowed_endpoints=user_data.get("allowed_endpoints"),
allowed_models=user_data.get("allowed_models"),
model_capability_settings=user_data.get("model_capability_settings"),
quota_usd=user_data.get("quota_usd"),
used_usd=user_data.get("used_usd", 0.0),
total_usd=user_data.get("total_usd", 0.0),
is_active=user_data.get("is_active", True),
)
db.add(new_user)
db.flush()
user_id = new_user.id
stats["users"]["created"] += 1
# 导入 API Keys
for key_data in user_data.get("api_keys", []):
# 检查是否已存在相同的 key_hash
if key_data.get("key_hash"):
existing_key = (
db.query(ApiKey)
.filter(ApiKey.key_hash == key_data["key_hash"])
.first()
)
if existing_key:
stats["api_keys"]["skipped"] += 1
continue
new_key = ApiKey(
id=str(uuid.uuid4()),
user_id=user_id,
key_hash=key_data.get("key_hash", ""),
key_encrypted=key_data.get("key_encrypted"),
name=key_data.get("name"),
is_standalone=key_data.get("is_standalone", False),
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
allowed_endpoints=key_data.get("allowed_endpoints"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
rate_limit=key_data.get("rate_limit", 100),
concurrent_limit=key_data.get("concurrent_limit", 5),
force_capabilities=key_data.get("force_capabilities"),
is_active=key_data.get("is_active", True),
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
total_requests=key_data.get("total_requests", 0),
total_cost_usd=key_data.get("total_cost_usd", 0.0),
)
db.add(new_key)
stats["api_keys"]["created"] += 1
db.commit()
return {
"message": "用户数据导入成功",
"stats": stats,
}
except InvalidRequestException:
db.rollback()
raise
except Exception as e:
db.rollback()
raise InvalidRequestException(f"导入失败: {str(e)}")

View File

@@ -628,6 +628,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"actual_cost": actual_cost,
"rate_multiplier": rate_multiplier,
"response_time_ms": usage.response_time_ms,
"first_byte_time_ms": usage.first_byte_time_ms, # 首字时间 (TTFB)
"created_at": usage.created_at.isoformat(),
"is_stream": usage.is_stream,
"input_price_per_1m": usage.input_price_per_1m,
@@ -738,6 +739,7 @@ class AdminUsageDetailAdapter(AdminApiAdapter):
"status_code": usage_record.status_code,
"error_message": usage_record.error_message,
"response_time_ms": usage_record.response_time_ms,
"first_byte_time_ms": usage_record.first_byte_time_ms, # 首字时间 (TTFB)
"created_at": usage_record.created_at.isoformat() if usage_record.created_at else None,
"request_headers": usage_record.request_headers,
"request_body": usage_record.get_request_body(),

View File

@@ -140,9 +140,9 @@ class AnnouncementOptionalAuthAdapter(ApiAdapter):
if not authorization or not authorization.lower().startswith("bearer "):
return None
token = authorization.replace("Bearer ", "").strip()
token = authorization[7:].strip()
try:
payload = await AuthService.verify_token(token)
payload = await AuthService.verify_token(token, token_type="access")
user_id = payload.get("user_id")
if not user_id:
return None

View File

@@ -211,7 +211,7 @@ class AuthRefreshAdapter(AuthPublicAdapter):
class AuthRegisterAdapter(AuthPublicAdapter):
async def handle(self, context): # type: ignore[override]
from ..models.database import SystemConfig
from src.models.database import SystemConfig
db = context.db
payload = context.ensure_json_body()

View File

@@ -18,7 +18,15 @@ from sqlalchemy.orm import Session, joinedload
from src.config.constants import CacheTTL
from src.core.cache_service import CacheService
from src.core.logger import logger
from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
from src.models.database import (
ApiKey,
GlobalModel,
Model,
Provider,
ProviderAPIKey,
ProviderEndpoint,
User,
)
# 缓存 key 前缀
_CACHE_KEY_PREFIX = "models:list"
@@ -55,6 +63,23 @@ async def _set_cached_models(api_formats: list[str], models: list["ModelInfo"])
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
async def invalidate_models_list_cache() -> None:
"""
清除所有 /v1/models 列表缓存
在模型创建、更新、删除时调用,确保模型列表实时更新
"""
# 清除所有格式的缓存
all_formats = ["CLAUDE", "OPENAI", "GEMINI"]
for fmt in all_formats:
cache_key = f"{_CACHE_KEY_PREFIX}:{fmt}"
try:
await CacheService.delete(cache_key)
logger.debug(f"[ModelsService] 已清除缓存: {cache_key}")
except Exception as e:
logger.warning(f"[ModelsService] 清除缓存失败 {cache_key}: {e}")
@dataclass
class ModelInfo:
"""统一的模型信息结构"""
@@ -65,6 +90,108 @@ class ModelInfo:
created_at: Optional[str] # ISO 格式
created_timestamp: int # Unix 时间戳
provider_name: str
provider_id: str = "" # Provider ID用于权限过滤
# 能力配置
streaming: bool = True
vision: bool = False
function_calling: bool = False
extended_thinking: bool = False
image_generation: bool = False
structured_output: bool = False
# 规格参数
context_limit: Optional[int] = None
output_limit: Optional[int] = None
# 元信息
family: Optional[str] = None
knowledge_cutoff: Optional[str] = None
input_modalities: Optional[list[str]] = None
output_modalities: Optional[list[str]] = None
@dataclass
class AccessRestrictions:
"""API Key 或 User 的访问限制"""
allowed_providers: Optional[list[str]] = None # 允许的 Provider ID 列表
allowed_models: Optional[list[str]] = None # 允许的模型名称列表
allowed_api_formats: Optional[list[str]] = None # 允许的 API 格式列表
@classmethod
def from_api_key_and_user(
cls, api_key: Optional[ApiKey], user: Optional[User]
) -> "AccessRestrictions":
"""
从 API Key 和 User 合并访问限制
限制逻辑:
- API Key 的限制优先于 User 的限制
- 如果 API Key 有限制,使用 API Key 的限制
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
- 两者都无限制则返回空限制
"""
allowed_providers: Optional[list[str]] = None
allowed_models: Optional[list[str]] = None
allowed_api_formats: Optional[list[str]] = None
# 优先使用 API Key 的限制
if api_key:
if api_key.allowed_providers is not None:
allowed_providers = api_key.allowed_providers
if api_key.allowed_models is not None:
allowed_models = api_key.allowed_models
if api_key.allowed_api_formats is not None:
allowed_api_formats = api_key.allowed_api_formats
# 如果 API Key 没有限制,检查 User 的限制
# 注意: User 没有 allowed_api_formats 字段
if user:
if allowed_providers is None and user.allowed_providers is not None:
allowed_providers = user.allowed_providers
if allowed_models is None and user.allowed_models is not None:
allowed_models = user.allowed_models
return cls(
allowed_providers=allowed_providers,
allowed_models=allowed_models,
allowed_api_formats=allowed_api_formats,
)
def is_api_format_allowed(self, api_format: str) -> bool:
"""
检查 API 格式是否被允许
Args:
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
Returns:
True 如果格式被允许False 否则
"""
if self.allowed_api_formats is None:
return True
return api_format in self.allowed_api_formats
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
"""
检查模型是否被允许访问
Args:
model_id: 模型 ID
provider_id: Provider ID
Returns:
True 如果模型被允许False 否则
"""
# 检查 Provider 限制
if self.allowed_providers is not None:
if provider_id not in self.allowed_providers:
return False
# 检查模型限制
if self.allowed_models is not None:
if model_id not in self.allowed_models:
return False
return True
def get_available_provider_ids(db: Session, api_formats: list[str]) -> set[str]:
@@ -181,12 +308,19 @@ def _extract_model_info(model: Any) -> ModelInfo:
global_model = model.global_model
model_id: str = global_model.name if global_model else model.provider_model_name
display_name: str = global_model.display_name if global_model else model.provider_model_name
description: Optional[str] = global_model.description if global_model else None
created_at: Optional[str] = (
model.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if model.created_at else None
)
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
provider_name: str = model.provider.name if model.provider else "unknown"
provider_id: str = model.provider_id or ""
# 从 GlobalModel.config 提取配置信息
config: dict = {}
description: Optional[str] = None
if global_model:
config = global_model.config or {}
description = config.get("description")
return ModelInfo(
id=model_id,
@@ -195,6 +329,22 @@ def _extract_model_info(model: Any) -> ModelInfo:
created_at=created_at,
created_timestamp=created_timestamp,
provider_name=provider_name,
provider_id=provider_id,
# 能力配置
streaming=config.get("streaming", True),
vision=config.get("vision", False),
function_calling=config.get("function_calling", False),
extended_thinking=config.get("extended_thinking", False),
image_generation=config.get("image_generation", False),
structured_output=config.get("structured_output", False),
# 规格参数
context_limit=config.get("context_limit"),
output_limit=config.get("output_limit"),
# 元信息
family=config.get("family"),
knowledge_cutoff=config.get("knowledge_cutoff"),
input_modalities=config.get("input_modalities"),
output_modalities=config.get("output_modalities"),
)
@@ -202,6 +352,7 @@ async def list_available_models(
db: Session,
available_provider_ids: set[str],
api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> list[ModelInfo]:
"""
获取可用模型列表(已去重,带缓存)
@@ -210,6 +361,7 @@ async def list_available_models(
db: 数据库会话
available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns:
去重后的 ModelInfo 列表,按创建时间倒序
@@ -217,8 +369,16 @@ async def list_available_models(
if not available_provider_ids:
return []
# 缓存策略:只有完全无访问限制时才使用缓存
# - restrictions is None: 未传入限制对象
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
# 以上两种情况返回的结果相同,可以共享全局缓存
use_cache = restrictions is None or (
restrictions.allowed_providers is None and restrictions.allowed_models is None
)
# 尝试从缓存获取
if api_formats:
if api_formats and use_cache:
cached = await _get_cached_models(api_formats)
if cached is not None:
return cached
@@ -253,14 +413,19 @@ async def list_available_models(
if available_model_ids is not None and info.id not in available_model_ids:
continue
# 检查 API Key/User 访问限制
if restrictions is not None:
if not restrictions.is_model_allowed(info.id, info.provider_id):
continue
if info.id in seen_model_ids:
continue
seen_model_ids.add(info.id)
result.append(info)
# 写入缓存
if api_formats:
# 只有无限制的情况才写入缓存
if api_formats and use_cache:
await _set_cached_models(api_formats, result)
return result
@@ -271,6 +436,7 @@ def find_model_by_id(
model_id: str,
available_provider_ids: set[str],
api_formats: Optional[list[str]] = None,
restrictions: Optional[AccessRestrictions] = None,
) -> Optional[ModelInfo]:
"""
按 ID 查找模型
@@ -285,6 +451,7 @@ def find_model_by_id(
model_id: 模型 ID
available_provider_ids: 有可用端点的 Provider ID 集合
api_formats: API 格式列表,用于检查 Key 的 allowed_models
restrictions: API Key/User 的访问限制
Returns:
ModelInfo 或 None
@@ -300,6 +467,11 @@ def find_model_by_id(
if available_model_ids is not None and model_id not in available_model_ids:
return None
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
if restrictions is not None and restrictions.allowed_models is not None:
if model_id not in restrictions.allowed_models:
return None
# 先按 GlobalModel.name 查找
models_by_global = (
db.query(Model)
@@ -315,8 +487,19 @@ def find_model_by_id(
.all()
)
def is_model_accessible(m: Model) -> bool:
"""检查模型是否可访问"""
if m.provider_id not in available_provider_ids:
return False
# 检查 API Key/User 访问限制
if restrictions is not None:
provider_id = m.provider_id or ""
if not restrictions.is_model_allowed(model_id, provider_id):
return False
return True
model = next(
(m for m in models_by_global if m.provider_id in available_provider_ids),
(m for m in models_by_global if is_model_accessible(m)),
None,
)
@@ -340,7 +523,7 @@ def find_model_by_id(
)
model = next(
(m for m in models_by_provider_name if m.provider_id in available_provider_ids),
(m for m in models_by_provider_name if is_model_accessible(m)),
None,
)

View File

@@ -5,13 +5,12 @@ from enum import Enum
from typing import Any, Optional, Tuple
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from src.core.exceptions import QuotaExceededException
from src.core.logger import logger
from src.models.database import ApiKey, AuditEventType, User, UserRole
from src.services.auth.service import AuthService
from src.services.cache.user_cache import UserCacheService
from src.services.system.audit import AuditService
from src.services.usage.service import UsageService
@@ -178,9 +177,9 @@ class ApiRequestPipeline:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少管理员凭证")
token = authorization.replace("Bearer ", "").strip()
token = authorization[7:].strip()
try:
payload = await self.auth_service.verify_token(token)
payload = await self.auth_service.verify_token(token, token_type="access")
except HTTPException:
raise
except Exception as exc:
@@ -191,8 +190,8 @@ class ApiRequestPipeline:
if not user_id:
raise HTTPException(status_code=401, detail="无效的管理员令牌")
# 使用缓存查询用户
user = await UserCacheService.get_user_by_id(db, user_id)
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
@@ -205,9 +204,9 @@ class ApiRequestPipeline:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="缺少用户凭证")
token = authorization.replace("Bearer ", "").strip()
token = authorization[7:].strip()
try:
payload = await self.auth_service.verify_token(token)
payload = await self.auth_service.verify_token(token, token_type="access")
except HTTPException:
raise
except Exception as exc:
@@ -218,8 +217,8 @@ class ApiRequestPipeline:
if not user_id:
raise HTTPException(status_code=401, detail="无效的用户令牌")
# 使用缓存查询用户
user = await UserCacheService.get_user_by_id(db, user_id)
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
user = db.query(User).filter(User.id == user_id).first()
if not user or not user.is_active:
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
@@ -242,11 +241,15 @@ class ApiRequestPipeline:
status_code: Optional[int] = None,
error: Optional[str] = None,
) -> None:
"""记录审计事件
事务策略:复用请求级 Session不单独提交。
审计记录随主事务一起提交,由中间件统一管理。
"""
if not getattr(adapter, "audit_log_enabled", True):
return
bind = context.db.get_bind()
if bind is None:
if context.db is None:
return
event_type = adapter.audit_success_event if success else adapter.audit_failure_event
@@ -266,11 +269,11 @@ class ApiRequestPipeline:
error=error,
)
SessionMaker = sessionmaker(bind=bind)
audit_session = SessionMaker()
try:
# 复用请求级 Session不创建新的连接
# 审计记录随主事务一起提交,由中间件统一管理
self.audit_service.log_event(
db=audit_session,
db=context.db,
event_type=event_type,
description=f"{context.request.method} {context.request.url.path} via {adapter.name}",
user_id=context.user.id if context.user else None,
@@ -282,12 +285,9 @@ class ApiRequestPipeline:
error_message=error,
metadata=metadata,
)
audit_session.commit()
except Exception as exc:
audit_session.rollback()
# 审计失败不应影响主请求,仅记录警告
logger.warning(f"[Audit] Failed to record event for adapter={adapter.name}: {exc}")
finally:
audit_session.close()
def _build_audit_metadata(
self,

View File

@@ -13,7 +13,7 @@ from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, Usage
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, Usage
from src.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService
from src.utils.cache_decorator import cache_result
@@ -118,7 +118,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据 ====================
# 从 stats_summary + 今日实时数据获取全局统计
@@ -208,7 +210,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(StatsDaily.cache_read_cost).label("cache_read_cost"),
func.sum(StatsDaily.fallback_count).label("fallback_count"),
)
.filter(StatsDaily.date >= last_month, StatsDaily.date < today)
.filter(StatsDaily.date >= month_start, StatsDaily.date < today)
.first()
)
@@ -227,24 +229,24 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
else:
# 回退到实时查询(没有预聚合数据时)
total_requests = (
db.query(func.count(Usage.id)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= last_month).scalar() or 0
.filter(Usage.created_at >= month_start).scalar() or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= last_month,
Usage.created_at >= month_start,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0
)
total_tokens = (
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= last_month).scalar() or 0
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
)
cache_stats = (
db.query(
@@ -253,7 +255,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(Usage.cache_creation_cost_usd).label("cache_creation_cost"),
func.sum(Usage.cache_read_cost_usd).label("cache_read_cost"),
)
.filter(Usage.created_at >= last_month)
.filter(Usage.created_at >= month_start)
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
@@ -267,7 +269,7 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
)
.filter(
RequestCandidate.created_at >= last_month,
RequestCandidate.created_at >= month_start,
RequestCandidate.status.in_(["success", "failed"]),
)
.group_by(RequestCandidate.request_id)
@@ -447,7 +449,9 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# 本月第一天(自然月)
month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc)
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = (
@@ -483,12 +487,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
# 本月请求统计
user_requests = (
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
or 0
)
@@ -532,18 +536,19 @@ class UserDashboardStatsAdapter(DashboardAdapter):
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= last_month))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
# 计算缓存命中率cache_read / (input_tokens + cache_read)
# 计算本月缓存命中率cache_read / (input_tokens + cache_read)
# input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的
# 总输入 = input_tokens + cache_read缓存命中率 = cache_read / 总输入
total_input_with_cache = all_time_input_tokens + all_time_cache_read
total_input_with_cache = monthly_input_tokens + cache_read_tokens
cache_hit_rate = (
round((all_time_cache_read / total_input_with_cache) * 100, 1)
round((cache_read_tokens / total_input_with_cache) * 100, 1)
if total_input_with_cache > 0
else 0
)
@@ -569,15 +574,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd and user.quota_usd > 0:
elif user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = "无限制"
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
else:
quota_value = "0%"
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
quota_high = True
return {
"stats": [
@@ -605,9 +610,15 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "TrendingUp",
},
{
"name": "本月费用",
"value": f"${user_cost:.2f}",
"icon": "DollarSign",
"name": "总Token",
"value": format_tokens(
all_time_input_tokens
+ all_time_output_tokens
+ all_time_cache_creation
+ all_time_cache_read
),
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
"icon": "Hash",
},
],
"today": {
@@ -631,6 +642,8 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"cache_hit_rate": cache_hit_rate,
"total_cache_tokens": cache_creation_tokens + cache_read_tokens,
},
# 本月费用(用于下方缓存区域显示)
"monthly_cost": float(user_cost),
}
@@ -731,8 +744,15 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
)
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 需要转回业务时区再取日期,才能与日期序列匹配
def _to_business_date_str(value: datetime) -> str:
if value.tzinfo is None:
value_utc = value.replace(tzinfo=timezone.utc)
else:
value_utc = value.astimezone(timezone.utc)
return value_utc.astimezone(app_tz).date().isoformat()
stats_map = {
stat.date.replace(tzinfo=timezone.utc).astimezone(app_tz).date().isoformat(): {
_to_business_date_str(stat.date): {
"requests": stat.total_requests,
"tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens,
"cost": stat.total_cost,
@@ -790,6 +810,38 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
"unique_providers": today_unique_providers,
"fallback_count": today_fallback_count,
}
# 历史预聚合缺失时兜底:按业务日范围实时计算(仅补最近少量缺失,避免全表扫描)
yesterday_date = today_local.date() - timedelta(days=1)
historical_end = min(end_date_local.date(), yesterday_date)
missing_dates: list[str] = []
cursor = start_date_local.date()
while cursor <= historical_end:
date_str = cursor.isoformat()
if date_str not in stats_map:
missing_dates.append(date_str)
cursor += timedelta(days=1)
if missing_dates:
for date_str in missing_dates[-7:]:
target_local = datetime.fromisoformat(date_str).replace(tzinfo=app_tz)
computed = StatsAggregatorService.compute_daily_stats(db, target_local)
stats_map[date_str] = {
"requests": computed["total_requests"],
"tokens": (
computed["input_tokens"]
+ computed["output_tokens"]
+ computed["cache_creation_tokens"]
+ computed["cache_read_tokens"]
),
"cost": computed["total_cost"],
"avg_response_time": computed["avg_response_time_ms"] / 1000.0
if computed["avg_response_time_ms"]
else 0,
"unique_models": computed["unique_models"],
"unique_providers": computed["unique_providers"],
"fallback_count": computed["fallback_count"],
}
else:
# 普通用户:仍需实时查询(用户级预聚合可选)
query = db.query(Usage).filter(
@@ -854,69 +906,172 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
})
current_date += timedelta(days=1)
# ==================== 模型统计(仍需实时查询)====================
model_query = db.query(Usage)
if not is_admin:
model_query = model_query.filter(Usage.user_id == user.id)
model_query = model_query.filter(
and_(Usage.created_at >= start_date, Usage.created_at <= end_date)
)
model_stats = (
model_query.with_entities(
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
func.avg(Usage.response_time_ms).label("avg_response_time"),
# ==================== 模型统计 ====================
if is_admin:
# 管理员:使用预聚合数据 + 今日实时数据
# 历史数据从 stats_daily_model 获取
historical_model_stats = (
db.query(StatsDailyModel)
.filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
.all()
)
.group_by(Usage.model)
.order_by(func.sum(Usage.total_cost_usd).desc())
.all()
)
model_summary = [
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
"avg_response_time": (
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
),
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
}
for stat in model_stats
]
# 按模型汇总历史数据
model_agg: dict = {}
daily_breakdown: dict = {}
daily_model_stats = (
model_query.with_entities(
func.date(Usage.created_at).label("date"),
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
for stat in historical_model_stats:
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
}
model_agg[model]["requests"] += stat.total_requests
tokens = (stat.input_tokens + stat.output_tokens +
stat.cache_creation_tokens + stat.cache_read_tokens)
model_agg[model]["tokens"] += tokens
model_agg[model]["cost"] += stat.total_cost
if stat.avg_response_time_ms is not None:
model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests
model_agg[model]["response_count"] += stat.total_requests
# 按日期分组
if stat.date.tzinfo is None:
date_utc = stat.date.replace(tzinfo=timezone.utc)
else:
date_utc = stat.date.astimezone(timezone.utc)
date_str = date_utc.astimezone(app_tz).date().isoformat()
daily_breakdown.setdefault(date_str, []).append({
"model": model,
"requests": stat.total_requests,
"tokens": tokens,
"cost": stat.total_cost,
})
# 今日实时模型统计
today_model_stats = (
db.query(
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
func.avg(Usage.response_time_ms).label("avg_response_time"),
)
.filter(Usage.created_at >= today)
.group_by(Usage.model)
.all()
)
.group_by(func.date(Usage.created_at), Usage.model)
.order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc())
.all()
)
breakdown = {}
for stat in daily_model_stats:
date_str = stat.date.isoformat()
breakdown.setdefault(date_str, []).append(
today_str = today_local.date().isoformat()
for stat in today_model_stats:
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
}
model_agg[model]["requests"] += stat.requests or 0
model_agg[model]["tokens"] += int(stat.tokens or 0)
model_agg[model]["cost"] += float(stat.cost or 0)
if stat.avg_response_time is not None:
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0)
model_agg[model]["response_count"] += stat.requests or 0
# 今日 breakdown
daily_breakdown.setdefault(today_str, []).append({
"model": model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
})
# 构建 model_summary
model_summary = []
for model, agg in model_agg.items():
avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0
if agg["response_count"] > 0 else 0)
model_summary.append({
"model": model,
"requests": agg["requests"],
"tokens": agg["tokens"],
"cost": agg["cost"],
"avg_response_time": avg_rt,
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
})
model_summary.sort(key=lambda x: x["cost"], reverse=True)
# 填充 model_breakdown
for item in formatted:
item["model_breakdown"] = daily_breakdown.get(item["date"], [])
else:
# 普通用户:实时查询(数据量较小)
model_query = db.query(Usage).filter(
and_(
Usage.user_id == user.id,
Usage.created_at >= start_date,
Usage.created_at <= end_date
)
)
model_stats = (
model_query.with_entities(
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
func.avg(Usage.response_time_ms).label("avg_response_time"),
)
.group_by(Usage.model)
.order_by(func.sum(Usage.total_cost_usd).desc())
.all()
)
model_summary = [
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
"avg_response_time": (
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
),
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
}
for stat in model_stats
]
daily_model_stats = (
model_query.with_entities(
func.date(Usage.created_at).label("date"),
Usage.model,
func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"),
)
.group_by(func.date(Usage.created_at), Usage.model)
.order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc())
.all()
)
for item in formatted:
item["model_breakdown"] = breakdown.get(item["date"], [])
breakdown = {}
for stat in daily_model_stats:
date_str = stat.date.isoformat()
breakdown.setdefault(date_str, []).append(
{
"model": stat.model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
}
)
for item in formatted:
item["model_breakdown"] = breakdown.get(item["date"], [])
return {
"daily_stats": formatted,

View File

@@ -28,7 +28,7 @@
from __future__ import annotations
import time
from typing import Any, Callable, Dict, Optional, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Protocol, runtime_checkable
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
@@ -43,6 +43,9 @@ from src.services.provider.format import normalize_api_format
from src.services.system.audit import audit_service
from src.services.usage.service import UsageService
if TYPE_CHECKING:
from src.api.handlers.base.stream_context import StreamContext
class MessageTelemetry:
@@ -100,6 +103,8 @@ class MessageTelemetry:
cache_read_tokens: int = 0,
is_stream: bool = False,
provider_request_headers: Optional[Dict[str, Any]] = None,
# 时间指标
first_byte_time_ms: Optional[int] = None, # 首字时间/TTFB
# Provider 侧追踪信息(用于记录真实成本)
provider_id: Optional[str] = None,
provider_endpoint_id: Optional[str] = None,
@@ -133,6 +138,7 @@ class MessageTelemetry:
api_format=api_format,
is_stream=is_stream,
response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms, # 传递首字时间
status_code=status_code,
request_headers=request_headers,
request_body=request_body,
@@ -370,6 +376,9 @@ class BaseMessageHandler:
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
注意TTFB首字节时间由 StreamContext.record_first_byte_time() 记录,
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
Args:
request_id: 请求 ID如果不传则使用 self.request_id
"""
@@ -396,6 +405,44 @@ class BaseMessageHandler:
# 创建后台任务,不阻塞当前流
asyncio.create_task(_do_update())
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
"""更新 Usage 状态为 streaming同时更新 provider 和 target_model
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
注意TTFB首字节时间由 StreamContext.record_first_byte_time() 记录,
并在最终 record_success 时传递到数据库,避免重复记录导致数据不一致。
Args:
ctx: 流式上下文,包含 provider_name 和 mapped_model
"""
import asyncio
from src.database.database import get_db
target_request_id = self.request_id
provider = ctx.provider_name
target_model = ctx.mapped_model
async def _do_update() -> None:
try:
db_gen = get_db()
db = next(db_gen)
try:
UsageService.update_usage_status(
db=db,
request_id=target_request_id,
status="streaming",
provider=provider,
target_model=target_model,
)
finally:
db.close()
except Exception as e:
logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}")
# 创建后台任务,不阻塞当前流
asyncio.create_task(_do_update())
def _log_request_error(self, message: str, error: Exception) -> None:
"""记录请求错误日志,对业务异常不打印堆栈
@@ -408,9 +455,10 @@ class BaseMessageHandler:
QuotaExceededException,
RateLimitException,
ModelNotSupportedException,
UpstreamClientException,
)
if isinstance(error, (ProviderException, QuotaExceededException, RateLimitException, ModelNotSupportedException)):
if isinstance(error, (ProviderException, QuotaExceededException, RateLimitException, ModelNotSupportedException, UpstreamClientException)):
# 业务异常:简洁日志,不打印堆栈
logger.error(f"{message}: [{type(error).__name__}] {error}")
else:

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