mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge remote-tracking branch 'origin/master' into dev
# Conflicts: # src/api/handlers/base/request_builder.py # src/api/handlers/openai_cli/adapter.py # src/services/system/maintenance_scheduler.py
This commit is contained in:
47
src/services/billing/token_normalization.py
Normal file
47
src/services/billing/token_normalization.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
计费相关 token 归一化工具。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from src.core.api_format.enums import ApiFamily
|
||||
from src.core.api_format.signature import parse_signature_key
|
||||
|
||||
|
||||
def _get_api_family(api_format: str | None) -> ApiFamily | None:
|
||||
"""解析 api_format 字符串,返回对应的 ApiFamily 枚举。"""
|
||||
if not api_format:
|
||||
return None
|
||||
text = str(api_format).strip()
|
||||
if not text:
|
||||
return None
|
||||
sig = parse_signature_key(text)
|
||||
return sig.api_family
|
||||
|
||||
|
||||
def normalize_input_tokens_for_billing(
|
||||
api_format: str | None,
|
||||
input_tokens: int,
|
||||
cache_read_tokens: int,
|
||||
) -> int:
|
||||
"""
|
||||
归一化 `input_tokens`,使其在计费中表示"非缓存输入 token"。
|
||||
|
||||
计费口径:`input_tokens`=非缓存输入 token;`cache_read_tokens`=缓存命中 token(折扣/免费维度)。
|
||||
|
||||
- Claude 系:保持上游口径(不扣除),因为 Claude API 的 input_tokens 本身就不包含缓存部分。
|
||||
- OpenAI 系:`input_tokens` 包含缓存命中部分,需要扣除 `cache_read_tokens`。
|
||||
- Gemini 系:`promptTokenCount` 包含 `cachedContentTokenCount`,需要扣除。
|
||||
"""
|
||||
if input_tokens <= 0:
|
||||
return 0 if input_tokens == 0 else input_tokens
|
||||
if cache_read_tokens <= 0:
|
||||
return input_tokens
|
||||
|
||||
api_family = _get_api_family(api_format)
|
||||
if api_family == ApiFamily.CLAUDE:
|
||||
return input_tokens
|
||||
if api_family in (ApiFamily.OPENAI, ApiFamily.GEMINI):
|
||||
return max(input_tokens - cache_read_tokens, 0)
|
||||
# 未知格式,保守处理,不扣除
|
||||
return input_tokens
|
||||
34
src/services/cache/aware_scheduler.py
vendored
34
src/services/cache/aware_scheduler.py
vendored
@@ -626,13 +626,15 @@ class CacheAwareScheduler:
|
||||
target_format,
|
||||
)
|
||||
|
||||
# 0. 解析 model_name 到 GlobalModel(支持直接匹配和映射名匹配,使用 ModelCacheService)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
|
||||
db, model_name
|
||||
)
|
||||
# 0. 解析 model_name 到 GlobalModel(仅接受 GlobalModel.name)
|
||||
normalized_name = model_name.strip() if isinstance(model_name, str) else ""
|
||||
if not normalized_name:
|
||||
logger.warning("GlobalModel not found: <empty model name>")
|
||||
raise ModelNotSupportedException(model=model_name)
|
||||
|
||||
if not global_model:
|
||||
logger.warning(f"GlobalModel not found: {model_name}")
|
||||
global_model = await ModelCacheService.get_global_model_by_name(db, normalized_name)
|
||||
if not global_model or not global_model.is_active:
|
||||
logger.warning(f"GlobalModel not found or inactive: {normalized_name}")
|
||||
raise ModelNotSupportedException(model=model_name)
|
||||
|
||||
logger.debug(
|
||||
@@ -828,14 +830,12 @@ class CacheAwareScheduler:
|
||||
- 模型支持的能力是全局的,与具体的 Key 无关
|
||||
- 如果模型不支持某能力,整个 Provider 的所有 Key 都应该被跳过
|
||||
|
||||
支持两种匹配方式:
|
||||
1. 直接匹配 GlobalModel.name
|
||||
2. 通过 ModelCacheService 匹配映射名(全局查找)
|
||||
仅支持直接匹配 GlobalModel.name(外部请求不接受映射名)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
provider: Provider 对象
|
||||
model_name: 模型名称(可以是 GlobalModel.name 或映射名)
|
||||
model_name: 模型名称(必须是 GlobalModel.name)
|
||||
is_stream: 是否是流式请求,如果为 True 则同时检查流式支持
|
||||
capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力
|
||||
|
||||
@@ -849,14 +849,14 @@ class CacheAwareScheduler:
|
||||
# Avoid holding a DB connection while awaiting cache/Redis inside ModelCacheService.
|
||||
self._release_db_connection_before_await(db)
|
||||
|
||||
# 使用 ModelCacheService 解析模型名称(支持映射名)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
|
||||
db, model_name
|
||||
)
|
||||
# 仅接受 GlobalModel.name(不允许映射名)
|
||||
normalized_name = model_name.strip() if isinstance(model_name, str) else ""
|
||||
if not normalized_name:
|
||||
return False, "模型不存在或名称无效", None, None
|
||||
|
||||
if not global_model:
|
||||
# 完全未找到匹配
|
||||
return False, "模型不存在或 Provider 未配置此模型", None, None
|
||||
global_model = await ModelCacheService.get_global_model_by_name(db, normalized_name)
|
||||
if not global_model or not global_model.is_active:
|
||||
return False, "模型不存在或已停用", None, None
|
||||
|
||||
# 找到 GlobalModel 后,检查当前 Provider 是否支持
|
||||
is_supported, skip_reason, caps, provider_model_names = (
|
||||
|
||||
@@ -86,30 +86,33 @@ class ModelMapperMiddleware:
|
||||
获取模型映射
|
||||
|
||||
简化后的逻辑:
|
||||
1. 通过 GlobalModel.name 或映射名解析 GlobalModel
|
||||
1. 通过 GlobalModel.name 解析 GlobalModel
|
||||
2. 找到 GlobalModel 后,查找该 Provider 的 Model 实现
|
||||
|
||||
Args:
|
||||
source_model: 用户请求的模型名(可以是 GlobalModel.name 或映射名)
|
||||
source_model: 用户请求的模型名(必须是 GlobalModel.name)
|
||||
provider_id: 提供商ID (UUID)
|
||||
|
||||
Returns:
|
||||
模型映射对象(包含 model 字段),如果没有找到返回None
|
||||
"""
|
||||
# 检查缓存
|
||||
cache_key = f"{provider_id}:{source_model}"
|
||||
# 步骤 1: 规范化模型名称
|
||||
normalized_name = source_model.strip() if isinstance(source_model, str) else ""
|
||||
if not normalized_name:
|
||||
logger.debug("GlobalModel not found: <empty model name>")
|
||||
return None
|
||||
|
||||
# 检查缓存(使用规范化后的名称)
|
||||
cache_key = f"{provider_id}:{normalized_name}"
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
mapping = None
|
||||
|
||||
# 步骤 1: 解析 GlobalModel(支持映射名)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_mapping(
|
||||
self.db, source_model
|
||||
)
|
||||
global_model = await ModelCacheService.get_global_model_by_name(self.db, normalized_name)
|
||||
|
||||
if not global_model:
|
||||
logger.debug(f"GlobalModel not found: {source_model}")
|
||||
if not global_model or not global_model.is_active:
|
||||
logger.debug(f"GlobalModel not found or inactive: {normalized_name}")
|
||||
self._cache[cache_key] = None
|
||||
return None
|
||||
|
||||
@@ -132,7 +135,7 @@ class ModelMapperMiddleware:
|
||||
)()
|
||||
|
||||
logger.debug(
|
||||
f"Found model mapping: {source_model} -> {model.provider_model_name} "
|
||||
f"Found model mapping: {normalized_name} -> {model.provider_model_name} "
|
||||
f"(provider={provider_id[:8]}...)"
|
||||
)
|
||||
|
||||
|
||||
@@ -123,6 +123,18 @@ class SystemConfigService:
|
||||
"value": "01:05",
|
||||
"description": "Provider 自动签到执行时间(HH:MM 格式,24小时制)",
|
||||
},
|
||||
"enable_user_quota_reset": {
|
||||
"value": False,
|
||||
"description": "是否启用用户配额自动重置任务(按配置时间触发,按周期执行)",
|
||||
},
|
||||
"user_quota_reset_time": {
|
||||
"value": "05:00",
|
||||
"description": "用户配额自动重置执行时间(HH:MM 格式,24小时制)",
|
||||
},
|
||||
"user_quota_reset_interval_days": {
|
||||
"value": 1,
|
||||
"description": "用户配额重置周期(天数)",
|
||||
},
|
||||
"provider_priority_mode": {
|
||||
"value": "provider",
|
||||
"description": "优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)",
|
||||
|
||||
@@ -41,6 +41,8 @@ class MaintenanceScheduler:
|
||||
CHECKIN_JOB_ID = "provider_checkin"
|
||||
# OAuth 刷新任务的 job_id
|
||||
OAUTH_REFRESH_JOB_ID = "oauth_token_refresh"
|
||||
# 用户配额重置任务的 job_id
|
||||
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.running = False
|
||||
@@ -72,6 +74,19 @@ class MaintenanceScheduler:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _get_user_quota_reset_time(self) -> tuple[int, int]:
|
||||
"""获取用户配额重置任务的执行时间
|
||||
|
||||
Returns:
|
||||
(hour, minute) 元组
|
||||
"""
|
||||
db = create_session()
|
||||
try:
|
||||
time_str = SystemConfigService.get_config(db, "user_quota_reset_time", "05:00")
|
||||
return self._parse_user_quota_reset_time_string(time_str)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def _parse_time_string(time_str: str) -> tuple[int, int]:
|
||||
"""解析时间字符串为 (hour, minute) 元组
|
||||
@@ -95,6 +110,26 @@ class MaintenanceScheduler:
|
||||
except (ValueError, IndexError):
|
||||
return (1, 5)
|
||||
|
||||
@staticmethod
|
||||
def _parse_user_quota_reset_time_string(time_str: str) -> tuple[int, int]:
|
||||
"""解析用户配额重置时间字符串为 (hour, minute) 元组
|
||||
|
||||
Returns:
|
||||
(hour, minute) 元组,解析失败返回默认值 (5, 0)
|
||||
"""
|
||||
try:
|
||||
if not time_str or ":" not in time_str:
|
||||
return (5, 0)
|
||||
parts = time_str.split(":")
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1])
|
||||
# 验证范围
|
||||
if 0 <= hour <= 23 and 0 <= minute <= 59:
|
||||
return (hour, minute)
|
||||
return (5, 0)
|
||||
except (ValueError, IndexError):
|
||||
return (5, 0)
|
||||
|
||||
def update_checkin_time(self, time_str: str) -> bool:
|
||||
"""更新签到任务的执行时间
|
||||
|
||||
@@ -118,6 +153,29 @@ class MaintenanceScheduler:
|
||||
|
||||
return success
|
||||
|
||||
def update_user_quota_reset_time(self, time_str: str) -> bool:
|
||||
"""更新用户配额重置任务的执行时间
|
||||
|
||||
Args:
|
||||
time_str: HH:MM 格式的时间字符串
|
||||
|
||||
Returns:
|
||||
是否成功更新
|
||||
"""
|
||||
hour, minute = self._parse_user_quota_reset_time_string(time_str)
|
||||
|
||||
scheduler = get_scheduler()
|
||||
success = scheduler.reschedule_cron_job(
|
||||
self.USER_QUOTA_RESET_JOB_ID,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"用户配额重置任务时间已更新为: {hour:02d}:{minute:02d}")
|
||||
|
||||
return success
|
||||
|
||||
def get_checkin_job_info(self) -> dict | None:
|
||||
"""获取签到任务的信息
|
||||
|
||||
@@ -223,6 +281,16 @@ class MaintenanceScheduler:
|
||||
# 启动时先执行一次,计算下次执行时间
|
||||
asyncio.create_task(self._schedule_next_oauth_refresh())
|
||||
|
||||
# 用户配额重置任务 - 根据配置时间执行(按周期配置决定是否执行)
|
||||
quota_reset_hour, quota_reset_minute = self._get_user_quota_reset_time()
|
||||
scheduler.add_cron_job(
|
||||
self._scheduled_user_quota_reset,
|
||||
hour=quota_reset_hour,
|
||||
minute=quota_reset_minute,
|
||||
job_id=self.USER_QUOTA_RESET_JOB_ID,
|
||||
name="用户配额自动重置",
|
||||
)
|
||||
|
||||
# 启动时执行一次初始化任务
|
||||
asyncio.create_task(self._run_startup_tasks())
|
||||
|
||||
@@ -440,6 +508,10 @@ class MaintenanceScheduler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _scheduled_user_quota_reset(self) -> None:
|
||||
"""用户配额重置任务(定时调用)"""
|
||||
await self._perform_user_quota_reset()
|
||||
|
||||
# ========== 实际任务实现 ==========
|
||||
|
||||
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
|
||||
@@ -843,6 +915,108 @@ class MaintenanceScheduler:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
async def _perform_user_quota_reset(self) -> None:
|
||||
"""执行用户配额自动重置任务
|
||||
|
||||
适用范围:
|
||||
- 未删除(is_deleted=false)
|
||||
- 仅对 quota_usd != NULL 的用户生效
|
||||
"""
|
||||
db = create_session()
|
||||
try:
|
||||
# 检查是否启用用户配额重置
|
||||
if not SystemConfigService.get_config(db, "enable_user_quota_reset", False):
|
||||
logger.info("用户配额自动重置已禁用,跳过任务")
|
||||
return
|
||||
|
||||
# 重置周期(天数),不限制上限
|
||||
interval_value = SystemConfigService.get_config(db, "user_quota_reset_interval_days", 1)
|
||||
try:
|
||||
interval_days = int(interval_value)
|
||||
except Exception:
|
||||
interval_days = 1
|
||||
if interval_days < 1:
|
||||
interval_days = 1
|
||||
|
||||
# 滚动计算:根据上次执行日(APP_TIMEZONE)判断是否到期
|
||||
last_reset_at = SystemConfigService.get_config(db, "user_quota_last_reset_at")
|
||||
|
||||
should_run = True
|
||||
if last_reset_at:
|
||||
last_dt: datetime | None = None
|
||||
try:
|
||||
if isinstance(last_reset_at, str):
|
||||
last_dt = datetime.fromisoformat(last_reset_at)
|
||||
except Exception:
|
||||
last_dt = None
|
||||
|
||||
if last_dt is None:
|
||||
logger.warning("user_quota_last_reset_at 格式无效,视为需要执行一次")
|
||||
else:
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.services.system.scheduler import APP_TIMEZONE
|
||||
|
||||
tz = ZoneInfo(APP_TIMEZONE)
|
||||
now_local = datetime.now(tz)
|
||||
last_local_date = last_dt.astimezone(tz).date()
|
||||
days_since_reset = (now_local.date() - last_local_date).days
|
||||
|
||||
if days_since_reset < 0:
|
||||
logger.warning(
|
||||
"user_quota_last_reset_at 在未来,跳过本次用户配额自动重置"
|
||||
)
|
||||
should_run = False
|
||||
elif days_since_reset < interval_days:
|
||||
logger.info(
|
||||
f"用户配额自动重置未到周期,跳过任务({days_since_reset}/{interval_days}天)"
|
||||
)
|
||||
should_run = False
|
||||
|
||||
if not should_run:
|
||||
return
|
||||
|
||||
from src.models.database import User as DBUser
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
reset_count = (
|
||||
db.query(DBUser)
|
||||
.filter(
|
||||
DBUser.is_deleted.is_(False),
|
||||
DBUser.quota_usd.isnot(None),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
DBUser.used_usd: 0.0,
|
||||
DBUser.updated_at: now_utc,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 记录 last_reset_at(成功执行后更新,滚动计算用)
|
||||
SystemConfigService.set_config(
|
||||
db,
|
||||
"user_quota_last_reset_at",
|
||||
now_utc.isoformat(),
|
||||
"用户配额自动重置的上次执行时间(UTC,内部使用)",
|
||||
)
|
||||
|
||||
logger.info(f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}")
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"用户配额自动重置任务执行失败: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def _perform_cleanup(self) -> None:
|
||||
"""执行清理任务"""
|
||||
db = create_session()
|
||||
|
||||
@@ -25,6 +25,7 @@ from src.models.database import (
|
||||
User,
|
||||
UserRole,
|
||||
)
|
||||
from src.services.billing.token_normalization import normalize_input_tokens_for_billing
|
||||
from src.services.model.cost import ModelCostService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.usage.error_classifier import classify_error
|
||||
@@ -843,9 +844,28 @@ class UsageService:
|
||||
Returns:
|
||||
(usage_params 字典, total_cost 总成本)
|
||||
"""
|
||||
# 计费口径以 Provider 为准(优先 endpoint_api_format)
|
||||
billing_api_format: str | None = None
|
||||
if params.endpoint_api_format:
|
||||
try:
|
||||
billing_api_format = normalize_signature_key(str(params.endpoint_api_format))
|
||||
except Exception:
|
||||
billing_api_format = None
|
||||
if billing_api_format is None and params.api_format:
|
||||
try:
|
||||
billing_api_format = normalize_signature_key(str(params.api_format))
|
||||
except Exception:
|
||||
billing_api_format = None
|
||||
|
||||
input_tokens_for_billing = normalize_input_tokens_for_billing(
|
||||
billing_api_format,
|
||||
params.input_tokens,
|
||||
params.cache_read_input_tokens,
|
||||
)
|
||||
|
||||
# 获取费率倍数和是否免费套餐(传递 api_format 支持按格式配置的倍率)
|
||||
actual_rate_multiplier, is_free_tier = await cls._get_rate_multiplier_and_free_tier(
|
||||
params.db, params.provider_api_key_id, params.provider_id, params.api_format
|
||||
params.db, params.provider_api_key_id, params.provider_id, billing_api_format
|
||||
)
|
||||
|
||||
metadata = dict(params.metadata or {})
|
||||
@@ -884,7 +904,7 @@ class UsageService:
|
||||
|
||||
request_count = 0 if is_failed_request else 1
|
||||
dims: dict[str, Any] = {
|
||||
"input_tokens": params.input_tokens,
|
||||
"input_tokens": input_tokens_for_billing,
|
||||
"output_tokens": params.output_tokens,
|
||||
"cache_creation_input_tokens": params.cache_creation_input_tokens,
|
||||
"cache_read_input_tokens": params.cache_read_input_tokens,
|
||||
@@ -956,11 +976,11 @@ class UsageService:
|
||||
db=params.db,
|
||||
provider=params.provider,
|
||||
model=params.model,
|
||||
input_tokens=params.input_tokens,
|
||||
input_tokens=input_tokens_for_billing,
|
||||
output_tokens=params.output_tokens,
|
||||
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
||||
cache_read_input_tokens=params.cache_read_input_tokens,
|
||||
api_format=params.api_format,
|
||||
api_format=billing_api_format,
|
||||
cache_ttl_minutes=params.cache_ttl_minutes,
|
||||
use_tiered_pricing=params.use_tiered_pricing,
|
||||
is_failed_request=is_failed_request,
|
||||
@@ -989,8 +1009,8 @@ class UsageService:
|
||||
provider_id=params.provider_id,
|
||||
model=params.model,
|
||||
task_type=billing_task_type,
|
||||
api_format=params.api_format,
|
||||
input_tokens=params.input_tokens,
|
||||
api_format=billing_api_format,
|
||||
input_tokens=input_tokens_for_billing,
|
||||
output_tokens=params.output_tokens,
|
||||
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
||||
cache_read_input_tokens=params.cache_read_input_tokens,
|
||||
@@ -1019,7 +1039,7 @@ class UsageService:
|
||||
api_key=params.api_key,
|
||||
provider=params.provider,
|
||||
model=params.model,
|
||||
input_tokens=params.input_tokens,
|
||||
input_tokens=input_tokens_for_billing,
|
||||
output_tokens=params.output_tokens,
|
||||
cache_creation_input_tokens=params.cache_creation_input_tokens,
|
||||
cache_read_input_tokens=params.cache_read_input_tokens,
|
||||
|
||||
Reference in New Issue
Block a user