mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
refactor: 拆分大型模块为 mixin/子模块结构
- cli_handler_base.py 拆分为 7 个 mixin (event/monitor/prefetch/request/sse_helpers/stream/sync) - usage/service.py 拆分为 6 个子模块 (types/active_requests/cache_analysis/lifecycle/pricing/query/recording) - models/database 拆分为独立模型文件 (auth/misc/model/provider/stats/usage/user) - DUMMY_THOUGHT_SIGNATURE 常量提升到 core/api_format/conversion/constants 统一管理 - task/service.py 内联导入提升为顶层导入 - 流处理函数签名移除冗余的 http_client 参数
This commit is contained in:
522
src/api/handlers/base/cli_event_mixin.py
Normal file
522
src/api/handlers/base/cli_event_mixin.py
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
"""CLI Handler - SSE 事件处理 + 格式转换 Mixin"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.api.handlers.base.parsers import get_parser_for_format
|
||||||
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
|
from src.api.handlers.base.utils import get_format_converter_registry
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.services.provider.behavior import get_provider_behavior
|
||||||
|
|
||||||
|
from .cli_sse_helpers import (
|
||||||
|
_format_converted_events_to_sse,
|
||||||
|
_parse_gemini_json_array_line,
|
||||||
|
_parse_sse_data_line,
|
||||||
|
_parse_sse_event_data_line,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CliEventMixin:
|
||||||
|
"""SSE 事件处理和格式转换相关方法的 Mixin"""
|
||||||
|
|
||||||
|
def _handle_sse_event(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
event_name: str | None,
|
||||||
|
data_str: str,
|
||||||
|
record_chunk: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
处理 SSE 事件
|
||||||
|
|
||||||
|
通用框架:解析 JSON、更新计数器
|
||||||
|
子类可覆盖 _process_event_data() 实现格式特定逻辑
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
event_name: 事件名称(如 message_start, content_block_delta 等)
|
||||||
|
data_str: 事件数据字符串(JSON 格式)
|
||||||
|
record_chunk: 是否记录到 parsed_chunks(不需要格式转换时应为 True)
|
||||||
|
当为 True 时,同时更新 data_count;
|
||||||
|
当为 False 时,data_count 由 _record_converted_chunks 更新
|
||||||
|
"""
|
||||||
|
if not data_str:
|
||||||
|
return
|
||||||
|
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
ctx.has_completion = True
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
behavior = get_provider_behavior(
|
||||||
|
provider_type=ctx.provider_type,
|
||||||
|
endpoint_sig=ctx.provider_api_format,
|
||||||
|
)
|
||||||
|
envelope = behavior.envelope
|
||||||
|
if envelope:
|
||||||
|
data = envelope.unwrap_response(data)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 当不需要格式转换时,更新 data_count;需要记录时再写入 parsed_chunks。
|
||||||
|
# 当需要格式转换时(record_chunk=False),data_count 由 _record_converted_chunks 更新
|
||||||
|
if record_chunk:
|
||||||
|
ctx.data_count += 1
|
||||||
|
if ctx.record_parsed_chunks:
|
||||||
|
ctx.parsed_chunks.append(data)
|
||||||
|
|
||||||
|
event_type = event_name or data.get("type", "")
|
||||||
|
|
||||||
|
if envelope:
|
||||||
|
envelope.postprocess_unwrapped_response(model=ctx.model, data=data)
|
||||||
|
|
||||||
|
# 调用格式特定的处理逻辑
|
||||||
|
# 注意:跨格式转换时,_process_event_data 会自动选择正确的 Provider 解析器
|
||||||
|
self._process_event_data(ctx, event_type, data)
|
||||||
|
|
||||||
|
def _process_event_data(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
event_type: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
处理解析后的事件数据 - 子类应覆盖此方法
|
||||||
|
|
||||||
|
默认实现使用 ResponseParser 提取 usage
|
||||||
|
"""
|
||||||
|
# 提取 response_id
|
||||||
|
if not ctx.response_id:
|
||||||
|
response_obj = data.get("response")
|
||||||
|
if isinstance(response_obj, dict) and response_obj.get("id"):
|
||||||
|
ctx.response_id = response_obj["id"]
|
||||||
|
elif "id" in data:
|
||||||
|
ctx.response_id = data["id"]
|
||||||
|
|
||||||
|
# 使用解析器提取 usage
|
||||||
|
# Claude/CLI 流式响应的 usage 可能在首个 chunk 或最后一个 chunk 中
|
||||||
|
# 首个 chunk 可能部分为 0,最后一个 chunk 包含完整值,因此取最大值确保正确计费
|
||||||
|
#
|
||||||
|
# 重要:当跨格式转换时,收到的数据是 Provider 格式,需要使用 Provider 格式的解析器
|
||||||
|
# 而不是客户端格式的解析器(self.parser)
|
||||||
|
parser = self.parser
|
||||||
|
if ctx.provider_api_format and ctx.provider_api_format != ctx.client_api_format:
|
||||||
|
# 跨格式转换:使用 Provider 格式的解析器
|
||||||
|
try:
|
||||||
|
provider_parser = get_parser_for_format(ctx.provider_api_format)
|
||||||
|
if provider_parser:
|
||||||
|
parser = provider_parser
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] 使用 Provider 解析器: "
|
||||||
|
f"{ctx.provider_api_format} (client={ctx.client_api_format})"
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] 未找到 Provider 格式解析器: "
|
||||||
|
f"{ctx.provider_api_format}, 回退使用客户端格式解析器"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = parser.extract_usage_from_response(data)
|
||||||
|
if usage:
|
||||||
|
new_input = usage.get("input_tokens", 0)
|
||||||
|
new_output = usage.get("output_tokens", 0)
|
||||||
|
new_cached = usage.get("cache_read_tokens", 0)
|
||||||
|
new_cache_creation = usage.get("cache_creation_tokens", 0)
|
||||||
|
|
||||||
|
# 取最大值更新
|
||||||
|
if new_input > ctx.input_tokens:
|
||||||
|
ctx.input_tokens = new_input
|
||||||
|
if new_output > ctx.output_tokens:
|
||||||
|
ctx.output_tokens = new_output
|
||||||
|
if new_cached > ctx.cached_tokens:
|
||||||
|
ctx.cached_tokens = new_cached
|
||||||
|
if new_cache_creation > ctx.cache_creation_tokens:
|
||||||
|
ctx.cache_creation_tokens = new_cache_creation
|
||||||
|
|
||||||
|
# 保存最后一个非空 usage 作为 final_usage
|
||||||
|
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||||
|
ctx.final_usage = usage
|
||||||
|
|
||||||
|
# 提取文本内容(同样使用正确的解析器)
|
||||||
|
text = parser.extract_text_content(data)
|
||||||
|
if text:
|
||||||
|
ctx.append_text(text)
|
||||||
|
|
||||||
|
# 检查完成事件
|
||||||
|
if event_type in ("response.completed", "message_stop"):
|
||||||
|
ctx.has_completion = True
|
||||||
|
response_obj = data.get("response")
|
||||||
|
if isinstance(response_obj, dict):
|
||||||
|
ctx.final_response = response_obj
|
||||||
|
|
||||||
|
def _record_converted_chunks(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
converted_events: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
记录转换后的 chunk 数据到 parsed_chunks,并更新统计信息
|
||||||
|
|
||||||
|
当需要格式转换时,记录的是转换后的数据(客户端实际收到的格式);
|
||||||
|
同时更新 data_count、has_completion 等统计信息。
|
||||||
|
|
||||||
|
重要:此方法也从转换后的事件中提取 usage 信息,作为 _process_event_data
|
||||||
|
从原始数据提取的补充。这确保即使原始 Provider 数据中没有 usage(如 OpenAI
|
||||||
|
未设置 stream_options),也能从转换后的格式中获取。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
converted_events: 转换后的事件列表
|
||||||
|
"""
|
||||||
|
for evt in converted_events:
|
||||||
|
if isinstance(evt, dict):
|
||||||
|
ctx.data_count += 1
|
||||||
|
if ctx.record_parsed_chunks:
|
||||||
|
ctx.parsed_chunks.append(evt)
|
||||||
|
|
||||||
|
# 检测完成事件(根据客户端格式判断)
|
||||||
|
# OpenAI 格式: choices[].finish_reason
|
||||||
|
# Claude 格式: type == "message_stop" 或 stop_reason
|
||||||
|
event_type = evt.get("type", "")
|
||||||
|
if event_type == "message_stop":
|
||||||
|
ctx.has_completion = True
|
||||||
|
elif event_type == "response.completed":
|
||||||
|
ctx.has_completion = True
|
||||||
|
elif "choices" in evt:
|
||||||
|
choices = evt.get("choices", [])
|
||||||
|
for choice in choices:
|
||||||
|
if isinstance(choice, dict) and choice.get("finish_reason"):
|
||||||
|
ctx.has_completion = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 从转换后的事件中提取 usage(补充 _process_event_data 的提取)
|
||||||
|
# Claude 格式: message_delta.usage 或 message_start.message.usage
|
||||||
|
# OpenAI 格式: chunk.usage
|
||||||
|
self._extract_usage_from_converted_event(ctx, evt, event_type)
|
||||||
|
|
||||||
|
def _extract_usage_from_converted_event(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
evt: dict[str, Any],
|
||||||
|
event_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
从转换后的事件中提取 usage 信息
|
||||||
|
|
||||||
|
支持多种格式:
|
||||||
|
- Claude: message_delta.usage, message_start.message.usage
|
||||||
|
- OpenAI: chunk.usage
|
||||||
|
- Gemini: usageMetadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
evt: 转换后的事件
|
||||||
|
event_type: 事件类型
|
||||||
|
"""
|
||||||
|
usage: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
# Claude 格式: message_delta 或 message_start
|
||||||
|
if event_type == "message_delta":
|
||||||
|
usage = evt.get("usage")
|
||||||
|
elif event_type == "message_start":
|
||||||
|
message = evt.get("message", {})
|
||||||
|
if isinstance(message, dict):
|
||||||
|
usage = message.get("usage")
|
||||||
|
# OpenAI Responses API (openai:cli) 格式: response.completed 中 usage 嵌套在 response 对象内
|
||||||
|
elif event_type == "response.completed":
|
||||||
|
resp_obj = evt.get("response")
|
||||||
|
if isinstance(resp_obj, dict):
|
||||||
|
usage = resp_obj.get("usage")
|
||||||
|
# 兼容: 部分实现可能在顶层也有 usage
|
||||||
|
if not usage:
|
||||||
|
usage = evt.get("usage")
|
||||||
|
# OpenAI Chat 格式: 直接在 chunk 中
|
||||||
|
elif "usage" in evt:
|
||||||
|
usage = evt.get("usage")
|
||||||
|
# Gemini 格式: usageMetadata
|
||||||
|
elif "usageMetadata" in evt:
|
||||||
|
meta = evt.get("usageMetadata", {})
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
usage = {
|
||||||
|
"input_tokens": meta.get("promptTokenCount", 0),
|
||||||
|
"output_tokens": meta.get("candidatesTokenCount", 0),
|
||||||
|
"cache_read_tokens": meta.get("cachedContentTokenCount", 0),
|
||||||
|
"cache_creation_tokens": 0, # Gemini 目前不支持缓存创建
|
||||||
|
}
|
||||||
|
|
||||||
|
if usage and isinstance(usage, dict):
|
||||||
|
new_input = usage.get("input_tokens", 0) or 0
|
||||||
|
new_output = usage.get("output_tokens", 0) or 0
|
||||||
|
new_cached = usage.get("cache_read_tokens") or usage.get("cache_read_input_tokens") or 0
|
||||||
|
new_cache_creation = (
|
||||||
|
usage.get("cache_creation_tokens") or usage.get("cache_creation_input_tokens") or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 取最大值更新(与 _process_event_data 相同的策略)
|
||||||
|
if new_input > ctx.input_tokens:
|
||||||
|
ctx.input_tokens = new_input
|
||||||
|
logger.debug("[{}] 从转换后事件更新 input_tokens: {}", ctx.request_id, new_input)
|
||||||
|
if new_output > ctx.output_tokens:
|
||||||
|
ctx.output_tokens = new_output
|
||||||
|
logger.debug("[{}] 从转换后事件更新 output_tokens: {}", ctx.request_id, new_output)
|
||||||
|
if new_cached > ctx.cached_tokens:
|
||||||
|
ctx.cached_tokens = new_cached
|
||||||
|
if new_cache_creation > ctx.cache_creation_tokens:
|
||||||
|
ctx.cache_creation_tokens = new_cache_creation
|
||||||
|
|
||||||
|
# 保存最后一个非空 usage
|
||||||
|
if any([new_input, new_output, new_cached, new_cache_creation]):
|
||||||
|
ctx.final_usage = usage
|
||||||
|
|
||||||
|
def _finalize_stream_metadata(self, ctx: StreamContext) -> None:
|
||||||
|
"""
|
||||||
|
在记录统计前从 parsed_chunks 中提取额外的元数据 - 子类可覆盖
|
||||||
|
|
||||||
|
这是一个后处理钩子,在流传输完成后、记录 Usage 之前调用。
|
||||||
|
子类可以覆盖此方法从 ctx.parsed_chunks 中提取格式特定的元数据,
|
||||||
|
如 Gemini 的 modelVersion、token 统计等。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文,包含 parsed_chunks 和 response_metadata
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _needs_format_conversion(self, ctx: StreamContext) -> bool:
|
||||||
|
"""
|
||||||
|
[已废弃] 仅根据格式差异判断是否需要转换
|
||||||
|
|
||||||
|
警告:此方法只检查格式是否不同,不检查端点的 format_acceptance_config 配置!
|
||||||
|
正确的判断应使用候选筛选阶段的结果(ctx.needs_conversion),该结果由
|
||||||
|
is_format_compatible() 函数根据全局开关和端点配置计算得出。
|
||||||
|
|
||||||
|
此方法保留仅供调试和日志输出使用,流生成器中不应调用此方法。
|
||||||
|
|
||||||
|
当 Provider 的 API 格式与客户端请求的 API 格式不同时,需要转换响应。
|
||||||
|
例如:客户端请求 Claude 格式,但 Provider 返回 OpenAI 格式。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- CLAUDE 和 CLAUDE_CLI、GEMINI 和 GEMINI_CLI:格式相同,只是认证不同,可透传
|
||||||
|
- OPENAI 和 OPENAI_CLI:格式不同(Chat Completions vs Responses API),需要转换
|
||||||
|
"""
|
||||||
|
from src.core.api_format.metadata import can_passthrough_endpoint
|
||||||
|
from src.core.api_format.signature import normalize_signature_key
|
||||||
|
|
||||||
|
if not ctx.provider_api_format or not ctx.client_api_format:
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] _needs_format_conversion: "
|
||||||
|
f"provider_api_format={ctx.provider_api_format!r}, client_api_format={ctx.client_api_format!r} -> False (missing)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
provider_format = normalize_signature_key(str(ctx.provider_api_format))
|
||||||
|
client_format = normalize_signature_key(str(ctx.client_api_format))
|
||||||
|
|
||||||
|
# 1. 格式完全匹配 -> 不需要转换
|
||||||
|
if provider_format == client_format:
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] _needs_format_conversion: "
|
||||||
|
f"provider={provider_format}, client={client_format} -> False (exact match)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. 根据 data_format_id 判断是否可透传(可透传则不需要转换)
|
||||||
|
if can_passthrough_endpoint(client_format, provider_format):
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] _needs_format_conversion: "
|
||||||
|
f"provider={provider_format}, client={client_format} -> False (passthroughable)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 其他情况 -> 需要转换
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] _needs_format_conversion: "
|
||||||
|
f"provider={provider_format}, client={client_format} -> True"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _mark_first_output(self, ctx: StreamContext, state: dict[str, bool]) -> None:
|
||||||
|
"""
|
||||||
|
标记首次输出:记录 TTFB 并更新 streaming 状态
|
||||||
|
|
||||||
|
在第一次 yield 数据前调用,确保:
|
||||||
|
1. 首字时间 (TTFB) 已记录到 ctx
|
||||||
|
2. Usage 状态已更新为 streaming(包含 provider/key/TTFB 信息)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
state: 包含 first_yield 和 streaming_updated 的状态字典
|
||||||
|
"""
|
||||||
|
if state["first_yield"]:
|
||||||
|
ctx.record_first_byte_time(self.start_time)
|
||||||
|
state["first_yield"] = False
|
||||||
|
if not state["streaming_updated"]:
|
||||||
|
# 优先使用当前请求的 DB 会话同步更新,避免状态延迟或丢失
|
||||||
|
try:
|
||||||
|
from src.services.usage import UsageService
|
||||||
|
|
||||||
|
UsageService.update_usage_status(
|
||||||
|
db=self.db,
|
||||||
|
request_id=self.request_id,
|
||||||
|
status="streaming",
|
||||||
|
provider=ctx.provider_name,
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
provider_id=ctx.provider_id,
|
||||||
|
provider_endpoint_id=ctx.endpoint_id,
|
||||||
|
provider_api_key_id=ctx.key_id,
|
||||||
|
first_byte_time_ms=ctx.first_byte_time_ms,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[{}] 同步更新 streaming 状态失败: {}", self.request_id, e)
|
||||||
|
# 回退到后台任务更新
|
||||||
|
self._update_usage_to_streaming_with_ctx(ctx)
|
||||||
|
state["streaming_updated"] = True
|
||||||
|
|
||||||
|
def _convert_sse_line(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
line: str,
|
||||||
|
events: list, # noqa: ARG002 - 预留给上下文感知转换
|
||||||
|
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
|
"""
|
||||||
|
将 SSE 行从 Provider 格式转换为客户端格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
line: 原始 SSE 行
|
||||||
|
events: 当前累积的事件列表(预留参数,用于未来上下文感知转换如合并相邻事件)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(sse_lines, converted_events) 元组:
|
||||||
|
- sse_lines: 转换后的 SSE 行列表(一入多出),空列表表示跳过该行
|
||||||
|
- converted_events: 转换后的事件对象列表(用于记录到 parsed_chunks)
|
||||||
|
"""
|
||||||
|
# 空行直接返回
|
||||||
|
if not line or line.strip() == "":
|
||||||
|
return ([line] if line else [], [])
|
||||||
|
|
||||||
|
client_format = (ctx.client_api_format or "").strip().lower()
|
||||||
|
|
||||||
|
# [DONE] 标记处理:只有 OpenAI 客户端需要,Claude 客户端不需要
|
||||||
|
if line == "data: [DONE]":
|
||||||
|
if client_format.startswith("openai"):
|
||||||
|
return [line], []
|
||||||
|
else:
|
||||||
|
# Claude/Gemini 客户端不需要 [DONE] 标记
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
provider_format = (ctx.provider_api_format or "").strip().lower()
|
||||||
|
|
||||||
|
# 过滤上游控制行(id/retry),避免与目标格式混淆
|
||||||
|
if line.startswith(("id:", "retry:")):
|
||||||
|
return [], []
|
||||||
|
|
||||||
|
# 解析 SSE 行为 JSON 对象
|
||||||
|
data_obj, status = self._parse_sse_line_to_json(line, provider_format)
|
||||||
|
|
||||||
|
# 根据解析状态决定行为
|
||||||
|
if status == "empty" or status == "skip":
|
||||||
|
return [], []
|
||||||
|
if status == "invalid" or status == "passthrough":
|
||||||
|
return [line], []
|
||||||
|
|
||||||
|
behavior = get_provider_behavior(
|
||||||
|
provider_type=ctx.provider_type,
|
||||||
|
endpoint_sig=ctx.provider_api_format,
|
||||||
|
)
|
||||||
|
envelope = behavior.envelope
|
||||||
|
if envelope:
|
||||||
|
data_obj = envelope.unwrap_response(data_obj)
|
||||||
|
envelope.postprocess_unwrapped_response(model=ctx.model, data=data_obj)
|
||||||
|
|
||||||
|
# 初始化流式转换状态
|
||||||
|
if ctx.stream_conversion_state is None:
|
||||||
|
from src.core.api_format.conversion.stream_state import StreamState
|
||||||
|
|
||||||
|
# 使用客户端请求的模型(ctx.model),而非映射后的上游模型(ctx.mapped_model)
|
||||||
|
init_model = ctx.model or ""
|
||||||
|
logger.debug(
|
||||||
|
f"[{ctx.request_id}] StreamState init: ctx.model={ctx.model!r}, "
|
||||||
|
f"mapped_model={ctx.mapped_model!r}, using={init_model!r}"
|
||||||
|
)
|
||||||
|
ctx.stream_conversion_state = StreamState(
|
||||||
|
model=init_model,
|
||||||
|
message_id=ctx.response_id or ctx.request_id or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 执行格式转换
|
||||||
|
try:
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
# status == "ok" 时 data_obj 必定是有效的 dict(防御性检查)
|
||||||
|
if data_obj is None:
|
||||||
|
return [], []
|
||||||
|
converted_events = registry.convert_stream_chunk(
|
||||||
|
data_obj,
|
||||||
|
provider_format,
|
||||||
|
client_format,
|
||||||
|
state=ctx.stream_conversion_state,
|
||||||
|
)
|
||||||
|
result = _format_converted_events_to_sse(converted_events, client_format)
|
||||||
|
if result:
|
||||||
|
logger.debug(
|
||||||
|
f"[{getattr(ctx, 'request_id', 'unknown')}] 流式转换: "
|
||||||
|
f"{provider_format}->{client_format}, events={len(converted_events)}, "
|
||||||
|
f"first_output={result[0][:100] if result else 'empty'}..."
|
||||||
|
)
|
||||||
|
return result, converted_events
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("格式转换失败,透传原始数据: {}", e)
|
||||||
|
return [line], []
|
||||||
|
|
||||||
|
def _parse_sse_line_to_json(self, line: str, provider_format: str) -> tuple[Any | None, str]:
|
||||||
|
"""
|
||||||
|
解析 SSE 行为 JSON 对象
|
||||||
|
|
||||||
|
支持多种格式:
|
||||||
|
- 标准 SSE: "data: {...}"
|
||||||
|
- event+data 同行: "event: xxx data: {...}"
|
||||||
|
- Gemini JSON-array: 裸 JSON 行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: 原始 SSE 行
|
||||||
|
provider_format: Provider API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(parsed_json, status) 元组:
|
||||||
|
- (obj, "ok") - 解析成功
|
||||||
|
- (None, "empty") - 内容为空,应跳过
|
||||||
|
- (None, "invalid") - JSON 解析失败,应透传原始行
|
||||||
|
- (None, "skip") - 应跳过(如纯 event 行)
|
||||||
|
- (None, "passthrough") - 无法识别,应透传原始行
|
||||||
|
"""
|
||||||
|
# 标准 SSE: data: {...}
|
||||||
|
if line.startswith("data:"):
|
||||||
|
return _parse_sse_data_line(line)
|
||||||
|
|
||||||
|
# event + data 同行: event: xxx data: {...}
|
||||||
|
if line.startswith("event:") and " data:" in line:
|
||||||
|
return _parse_sse_event_data_line(line)
|
||||||
|
|
||||||
|
# 纯 event 行不参与转换
|
||||||
|
if line.startswith("event:"):
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
# Gemini JSON-array 格式
|
||||||
|
if provider_format.startswith("gemini"):
|
||||||
|
return _parse_gemini_json_array_line(line)
|
||||||
|
|
||||||
|
# 其他格式:无法识别,透传
|
||||||
|
return None, "passthrough"
|
||||||
File diff suppressed because it is too large
Load Diff
503
src/api/handlers/base/cli_monitor_mixin.py
Normal file
503
src/api/handlers/base/cli_monitor_mixin.py
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
"""CLI Handler - 监控/统计 Mixin"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from src.api.handlers.base.base_handler import MessageTelemetry
|
||||||
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
|
from src.api.handlers.base.utils import filter_proxy_response_headers
|
||||||
|
from src.core.error_utils import extract_client_error_message
|
||||||
|
from src.core.exceptions import (
|
||||||
|
ProviderAuthException,
|
||||||
|
ProviderRateLimitException,
|
||||||
|
ProviderTimeoutException,
|
||||||
|
ThinkingSignatureException,
|
||||||
|
)
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import User
|
||||||
|
from src.services.provider.behavior import get_provider_behavior
|
||||||
|
|
||||||
|
|
||||||
|
class CliMonitorMixin:
|
||||||
|
"""监控和统计相关方法的 Mixin"""
|
||||||
|
|
||||||
|
async def _create_monitored_stream(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
stream_generator: AsyncGenerator[bytes],
|
||||||
|
http_request: Request | None = None,
|
||||||
|
) -> AsyncGenerator[bytes]:
|
||||||
|
"""
|
||||||
|
创建带监控的流生成器
|
||||||
|
|
||||||
|
支持两种断连检测方式:
|
||||||
|
1. 如果提供了 http_request,使用后台任务主动检测客户端断连
|
||||||
|
2. 如果未提供,仅依赖 asyncio.CancelledError 被动检测
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: 流上下文
|
||||||
|
stream_generator: 底层流生成器
|
||||||
|
http_request: FastAPI Request 对象,用于检测客户端断连
|
||||||
|
"""
|
||||||
|
import time as time_module
|
||||||
|
|
||||||
|
last_chunk_time = time_module.time()
|
||||||
|
chunk_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
if http_request is not None:
|
||||||
|
# 使用后台任务检测断连,完全不阻塞流式传输
|
||||||
|
disconnected = False
|
||||||
|
|
||||||
|
async def check_disconnect_background() -> None:
|
||||||
|
nonlocal disconnected
|
||||||
|
while not disconnected and not ctx.has_completion:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
try:
|
||||||
|
if await http_request.is_disconnected():
|
||||||
|
disconnected = True
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
# 检测失败时不中断流,继续传输
|
||||||
|
logger.debug("ID:{} | 断连检测异常: {}", ctx.request_id, e)
|
||||||
|
|
||||||
|
# 启动后台检查任务
|
||||||
|
check_task = asyncio.create_task(check_disconnect_background())
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
if disconnected:
|
||||||
|
# 如果响应已完成,客户端断开不算失败
|
||||||
|
if ctx.has_completion:
|
||||||
|
logger.info(
|
||||||
|
f"ID:{ctx.request_id} | Client disconnected after completion"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("ID:{} | Client disconnected", ctx.request_id)
|
||||||
|
ctx.status_code = 499
|
||||||
|
ctx.error_message = "client_disconnected"
|
||||||
|
break
|
||||||
|
last_chunk_time = time_module.time()
|
||||||
|
chunk_count += 1
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
check_task.cancel()
|
||||||
|
try:
|
||||||
|
await check_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# 无 http_request,仅被动监控
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
last_chunk_time = time_module.time()
|
||||||
|
chunk_count += 1
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 注意:CancelledError 不等于"用户手动取消",它既可能是客户端断连触发,
|
||||||
|
# 也可能是服务端(重载/关停/内部取消)导致的协程取消。
|
||||||
|
# 这里尽量做一次"断连归因":仅当能确认客户端已断开时才记为 499 cancelled。
|
||||||
|
time_since_last_chunk = time_module.time() - last_chunk_time
|
||||||
|
|
||||||
|
is_client_disconnected = False
|
||||||
|
if http_request is not None:
|
||||||
|
try:
|
||||||
|
# shield + timeout: 避免在取消态下二次被 CancelledError 打断,尽力取到断连状态
|
||||||
|
# 限时 0.5s 防止极端情况下的阻塞
|
||||||
|
is_client_disconnected = await asyncio.wait_for(
|
||||||
|
asyncio.shield(http_request.is_disconnected()),
|
||||||
|
timeout=0.5,
|
||||||
|
)
|
||||||
|
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||||
|
# 无法在取消态/超时下完成断连检查,保守视为未知(不强行归因为客户端)
|
||||||
|
is_client_disconnected = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("ID:{} | cancel 断连检测失败: {}", ctx.request_id, e)
|
||||||
|
is_client_disconnected = False
|
||||||
|
|
||||||
|
# 如果响应已完成,不标记为失败/取消
|
||||||
|
if not ctx.has_completion:
|
||||||
|
if is_client_disconnected:
|
||||||
|
ctx.status_code = 499
|
||||||
|
ctx.error_message = "client_disconnected"
|
||||||
|
logger.warning(
|
||||||
|
f"ID:{ctx.request_id} | Stream cancelled by client: "
|
||||||
|
f"chunks={chunk_count}, "
|
||||||
|
f"has_completion={ctx.has_completion}, "
|
||||||
|
f"time_since_last_chunk={time_since_last_chunk:.2f}s, "
|
||||||
|
f"output_tokens={ctx.output_tokens}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 服务端中断(例如重载/关停/内部取消) -- 不应伪装成客户端取消
|
||||||
|
ctx.status_code = 503
|
||||||
|
ctx.error_message = "server_cancelled"
|
||||||
|
logger.error(
|
||||||
|
f"ID:{ctx.request_id} | Stream interrupted by server: "
|
||||||
|
f"chunks={chunk_count}, "
|
||||||
|
f"has_completion={ctx.has_completion}, "
|
||||||
|
f"time_since_last_chunk={time_since_last_chunk:.2f}s, "
|
||||||
|
f"output_tokens={ctx.output_tokens}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
ctx.status_code = 504
|
||||||
|
ctx.error_message = str(e)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
ctx.status_code = 500
|
||||||
|
ctx.error_message = str(e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _record_stream_stats(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
original_headers: dict[str, str],
|
||||||
|
original_request_body: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""在流完成后记录统计信息"""
|
||||||
|
try:
|
||||||
|
# 使用 self.start_time 作为时间基准,与首字时间保持一致
|
||||||
|
# 注意:不要把统计延迟算进响应时间里
|
||||||
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
if not ctx.provider_name:
|
||||||
|
logger.warning("[{}] 流式请求失败,未选中提供商", ctx.request_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
behavior = get_provider_behavior(
|
||||||
|
provider_type=ctx.provider_type,
|
||||||
|
endpoint_sig=ctx.provider_api_format,
|
||||||
|
)
|
||||||
|
envelope = behavior.envelope
|
||||||
|
if envelope:
|
||||||
|
envelope.on_http_status(
|
||||||
|
base_url=ctx.selected_base_url,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取新的 DB session
|
||||||
|
db_gen = get_db()
|
||||||
|
bg_db = next(db_gen)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.models.database import ApiKey as ApiKeyModel
|
||||||
|
|
||||||
|
user = bg_db.query(User).filter(User.id == ctx.user_id).first()
|
||||||
|
api_key = bg_db.query(ApiKeyModel).filter(ApiKeyModel.id == ctx.api_key_id).first()
|
||||||
|
|
||||||
|
if not user or not api_key:
|
||||||
|
logger.warning(
|
||||||
|
"[{}] 无法记录统计: user={} api_key={}",
|
||||||
|
ctx.request_id,
|
||||||
|
user is not None,
|
||||||
|
api_key is not None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
bg_telemetry = MessageTelemetry(
|
||||||
|
bg_db, user, api_key, ctx.request_id, self.client_ip
|
||||||
|
)
|
||||||
|
|
||||||
|
response_body = {
|
||||||
|
"chunks": ctx.parsed_chunks,
|
||||||
|
"metadata": {
|
||||||
|
"stream": True,
|
||||||
|
"total_chunks": len(ctx.parsed_chunks),
|
||||||
|
"data_count": ctx.data_count,
|
||||||
|
"has_completion": ctx.has_completion,
|
||||||
|
"response_time_ms": response_time_ms,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 使用实际发送给 Provider 的请求体(如果有),否则用原始请求体
|
||||||
|
actual_request_body = ctx.provider_request_body or original_request_body
|
||||||
|
|
||||||
|
# 根据状态码决定记录成功还是失败
|
||||||
|
# 499 = 客户端取消(不算系统失败);其他 4xx/5xx 视为失败
|
||||||
|
if ctx.status_code and ctx.status_code >= 400:
|
||||||
|
client_response_headers = ctx.client_response_headers or {
|
||||||
|
"content-type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.is_client_disconnected():
|
||||||
|
# 客户端取消:记录为 cancelled(不算系统失败)
|
||||||
|
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
|
||||||
|
await bg_telemetry.record_cancelled(
|
||||||
|
provider=ctx.provider_name or "unknown",
|
||||||
|
model=ctx.model,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
first_byte_time_ms=ctx.first_byte_time_ms,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
is_stream=True,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
provider_request_headers=ctx.provider_request_headers,
|
||||||
|
input_tokens=ctx.input_tokens,
|
||||||
|
output_tokens=ctx.output_tokens,
|
||||||
|
cache_creation_tokens=ctx.cache_creation_tokens,
|
||||||
|
cache_read_tokens=ctx.cached_tokens,
|
||||||
|
response_body=response_body,
|
||||||
|
response_headers=ctx.response_headers,
|
||||||
|
client_response_headers=client_response_headers,
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
request_metadata=request_metadata,
|
||||||
|
)
|
||||||
|
logger.debug("{} 流式响应被客户端取消", self.FORMAT_ID)
|
||||||
|
logger.info(
|
||||||
|
f"[CANCEL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
|
||||||
|
f"{ctx.status_code} | in:{ctx.input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 服务端/上游异常:记录为失败
|
||||||
|
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
|
||||||
|
await bg_telemetry.record_failure(
|
||||||
|
provider=ctx.provider_name or "unknown",
|
||||||
|
model=ctx.model,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
is_stream=True,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
provider_request_headers=ctx.provider_request_headers,
|
||||||
|
# 预估 token 信息(来自 message_start 事件)
|
||||||
|
input_tokens=ctx.input_tokens,
|
||||||
|
output_tokens=ctx.output_tokens,
|
||||||
|
cache_creation_tokens=ctx.cache_creation_tokens,
|
||||||
|
cache_read_tokens=ctx.cached_tokens,
|
||||||
|
response_body=response_body,
|
||||||
|
response_headers=ctx.response_headers,
|
||||||
|
client_response_headers=client_response_headers,
|
||||||
|
# 格式转换追踪
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
|
# 模型映射信息
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
request_metadata=request_metadata,
|
||||||
|
)
|
||||||
|
logger.debug("{} 流式响应中断", self.FORMAT_ID)
|
||||||
|
logger.info(
|
||||||
|
f"[FAIL] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name} | {response_time_ms}ms | "
|
||||||
|
f"{ctx.status_code} | in:{ctx.input_tokens} out:{ctx.output_tokens} cache:{ctx.cached_tokens}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 在记录统计前,允许子类从 parsed_chunks 中提取额外的元数据
|
||||||
|
self._finalize_stream_metadata(ctx)
|
||||||
|
|
||||||
|
# 流未正常完成(如上游截断/连接中断)且无 token 数据时,
|
||||||
|
# 从已收集的文本和请求体估算 tokens,避免 usage 记录为 0
|
||||||
|
if (
|
||||||
|
not ctx.has_completion
|
||||||
|
and ctx.data_count > 0
|
||||||
|
and ctx.input_tokens == 0
|
||||||
|
and ctx.output_tokens == 0
|
||||||
|
):
|
||||||
|
self._estimate_tokens_for_incomplete_stream(ctx, actual_request_body)
|
||||||
|
|
||||||
|
# 流式成功时,返回给客户端的是提供商响应头 + SSE 必需头
|
||||||
|
client_response_headers = filter_proxy_response_headers(ctx.response_headers)
|
||||||
|
client_response_headers.update(
|
||||||
|
{
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[{ctx.request_id}] 开始记录 Usage: "
|
||||||
|
f"provider={ctx.provider_name}, model={ctx.model}, "
|
||||||
|
f"in={ctx.input_tokens}, out={ctx.output_tokens}"
|
||||||
|
)
|
||||||
|
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
|
||||||
|
total_cost = await bg_telemetry.record_success(
|
||||||
|
provider=ctx.provider_name,
|
||||||
|
model=ctx.model,
|
||||||
|
input_tokens=ctx.input_tokens,
|
||||||
|
output_tokens=ctx.output_tokens,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
first_byte_time_ms=ctx.first_byte_time_ms, # 传递首字时间
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
response_headers=ctx.response_headers,
|
||||||
|
client_response_headers=client_response_headers,
|
||||||
|
response_body=response_body,
|
||||||
|
cache_creation_tokens=ctx.cache_creation_tokens,
|
||||||
|
cache_read_tokens=ctx.cached_tokens,
|
||||||
|
is_stream=True,
|
||||||
|
provider_request_headers=ctx.provider_request_headers,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
# 格式转换追踪
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
|
# Provider 侧追踪信息(用于记录真实成本)
|
||||||
|
provider_id=ctx.provider_id,
|
||||||
|
provider_endpoint_id=ctx.endpoint_id,
|
||||||
|
provider_api_key_id=ctx.key_id,
|
||||||
|
# 模型映射信息
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
# Provider 响应元数据(如 Gemini 的 modelVersion)
|
||||||
|
response_metadata=ctx.response_metadata if ctx.response_metadata else None,
|
||||||
|
request_metadata=request_metadata,
|
||||||
|
)
|
||||||
|
logger.debug("[{}] Usage 记录完成: cost=${:.6f}", ctx.request_id, total_cost)
|
||||||
|
# 简洁的请求完成摘要(两行格式)
|
||||||
|
ttfb_part = (
|
||||||
|
f" | TTFB: {ctx.first_byte_time_ms}ms" if ctx.first_byte_time_ms else ""
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[OK] {} | {} | {}{}\n Total: {}ms | in:{} out:{}",
|
||||||
|
self.request_id[:8],
|
||||||
|
ctx.model,
|
||||||
|
ctx.provider_name,
|
||||||
|
ttfb_part,
|
||||||
|
response_time_ms,
|
||||||
|
ctx.input_tokens or 0,
|
||||||
|
ctx.output_tokens or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新候选记录的最终状态和延迟时间
|
||||||
|
# 注意:RequestExecutor 会在流开始时过早地标记成功(只记录了连接建立的时间)
|
||||||
|
# 这里用流传输完成后的实际时间覆盖
|
||||||
|
if ctx.attempt_id:
|
||||||
|
from src.services.request.candidate import RequestCandidateService
|
||||||
|
|
||||||
|
# 计算候选自身的 TTFB
|
||||||
|
candidate_first_byte_time_ms: int | None = None
|
||||||
|
if ctx.first_byte_time_ms is not None:
|
||||||
|
candidate_first_byte_time_ms = (
|
||||||
|
RequestCandidateService.calculate_candidate_ttfb(
|
||||||
|
db=bg_db,
|
||||||
|
candidate_id=ctx.attempt_id,
|
||||||
|
request_start_time=self.start_time,
|
||||||
|
global_first_byte_time_ms=ctx.first_byte_time_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 根据状态码决定是成功还是失败
|
||||||
|
# 499 = 客户端断开连接,应标记为失败
|
||||||
|
# 503 = 服务不可用(如流中断),应标记为失败
|
||||||
|
if ctx.status_code and ctx.status_code >= 400:
|
||||||
|
# 请求链路追踪使用 upstream_response(原始响应),回退到 error_message(友好消息)
|
||||||
|
trace_error_message = (
|
||||||
|
ctx.upstream_response or ctx.error_message or f"HTTP {ctx.status_code}"
|
||||||
|
)
|
||||||
|
extra_data = {
|
||||||
|
"stream_completed": False,
|
||||||
|
"chunk_count": ctx.chunk_count,
|
||||||
|
"data_count": ctx.data_count,
|
||||||
|
}
|
||||||
|
if ctx.proxy_info:
|
||||||
|
extra_data["proxy"] = ctx.proxy_info
|
||||||
|
if candidate_first_byte_time_ms is not None:
|
||||||
|
extra_data["first_byte_time_ms"] = candidate_first_byte_time_ms
|
||||||
|
if ctx.is_client_disconnected():
|
||||||
|
RequestCandidateService.mark_candidate_cancelled(
|
||||||
|
db=bg_db,
|
||||||
|
candidate_id=ctx.attempt_id,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
latency_ms=response_time_ms,
|
||||||
|
extra_data=extra_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
RequestCandidateService.mark_candidate_failed(
|
||||||
|
db=bg_db,
|
||||||
|
candidate_id=ctx.attempt_id,
|
||||||
|
error_type="stream_error",
|
||||||
|
error_message=trace_error_message,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
latency_ms=response_time_ms,
|
||||||
|
extra_data=extra_data,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
extra_data = {
|
||||||
|
"stream_completed": True,
|
||||||
|
"chunk_count": ctx.chunk_count,
|
||||||
|
"data_count": ctx.data_count,
|
||||||
|
}
|
||||||
|
if ctx.proxy_info:
|
||||||
|
extra_data["proxy"] = ctx.proxy_info
|
||||||
|
if ctx.rectified:
|
||||||
|
extra_data["rectified"] = True
|
||||||
|
if candidate_first_byte_time_ms is not None:
|
||||||
|
extra_data["first_byte_time_ms"] = candidate_first_byte_time_ms
|
||||||
|
RequestCandidateService.mark_candidate_success(
|
||||||
|
db=bg_db,
|
||||||
|
candidate_id=ctx.attempt_id,
|
||||||
|
status_code=ctx.status_code,
|
||||||
|
latency_ms=response_time_ms,
|
||||||
|
extra_data=extra_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
bg_db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("记录流式统计信息时出错")
|
||||||
|
|
||||||
|
async def _record_stream_failure(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
error: Exception,
|
||||||
|
original_headers: dict[str, str],
|
||||||
|
original_request_body: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""记录流式请求失败"""
|
||||||
|
# 使用 self.start_time 作为时间基准,与首字时间保持一致
|
||||||
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
||||||
|
|
||||||
|
status_code = 503
|
||||||
|
if isinstance(error, ThinkingSignatureException):
|
||||||
|
status_code = 400
|
||||||
|
elif isinstance(error, ProviderAuthException):
|
||||||
|
status_code = 503
|
||||||
|
elif isinstance(error, ProviderRateLimitException):
|
||||||
|
status_code = 429
|
||||||
|
elif isinstance(error, ProviderTimeoutException):
|
||||||
|
status_code = 504
|
||||||
|
|
||||||
|
ctx.status_code = status_code
|
||||||
|
ctx.error_message = str(error)
|
||||||
|
|
||||||
|
# 使用实际发送给 Provider 的请求体(如果有),否则用原始请求体
|
||||||
|
actual_request_body = ctx.provider_request_body or original_request_body
|
||||||
|
|
||||||
|
# 失败时返回给客户端的是 JSON 错误响应
|
||||||
|
client_response_headers = {"content-type": "application/json"}
|
||||||
|
|
||||||
|
request_metadata = {"perf": ctx.perf_metrics} if ctx.perf_metrics else None
|
||||||
|
await self.telemetry.record_failure(
|
||||||
|
provider=ctx.provider_name or "unknown",
|
||||||
|
model=ctx.model,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=status_code,
|
||||||
|
error_message=extract_client_error_message(error),
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
is_stream=True,
|
||||||
|
api_format=ctx.api_format,
|
||||||
|
provider_request_headers=ctx.provider_request_headers,
|
||||||
|
response_headers=ctx.response_headers,
|
||||||
|
client_response_headers=client_response_headers,
|
||||||
|
# 格式转换追踪
|
||||||
|
endpoint_api_format=ctx.provider_api_format or None,
|
||||||
|
has_format_conversion=ctx.has_format_conversion,
|
||||||
|
# 模型映射信息
|
||||||
|
target_model=ctx.mapped_model,
|
||||||
|
request_metadata=request_metadata,
|
||||||
|
)
|
||||||
610
src/api/handlers/base/cli_prefetch_mixin.py
Normal file
610
src/api/handlers/base/cli_prefetch_mixin.py
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
"""CLI Handler - Prefetch 和错误检测 Mixin"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import codecs
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.api.handlers.base.parsers import get_parser_for_format
|
||||||
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
|
from src.api.handlers.base.utils import (
|
||||||
|
check_html_response,
|
||||||
|
check_prefetched_response_error,
|
||||||
|
)
|
||||||
|
from src.config.constants import StreamDefaults
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.core.exceptions import (
|
||||||
|
EmbeddedErrorException,
|
||||||
|
ProviderNotAvailableException,
|
||||||
|
ProviderTimeoutException,
|
||||||
|
)
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.services.provider.behavior import get_provider_behavior
|
||||||
|
from src.utils.sse_parser import SSEEventParser
|
||||||
|
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.database import Provider, ProviderEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class CliPrefetchMixin:
|
||||||
|
"""Prefetch 和错误检测相关方法的 Mixin"""
|
||||||
|
|
||||||
|
def _flush_remaining_sse_data(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
buffer: bytes,
|
||||||
|
decoder: codecs.IncrementalDecoder,
|
||||||
|
sse_parser: SSEEventParser,
|
||||||
|
*,
|
||||||
|
record_chunk: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
异常发生时 flush 残留的字节 buffer 和 SSE parser 内部缓冲区。
|
||||||
|
|
||||||
|
用于 StreamClosed / RemoteProtocolError 等场景:
|
||||||
|
连接断开可能恰好发生在最后一个 SSE 事件(如 response.completed)
|
||||||
|
的 data 行已收到、但终止空行尚未到达之时。此方法确保这些事件仍能被处理,
|
||||||
|
从而正确捕获 usage 等关键信息。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1) flush 字节 buffer 中的残余行
|
||||||
|
if buffer:
|
||||||
|
remaining = decoder.decode(buffer, True)
|
||||||
|
for line in remaining.split("\n"):
|
||||||
|
stripped = line.rstrip("\r")
|
||||||
|
events = sse_parser.feed_line(stripped)
|
||||||
|
for event in events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=record_chunk,
|
||||||
|
)
|
||||||
|
# 2) flush SSE parser 内部累积的未完成事件
|
||||||
|
for event in sse_parser.flush():
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=record_chunk,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# best-effort: 不应因 flush 失败影响后续流程
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _estimate_tokens_for_incomplete_stream(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
流未正常完成(无 response.completed)且 token 均为 0 时的兜底估算。
|
||||||
|
|
||||||
|
从已收集的输出文本和请求体粗略估算 token 数,确保 usage 记录不为 0。
|
||||||
|
估算采用 ~4 字符/token 的保守比例。
|
||||||
|
"""
|
||||||
|
# 输出 tokens:从已收集的文本估算
|
||||||
|
collected = ctx.collected_text
|
||||||
|
if collected:
|
||||||
|
ctx.output_tokens = max(1, len(collected) // 4)
|
||||||
|
|
||||||
|
# 输入 tokens:从请求体文本内容估算
|
||||||
|
try:
|
||||||
|
total_input_len = 0
|
||||||
|
instructions = request_body.get("instructions")
|
||||||
|
if isinstance(instructions, str):
|
||||||
|
total_input_len += len(instructions)
|
||||||
|
# OpenAI Responses API 使用 input 字段;Claude 使用 messages
|
||||||
|
input_items = request_body.get("input") or request_body.get("messages") or []
|
||||||
|
if isinstance(input_items, list):
|
||||||
|
for item in input_items:
|
||||||
|
if isinstance(item, str):
|
||||||
|
total_input_len += len(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
content = item.get("content", "")
|
||||||
|
if isinstance(content, str):
|
||||||
|
total_input_len += len(content)
|
||||||
|
elif isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
text = block.get("text", "")
|
||||||
|
if isinstance(text, str):
|
||||||
|
total_input_len += len(text)
|
||||||
|
if total_input_len > 0:
|
||||||
|
ctx.input_tokens = max(1, total_input_len // 4)
|
||||||
|
else:
|
||||||
|
# fallback: 整个请求体 JSON 大小
|
||||||
|
body_str = json.dumps(request_body, ensure_ascii=False)
|
||||||
|
ctx.input_tokens = max(1, len(body_str) // 4)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if ctx.input_tokens > 0 or ctx.output_tokens > 0:
|
||||||
|
logger.warning(
|
||||||
|
"[{}] 流未正常完成 (has_completion=False, data_count={}), "
|
||||||
|
"使用估算 tokens: in={}, out={}",
|
||||||
|
ctx.request_id,
|
||||||
|
ctx.data_count,
|
||||||
|
ctx.input_tokens,
|
||||||
|
ctx.output_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _prefetch_and_check_embedded_error(
|
||||||
|
self,
|
||||||
|
byte_iterator: Any,
|
||||||
|
provider: "Provider",
|
||||||
|
endpoint: "ProviderEndpoint",
|
||||||
|
ctx: StreamContext,
|
||||||
|
) -> list:
|
||||||
|
"""
|
||||||
|
预读流的前几行,检测嵌套错误
|
||||||
|
|
||||||
|
某些 Provider(如 Gemini)可能返回 HTTP 200,但在响应体中包含错误信息。
|
||||||
|
这种情况需要在流开始输出之前检测,以便触发重试逻辑。
|
||||||
|
|
||||||
|
同时检测 HTML 响应(通常是 base_url 配置错误导致返回网页)。
|
||||||
|
|
||||||
|
首次读取时会应用 TTFB(首字节超时)检测,超时则触发故障转移。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
byte_iterator: 字节流迭代器
|
||||||
|
provider: Provider 对象
|
||||||
|
endpoint: Endpoint 对象
|
||||||
|
ctx: 流上下文
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
预读的字节块列表(需要在后续流中先输出)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
EmbeddedErrorException: 如果检测到嵌套错误
|
||||||
|
ProviderNotAvailableException: 如果检测到 HTML 响应(配置错误)
|
||||||
|
ProviderTimeoutException: 如果首字节超时(TTFB timeout)
|
||||||
|
"""
|
||||||
|
prefetched_chunks: list = []
|
||||||
|
max_prefetch_lines = config.stream_prefetch_lines # 最多预读行数来检测错误
|
||||||
|
max_prefetch_bytes = StreamDefaults.MAX_PREFETCH_BYTES # 避免无换行响应导致 buffer 增长
|
||||||
|
total_prefetched_bytes = 0
|
||||||
|
buffer = b""
|
||||||
|
line_count = 0
|
||||||
|
should_stop = False
|
||||||
|
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取对应格式的解析器
|
||||||
|
provider_format = ctx.provider_api_format
|
||||||
|
if provider_format:
|
||||||
|
try:
|
||||||
|
provider_parser = get_parser_for_format(provider_format)
|
||||||
|
except KeyError:
|
||||||
|
provider_parser = self.parser
|
||||||
|
else:
|
||||||
|
provider_parser = self.parser
|
||||||
|
|
||||||
|
# 使用共享的 TTFB 超时函数读取首字节
|
||||||
|
# 优先使用 Provider 配置,否则使用全局配置
|
||||||
|
ttfb_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
|
||||||
|
first_chunk, aiter = await read_first_chunk_with_ttfb_timeout(
|
||||||
|
byte_iterator,
|
||||||
|
timeout=ttfb_timeout,
|
||||||
|
request_id=self.request_id,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
)
|
||||||
|
prefetched_chunks.append(first_chunk)
|
||||||
|
total_prefetched_bytes += len(first_chunk)
|
||||||
|
buffer += first_chunk
|
||||||
|
|
||||||
|
# 继续读取剩余的预读数据
|
||||||
|
async for chunk in aiter:
|
||||||
|
prefetched_chunks.append(chunk)
|
||||||
|
total_prefetched_bytes += len(chunk)
|
||||||
|
buffer += chunk
|
||||||
|
|
||||||
|
# 尝试按行解析缓冲区(SSE 格式)
|
||||||
|
while b"\n" in buffer:
|
||||||
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||||
|
try:
|
||||||
|
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
|
||||||
|
line = decoder.decode(line_bytes + b"\n", False).rstrip("\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{self.request_id}] 预读时 UTF-8 解码失败: {e}, "
|
||||||
|
f"bytes={line_bytes[:50]!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_count += 1
|
||||||
|
normalized_line = line.rstrip("\r")
|
||||||
|
|
||||||
|
# 检测 HTML 响应(base_url 配置错误的常见症状)
|
||||||
|
if check_html_response(normalized_line):
|
||||||
|
logger.error(
|
||||||
|
f" [{self.request_id}] 检测到 HTML 响应,可能是 base_url 配置错误: "
|
||||||
|
f"Provider={provider.name}, Endpoint={endpoint.id[:8]}..., "
|
||||||
|
f"base_url={endpoint.base_url}"
|
||||||
|
)
|
||||||
|
raise ProviderNotAvailableException(
|
||||||
|
"上游服务返回了非预期的响应格式",
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=200,
|
||||||
|
upstream_response=(
|
||||||
|
normalized_line[:500] if normalized_line else "(empty)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not normalized_line or normalized_line.startswith(":"):
|
||||||
|
# 空行或注释行,继续预读
|
||||||
|
if line_count >= max_prefetch_lines:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 尝试解析 SSE 数据
|
||||||
|
data_str = normalized_line
|
||||||
|
if normalized_line.startswith("data: "):
|
||||||
|
data_str = normalized_line[6:]
|
||||||
|
|
||||||
|
if data_str == "[DONE]":
|
||||||
|
should_stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# 不是有效 JSON,可能是部分数据,继续
|
||||||
|
if line_count >= max_prefetch_lines:
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 使用解析器检查是否为错误响应
|
||||||
|
if isinstance(data, dict) and provider_parser.is_error_response(data):
|
||||||
|
# 提取错误信息
|
||||||
|
parsed = provider_parser.parse_response(data, 200)
|
||||||
|
logger.warning(
|
||||||
|
f" [{self.request_id}] 检测到嵌套错误: "
|
||||||
|
f"Provider={provider.name}, "
|
||||||
|
f"error_type={parsed.error_type}, "
|
||||||
|
f"message={parsed.error_message}"
|
||||||
|
)
|
||||||
|
raise EmbeddedErrorException(
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
error_code=(
|
||||||
|
int(parsed.error_type)
|
||||||
|
if parsed.error_type and parsed.error_type.isdigit()
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
error_message=parsed.error_message,
|
||||||
|
error_status=parsed.error_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预读到有效数据,没有错误,停止预读
|
||||||
|
should_stop = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# 达到预读字节上限,停止继续预读(避免无换行响应导致内存增长)
|
||||||
|
if not should_stop and total_prefetched_bytes >= max_prefetch_bytes:
|
||||||
|
logger.debug(
|
||||||
|
f" [{self.request_id}] 预读达到字节上限,停止继续预读: "
|
||||||
|
f"Provider={provider.name}, bytes={total_prefetched_bytes}, "
|
||||||
|
f"max_bytes={max_prefetch_bytes}"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if should_stop or line_count >= max_prefetch_lines:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 预读结束后,检查是否为非 SSE 格式的 HTML/JSON 响应
|
||||||
|
# 处理某些代理返回的纯 JSON 错误(可能无换行/多行 JSON)以及 HTML 页面(base_url 配置错误)
|
||||||
|
if not should_stop and prefetched_chunks:
|
||||||
|
check_prefetched_response_error(
|
||||||
|
prefetched_chunks=prefetched_chunks,
|
||||||
|
parser=provider_parser,
|
||||||
|
request_id=self.request_id,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
endpoint_id=endpoint.id,
|
||||||
|
base_url=endpoint.base_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
except (EmbeddedErrorException, ProviderTimeoutException, ProviderNotAvailableException):
|
||||||
|
# 重新抛出可重试的 Provider 异常,触发故障转移
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
# 网络 I/O 异常:记录警告,可能需要重试
|
||||||
|
logger.warning(
|
||||||
|
" [{}] 预读流时发生网络异常: {}: {}", self.request_id, type(e).__name__, e
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# 未预期的严重异常:记录错误并重新抛出,避免掩盖问题
|
||||||
|
logger.error(
|
||||||
|
f" [{self.request_id}] 预读流时发生严重异常: {type(e).__name__}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return prefetched_chunks
|
||||||
|
|
||||||
|
async def _create_response_stream_with_prefetch(
|
||||||
|
self,
|
||||||
|
ctx: StreamContext,
|
||||||
|
byte_iterator: Any,
|
||||||
|
response_ctx: Any,
|
||||||
|
prefetched_chunks: list,
|
||||||
|
) -> AsyncGenerator[bytes]:
|
||||||
|
"""创建响应流生成器(带预读数据,使用字节流)"""
|
||||||
|
try:
|
||||||
|
sse_parser = SSEEventParser()
|
||||||
|
last_data_time = time.time()
|
||||||
|
buffer = b""
|
||||||
|
output_state = {"first_yield": True, "streaming_updated": False}
|
||||||
|
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
|
||||||
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||||
|
|
||||||
|
# 使用已设置的 ctx.needs_conversion(由候选筛选阶段根据端点配置判断)
|
||||||
|
# 不再调用 _needs_format_conversion,它只检查格式差异,不检查端点配置
|
||||||
|
needs_conversion = ctx.needs_conversion
|
||||||
|
behavior = get_provider_behavior(
|
||||||
|
provider_type=ctx.provider_type,
|
||||||
|
endpoint_sig=ctx.provider_api_format,
|
||||||
|
)
|
||||||
|
envelope = behavior.envelope
|
||||||
|
if envelope and envelope.force_stream_rewrite():
|
||||||
|
needs_conversion = True
|
||||||
|
ctx.needs_conversion = True
|
||||||
|
|
||||||
|
# Kiro 特殊处理:AWS Event Stream 二进制流需要重写为 SSE
|
||||||
|
ctx_provider_type = str(ctx.provider_type or "").strip().lower()
|
||||||
|
if ctx_provider_type == "kiro" and envelope and envelope.force_stream_rewrite():
|
||||||
|
from src.services.provider.adapters.kiro.eventstream_rewriter import (
|
||||||
|
apply_kiro_stream_rewrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
byte_iterator = apply_kiro_stream_rewrite(
|
||||||
|
byte_iterator,
|
||||||
|
model=str(ctx.model or ""),
|
||||||
|
input_tokens=int(ctx.input_tokens or 0),
|
||||||
|
prefetched_chunks=list(prefetched_chunks) if prefetched_chunks else None,
|
||||||
|
)
|
||||||
|
prefetched_chunks = []
|
||||||
|
|
||||||
|
# Kiro 重写后输出的是 Claude SSE 格式
|
||||||
|
# 客户端也是 Claude CLI,不需要再进行格式转换
|
||||||
|
needs_conversion = False
|
||||||
|
ctx.needs_conversion = False
|
||||||
|
|
||||||
|
# 先处理预读的字节块
|
||||||
|
for chunk in prefetched_chunks:
|
||||||
|
buffer += chunk
|
||||||
|
# 处理缓冲区中的完整行
|
||||||
|
while b"\n" in buffer:
|
||||||
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||||
|
try:
|
||||||
|
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
|
||||||
|
line = decoder.decode(line_bytes + b"\n", False).rstrip("\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{self.request_id}] UTF-8 解码失败: {e}, "
|
||||||
|
f"bytes={line_bytes[:50]!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_line = line.rstrip("\r")
|
||||||
|
events = sse_parser.feed_line(normalized_line)
|
||||||
|
|
||||||
|
if normalized_line == "":
|
||||||
|
for event in events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=not needs_conversion,
|
||||||
|
)
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield b"\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
ctx.chunk_count += 1
|
||||||
|
|
||||||
|
# 格式转换或直接透传
|
||||||
|
if needs_conversion:
|
||||||
|
converted_lines, converted_events = self._convert_sse_line(
|
||||||
|
ctx, line, events
|
||||||
|
)
|
||||||
|
# 记录转换后的数据到 parsed_chunks
|
||||||
|
self._record_converted_chunks(ctx, converted_events)
|
||||||
|
for converted_line in converted_lines:
|
||||||
|
if converted_line:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield (converted_line + "\n").encode("utf-8")
|
||||||
|
else:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield (line + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=not needs_conversion,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ctx.data_count > 0:
|
||||||
|
last_data_time = time.time()
|
||||||
|
|
||||||
|
# 继续处理剩余的流数据(使用同一个迭代器)
|
||||||
|
async for chunk in byte_iterator:
|
||||||
|
buffer += chunk
|
||||||
|
# 处理缓冲区中的完整行
|
||||||
|
while b"\n" in buffer:
|
||||||
|
line_bytes, buffer = buffer.split(b"\n", 1)
|
||||||
|
try:
|
||||||
|
# 使用增量解码器,可以正确处理跨 chunk 的多字节字符
|
||||||
|
line = decoder.decode(line_bytes + b"\n", False).rstrip("\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{self.request_id}] UTF-8 解码失败: {e}, "
|
||||||
|
f"bytes={line_bytes[:50]!r}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_line = line.rstrip("\r")
|
||||||
|
events = sse_parser.feed_line(normalized_line)
|
||||||
|
|
||||||
|
if normalized_line == "":
|
||||||
|
for event in events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=not needs_conversion,
|
||||||
|
)
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield b"\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
ctx.chunk_count += 1
|
||||||
|
|
||||||
|
# 空流检测:超过阈值且无数据,发送错误事件并结束
|
||||||
|
if ctx.chunk_count > self.EMPTY_CHUNK_THRESHOLD and ctx.data_count == 0:
|
||||||
|
elapsed = time.time() - last_data_time
|
||||||
|
if elapsed > self.DATA_TIMEOUT:
|
||||||
|
logger.warning("Provider '{}' 流超时且无数据", ctx.provider_name)
|
||||||
|
# 设置错误状态用于后续记录
|
||||||
|
ctx.status_code = 504
|
||||||
|
ctx.error_message = "流式响应超时,未收到有效数据"
|
||||||
|
ctx.upstream_response = f"流超时: Provider={ctx.provider_name}, elapsed={elapsed:.1f}s, chunk_count={ctx.chunk_count}, data_count=0"
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "empty_stream_timeout",
|
||||||
|
"message": ctx.error_message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 格式转换或直接透传
|
||||||
|
if needs_conversion:
|
||||||
|
converted_lines, converted_events = self._convert_sse_line(
|
||||||
|
ctx, line, events
|
||||||
|
)
|
||||||
|
# 记录转换后的数据到 parsed_chunks
|
||||||
|
self._record_converted_chunks(ctx, converted_events)
|
||||||
|
for converted_line in converted_lines:
|
||||||
|
if converted_line:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield (converted_line + "\n").encode("utf-8")
|
||||||
|
else:
|
||||||
|
self._mark_first_output(ctx, output_state)
|
||||||
|
yield (line + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=not needs_conversion,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ctx.data_count > 0:
|
||||||
|
last_data_time = time.time()
|
||||||
|
|
||||||
|
# 处理剩余事件
|
||||||
|
flushed_events = sse_parser.flush()
|
||||||
|
for event in flushed_events:
|
||||||
|
self._handle_sse_event(
|
||||||
|
ctx,
|
||||||
|
event.get("event"),
|
||||||
|
event.get("data") or "",
|
||||||
|
record_chunk=not needs_conversion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查是否收到数据
|
||||||
|
if ctx.data_count == 0:
|
||||||
|
# 空流通常意味着配置错误(如 base_url 指向了网页而非 API)
|
||||||
|
logger.error(
|
||||||
|
f"Provider '{ctx.provider_name}' 返回空流式响应 (收到 {ctx.chunk_count} 个非数据行), "
|
||||||
|
f"可能是 endpoint base_url 配置错误"
|
||||||
|
)
|
||||||
|
# 设置错误状态用于后续记录
|
||||||
|
ctx.status_code = 503
|
||||||
|
ctx.error_message = "上游服务返回了空的流式响应"
|
||||||
|
ctx.upstream_response = f"空流式响应: Provider={ctx.provider_name}, chunk_count={ctx.chunk_count}, data_count=0, 可能是 base_url 配置错误"
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "empty_response",
|
||||||
|
"message": ctx.error_message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||||
|
else:
|
||||||
|
logger.debug("流式数据转发完成")
|
||||||
|
# 为 OpenAI 客户端补齐 [DONE] 标记(非 CLI 格式)
|
||||||
|
client_fmt = (ctx.client_api_format or "").strip().lower()
|
||||||
|
if needs_conversion and client_fmt == "openai:chat":
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
except GeneratorExit:
|
||||||
|
raise
|
||||||
|
except httpx.StreamClosed:
|
||||||
|
# 连接关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage)
|
||||||
|
self._flush_remaining_sse_data(
|
||||||
|
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
|
||||||
|
)
|
||||||
|
if ctx.data_count == 0:
|
||||||
|
logger.warning("Provider '{}' 流连接关闭且无数据", ctx.provider_name)
|
||||||
|
# 设置错误状态用于后续记录
|
||||||
|
ctx.status_code = 503
|
||||||
|
ctx.error_message = "上游服务连接关闭且未返回数据"
|
||||||
|
ctx.upstream_response = f"流连接关闭: Provider={ctx.provider_name}, chunk_count={ctx.chunk_count}, data_count=0"
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "stream_closed",
|
||||||
|
"message": ctx.error_message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||||
|
except httpx.RemoteProtocolError:
|
||||||
|
# 连接异常关闭前 flush 残余数据,尝试捕获尾部事件(如 response.completed 中的 usage)
|
||||||
|
self._flush_remaining_sse_data(
|
||||||
|
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
|
||||||
|
)
|
||||||
|
if ctx.data_count > 0:
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "connection_error",
|
||||||
|
"message": "上游连接意外关闭,部分响应已成功传输",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
except httpx.ReadError:
|
||||||
|
# 代理/上游连接读取失败(如 aether-proxy 中断),与 RemoteProtocolError 处理逻辑一致
|
||||||
|
self._flush_remaining_sse_data(
|
||||||
|
ctx, buffer, decoder, sse_parser, record_chunk=not needs_conversion
|
||||||
|
)
|
||||||
|
if ctx.data_count > 0:
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {
|
||||||
|
"type": "connection_error",
|
||||||
|
"message": "代理或上游连接读取失败,部分响应已成功传输",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await response_ctx.__aexit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
319
src/api/handlers/base/cli_request_mixin.py
Normal file
319
src/api/handlers/base/cli_request_mixin.py
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"""CLI Handler - 请求准备 Mixin"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
)
|
||||||
|
|
||||||
|
from src.api.handlers.base.utils import get_format_converter_registry
|
||||||
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.core.api_format import EndpointDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class CliRequestMixin:
|
||||||
|
"""请求准备相关方法的 Mixin"""
|
||||||
|
|
||||||
|
async def _get_mapped_model(
|
||||||
|
self,
|
||||||
|
source_model: str,
|
||||||
|
provider_id: str,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取模型映射后的实际模型名
|
||||||
|
|
||||||
|
查找逻辑:
|
||||||
|
1. 直接通过 GlobalModel.name 匹配
|
||||||
|
2. 查找该 Provider 的 Model 实现
|
||||||
|
3. 使用 provider_model_name / provider_model_mappings 选择最终名称
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_model: 用户请求的模型名(必须是 GlobalModel.name)
|
||||||
|
provider_id: Provider ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
映射后的 Provider 模型名,如果没有找到映射则返回 None
|
||||||
|
"""
|
||||||
|
from src.services.model.mapper import ModelMapperMiddleware
|
||||||
|
|
||||||
|
mapper = ModelMapperMiddleware(self.db)
|
||||||
|
mapping = await mapper.get_mapping(source_model, provider_id)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mapping and mapping.model:
|
||||||
|
# 使用 select_provider_model_name 支持模型映射功能
|
||||||
|
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射
|
||||||
|
# 传入 api_format 用于过滤适用的映射作用域
|
||||||
|
affinity_key = self.api_key.id if self.api_key else None
|
||||||
|
mapped_name = mapping.model.select_provider_model_name(
|
||||||
|
affinity_key, api_format=self.FORMAT_ID
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[CLI] 模型映射: {source_model} -> {mapped_name} (provider={provider_id[:8]}...)"
|
||||||
|
)
|
||||||
|
return mapped_name
|
||||||
|
|
||||||
|
logger.debug("[CLI] 无模型映射,使用原始名称: {}", source_model)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_model_from_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
path_params: dict[str, Any] | None = None, # noqa: ARG002 - 子类使用
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
从请求中提取模型名 - 子类可覆盖
|
||||||
|
|
||||||
|
不同 API 格式的 model 位置不同:
|
||||||
|
- OpenAI/Claude: 在请求体中 request_body["model"]
|
||||||
|
- Gemini: 在 URL 路径中 path_params["model"]
|
||||||
|
|
||||||
|
子类应覆盖此方法实现各自的提取逻辑。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 请求体
|
||||||
|
path_params: URL 路径参数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
模型名,如果无法提取则返回 "unknown"
|
||||||
|
"""
|
||||||
|
# 默认实现:从请求体获取
|
||||||
|
model = request_body.get("model")
|
||||||
|
return str(model) if model else "unknown"
|
||||||
|
|
||||||
|
def apply_mapped_model(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
mapped_model: str, # noqa: ARG002 - 子类使用
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将映射后的模型名应用到请求体
|
||||||
|
|
||||||
|
基类默认实现:不修改请求体,保持原样透传。
|
||||||
|
子类应覆盖此方法实现各自的模型名替换逻辑。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 原始请求体
|
||||||
|
mapped_model: 映射后的模型名(子类使用)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
请求体(默认不修改)
|
||||||
|
"""
|
||||||
|
# 基类不修改请求体,子类覆盖此方法实现特定格式的处理
|
||||||
|
return request_body
|
||||||
|
|
||||||
|
def prepare_provider_request_body(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
准备发送给 Provider 的请求体 - 子类可覆盖
|
||||||
|
|
||||||
|
在模型映射之后、发送请求之前调用,用于移除不需要发送给上游的字段。
|
||||||
|
例如 Gemini API 需要移除请求体中的 model 字段(因为 model 在 URL 路径中)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 经过模型映射处理后的请求体
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
准备好的请求体
|
||||||
|
"""
|
||||||
|
return request_body
|
||||||
|
|
||||||
|
def finalize_provider_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
*,
|
||||||
|
mapped_model: str | None,
|
||||||
|
provider_api_format: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
格式转换完成后、envelope 之前的模型感知后处理钩子 - 子类可覆盖
|
||||||
|
|
||||||
|
用于根据目标模型的特性对请求体做最终调整,例如:
|
||||||
|
- 图像生成模型需要移除不兼容的 tools/system_instruction 并注入 imageConfig
|
||||||
|
- 特定模型需要注入/移除某些字段
|
||||||
|
- Gemini 格式:清理无效 parts 和合并连续同角色 contents
|
||||||
|
|
||||||
|
此方法在流式和非流式路径中均会被调用,且 mapped_model 已确定。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 已完成格式转换的请求体
|
||||||
|
mapped_model: 映射后的目标模型名
|
||||||
|
provider_api_format: Provider 侧 API 格式标识
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
调整后的请求体
|
||||||
|
"""
|
||||||
|
# Gemini 格式请求:清理无效 parts 和合并连续同角色 contents
|
||||||
|
# 跨格式转换(如 Claude -> Gemini)可能产生 thinking 等无法表示的块,
|
||||||
|
# 导致 parts 为空或缺少有效 data-oneof 字段,被 Google API 拒绝。
|
||||||
|
if provider_api_format and "gemini" in str(provider_api_format).lower():
|
||||||
|
contents = request_body.get("contents")
|
||||||
|
if isinstance(contents, list):
|
||||||
|
from src.core.api_format.conversion.normalizers.gemini import (
|
||||||
|
compact_gemini_contents,
|
||||||
|
)
|
||||||
|
|
||||||
|
request_body["contents"] = compact_gemini_contents(contents)
|
||||||
|
|
||||||
|
return request_body
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_format_metadata(format_id: str) -> "EndpointDefinition | None":
|
||||||
|
"""获取 endpoint 元数据(解析失败返回 None)"""
|
||||||
|
from src.core.api_format.metadata import resolve_endpoint_definition
|
||||||
|
|
||||||
|
return resolve_endpoint_definition(format_id)
|
||||||
|
|
||||||
|
def _finalize_converted_request(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
client_api_format: str,
|
||||||
|
provider_api_format: str,
|
||||||
|
mapped_model: str | None,
|
||||||
|
fallback_model: str,
|
||||||
|
is_stream: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
跨格式转换后统一设置并清理 model/stream 字段(原地修改)
|
||||||
|
|
||||||
|
处理逻辑:
|
||||||
|
1. 根据目标格式决定是否在 body 中设置 model
|
||||||
|
2. 若客户端格式不含 stream 字段但 Provider 需要,则显式设置
|
||||||
|
3. 移除目标格式不允许在 body 中携带的字段(如 Gemini 的 model/stream)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 转换后的请求体(会被原地修改)
|
||||||
|
client_api_format: 客户端 API 格式
|
||||||
|
provider_api_format: Provider API 格式
|
||||||
|
mapped_model: 映射后的模型名
|
||||||
|
fallback_model: 备用模型名
|
||||||
|
is_stream: 是否流式请求
|
||||||
|
"""
|
||||||
|
client_meta = self._get_format_metadata(client_api_format)
|
||||||
|
provider_meta = self._get_format_metadata(provider_api_format)
|
||||||
|
|
||||||
|
# 默认:model_in_body=True, stream_in_body=True(如 OpenAI/Claude)
|
||||||
|
client_uses_stream = client_meta.stream_in_body if client_meta else True
|
||||||
|
provider_model_in_body = provider_meta.model_in_body if provider_meta else True
|
||||||
|
provider_stream_in_body = provider_meta.stream_in_body if provider_meta else True
|
||||||
|
|
||||||
|
# 设置 model(仅当 Provider 允许且 body 中需要)
|
||||||
|
if provider_model_in_body:
|
||||||
|
request_body["model"] = mapped_model or fallback_model
|
||||||
|
else:
|
||||||
|
request_body.pop("model", None)
|
||||||
|
|
||||||
|
# 设置 stream(客户端不带但 Provider 需要时显式设置;Provider 不需要时移除)
|
||||||
|
if provider_stream_in_body:
|
||||||
|
if not client_uses_stream:
|
||||||
|
request_body["stream"] = is_stream
|
||||||
|
else:
|
||||||
|
request_body.pop("stream", None)
|
||||||
|
|
||||||
|
# OpenAI Chat Completions: request usage in streaming mode.
|
||||||
|
provider_fmt = str(provider_api_format or "").strip().lower()
|
||||||
|
if is_stream and provider_fmt == "openai:chat":
|
||||||
|
stream_options = request_body.get("stream_options")
|
||||||
|
if not isinstance(stream_options, dict):
|
||||||
|
stream_options = {}
|
||||||
|
stream_options["include_usage"] = True
|
||||||
|
request_body["stream_options"] = stream_options
|
||||||
|
|
||||||
|
def _convert_request_for_cross_format(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
client_api_format: str,
|
||||||
|
provider_api_format: str,
|
||||||
|
mapped_model: str | None,
|
||||||
|
fallback_model: str,
|
||||||
|
is_stream: bool,
|
||||||
|
*,
|
||||||
|
target_variant: str | None = None,
|
||||||
|
) -> tuple[dict[str, Any], str]:
|
||||||
|
"""
|
||||||
|
跨格式请求转换的公共逻辑
|
||||||
|
|
||||||
|
将客户端格式的请求体转换为 Provider 格式,并处理 model/stream 字段的补齐和清理。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 原始请求体(会被修改)
|
||||||
|
client_api_format: 客户端 API 格式
|
||||||
|
provider_api_format: Provider API 格式
|
||||||
|
mapped_model: 映射后的模型名
|
||||||
|
fallback_model: 备用模型名(通常是原始请求的 model)
|
||||||
|
is_stream: 是否流式请求
|
||||||
|
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(转换后的请求体, 用于 URL 的模型名)
|
||||||
|
"""
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
converted_body = registry.convert_request(
|
||||||
|
request_body,
|
||||||
|
str(client_api_format),
|
||||||
|
str(provider_api_format),
|
||||||
|
target_variant=target_variant,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
|
||||||
|
url_model = (
|
||||||
|
self.get_model_for_url(converted_body, mapped_model) or mapped_model or fallback_model
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统一设置并清理 model/stream 字段
|
||||||
|
self._finalize_converted_request(
|
||||||
|
converted_body,
|
||||||
|
str(client_api_format),
|
||||||
|
str(provider_api_format),
|
||||||
|
mapped_model,
|
||||||
|
fallback_model,
|
||||||
|
is_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
return converted_body, url_model
|
||||||
|
|
||||||
|
def get_model_for_url(
|
||||||
|
self,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
mapped_model: str | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
获取用于 URL 路径的模型名
|
||||||
|
|
||||||
|
某些 API 格式(如 Gemini)需要将 model 放入 URL 路径中。
|
||||||
|
子类应覆盖此方法返回正确的值。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_body: 请求体
|
||||||
|
mapped_model: 映射后的模型名(如果有)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
用于 URL 路径的模型名,默认优先使用映射后的名称
|
||||||
|
"""
|
||||||
|
return mapped_model or request_body.get("model")
|
||||||
|
|
||||||
|
def _extract_response_metadata(
|
||||||
|
self,
|
||||||
|
response: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
从响应中提取 Provider 特有的元数据 - 子类可覆盖
|
||||||
|
|
||||||
|
例如 Gemini 返回的 modelVersion 字段。
|
||||||
|
这些元数据会存储到 Usage.request_metadata 中。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
response: Provider 返回的响应
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
元数据字典,默认为空
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
105
src/api/handlers/base/cli_sse_helpers.py
Normal file
105
src/api/handlers/base/cli_sse_helpers.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""SSE 解析辅助函数"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_data_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""
|
||||||
|
解析标准 SSE data 行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: 以 "data:" 开头的 SSE 行
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(parsed_json, status) 元组:
|
||||||
|
- (parsed_dict, "ok") - 解析成功
|
||||||
|
- (None, "empty") - 内容为空
|
||||||
|
- (None, "invalid") - JSON 解析失败,调用方应透传原始行
|
||||||
|
"""
|
||||||
|
data_content = line[5:].strip()
|
||||||
|
if not data_content:
|
||||||
|
return None, "empty"
|
||||||
|
try:
|
||||||
|
return json.loads(data_content), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse_event_data_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""
|
||||||
|
解析 event + data 同行格式(如 "event: xxx data: {...}")
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: 以 "event:" 开头且包含 " data:" 的 SSE 行
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(parsed_json, status) 元组
|
||||||
|
"""
|
||||||
|
_event_part, data_part = line.split(" data:", 1)
|
||||||
|
data_content = data_part.strip()
|
||||||
|
try:
|
||||||
|
return json.loads(data_content), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_gemini_json_array_line(line: str) -> tuple[Any | None, str]:
|
||||||
|
"""
|
||||||
|
解析 Gemini JSON-array 格式的裸 JSON 行
|
||||||
|
|
||||||
|
Gemini 流式响应可能是 JSON 数组格式,每行是数组元素。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: 原始行(可能是 "[", "]", ",", 或 JSON 对象)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(parsed_json, status) 元组
|
||||||
|
"""
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped in ("", "[", "]", ","):
|
||||||
|
return None, "skip"
|
||||||
|
|
||||||
|
candidate = stripped.lstrip(",").rstrip(",").strip()
|
||||||
|
try:
|
||||||
|
return json.loads(candidate), "ok"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.debug("Gemini JSON-array line skip: {}", stripped[:50])
|
||||||
|
return None, "invalid"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_converted_events_to_sse(
|
||||||
|
converted_events: list[dict[str, Any]],
|
||||||
|
client_format: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
将转换后的事件格式化为 SSE 行
|
||||||
|
|
||||||
|
Args:
|
||||||
|
converted_events: 转换后的事件列表
|
||||||
|
client_format: 客户端 API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE 行列表(每个元素是完整的 SSE 事件,包含尾部空行)
|
||||||
|
"""
|
||||||
|
result: list[str] = []
|
||||||
|
needs_event_line = str(client_format or "").strip().lower().startswith("claude:")
|
||||||
|
|
||||||
|
for evt in converted_events:
|
||||||
|
payload = json.dumps(evt, ensure_ascii=False)
|
||||||
|
if needs_event_line:
|
||||||
|
evt_type = evt.get("type") if isinstance(evt, dict) else None
|
||||||
|
if isinstance(evt_type, str) and evt_type:
|
||||||
|
# Claude 格式:event + data + 空行
|
||||||
|
result.append(f"event: {evt_type}\ndata: {payload}\n")
|
||||||
|
else:
|
||||||
|
result.append(f"data: {payload}\n")
|
||||||
|
else:
|
||||||
|
# OpenAI 格式:data + 空行
|
||||||
|
result.append(f"data: {payload}\n")
|
||||||
|
|
||||||
|
return result
|
||||||
1001
src/api/handlers/base/cli_stream_mixin.py
Normal file
1001
src/api/handlers/base/cli_stream_mixin.py
Normal file
File diff suppressed because it is too large
Load Diff
650
src/api/handlers/base/cli_sync_mixin.py
Normal file
650
src/api/handlers/base/cli_sync_mixin.py
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
"""CLI Handler - 同步处理 Mixin"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from src.api.handlers.base.parsers import get_parser_for_format
|
||||||
|
from src.api.handlers.base.request_builder import get_provider_auth
|
||||||
|
from src.api.handlers.base.stream_context import extract_proxy_timing, is_format_converted
|
||||||
|
from src.api.handlers.base.upstream_stream_bridge import (
|
||||||
|
aggregate_upstream_stream_to_internal_response,
|
||||||
|
)
|
||||||
|
from src.api.handlers.base.utils import (
|
||||||
|
filter_proxy_response_headers,
|
||||||
|
get_format_converter_registry,
|
||||||
|
)
|
||||||
|
from src.config.settings import config
|
||||||
|
from src.core.error_utils import extract_client_error_message
|
||||||
|
from src.core.exceptions import (
|
||||||
|
ProviderAuthException,
|
||||||
|
ProviderNotAvailableException,
|
||||||
|
ProviderRateLimitException,
|
||||||
|
ProviderTimeoutException,
|
||||||
|
ThinkingSignatureException,
|
||||||
|
)
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.services.cache.aware_scheduler import ProviderCandidate
|
||||||
|
from src.services.provider.behavior import get_provider_behavior
|
||||||
|
from src.services.provider.stream_policy import (
|
||||||
|
enforce_stream_mode_for_upstream,
|
||||||
|
get_upstream_stream_policy,
|
||||||
|
resolve_upstream_is_stream,
|
||||||
|
)
|
||||||
|
from src.services.provider.transport import build_provider_url
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
class CliSyncMixin:
|
||||||
|
"""同步处理相关方法的 Mixin"""
|
||||||
|
|
||||||
|
async def process_sync(
|
||||||
|
self,
|
||||||
|
original_request_body: dict[str, Any],
|
||||||
|
original_headers: dict[str, str],
|
||||||
|
query_params: dict[str, str] | None = None,
|
||||||
|
path_params: dict[str, Any] | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
处理非流式请求
|
||||||
|
|
||||||
|
通用流程:
|
||||||
|
1. 构建请求
|
||||||
|
2. 通过 TaskService/FailoverEngine 执行
|
||||||
|
3. 解析响应并记录统计
|
||||||
|
"""
|
||||||
|
logger.debug("开始非流式响应处理 ({})", self.FORMAT_ID)
|
||||||
|
|
||||||
|
# 使用子类实现的方法提取 model(不同 API 格式的 model 位置不同)
|
||||||
|
model = self.extract_model_from_request(original_request_body, path_params)
|
||||||
|
api_format = self.allowed_api_formats[0]
|
||||||
|
sync_start_time = time.time()
|
||||||
|
|
||||||
|
# 提前创建 pending 记录,让前端可以立即看到"处理中"
|
||||||
|
self._create_pending_usage(
|
||||||
|
model=model,
|
||||||
|
is_stream=False,
|
||||||
|
request_type="chat",
|
||||||
|
api_format=self.FORMAT_ID,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_name = None
|
||||||
|
response_json = None
|
||||||
|
status_code = 200
|
||||||
|
response_headers = {}
|
||||||
|
provider_api_format = "" # 用于追踪 Provider 的 API 格式
|
||||||
|
provider_request_headers = {} # 发送给 Provider 的请求头
|
||||||
|
provider_request_body = None # 实际发送给 Provider 的请求体
|
||||||
|
provider_id = None # Provider ID(用于失败记录)
|
||||||
|
endpoint_id = None # Endpoint ID(用于失败记录)
|
||||||
|
key_id = None # Key ID(用于失败记录)
|
||||||
|
mapped_model_result = None # 映射后的目标模型名(用于 Usage 记录)
|
||||||
|
response_metadata_result: dict[str, Any] = {} # Provider 响应元数据
|
||||||
|
needs_conversion = False # 是否需要格式转换(由 candidate 决定)
|
||||||
|
sync_proxy_info: dict[str, Any] | None = None # 代理信息
|
||||||
|
|
||||||
|
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
|
||||||
|
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
|
||||||
|
request_body_ref: dict[str, Any] = {"body": original_request_body}
|
||||||
|
|
||||||
|
async def sync_request_func(
|
||||||
|
provider: "Provider",
|
||||||
|
endpoint: "ProviderEndpoint",
|
||||||
|
key: "ProviderAPIKey",
|
||||||
|
candidate: ProviderCandidate,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
nonlocal provider_name, response_json, status_code, response_headers, provider_api_format, provider_request_headers, provider_request_body, mapped_model_result, response_metadata_result, needs_conversion, sync_proxy_info
|
||||||
|
provider_name = str(provider.name)
|
||||||
|
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||||
|
|
||||||
|
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
|
||||||
|
mapped_model = candidate.mapping_matched_model if candidate else None
|
||||||
|
if not mapped_model:
|
||||||
|
mapped_model = await self._get_mapped_model(
|
||||||
|
source_model=model,
|
||||||
|
provider_id=str(provider.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 应用模型映射到请求体(子类可覆盖此方法处理不同格式)
|
||||||
|
if mapped_model:
|
||||||
|
mapped_model_result = mapped_model # 保存映射后的模型名,用于 Usage 记录
|
||||||
|
request_body = self.apply_mapped_model(request_body_ref["body"], mapped_model)
|
||||||
|
else:
|
||||||
|
request_body = dict(request_body_ref["body"])
|
||||||
|
|
||||||
|
client_api_format = (
|
||||||
|
api_format.value if hasattr(api_format, "value") else str(api_format)
|
||||||
|
)
|
||||||
|
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
|
||||||
|
|
||||||
|
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||||
|
behavior = get_provider_behavior(
|
||||||
|
provider_type=provider_type,
|
||||||
|
endpoint_sig=provider_api_format,
|
||||||
|
)
|
||||||
|
envelope = behavior.envelope
|
||||||
|
target_variant = behavior.same_format_variant
|
||||||
|
# 跨格式转换也允许变体(Antigravity 需要保留/翻译 Claude thinking 块)
|
||||||
|
conversion_variant = behavior.cross_format_variant
|
||||||
|
|
||||||
|
# Upstream streaming policy (per-endpoint).
|
||||||
|
upstream_policy = get_upstream_stream_policy(
|
||||||
|
endpoint,
|
||||||
|
provider_type=provider_type,
|
||||||
|
endpoint_sig=provider_api_format,
|
||||||
|
)
|
||||||
|
upstream_is_stream = resolve_upstream_is_stream(
|
||||||
|
client_is_stream=False,
|
||||||
|
policy=upstream_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跨格式:先做请求体转换(失败触发 failover)
|
||||||
|
if needs_conversion and provider_api_format:
|
||||||
|
request_body, url_model = self._convert_request_for_cross_format(
|
||||||
|
request_body,
|
||||||
|
client_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
mapped_model,
|
||||||
|
model,
|
||||||
|
is_stream=upstream_is_stream,
|
||||||
|
target_variant=conversion_variant,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
||||||
|
request_body = self.prepare_provider_request_body(request_body)
|
||||||
|
url_model = (
|
||||||
|
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
|
||||||
|
)
|
||||||
|
# 同格式时也需要应用 target_variant 转换(如 Codex)
|
||||||
|
if target_variant and provider_api_format:
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
request_body = registry.convert_request(
|
||||||
|
request_body,
|
||||||
|
provider_api_format,
|
||||||
|
provider_api_format,
|
||||||
|
target_variant=target_variant,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
|
||||||
|
request_body = self.finalize_provider_request(
|
||||||
|
request_body,
|
||||||
|
mapped_model=mapped_model,
|
||||||
|
provider_api_format=provider_api_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Force upstream stream/sync mode in request body (best-effort).
|
||||||
|
if provider_api_format:
|
||||||
|
enforce_stream_mode_for_upstream(
|
||||||
|
request_body,
|
||||||
|
provider_api_format=provider_api_format,
|
||||||
|
upstream_is_stream=upstream_is_stream,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取认证信息(处理 Service Account 等异步认证场景)
|
||||||
|
auth_info = await get_provider_auth(endpoint, key)
|
||||||
|
|
||||||
|
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
|
||||||
|
if envelope:
|
||||||
|
request_body, url_model = envelope.wrap_request(
|
||||||
|
request_body,
|
||||||
|
model=url_model or model or "",
|
||||||
|
url_model=url_model,
|
||||||
|
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
|
||||||
|
extra_headers: dict[str, str] = {}
|
||||||
|
if envelope:
|
||||||
|
extra_headers.update(envelope.extra_headers() or {})
|
||||||
|
|
||||||
|
# 使用 RequestBuilder 构建请求体和请求头
|
||||||
|
# 注意:mapped_model 已经应用到 request_body,这里不再传递
|
||||||
|
# 上游始终使用 header 认证,不跟随客户端的 query 方式
|
||||||
|
provider_payload, provider_headers = self._request_builder.build(
|
||||||
|
request_body,
|
||||||
|
original_headers,
|
||||||
|
endpoint,
|
||||||
|
key,
|
||||||
|
is_stream=upstream_is_stream,
|
||||||
|
extra_headers=extra_headers if extra_headers else None,
|
||||||
|
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
|
||||||
|
)
|
||||||
|
if upstream_is_stream:
|
||||||
|
from src.core.api_format.headers import set_accept_if_absent
|
||||||
|
|
||||||
|
set_accept_if_absent(provider_headers)
|
||||||
|
|
||||||
|
# 保存发送给 Provider 的请求信息(用于调试和统计)
|
||||||
|
provider_request_headers = provider_headers
|
||||||
|
provider_request_body = provider_payload
|
||||||
|
|
||||||
|
url = build_provider_url(
|
||||||
|
endpoint,
|
||||||
|
query_params=query_params,
|
||||||
|
path_params={"model": url_model},
|
||||||
|
is_stream=upstream_is_stream, # sync handler may still force upstream streaming
|
||||||
|
key=key,
|
||||||
|
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
|
||||||
|
)
|
||||||
|
# 非流式:必须在 build_provider_url 调用后立即缓存(避免 contextvar 被后续调用覆盖)
|
||||||
|
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
|
||||||
|
|
||||||
|
# 解析有效代理(Key 级别优先于 Provider 级别)
|
||||||
|
from src.services.proxy_node.resolver import (
|
||||||
|
get_proxy_label,
|
||||||
|
resolve_effective_proxy,
|
||||||
|
resolve_proxy_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
_effective_proxy = resolve_effective_proxy(provider.proxy, getattr(key, "proxy", None))
|
||||||
|
sync_proxy_info = resolve_proxy_info(_effective_proxy)
|
||||||
|
_proxy_label = get_proxy_label(sync_proxy_info)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f" └─ [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
|
||||||
|
f"Provider={provider.name}, Endpoint={endpoint.id[:8] if endpoint.id else 'N/A'}..., "
|
||||||
|
f"Key=***{key.api_key[-4:] if key.api_key else 'N/A'}, "
|
||||||
|
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}, "
|
||||||
|
f"代理={_proxy_label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取复用的 HTTP 客户端(支持代理配置,Key 级别优先于 Provider 级别)
|
||||||
|
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
|
||||||
|
from src.clients.http_client import HTTPClientPool
|
||||||
|
from src.services.proxy_node.resolver import (
|
||||||
|
build_post_kwargs,
|
||||||
|
build_stream_kwargs,
|
||||||
|
resolve_delegate_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 非流式请求使用 http_request_timeout 作为整体超时
|
||||||
|
# 优先使用 Provider 配置,否则使用全局配置
|
||||||
|
request_timeout = provider.request_timeout or config.http_request_timeout
|
||||||
|
|
||||||
|
delegate_cfg = resolve_delegate_config(_effective_proxy)
|
||||||
|
http_client = await HTTPClientPool.get_upstream_client(
|
||||||
|
delegate_cfg, proxy_config=_effective_proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注意:不使用 async with,因为复用的客户端不应该被关闭
|
||||||
|
# 超时通过 timeout 参数控制
|
||||||
|
resp: httpx.Response | None = None
|
||||||
|
if not upstream_is_stream:
|
||||||
|
try:
|
||||||
|
_pkw = build_post_kwargs(
|
||||||
|
delegate_cfg,
|
||||||
|
url=url,
|
||||||
|
headers=provider_headers,
|
||||||
|
payload=provider_payload,
|
||||||
|
timeout=request_timeout,
|
||||||
|
)
|
||||||
|
resp = await http_client.post(**_pkw)
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
|
||||||
|
if envelope:
|
||||||
|
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
|
||||||
|
if selected_base_url_cached:
|
||||||
|
logger.warning(
|
||||||
|
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
# Forced upstream streaming: aggregate SSE to a sync JSON response.
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
provider_parser = (
|
||||||
|
get_parser_for_format(provider_api_format) if provider_api_format else None
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_stream_args = build_stream_kwargs(
|
||||||
|
delegate_cfg,
|
||||||
|
url=url,
|
||||||
|
headers=provider_headers,
|
||||||
|
payload=provider_payload,
|
||||||
|
timeout=request_timeout,
|
||||||
|
)
|
||||||
|
async with http_client.stream(**_stream_args) as stream_resp:
|
||||||
|
resp = stream_resp
|
||||||
|
|
||||||
|
status_code = stream_resp.status_code
|
||||||
|
response_headers = dict(stream_resp.headers)
|
||||||
|
extract_proxy_timing(sync_proxy_info, response_headers)
|
||||||
|
|
||||||
|
if envelope:
|
||||||
|
envelope.on_http_status(
|
||||||
|
base_url=selected_base_url_cached,
|
||||||
|
status_code=status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_resp.raise_for_status()
|
||||||
|
|
||||||
|
byte_iter = stream_resp.aiter_bytes()
|
||||||
|
if provider_type == "kiro" and envelope and envelope.force_stream_rewrite():
|
||||||
|
from src.services.provider.adapters.kiro.eventstream_rewriter import (
|
||||||
|
apply_kiro_stream_rewrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
byte_iter = apply_kiro_stream_rewrite(byte_iter, model=str(model or ""))
|
||||||
|
|
||||||
|
internal_resp = await aggregate_upstream_stream_to_internal_response(
|
||||||
|
byte_iter,
|
||||||
|
provider_api_format=provider_api_format,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
model=str(model or ""),
|
||||||
|
request_id=str(self.request_id or ""),
|
||||||
|
envelope=envelope,
|
||||||
|
provider_parser=provider_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
tgt_norm = (
|
||||||
|
registry.get_normalizer(client_api_format)
|
||||||
|
if client_api_format
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if tgt_norm is None:
|
||||||
|
raise RuntimeError(f"未注册 Normalizer: {client_api_format}")
|
||||||
|
|
||||||
|
response_json = tgt_norm.response_from_internal(
|
||||||
|
internal_resp,
|
||||||
|
requested_model=model,
|
||||||
|
)
|
||||||
|
response_json = response_json if isinstance(response_json, dict) else {}
|
||||||
|
|
||||||
|
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
|
||||||
|
if envelope:
|
||||||
|
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
|
||||||
|
if selected_base_url_cached:
|
||||||
|
logger.warning(
|
||||||
|
f"[{envelope.name}] Connection error: {selected_base_url_cached} ({e})"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
status_code = resp.status_code
|
||||||
|
response_headers = dict(resp.headers)
|
||||||
|
extract_proxy_timing(sync_proxy_info, response_headers)
|
||||||
|
|
||||||
|
if envelope:
|
||||||
|
envelope.on_http_status(base_url=selected_base_url_cached, status_code=status_code)
|
||||||
|
|
||||||
|
# Forced upstream streaming already built response_json via aggregator.
|
||||||
|
if upstream_is_stream:
|
||||||
|
response_metadata_result = self._extract_response_metadata(response_json or {})
|
||||||
|
return response_json if isinstance(response_json, dict) else {}
|
||||||
|
|
||||||
|
# Reuse HTTPStatusError classification path (handled by TaskService/error_classifier).
|
||||||
|
try:
|
||||||
|
resp.raise_for_status()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_body = ""
|
||||||
|
try:
|
||||||
|
error_body = resp.text[:4000] if resp.text else ""
|
||||||
|
except Exception:
|
||||||
|
error_body = ""
|
||||||
|
e.upstream_response = error_body # type: ignore[attr-defined]
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 安全解析 JSON 响应,处理可能的编码错误
|
||||||
|
try:
|
||||||
|
response_json = resp.json()
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||||
|
# 获取原始响应内容用于调试(存入 upstream_response)
|
||||||
|
content_type = resp.headers.get("content-type", "unknown")
|
||||||
|
content_encoding = resp.headers.get("content-encoding", "none")
|
||||||
|
raw_content = ""
|
||||||
|
try:
|
||||||
|
raw_content = resp.text[:500] if resp.text else "(empty)"
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
raw_content = repr(resp.content[:500]) if resp.content else "(empty)"
|
||||||
|
except Exception:
|
||||||
|
raw_content = "(unable to read)"
|
||||||
|
logger.error(
|
||||||
|
f"[{self.request_id}] 无法解析响应 JSON: {e}, "
|
||||||
|
f"Content-Type: {content_type}, Content-Encoding: {content_encoding}, "
|
||||||
|
f"响应长度: {len(resp.content)} bytes, 原始内容: {raw_content}"
|
||||||
|
)
|
||||||
|
# 判断错误类型,生成友好的客户端错误消息(不暴露提供商信息)
|
||||||
|
if raw_content == "(empty)" or not raw_content.strip():
|
||||||
|
client_message = "上游服务返回了空响应"
|
||||||
|
elif raw_content.strip().startswith(("<", "<!doctype", "<!DOCTYPE")):
|
||||||
|
client_message = "上游服务返回了非预期的响应格式"
|
||||||
|
else:
|
||||||
|
client_message = "上游服务返回了无效的响应"
|
||||||
|
raise ProviderNotAvailableException(
|
||||||
|
client_message,
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=resp.status_code,
|
||||||
|
upstream_response=raw_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
if envelope:
|
||||||
|
response_json = envelope.unwrap_response(response_json)
|
||||||
|
envelope.postprocess_unwrapped_response(model=model, data=response_json)
|
||||||
|
|
||||||
|
# 提取 Provider 响应元数据(子类可覆盖)
|
||||||
|
response_metadata_result = self._extract_response_metadata(response_json)
|
||||||
|
|
||||||
|
return response_json if isinstance(response_json, dict) else {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 解析能力需求
|
||||||
|
capability_requirements = self._resolve_capability_requirements(
|
||||||
|
model_name=model,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=original_request_body,
|
||||||
|
)
|
||||||
|
preferred_key_ids = await self._resolve_preferred_key_ids(
|
||||||
|
model_name=model,
|
||||||
|
request_body=original_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 统一入口:总是通过 TaskService
|
||||||
|
from src.services.task import TaskService
|
||||||
|
from src.services.task.context import TaskMode
|
||||||
|
|
||||||
|
exec_result = await TaskService(self.db, self.redis).execute(
|
||||||
|
task_type="cli",
|
||||||
|
task_mode=TaskMode.SYNC,
|
||||||
|
api_format=api_format,
|
||||||
|
model_name=model,
|
||||||
|
user_api_key=self.api_key,
|
||||||
|
request_func=sync_request_func,
|
||||||
|
request_id=self.request_id,
|
||||||
|
is_stream=False,
|
||||||
|
capability_requirements=capability_requirements or None,
|
||||||
|
preferred_key_ids=preferred_key_ids or None,
|
||||||
|
request_body_ref=request_body_ref,
|
||||||
|
)
|
||||||
|
result = exec_result.response
|
||||||
|
actual_provider_name = exec_result.provider_name or "unknown"
|
||||||
|
attempt_id = exec_result.request_candidate_id
|
||||||
|
provider_id = exec_result.provider_id
|
||||||
|
endpoint_id = exec_result.endpoint_id
|
||||||
|
key_id = exec_result.key_id
|
||||||
|
|
||||||
|
provider_name = actual_provider_name
|
||||||
|
response_time_ms = int((time.time() - sync_start_time) * 1000)
|
||||||
|
|
||||||
|
# 确保 response_json 不为 None
|
||||||
|
if response_json is None:
|
||||||
|
response_json = {}
|
||||||
|
|
||||||
|
# 跨格式:响应转换回 client_format(失败不触发 failover,保守回退为原始响应)
|
||||||
|
if (
|
||||||
|
needs_conversion
|
||||||
|
and provider_api_format
|
||||||
|
and api_format
|
||||||
|
and isinstance(response_json, dict)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
registry = get_format_converter_registry()
|
||||||
|
response_json = registry.convert_response(
|
||||||
|
response_json,
|
||||||
|
provider_api_format,
|
||||||
|
api_format,
|
||||||
|
requested_model=model, # 使用用户请求的原始模型名
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"非流式响应格式转换完成: {} -> {}", provider_api_format, api_format
|
||||||
|
)
|
||||||
|
except Exception as conv_err:
|
||||||
|
logger.warning("非流式响应格式转换失败,使用原始响应: {}", conv_err)
|
||||||
|
|
||||||
|
# 使用解析器提取 usage
|
||||||
|
usage = self.parser.extract_usage_from_response(response_json)
|
||||||
|
input_tokens = usage.get("input_tokens", 0)
|
||||||
|
output_tokens = usage.get("output_tokens", 0)
|
||||||
|
cached_tokens = usage.get("cache_read_tokens", 0)
|
||||||
|
cache_creation_tokens = usage.get("cache_creation_tokens", 0)
|
||||||
|
|
||||||
|
output_text = self.parser.extract_text_content(response_json)[:200]
|
||||||
|
|
||||||
|
# 使用实际发送给 Provider 的请求体(如果有),否则用原始请求体
|
||||||
|
actual_request_body = provider_request_body or original_request_body
|
||||||
|
|
||||||
|
# 非流式成功时,返回给客户端的是提供商响应头(透传)
|
||||||
|
client_response_headers = filter_proxy_response_headers(response_headers)
|
||||||
|
client_response_headers["content-type"] = "application/json"
|
||||||
|
|
||||||
|
request_metadata = self._build_request_metadata() or {}
|
||||||
|
if sync_proxy_info:
|
||||||
|
request_metadata["proxy"] = sync_proxy_info
|
||||||
|
total_cost = await self.telemetry.record_success(
|
||||||
|
provider=provider_name,
|
||||||
|
model=model,
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=status_code,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
response_headers=response_headers,
|
||||||
|
client_response_headers=client_response_headers,
|
||||||
|
response_body=response_json,
|
||||||
|
cache_creation_tokens=cache_creation_tokens,
|
||||||
|
cache_read_tokens=cached_tokens,
|
||||||
|
is_stream=False,
|
||||||
|
provider_request_headers=provider_request_headers,
|
||||||
|
api_format=api_format,
|
||||||
|
# 格式转换追踪
|
||||||
|
endpoint_api_format=provider_api_format or None,
|
||||||
|
has_format_conversion=is_format_converted(provider_api_format, str(api_format)),
|
||||||
|
# Provider 侧追踪信息(用于记录真实成本)
|
||||||
|
provider_id=provider_id,
|
||||||
|
provider_endpoint_id=endpoint_id,
|
||||||
|
provider_api_key_id=key_id,
|
||||||
|
# 模型映射信息
|
||||||
|
target_model=mapped_model_result,
|
||||||
|
# Provider 响应元数据(如 Gemini 的 modelVersion)
|
||||||
|
response_metadata=response_metadata_result if response_metadata_result else None,
|
||||||
|
request_metadata=request_metadata or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("{} 非流式响应处理完成", self.FORMAT_ID)
|
||||||
|
|
||||||
|
# 透传提供商的响应头
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content=response_json,
|
||||||
|
headers=client_response_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ThinkingSignatureException as e:
|
||||||
|
# Thinking 签名错误:TaskService 层已处理整流重试但仍失败
|
||||||
|
# 记录实际发送给 Provider 的请求体,便于排查问题根因
|
||||||
|
response_time_ms = int((time.time() - sync_start_time) * 1000)
|
||||||
|
actual_request_body = provider_request_body or original_request_body
|
||||||
|
request_metadata = self._build_request_metadata() or {}
|
||||||
|
if sync_proxy_info:
|
||||||
|
request_metadata["proxy"] = sync_proxy_info
|
||||||
|
await self.telemetry.record_failure(
|
||||||
|
provider=provider_name or "unknown",
|
||||||
|
model=model,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=e.status_code or 400,
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
error_message=str(e),
|
||||||
|
is_stream=False,
|
||||||
|
api_format=api_format,
|
||||||
|
request_metadata=request_metadata or None,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
response_time_ms = int((time.time() - sync_start_time) * 1000)
|
||||||
|
|
||||||
|
status_code = 503
|
||||||
|
if isinstance(e, ProviderAuthException):
|
||||||
|
status_code = 503
|
||||||
|
elif isinstance(e, ProviderRateLimitException):
|
||||||
|
status_code = 429
|
||||||
|
elif isinstance(e, ProviderTimeoutException):
|
||||||
|
status_code = 504
|
||||||
|
|
||||||
|
# 使用实际发送给 Provider 的请求体(如果有),否则用原始请求体
|
||||||
|
actual_request_body = provider_request_body or original_request_body
|
||||||
|
|
||||||
|
# 尝试从异常中提取响应头
|
||||||
|
error_response_headers: dict[str, str] = {}
|
||||||
|
if isinstance(e, ProviderRateLimitException) and e.response_headers:
|
||||||
|
error_response_headers = e.response_headers
|
||||||
|
elif isinstance(e, httpx.HTTPStatusError) and hasattr(e, "response"):
|
||||||
|
error_response_headers = dict(e.response.headers)
|
||||||
|
|
||||||
|
request_metadata = self._build_request_metadata() or {}
|
||||||
|
if sync_proxy_info:
|
||||||
|
request_metadata["proxy"] = sync_proxy_info
|
||||||
|
await self.telemetry.record_failure(
|
||||||
|
provider=provider_name or "unknown",
|
||||||
|
model=model,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
status_code=status_code,
|
||||||
|
error_message=extract_client_error_message(e),
|
||||||
|
request_headers=original_headers,
|
||||||
|
request_body=actual_request_body,
|
||||||
|
is_stream=False,
|
||||||
|
api_format=api_format,
|
||||||
|
provider_request_headers=provider_request_headers,
|
||||||
|
response_headers=error_response_headers,
|
||||||
|
# 非流式失败返回给客户端的是 JSON 错误响应
|
||||||
|
client_response_headers={"content-type": "application/json"},
|
||||||
|
# 格式转换追踪
|
||||||
|
endpoint_api_format=provider_api_format or None,
|
||||||
|
has_format_conversion=is_format_converted(provider_api_format, str(api_format)),
|
||||||
|
# 模型映射信息
|
||||||
|
target_model=mapped_model_result,
|
||||||
|
request_metadata=request_metadata or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _extract_error_text(self, e: httpx.HTTPStatusError) -> str:
|
||||||
|
"""从 HTTP 错误中提取错误文本"""
|
||||||
|
try:
|
||||||
|
if hasattr(e.response, "is_stream_consumed") and not e.response.is_stream_consumed:
|
||||||
|
error_bytes = await e.response.aread()
|
||||||
|
|
||||||
|
for encoding in ["utf-8", "gbk", "latin1"]:
|
||||||
|
try:
|
||||||
|
return error_bytes.decode(encoding)
|
||||||
|
except (UnicodeDecodeError, LookupError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return error_bytes.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
e.response.text
|
||||||
|
if hasattr(e.response, "_content")
|
||||||
|
else "Unable to read response"
|
||||||
|
)
|
||||||
|
except Exception as decode_error:
|
||||||
|
return f"Unable to read error response: {decode_error}"
|
||||||
10
src/core/api_format/conversion/constants.py
Normal file
10
src/core/api_format/conversion/constants.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""格式转换层常量定义。
|
||||||
|
|
||||||
|
将跨层共享的常量集中在 core 层,避免 core -> services 的反向依赖。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Thinking 签名验证的跳过标记
|
||||||
|
# 当无法获取真实签名时,使用此值作为占位符
|
||||||
|
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
||||||
@@ -379,14 +379,11 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
isinstance(p, dict) and p.get("thought") is True for p in parts
|
isinstance(p, dict) and p.get("thought") is True for p in parts
|
||||||
)
|
)
|
||||||
if not has_thought:
|
if not has_thought:
|
||||||
try:
|
from src.core.api_format.conversion.constants import (
|
||||||
from src.services.provider.adapters.antigravity.constants import (
|
DUMMY_THOUGHT_SIGNATURE,
|
||||||
DUMMY_THOUGHT_SIGNATURE,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
dummy_sig = DUMMY_THOUGHT_SIGNATURE
|
dummy_sig = DUMMY_THOUGHT_SIGNATURE
|
||||||
except Exception:
|
|
||||||
dummy_sig = "skip_thought_signature_validator"
|
|
||||||
|
|
||||||
dummy_part: dict[str, Any] = {
|
dummy_part: dict[str, Any] = {
|
||||||
"text": "Thinking...",
|
"text": "Thinking...",
|
||||||
@@ -1423,10 +1420,11 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
|
|
||||||
if signature is None and target_variant == "antigravity":
|
if signature is None and target_variant == "antigravity":
|
||||||
model_str = str(model or "")
|
model_str = str(model or "")
|
||||||
|
from src.core.api_format.conversion.constants import (
|
||||||
|
DUMMY_THOUGHT_SIGNATURE,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.services.provider.adapters.antigravity.constants import (
|
|
||||||
DUMMY_THOUGHT_SIGNATURE,
|
|
||||||
)
|
|
||||||
from src.services.provider.adapters.antigravity.signature_cache import (
|
from src.services.provider.adapters.antigravity.signature_cache import (
|
||||||
signature_cache,
|
signature_cache,
|
||||||
)
|
)
|
||||||
@@ -1445,7 +1443,7 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Best-effort fallback: Gemini models can accept a dummy signature.
|
# Best-effort fallback: Gemini models can accept a dummy signature.
|
||||||
if model_str.startswith("gemini-"):
|
if model_str.startswith("gemini-"):
|
||||||
signature = "skip_thought_signature_validator"
|
signature = DUMMY_THOUGHT_SIGNATURE
|
||||||
|
|
||||||
# For Antigravity, missing signature is likely to fail upstream validation.
|
# For Antigravity, missing signature is likely to fail upstream validation.
|
||||||
if target_variant == "antigravity" and not signature:
|
if target_variant == "antigravity" and not signature:
|
||||||
@@ -1564,8 +1562,9 @@ class GeminiNormalizer(FormatNormalizer):
|
|||||||
payload_sig = None
|
payload_sig = None
|
||||||
|
|
||||||
signature: str | None = None
|
signature: str | None = None
|
||||||
|
from src.core.api_format.conversion.constants import DUMMY_THOUGHT_SIGNATURE
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.services.provider.adapters.antigravity.constants import DUMMY_THOUGHT_SIGNATURE
|
|
||||||
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
|
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
|
||||||
|
|
||||||
cached_or_dummy = signature_cache.get_or_dummy(model, text_val)
|
cached_or_dummy = signature_cache.get_or_dummy(model, text_val)
|
||||||
|
|||||||
10
src/models/_base.py
Normal file
10
src/models/_base.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
SQLAlchemy Base 声明基类
|
||||||
|
|
||||||
|
所有数据库模型子模块从此文件导入 Base,确保全局唯一。
|
||||||
|
直接复用 database.py 的 Base,避免出现两套 MetaData 实例。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.models.database import Base
|
||||||
|
|
||||||
|
__all__ = ["Base"]
|
||||||
178
src/models/auth.py
Normal file
178
src/models/auth.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
认证相关数据库模型
|
||||||
|
|
||||||
|
包含: LDAPConfig, OAuthProvider, UserOAuthLink
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfig(Base):
|
||||||
|
"""LDAP认证配置表 - 单行配置"""
|
||||||
|
|
||||||
|
__tablename__ = "ldap_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
server_url = Column(String(255), nullable=False) # ldap://host:389 或 ldaps://host:636
|
||||||
|
bind_dn = Column(Text, nullable=False) # 绑定账号 DN(可能很长)
|
||||||
|
bind_password_encrypted = Column(Text, nullable=True) # 加密的绑定密码(允许 NULL 表示已清除)
|
||||||
|
base_dn = Column(Text, nullable=False) # 用户搜索基础 DN(可能很长)
|
||||||
|
user_search_filter = Column(
|
||||||
|
Text, default="(uid={username})", nullable=False
|
||||||
|
) # 用户搜索过滤器(可能很复杂)
|
||||||
|
username_attr = Column(
|
||||||
|
String(50), default="uid", nullable=False
|
||||||
|
) # 用户名属性 (uid/sAMAccountName)
|
||||||
|
email_attr = Column(String(50), default="mail", nullable=False) # 邮箱属性
|
||||||
|
display_name_attr = Column(String(50), default="cn", nullable=False) # 显示名称属性
|
||||||
|
is_enabled = Column(Boolean, default=False, nullable=False) # 是否启用 LDAP 认证
|
||||||
|
is_exclusive = Column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
) # 是否仅允许 LDAP 登录(禁用本地认证)
|
||||||
|
use_starttls = Column(Boolean, default=False, nullable=False) # 是否使用 STARTTLS
|
||||||
|
connect_timeout = Column(Integer, default=10, nullable=False) # 连接超时时间(秒)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_bind_password(self, password: str) -> None:
|
||||||
|
"""
|
||||||
|
设置并加密绑定密码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: 明文密码
|
||||||
|
"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
self.bind_password_encrypted = crypto_service.encrypt(password)
|
||||||
|
|
||||||
|
def get_bind_password(self) -> str:
|
||||||
|
"""
|
||||||
|
获取解密后的绑定密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 解密后的明文密码
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DecryptionException: 解密失败时抛出异常
|
||||||
|
"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
if not self.bind_password_encrypted:
|
||||||
|
return ""
|
||||||
|
return crypto_service.decrypt(self.bind_password_encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider(Base):
|
||||||
|
"""OAuth Provider 配置表(按 provider_type 唯一)"""
|
||||||
|
|
||||||
|
__tablename__ = "oauth_providers"
|
||||||
|
|
||||||
|
# 使用 provider_type 作为主键,便于通过 URL 参数直接定位配置
|
||||||
|
provider_type = Column(String(50), primary_key=True)
|
||||||
|
display_name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
client_id = Column(Text, nullable=False) # 某些 OAuth 提供商可能使用很长的 client_id
|
||||||
|
client_secret_encrypted = Column(Text, nullable=True) # 允许 NULL 表示尚未配置/已清除
|
||||||
|
|
||||||
|
# 可选覆盖端点(需在业务层做白名单校验)
|
||||||
|
authorization_url_override = Column(String(500), nullable=True)
|
||||||
|
token_url_override = Column(String(500), nullable=True)
|
||||||
|
userinfo_url_override = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# 可选覆盖 scopes(JSON 列表)
|
||||||
|
scopes = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# 服务端控制 redirect_uri 与前端回调 URL
|
||||||
|
redirect_uri = Column(String(500), nullable=False)
|
||||||
|
frontend_callback_url = Column(String(500), nullable=False)
|
||||||
|
|
||||||
|
# Provider 特定配置/映射
|
||||||
|
attribute_mapping = Column(JSON, nullable=True)
|
||||||
|
extra_config = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
is_enabled = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_client_secret(self, secret: str) -> None:
|
||||||
|
"""设置并加密 client_secret"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
self.client_secret_encrypted = crypto_service.encrypt(secret)
|
||||||
|
|
||||||
|
def get_client_secret(self) -> str:
|
||||||
|
"""获取解密后的 client_secret(未配置时返回空串)"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
if not self.client_secret_encrypted:
|
||||||
|
return ""
|
||||||
|
return crypto_service.decrypt(self.client_secret_encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
class UserOAuthLink(Base):
|
||||||
|
"""用户与 OAuth Provider 的绑定关系"""
|
||||||
|
|
||||||
|
__tablename__ = "user_oauth_links"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id = Column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
provider_type = Column(
|
||||||
|
String(50),
|
||||||
|
ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
provider_user_id = Column(String(255), nullable=False)
|
||||||
|
provider_username = Column(String(255), nullable=True)
|
||||||
|
provider_email = Column(String(255), nullable=True)
|
||||||
|
extra_data = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
linked_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("provider_type", "provider_user_id", name="uq_oauth_provider_user"),
|
||||||
|
UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
|
||||||
|
)
|
||||||
360
src/models/misc.py
Normal file
360
src/models/misc.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""
|
||||||
|
杂项数据库模型
|
||||||
|
|
||||||
|
包含: SystemConfig, VideoTask, Announcement, AnnouncementRead, AuditEventType, AuditLog,
|
||||||
|
GeminiFileMapping, _generate_short_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(Base):
|
||||||
|
"""系统配置表"""
|
||||||
|
|
||||||
|
__tablename__ = "system_configs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
key = Column(String(100), unique=True, nullable=False)
|
||||||
|
value = Column(JSON, nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_short_id(length: int = 12) -> str:
|
||||||
|
"""生成 Gemini 风格的短 ID(小写字母+数字)"""
|
||||||
|
alphabet = string.ascii_lowercase + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
class VideoTask(Base):
|
||||||
|
"""视频生成任务"""
|
||||||
|
|
||||||
|
__tablename__ = "video_tasks"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
# Gemini 风格的短 ID,用于对外暴露(如 operations/xxx)
|
||||||
|
short_id = Column(String(16), unique=True, index=True, default=_generate_short_id)
|
||||||
|
request_id = Column(
|
||||||
|
String(100), unique=True, index=True, nullable=False
|
||||||
|
) # 关联 Usage/RequestCandidate
|
||||||
|
external_task_id = Column(String(200))
|
||||||
|
|
||||||
|
# 关联
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
api_key_id = Column(String(36), ForeignKey("api_keys.id"))
|
||||||
|
provider_id = Column(String(36), ForeignKey("providers.id"))
|
||||||
|
endpoint_id = Column(String(36), ForeignKey("provider_endpoints.id"))
|
||||||
|
key_id = Column(String(36), ForeignKey("provider_api_keys.id"))
|
||||||
|
|
||||||
|
# 格式转换追踪
|
||||||
|
client_api_format = Column(String(50), nullable=False)
|
||||||
|
provider_api_format = Column(String(50), nullable=False)
|
||||||
|
format_converted = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# 任务配置
|
||||||
|
model = Column(String(100), nullable=False)
|
||||||
|
prompt = Column(Text, nullable=False)
|
||||||
|
original_request_body = Column(JSON)
|
||||||
|
converted_request_body = Column(JSON)
|
||||||
|
|
||||||
|
# 视频参数 (统一内部格式)
|
||||||
|
duration_seconds = Column(Integer, default=4)
|
||||||
|
resolution = Column(String(20), default="720p")
|
||||||
|
aspect_ratio = Column(String(10), default="16:9")
|
||||||
|
size = Column(String(20))
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
status = Column(String(20), default="pending")
|
||||||
|
progress_percent = Column(Integer, default=0)
|
||||||
|
progress_message = Column(String(500))
|
||||||
|
|
||||||
|
# 结果
|
||||||
|
video_url = Column(String(2000))
|
||||||
|
video_urls = Column(JSON)
|
||||||
|
thumbnail_url = Column(String(2000))
|
||||||
|
video_size_bytes = Column(BigInteger)
|
||||||
|
video_duration_seconds = Column(Float) # 实际视频时长(秒)
|
||||||
|
video_expires_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# 存储 (可选)
|
||||||
|
stored_video_path = Column(String(500))
|
||||||
|
storage_provider = Column(String(50))
|
||||||
|
|
||||||
|
# 错误
|
||||||
|
error_code = Column(String(50))
|
||||||
|
error_message = Column(Text)
|
||||||
|
retry_count = Column(Integer, default=0)
|
||||||
|
max_retries = Column(Integer, default=3)
|
||||||
|
|
||||||
|
# 轮询配置
|
||||||
|
poll_interval_seconds = Column(Integer, default=10)
|
||||||
|
next_poll_at = Column(DateTime(timezone=True)) # 索引在 __table_args__ 中定义
|
||||||
|
poll_count = Column(Integer, default=0)
|
||||||
|
max_poll_count = Column(Integer, default=360)
|
||||||
|
|
||||||
|
# Remix 支持
|
||||||
|
remixed_from_task_id = Column(
|
||||||
|
String(36), ForeignKey("video_tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用追踪(候选 key、请求头等)
|
||||||
|
request_metadata = Column(JSON, nullable=True) # 存储候选 key 列表、请求头等追踪信息
|
||||||
|
# 示例: {
|
||||||
|
# "candidate_keys": [{"key_id": "xxx", "endpoint_id": "yyy", "priority": 1}, ...],
|
||||||
|
# "selected_key_index": 0,
|
||||||
|
# "client_ip": "1.2.3.4",
|
||||||
|
# "user_agent": "...",
|
||||||
|
# "request_headers": {...}
|
||||||
|
# }
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
submitted_at = Column(DateTime(timezone=True))
|
||||||
|
completed_at = Column(DateTime(timezone=True))
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", backref="video_tasks")
|
||||||
|
remixed_from = relationship("VideoTask", remote_side=[id], backref="remixes")
|
||||||
|
|
||||||
|
# 复合索引和唯一约束
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_video_tasks_user_status", "user_id", "status"),
|
||||||
|
Index("idx_video_tasks_next_poll", "next_poll_at"),
|
||||||
|
Index("idx_video_tasks_external_id", "external_task_id"),
|
||||||
|
UniqueConstraint("user_id", "external_task_id", name="uq_video_tasks_user_external_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(Base):
|
||||||
|
"""公告表"""
|
||||||
|
|
||||||
|
__tablename__ = "announcements"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
content = Column(Text, nullable=False) # 支持 Markdown
|
||||||
|
type = Column(String(20), default="info") # info, warning, maintenance, important
|
||||||
|
priority = Column(Integer, default=0) # 优先级,数字越大越重要
|
||||||
|
|
||||||
|
# 发布信息
|
||||||
|
author_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True, index=True)
|
||||||
|
is_pinned = Column(Boolean, default=False) # 置顶
|
||||||
|
|
||||||
|
# 时间范围
|
||||||
|
start_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
end_time = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
author = relationship("User", back_populates="authored_announcements")
|
||||||
|
reads = relationship(
|
||||||
|
"AnnouncementRead", back_populates="announcement", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementRead(Base):
|
||||||
|
"""公告已读记录表"""
|
||||||
|
|
||||||
|
__tablename__ = "announcement_reads"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
announcement_id = Column(String(36), ForeignKey("announcements.id"), nullable=False)
|
||||||
|
read_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 唯一约束
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="announcement_reads")
|
||||||
|
announcement = relationship("Announcement", back_populates="reads")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventType(PyEnum):
|
||||||
|
"""审计事件类型"""
|
||||||
|
|
||||||
|
# 认证相关
|
||||||
|
LOGIN_SUCCESS = "login_success"
|
||||||
|
LOGIN_FAILED = "login_failed"
|
||||||
|
LOGOUT = "logout"
|
||||||
|
API_KEY_CREATED = "api_key_created"
|
||||||
|
API_KEY_DELETED = "api_key_deleted"
|
||||||
|
API_KEY_USED = "api_key_used"
|
||||||
|
|
||||||
|
# 请求相关
|
||||||
|
REQUEST_SUCCESS = "request_success"
|
||||||
|
REQUEST_FAILED = "request_failed"
|
||||||
|
REQUEST_RATE_LIMITED = "request_rate_limited"
|
||||||
|
REQUEST_QUOTA_EXCEEDED = "request_quota_exceeded"
|
||||||
|
|
||||||
|
# 管理操作
|
||||||
|
USER_CREATED = "user_created"
|
||||||
|
USER_UPDATED = "user_updated"
|
||||||
|
USER_DELETED = "user_deleted"
|
||||||
|
PROVIDER_ADDED = "provider_added"
|
||||||
|
PROVIDER_UPDATED = "provider_updated"
|
||||||
|
PROVIDER_REMOVED = "provider_removed"
|
||||||
|
|
||||||
|
# 安全事件
|
||||||
|
SUSPICIOUS_ACTIVITY = "suspicious_activity"
|
||||||
|
UNAUTHORIZED_ACCESS = "unauthorized_access"
|
||||||
|
DATA_EXPORT = "data_export"
|
||||||
|
CONFIG_CHANGED = "config_changed"
|
||||||
|
|
||||||
|
# Management Token 相关
|
||||||
|
MANAGEMENT_TOKEN_CREATED = "management_token_created"
|
||||||
|
MANAGEMENT_TOKEN_UPDATED = "management_token_updated"
|
||||||
|
MANAGEMENT_TOKEN_DELETED = "management_token_deleted"
|
||||||
|
MANAGEMENT_TOKEN_USED = "management_token_used"
|
||||||
|
MANAGEMENT_TOKEN_EXPIRED = "management_token_expired"
|
||||||
|
MANAGEMENT_TOKEN_IP_BLOCKED = "management_token_ip_blocked"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""审计日志模型"""
|
||||||
|
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
event_type = Column(String(50), nullable=False, index=True)
|
||||||
|
user_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
api_key_id = Column(String(36), nullable=True)
|
||||||
|
|
||||||
|
# 事件详情
|
||||||
|
description = Column(Text, nullable=False)
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
user_agent = Column(String(500), nullable=True)
|
||||||
|
request_id = Column(String(100), nullable=True, index=True)
|
||||||
|
|
||||||
|
# 相关数据
|
||||||
|
event_metadata = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# 响应信息
|
||||||
|
status_code = Column(Integer, nullable=True)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="audit_logs")
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiFileMapping(Base):
|
||||||
|
"""
|
||||||
|
Gemini Files API 文件与 Provider Key 的映射关系
|
||||||
|
|
||||||
|
用于持久化存储 file_id -> key_id 的绑定关系,
|
||||||
|
确保后续 generateContent 请求使用上传时的同一 Key。
|
||||||
|
|
||||||
|
Gemini 文件有 48 小时有效期,此表中的记录也会在过期后被清理。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "gemini_file_mappings"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
|
||||||
|
# 文件名(如 files/abc123xyz)
|
||||||
|
file_name = Column(String(255), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# Provider Key ID(关联到 provider_api_keys 表)
|
||||||
|
key_id = Column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 用户 ID(用于权限验证,可选)
|
||||||
|
user_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 文件元数据(可选,用于调试)
|
||||||
|
display_name = Column(String(255), nullable=True)
|
||||||
|
mime_type = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
# 源文件哈希(用于关联相同源文件的不同上传,可选)
|
||||||
|
# 当同一源文件上传到多个 Key 时,可通过此字段找到所有等效文件
|
||||||
|
source_hash = Column(String(64), nullable=True, index=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
# 过期时间(Gemini 文件 48 小时后过期)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
key = relationship("ProviderAPIKey")
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_gemini_file_mappings_expires", "expires_at"),
|
||||||
|
Index("idx_gemini_file_mappings_source_hash", "source_hash"),
|
||||||
|
)
|
||||||
516
src/models/model.py
Normal file
516
src/models/model.py
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
"""
|
||||||
|
模型相关数据库模型
|
||||||
|
|
||||||
|
包含: GlobalModel, Model, BillingRule, DimensionCollector
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalModel(Base):
|
||||||
|
"""全局统一模型定义 - 包含价格和能力配置
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- 定义模型的基本信息和价格配置(价格为必填项)
|
||||||
|
- Provider 级别的 Model 可以覆盖这些默认值
|
||||||
|
- 如果 Model 的价格/能力字段为空,则使用 GlobalModel 的值
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "global_models"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False, index=True) # 统一模型名(唯一)
|
||||||
|
display_name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# 按次计费配置(每次请求的固定费用,美元)- 可选,与按 token 计费叠加
|
||||||
|
default_price_per_request = Column(Float, nullable=True, default=None) # 每次请求固定费用
|
||||||
|
|
||||||
|
# 统一阶梯计费配置(JSON格式)- 必填
|
||||||
|
# 固定价格也用单阶梯表示: {"tiers": [{"up_to": null, "input_price_per_1m": X, ...}]}
|
||||||
|
# 结构示例:
|
||||||
|
# {
|
||||||
|
# "tiers": [
|
||||||
|
# {
|
||||||
|
# "up_to": 128000, # 阶梯上限(tokens),null 表示无上限
|
||||||
|
# "input_price_per_1m": 2.50,
|
||||||
|
# "output_price_per_1m": 10.00,
|
||||||
|
# "cache_creation_price_per_1m": 3.75, # 可选
|
||||||
|
# "cache_read_price_per_1m": 0.30, # 可选
|
||||||
|
# "cache_ttl_pricing": [ # 可选:按缓存时长分价格
|
||||||
|
# {"ttl_minutes": 5, "cache_read_price_per_1m": 0.30},
|
||||||
|
# {"ttl_minutes": 60, "cache_read_price_per_1m": 0.50}
|
||||||
|
# ]
|
||||||
|
# },
|
||||||
|
# {"up_to": null, "input_price_per_1m": 1.25, ...}
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
default_tiered_pricing = Column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"])
|
||||||
|
# Key 只能启用模型支持的能力
|
||||||
|
supported_capabilities = Column(JSON, nullable=True, default=list)
|
||||||
|
|
||||||
|
# 模型配置(JSON格式)- 包含能力、规格、元信息等
|
||||||
|
# 结构示例:
|
||||||
|
# {
|
||||||
|
# # 能力配置
|
||||||
|
# "streaming": true,
|
||||||
|
# "vision": true,
|
||||||
|
# "function_calling": true,
|
||||||
|
# "extended_thinking": false,
|
||||||
|
# "image_generation": false,
|
||||||
|
# # 规格参数
|
||||||
|
# "context_limit": 200000,
|
||||||
|
# "output_limit": 8192,
|
||||||
|
# # 元信息
|
||||||
|
# "description": "...",
|
||||||
|
# "icon_url": "...",
|
||||||
|
# "official_url": "...",
|
||||||
|
# "knowledge_cutoff": "2024-04",
|
||||||
|
# "family": "claude-3.5",
|
||||||
|
# "release_date": "2024-10-22",
|
||||||
|
# "input_modalities": ["text", "image"],
|
||||||
|
# "output_modalities": ["text"],
|
||||||
|
# }
|
||||||
|
config = Column(JSONB, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# 统计计数器(优化性能,避免实时查询)
|
||||||
|
usage_count = Column(Integer, default=0, nullable=False, index=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
models = relationship("Model", back_populates="global_model")
|
||||||
|
|
||||||
|
|
||||||
|
class Model(Base):
|
||||||
|
"""Provider 模型配置表 - Provider 如何使用某个 GlobalModel
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- Model 表示 Provider 对某个模型的具体实现
|
||||||
|
- global_model_id 可为空:
|
||||||
|
- 为空时:模型尚未关联到 GlobalModel,不参与路由
|
||||||
|
- 不为空时:模型已关联 GlobalModel,参与路由
|
||||||
|
- provider_model_name 是 Provider 侧的实际模型名称 (可能与 GlobalModel.name 不同)
|
||||||
|
- 价格和能力配置可为空,为空时使用 GlobalModel 的默认值
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "models"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
provider_id = Column(String(36), ForeignKey("providers.id"), nullable=False)
|
||||||
|
# 可为空:NULL 表示未关联,不参与路由;非 NULL 表示已关联,参与路由
|
||||||
|
global_model_id = Column(String(36), ForeignKey("global_models.id"), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Provider 映射配置
|
||||||
|
provider_model_name = Column(String(200), nullable=False) # Provider 侧的主模型名称
|
||||||
|
# 模型名称映射列表(带优先级),用于同一模型在 Provider 侧有多个名称变体的场景
|
||||||
|
# 格式: [{"name": "Claude-Sonnet-4.5", "priority": 1}, {"name": "Claude-Sonnet-4-5", "priority": 2}]
|
||||||
|
# 为空时只使用 provider_model_name
|
||||||
|
provider_model_mappings = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
# 按次计费配置(每次请求的固定费用,美元)- 可为空,为空时使用 GlobalModel 的默认值
|
||||||
|
price_per_request = Column(Float, nullable=True) # 每次请求固定费用
|
||||||
|
|
||||||
|
# 阶梯计费配置(JSON格式)- 可为空,为空时使用 GlobalModel 的默认值
|
||||||
|
tiered_pricing = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
# Provider 能力配置 - 可为空,为空时使用 GlobalModel 的默认值
|
||||||
|
supports_vision = Column(Boolean, nullable=True)
|
||||||
|
supports_function_calling = Column(Boolean, nullable=True)
|
||||||
|
supports_streaming = Column(Boolean, nullable=True)
|
||||||
|
supports_extended_thinking = Column(Boolean, nullable=True)
|
||||||
|
supports_image_generation = Column(Boolean, nullable=True)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
is_available = Column(Boolean, default=True) # 是否当前可用
|
||||||
|
|
||||||
|
# 扩展配置
|
||||||
|
config = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
provider = relationship("Provider", back_populates="models")
|
||||||
|
global_model = relationship("GlobalModel", back_populates="models")
|
||||||
|
|
||||||
|
# 唯一约束:同一个提供商下的 provider_model_name 不能重复
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 辅助方法:获取有效的阶梯计费配置
|
||||||
|
def get_effective_tiered_pricing(self) -> dict | None:
|
||||||
|
"""获取有效的阶梯计费配置"""
|
||||||
|
if self.tiered_pricing is not None:
|
||||||
|
return self.tiered_pricing
|
||||||
|
if self.global_model:
|
||||||
|
return self.global_model.default_tiered_pricing
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_first_tier(self) -> dict | None:
|
||||||
|
"""获取第一个阶梯(用于获取默认价格)"""
|
||||||
|
tiered = self.get_effective_tiered_pricing()
|
||||||
|
if tiered and tiered.get("tiers"):
|
||||||
|
return tiered["tiers"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_effective_input_price(self) -> float:
|
||||||
|
"""获取有效的输入价格(从第一个阶梯)"""
|
||||||
|
tier = self._get_first_tier()
|
||||||
|
if tier:
|
||||||
|
return tier.get("input_price_per_1m", 0.0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_effective_output_price(self) -> float:
|
||||||
|
"""获取有效的输出价格(从第一个阶梯)"""
|
||||||
|
tier = self._get_first_tier()
|
||||||
|
if tier:
|
||||||
|
return tier.get("output_price_per_1m", 0.0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_effective_cache_creation_price(self) -> float | None:
|
||||||
|
"""获取有效的缓存创建价格(从第一个阶梯)"""
|
||||||
|
tier = self._get_first_tier()
|
||||||
|
if tier:
|
||||||
|
return tier.get("cache_creation_price_per_1m")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_effective_cache_read_price(self) -> float | None:
|
||||||
|
"""获取有效的缓存读取价格(从第一个阶梯)"""
|
||||||
|
tier = self._get_first_tier()
|
||||||
|
if tier:
|
||||||
|
return tier.get("cache_read_price_per_1m")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_effective_1h_cache_creation_price(self) -> float | None:
|
||||||
|
"""获取有效的 1h 缓存创建价格(从第一个阶梯)"""
|
||||||
|
tier = self._get_first_tier()
|
||||||
|
if tier:
|
||||||
|
cache_ttl_pricing = tier.get("cache_ttl_pricing") or []
|
||||||
|
for ttl_entry in cache_ttl_pricing:
|
||||||
|
if ttl_entry.get("ttl_minutes") == 60:
|
||||||
|
return ttl_entry.get("cache_creation_price_per_1m")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_effective_price_per_request(self) -> float | None:
|
||||||
|
"""获取有效的按次计费价格"""
|
||||||
|
if self.price_per_request is not None:
|
||||||
|
return self.price_per_request
|
||||||
|
if self.global_model:
|
||||||
|
return self.global_model.default_price_per_request
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_effective_capability(self, attr_name: str, default: bool = False) -> bool:
|
||||||
|
"""获取有效的能力配置(通用辅助方法)"""
|
||||||
|
local_value = getattr(self, attr_name, None)
|
||||||
|
if local_value is not None:
|
||||||
|
return bool(local_value)
|
||||||
|
if self.global_model:
|
||||||
|
config_key_map = {
|
||||||
|
"supports_vision": "vision",
|
||||||
|
"supports_function_calling": "function_calling",
|
||||||
|
"supports_streaming": "streaming",
|
||||||
|
"supports_extended_thinking": "extended_thinking",
|
||||||
|
"supports_image_generation": "image_generation",
|
||||||
|
}
|
||||||
|
config_key = config_key_map.get(attr_name)
|
||||||
|
if config_key:
|
||||||
|
global_config = getattr(self.global_model, "config", None)
|
||||||
|
if isinstance(global_config, dict):
|
||||||
|
global_value = global_config.get(config_key)
|
||||||
|
if global_value is not None:
|
||||||
|
return bool(global_value)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_effective_supports_vision(self) -> bool:
|
||||||
|
return self._get_effective_capability("supports_vision", False)
|
||||||
|
|
||||||
|
def get_effective_supports_function_calling(self) -> bool:
|
||||||
|
return self._get_effective_capability("supports_function_calling", False)
|
||||||
|
|
||||||
|
def get_effective_supports_streaming(self) -> bool:
|
||||||
|
return self._get_effective_capability("supports_streaming", True)
|
||||||
|
|
||||||
|
def get_effective_supports_extended_thinking(self) -> bool:
|
||||||
|
return self._get_effective_capability("supports_extended_thinking", False)
|
||||||
|
|
||||||
|
def get_effective_supports_image_generation(self) -> bool:
|
||||||
|
return self._get_effective_capability("supports_image_generation", False)
|
||||||
|
|
||||||
|
def get_effective_config(self) -> dict | None:
|
||||||
|
"""获取有效的 config(合并 Model 和 GlobalModel 的 config)
|
||||||
|
|
||||||
|
合并策略:
|
||||||
|
- GlobalModel.config 作为基础
|
||||||
|
- Model.config 覆盖 GlobalModel.config
|
||||||
|
- 深度合并 billing 子字段
|
||||||
|
"""
|
||||||
|
global_config = {}
|
||||||
|
if self.global_model and self.global_model.config:
|
||||||
|
global_config = dict(self.global_model.config)
|
||||||
|
|
||||||
|
if not self.config:
|
||||||
|
return global_config if global_config else None
|
||||||
|
|
||||||
|
# 深度合并 config
|
||||||
|
result = dict(global_config)
|
||||||
|
for key, value in self.config.items():
|
||||||
|
if key == "billing" and isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
|
# 深度合并 billing
|
||||||
|
result[key] = {**result[key], **value}
|
||||||
|
else:
|
||||||
|
result[key] = value
|
||||||
|
|
||||||
|
return result if result else None
|
||||||
|
|
||||||
|
def select_provider_model_name(
|
||||||
|
self, affinity_key: str | None = None, api_format: str | None = None
|
||||||
|
) -> str:
|
||||||
|
"""按优先级选择要使用的 Provider 模型名称
|
||||||
|
|
||||||
|
如果配置了 provider_model_mappings,按优先级选择(数字越小越优先);
|
||||||
|
相同优先级的映射通过哈希分散实现负载均衡(与 Key 调度策略一致);
|
||||||
|
否则返回 provider_model_name。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一映射
|
||||||
|
api_format: 当前请求的 endpoint signature(如 "openai:chat"),用于过滤适用的映射
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
if not self.provider_model_mappings:
|
||||||
|
return self.provider_model_name
|
||||||
|
|
||||||
|
raw_mappings = self.provider_model_mappings
|
||||||
|
if not isinstance(raw_mappings, list) or len(raw_mappings) == 0:
|
||||||
|
return self.provider_model_name
|
||||||
|
|
||||||
|
mappings: list[dict] = []
|
||||||
|
for raw in raw_mappings:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
name = raw.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检查 api_formats 作用域(如果配置了且当前有 api_format)
|
||||||
|
mapping_api_formats = raw.get("api_formats")
|
||||||
|
if api_format and mapping_api_formats:
|
||||||
|
# 如果配置了作用域,只有匹配时才生效
|
||||||
|
if isinstance(mapping_api_formats, list):
|
||||||
|
target = str(api_format).strip().lower()
|
||||||
|
allowed = {str(fmt).strip().lower() for fmt in mapping_api_formats if fmt}
|
||||||
|
if target not in allowed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_priority = raw.get("priority", 1)
|
||||||
|
try:
|
||||||
|
priority = int(raw_priority)
|
||||||
|
except Exception:
|
||||||
|
priority = 1
|
||||||
|
if priority < 1:
|
||||||
|
priority = 1
|
||||||
|
|
||||||
|
mappings.append({"name": name.strip(), "priority": priority})
|
||||||
|
|
||||||
|
if not mappings:
|
||||||
|
return self.provider_model_name
|
||||||
|
|
||||||
|
# 按优先级排序(数字越小越优先)
|
||||||
|
sorted_mappings = sorted(mappings, key=lambda x: x["priority"])
|
||||||
|
|
||||||
|
# 获取最高优先级(最小数字)
|
||||||
|
highest_priority = sorted_mappings[0]["priority"]
|
||||||
|
|
||||||
|
# 获取所有最高优先级的映射
|
||||||
|
top_priority_mappings = [
|
||||||
|
mapping for mapping in sorted_mappings if mapping["priority"] == highest_priority
|
||||||
|
]
|
||||||
|
|
||||||
|
# 如果有多个相同优先级的映射,通过哈希分散选择
|
||||||
|
if len(top_priority_mappings) > 1 and affinity_key:
|
||||||
|
# 为每个映射计算哈希得分,选择得分最小的
|
||||||
|
def hash_score(mapping: dict) -> int:
|
||||||
|
combined = f"{affinity_key}:{mapping['name']}"
|
||||||
|
return int(hashlib.md5(combined.encode()).hexdigest(), 16)
|
||||||
|
|
||||||
|
selected = min(top_priority_mappings, key=hash_score)
|
||||||
|
elif len(top_priority_mappings) > 1:
|
||||||
|
# 没有 affinity_key 时,使用确定性选择(按名称排序后取第一个)
|
||||||
|
# 避免随机选择导致同一请求重试时选择不同的模型名称
|
||||||
|
selected = min(top_priority_mappings, key=lambda x: x["name"])
|
||||||
|
else:
|
||||||
|
selected = top_priority_mappings[0]
|
||||||
|
|
||||||
|
return selected["name"]
|
||||||
|
|
||||||
|
def get_all_provider_model_names(self) -> list[str]:
|
||||||
|
"""获取所有可用的 Provider 模型名称(主名称 + 映射名称)"""
|
||||||
|
names = [self.provider_model_name]
|
||||||
|
if self.provider_model_mappings:
|
||||||
|
for mapping in self.provider_model_mappings:
|
||||||
|
if isinstance(mapping, dict) and mapping.get("name"):
|
||||||
|
names.append(mapping["name"])
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRule(Base):
|
||||||
|
"""计费规则表(单条 formula 规则,支持 Model 覆盖 GlobalModel)。"""
|
||||||
|
|
||||||
|
__tablename__ = "billing_rules"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 规则关联(两者必有其一)
|
||||||
|
global_model_id = Column(
|
||||||
|
String(36), ForeignKey("global_models.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
model_id = Column(
|
||||||
|
String(36), ForeignKey("models.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
# 注:CLI 在计费域里恒等于 chat,不单独存 "cli"
|
||||||
|
task_type = Column(String(20), nullable=False, default="chat")
|
||||||
|
|
||||||
|
# Formula 表达式及其配置
|
||||||
|
expression = Column(Text, nullable=False)
|
||||||
|
variables = Column(JSONB, nullable=False, default=dict)
|
||||||
|
dimension_mappings = Column(JSONB, nullable=False, default=dict)
|
||||||
|
|
||||||
|
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
global_model = relationship("GlobalModel", foreign_keys=[global_model_id])
|
||||||
|
model = relationship("Model", foreign_keys=[model_id])
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(global_model_id IS NOT NULL AND model_id IS NULL) OR "
|
||||||
|
"(global_model_id IS NULL AND model_id IS NOT NULL)",
|
||||||
|
name="chk_billing_rules_model_ref",
|
||||||
|
),
|
||||||
|
# 同级同 task_type 只允许一条启用规则(partial unique index)
|
||||||
|
Index(
|
||||||
|
"uq_billing_rules_global_model_task",
|
||||||
|
"global_model_id",
|
||||||
|
"task_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("is_enabled = TRUE AND global_model_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"uq_billing_rules_model_task",
|
||||||
|
"model_id",
|
||||||
|
"task_type",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("is_enabled = TRUE AND model_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DimensionCollector(Base):
|
||||||
|
"""维度收集器配置表(从请求/响应/元数据/派生计算收集维度)。"""
|
||||||
|
|
||||||
|
__tablename__ = "dimension_collectors"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
api_format = Column(String(50), nullable=False)
|
||||||
|
task_type = Column(String(20), nullable=False)
|
||||||
|
dimension_name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# 来源配置
|
||||||
|
# - response / request / metadata / computed
|
||||||
|
source_type = Column(String(20), nullable=False)
|
||||||
|
source_path = Column(String(200), nullable=True) # computed 允许为空
|
||||||
|
|
||||||
|
# 值类型与转换
|
||||||
|
value_type = Column(String(20), nullable=False, default="float") # float/int/string
|
||||||
|
transform_expression = Column(Text, nullable=True) # computed 时为派生公式
|
||||||
|
default_value = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
priority = Column(Integer, nullable=False, default=0)
|
||||||
|
is_enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(source_type = 'computed' AND source_path IS NULL AND transform_expression IS NOT NULL) OR "
|
||||||
|
"(source_type != 'computed' AND source_path IS NOT NULL)",
|
||||||
|
name="chk_dimension_collectors_source_config",
|
||||||
|
),
|
||||||
|
# 同维度 + 同优先级 + enabled 才唯一(允许禁用旧配置后重建)
|
||||||
|
Index(
|
||||||
|
"uq_dimension_collectors_enabled",
|
||||||
|
"api_format",
|
||||||
|
"task_type",
|
||||||
|
"dimension_name",
|
||||||
|
"priority",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("is_enabled = TRUE"),
|
||||||
|
),
|
||||||
|
)
|
||||||
426
src/models/provider.py
Normal file
426
src/models/provider.py
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
"""
|
||||||
|
提供商相关数据库模型
|
||||||
|
|
||||||
|
包含: Provider, ProviderEndpoint, ProxyNodeStatus, ProxyNode, ProviderAPIKey
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum as PyEnum
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from src.core.enums import ProviderBillingType
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(Base):
|
||||||
|
"""提供商配置表"""
|
||||||
|
|
||||||
|
__tablename__ = "providers"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False, index=True) # 提供商名称(唯一)
|
||||||
|
description = Column(Text, nullable=True) # 提供商描述
|
||||||
|
website = Column(String(500), nullable=True) # 主站网站
|
||||||
|
|
||||||
|
# Provider 类型(用于模板化固定 Provider / 自定义 Provider)
|
||||||
|
# - custom: 自定义
|
||||||
|
# - claude_code / codex / gemini_cli / antigravity: 固定类型
|
||||||
|
provider_type = Column(String(20), default="custom", nullable=False)
|
||||||
|
|
||||||
|
# 计费类型配置
|
||||||
|
billing_type = Column(
|
||||||
|
Enum(
|
||||||
|
ProviderBillingType,
|
||||||
|
name="providerbillingtype",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda x: [e.value for e in x],
|
||||||
|
),
|
||||||
|
default=ProviderBillingType.PAY_AS_YOU_GO,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 月卡配置
|
||||||
|
monthly_quota_usd = Column(Float, nullable=True) # 月卡总额度
|
||||||
|
monthly_used_usd = Column(Float, default=0.0) # 本月已用额度
|
||||||
|
quota_reset_day = Column(Integer, default=30) # 额度重置周期(天数),例如:7=每周,30=每月
|
||||||
|
quota_last_reset_at = Column(DateTime(timezone=True), nullable=True) # 上次额度重置时间
|
||||||
|
quota_expires_at = Column(DateTime(timezone=True), nullable=True) # 月卡过期时间
|
||||||
|
|
||||||
|
# 提供商优先级 (数字越小越优先,用于提供商优先模式下的 Provider 排序)
|
||||||
|
# 0-10: 急需消耗(如即将过期的月卡)
|
||||||
|
# 11-50: 优先消耗(月卡)
|
||||||
|
# 51-100: 正常消费(按量付费)
|
||||||
|
# 101+: 备用(高成本或限制严格的)
|
||||||
|
provider_priority = Column(Integer, default=100)
|
||||||
|
|
||||||
|
# 格式转换时是否保持优先级(默认 False)
|
||||||
|
# - False: 需要格式转换时,该提供商的候选会被降级到不需要转换的候选之后
|
||||||
|
# - True: 即使需要格式转换,也保持原优先级排名
|
||||||
|
# 注意:如果系统配置 keep_priority_on_conversion=true,此字段被忽略(所有提供商都保持优先级)
|
||||||
|
keep_priority_on_conversion = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# 是否允许格式转换(默认 True)
|
||||||
|
# - True: 该提供商可以作为格式转换的目标(如 OpenAI 客户端请求可以路由到此 Gemini 提供商)
|
||||||
|
# - False: 该提供商不接受需要格式转换的请求
|
||||||
|
# 优先级逻辑:
|
||||||
|
# - 全局开关 ON -> 强制允许所有提供商的格式转换(忽略此字段)
|
||||||
|
# - 全局开关 OFF -> 由此字段决定是否允许该提供商的格式转换
|
||||||
|
enable_format_conversion = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# 限制
|
||||||
|
concurrent_limit = Column(Integer, nullable=True) # 并发请求限制
|
||||||
|
|
||||||
|
# 请求配置
|
||||||
|
max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数
|
||||||
|
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled}
|
||||||
|
|
||||||
|
# 超时配置(秒),为 None 时使用全局配置
|
||||||
|
stream_first_byte_timeout = Column(Float, nullable=True) # 流式请求首字节超时
|
||||||
|
request_timeout = Column(Float, nullable=True) # 非流式请求整体超时
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
config = Column(JSON, nullable=True) # 额外配置(如Azure deployment name等)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
models = relationship("Model", back_populates="provider", cascade="all, delete-orphan")
|
||||||
|
endpoints = relationship(
|
||||||
|
"ProviderEndpoint", back_populates="provider", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
api_keys = relationship(
|
||||||
|
"ProviderAPIKey", back_populates="provider", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
api_key_mappings = relationship(
|
||||||
|
"ApiKeyProviderMapping", back_populates="provider", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
usage_tracking = relationship(
|
||||||
|
"ProviderUsageTracking", back_populates="provider", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderEndpoint(Base):
|
||||||
|
"""提供商端点 - 一个提供商可以有多个 API 格式端点"""
|
||||||
|
|
||||||
|
__tablename__ = "provider_endpoints"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
# API 格式和配置
|
||||||
|
# 新模式:存储 endpoint signature key(family:kind),如 "openai:chat"
|
||||||
|
api_format = Column(String(50), nullable=False)
|
||||||
|
# 新架构字段(Phase 1/3):用于将 api_format 拆分为结构化维度
|
||||||
|
api_family = Column(String(50), nullable=True) # openai/claude/gemini
|
||||||
|
endpoint_kind = Column(String(50), nullable=True) # chat/cli/video/...
|
||||||
|
base_url = Column(String(500), nullable=False)
|
||||||
|
|
||||||
|
# 请求配置
|
||||||
|
header_rules = Column(JSON, nullable=True) # 请求头规则 [{action, key, value, from, to}]
|
||||||
|
body_rules = Column(JSON, nullable=True) # 请求体规则 [{action, path, value, from, to}]
|
||||||
|
max_retries = Column(Integer, default=2) # 最大重试次数
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# 路径配置
|
||||||
|
custom_path = Column(
|
||||||
|
String(200), nullable=True
|
||||||
|
) # 自定义请求路径,为空则使用 API 格式的默认路径
|
||||||
|
|
||||||
|
# 额外配置
|
||||||
|
config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段)
|
||||||
|
|
||||||
|
# 格式转换配置
|
||||||
|
format_acceptance_config = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
comment="格式接受策略配置(跨格式转换开关/白黑名单等)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 代理配置
|
||||||
|
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password}
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
provider = relationship("Provider", back_populates="endpoints")
|
||||||
|
|
||||||
|
# 唯一约束和索引在表定义后
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
|
||||||
|
Index("idx_endpoint_format_active", "api_format", "is_active"),
|
||||||
|
Index("idx_provider_family_kind", "provider_id", "api_family", "endpoint_kind"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyNodeStatus(PyEnum):
|
||||||
|
"""代理节点状态"""
|
||||||
|
|
||||||
|
ONLINE = "online"
|
||||||
|
UNHEALTHY = "unhealthy"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyNode(Base):
|
||||||
|
"""代理节点表(aether-proxy 自动注册 + 手动添加)"""
|
||||||
|
|
||||||
|
__tablename__ = "proxy_nodes"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
name = Column(String(100), nullable=False) # 节点名
|
||||||
|
ip = Column(String(512), nullable=False) # 公网 IP 或手动节点的主机名(含协议前缀)
|
||||||
|
port = Column(Integer, nullable=False) # 代理端口
|
||||||
|
region = Column(String(100), nullable=True) # 区域标签
|
||||||
|
|
||||||
|
# 手动节点专用字段
|
||||||
|
is_manual = Column(Boolean, default=False, nullable=False, comment="是否为手动添加的代理节点")
|
||||||
|
proxy_url = Column(String(500), nullable=True, comment="手动节点的完整代理 URL")
|
||||||
|
proxy_username = Column(String(255), nullable=True, comment="手动节点的代理用户名")
|
||||||
|
proxy_password = Column(String(500), nullable=True, comment="手动节点的代理密码")
|
||||||
|
|
||||||
|
status = Column(
|
||||||
|
Enum(
|
||||||
|
ProxyNodeStatus,
|
||||||
|
name="proxynodestatus",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda x: [e.value for e in x],
|
||||||
|
),
|
||||||
|
default=ProxyNodeStatus.ONLINE,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
registered_by = Column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
comment="注册该节点的管理员用户 ID(可空)",
|
||||||
|
)
|
||||||
|
last_heartbeat_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
heartbeat_interval = Column(Integer, default=30, nullable=False)
|
||||||
|
|
||||||
|
# 性能指标(心跳上报)
|
||||||
|
active_connections = Column(Integer, default=0, nullable=False)
|
||||||
|
total_requests = Column(BigInteger, default=0, nullable=False)
|
||||||
|
avg_latency_ms = Column(Float, nullable=True)
|
||||||
|
|
||||||
|
# TLS 加密
|
||||||
|
tls_enabled = Column(Boolean, default=False, nullable=False, comment="是否启用 TLS 加密")
|
||||||
|
tls_cert_fingerprint = Column(
|
||||||
|
String(128), nullable=True, comment="TLS 证书 SHA-256 指纹(hex)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 硬件信息(注册时上报,JSON 可扩展)
|
||||||
|
hardware_info = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
|
||||||
|
)
|
||||||
|
estimated_max_concurrency = Column(
|
||||||
|
Integer, nullable=True, comment="基于硬件估算的最大并发连接数"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 管理端远程配置(通过心跳下发给 aether-proxy)
|
||||||
|
remote_config = Column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)",
|
||||||
|
)
|
||||||
|
config_version = Column(
|
||||||
|
Integer, default=0, nullable=False, comment="远程配置版本号,每次更新 +1"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("ip", "port", name="uq_proxy_node_ip_port"),)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderAPIKey(Base):
|
||||||
|
"""Provider API密钥表 - 直接归属于 Provider,支持多种 API 格式"""
|
||||||
|
|
||||||
|
__tablename__ = "provider_api_keys"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
|
||||||
|
# 外键关系 - 直接关联 Provider
|
||||||
|
provider_id = Column(
|
||||||
|
String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# API 格式支持列表(核心字段)
|
||||||
|
# None 表示支持所有格式(兼容历史数据),空列表 [] 表示不支持任何格式
|
||||||
|
api_formats = Column(JSON, nullable=True, default=list) # ["claude:chat", "claude:cli"]
|
||||||
|
|
||||||
|
# 认证类型
|
||||||
|
# - "api_key": 标准 API Key 认证(默认)
|
||||||
|
# - "vertex_ai": Google Vertex AI 认证(Service Account JSON)
|
||||||
|
# - 未来可扩展:oauth2, azure_ad, aws_iam 等
|
||||||
|
auth_type = Column(String(20), default="api_key", nullable=False)
|
||||||
|
|
||||||
|
# API密钥(加密存储)
|
||||||
|
# - auth_type="api_key" 时:存储 API Key 字符串
|
||||||
|
# - auth_type="vertex_ai" 等:可为空,敏感凭证存在 auth_config 中
|
||||||
|
api_key = Column(Text, nullable=False) # 使用 Text 支持加密后的 OAuth token
|
||||||
|
|
||||||
|
# 认证配置(加密存储)
|
||||||
|
# - auth_type="api_key" 时:可为空
|
||||||
|
# - auth_type="vertex_ai" 时:存储加密后的 Service Account JSON
|
||||||
|
# - auth_type="oauth2" 时:存储加密后的 {client_id, client_secret, token_url, scope}
|
||||||
|
auth_config = Column(Text, nullable=True)
|
||||||
|
name = Column(String(100), nullable=False) # 密钥名称(必填,用于识别)
|
||||||
|
note = Column(String(500), nullable=True) # 备注说明(可选)
|
||||||
|
|
||||||
|
# 成本计算
|
||||||
|
rate_multipliers = Column(
|
||||||
|
JSON, nullable=True
|
||||||
|
) # 按 endpoint signature 的成本倍率 {"claude:cli": 1.0, "openai:cli": 0.8}
|
||||||
|
|
||||||
|
# 优先级配置 (数字越小越优先)
|
||||||
|
internal_priority = Column(
|
||||||
|
Integer, default=50
|
||||||
|
) # Endpoint 内部优先级(用于提供商优先模式,同 Endpoint 内 Keys 的排序,同优先级参与负载均衡)
|
||||||
|
global_priority_by_format = Column(
|
||||||
|
JSON, nullable=True
|
||||||
|
) # 按 endpoint signature 的全局优先级 {"claude:chat": 1, "claude:cli": 2}
|
||||||
|
|
||||||
|
# RPM 限制配置(自适应学习)
|
||||||
|
# rpm_limit 决定 RPM 控制模式:
|
||||||
|
# - NULL: 自适应模式,系统自动学习并调整(使用 learned_rpm_limit)
|
||||||
|
# - 数字: 固定限制模式,使用用户指定的值
|
||||||
|
rpm_limit = Column(Integer, nullable=True, default=None)
|
||||||
|
|
||||||
|
# 模型权限控制
|
||||||
|
allowed_models = Column(JSON, nullable=True) # 允许使用的模型列表(null = 支持所有模型)
|
||||||
|
|
||||||
|
# Key 能力标签
|
||||||
|
capabilities = Column(JSON, nullable=True) # Key 拥有的能力
|
||||||
|
# 示例: {"cache_1h": true, "context_1m": true}
|
||||||
|
|
||||||
|
# 自适应 RPM 调整(仅当 rpm_limit = NULL 时生效)
|
||||||
|
learned_rpm_limit = Column(Integer, nullable=True) # 学习到的 RPM 限制(自适应模式下的有效值)
|
||||||
|
concurrent_429_count = Column(Integer, default=0, nullable=False) # 因并发导致的429次数
|
||||||
|
rpm_429_count = Column(Integer, default=0, nullable=False) # 因RPM导致的429次数
|
||||||
|
last_429_at = Column(DateTime(timezone=True), nullable=True) # 最后429时间
|
||||||
|
last_429_type = Column(String(50), nullable=True) # 最后429类型: concurrent/rpm/unknown
|
||||||
|
last_rpm_peak = Column(Integer, nullable=True) # 触发429时的RPM峰值
|
||||||
|
adjustment_history = Column(JSON, nullable=True) # RPM调整历史
|
||||||
|
# 基于滑动窗口的利用率追踪
|
||||||
|
utilization_samples = Column(
|
||||||
|
JSON, nullable=True
|
||||||
|
) # 利用率采样窗口 [{"ts": timestamp, "util": 0.8}, ...]
|
||||||
|
last_probe_increase_at = Column(DateTime(timezone=True), nullable=True) # 上次探测性扩容时间
|
||||||
|
|
||||||
|
# 健康度追踪(按 endpoint signature 存储)
|
||||||
|
# 结构: {"claude:chat": {"health_score": 1.0, "consecutive_failures": 0, ...}, ...}
|
||||||
|
health_by_format = Column(JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# 缓存与熔断配置
|
||||||
|
cache_ttl_minutes = Column(
|
||||||
|
Integer, default=5, nullable=False
|
||||||
|
) # 缓存TTL(分钟),0表示不支持缓存,默认5分钟
|
||||||
|
max_probe_interval_minutes = Column(
|
||||||
|
Integer, default=32, nullable=False
|
||||||
|
) # 最大探测间隔(分钟),默认32分钟(硬上限)
|
||||||
|
|
||||||
|
# 熔断器状态(按 endpoint signature 存储)
|
||||||
|
# 结构: {"claude:chat": {"open": false, "open_at": null, ...}, ...}
|
||||||
|
circuit_breaker_by_format = Column(JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# 使用统计
|
||||||
|
request_count = Column(Integer, default=0) # 请求次数
|
||||||
|
success_count = Column(Integer, default=0) # 成功次数
|
||||||
|
error_count = Column(Integer, default=0) # 错误次数
|
||||||
|
total_response_time_ms = Column(Integer, default=0) # 总响应时间(用于计算平均值)
|
||||||
|
last_used_at = Column(DateTime(timezone=True), nullable=True) # 最后使用时间
|
||||||
|
last_error_at = Column(DateTime(timezone=True), nullable=True) # 最后错误时间
|
||||||
|
last_error_msg = Column(Text, nullable=True) # 最后错误信息
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
|
||||||
|
|
||||||
|
# 自动获取模型配置
|
||||||
|
auto_fetch_models = Column(Boolean, default=False, nullable=False) # 是否启用自动获取模型
|
||||||
|
last_models_fetch_at = Column(DateTime(timezone=True), nullable=True) # 最后获取时间
|
||||||
|
last_models_fetch_error = Column(Text, nullable=True) # 最后获取错误信息
|
||||||
|
locked_models = Column(JSON, nullable=True) # 被锁定的模型列表(刷新时不会被删除)
|
||||||
|
# 模型过滤规则(支持 * 和 ? 通配符,如 "gpt-*", "claude-?-sonnet")
|
||||||
|
model_include_patterns = Column(JSON, nullable=True) # 包含规则列表,空表示不过滤(包含所有)
|
||||||
|
model_exclude_patterns = Column(JSON, nullable=True) # 排除规则列表,空表示不排除
|
||||||
|
|
||||||
|
# 上游元数据(由响应头解析器采集,如 Codex 额度信息)
|
||||||
|
upstream_metadata = Column(JSON, nullable=True, default=dict)
|
||||||
|
|
||||||
|
# OAuth 失效状态(账号被封、授权撤销、刷新失败等)
|
||||||
|
oauth_invalid_at = Column(DateTime(timezone=True), nullable=True) # 失效时间
|
||||||
|
oauth_invalid_reason = Column(String(255), nullable=True) # 失效原因
|
||||||
|
|
||||||
|
# Key 级别的代理配置(覆盖 Provider 级别的代理设置)
|
||||||
|
# 结构: {"node_id": "xxx", "enabled": true} 或 {"url": "socks5://...", "enabled": true}
|
||||||
|
# null 表示使用 Provider 级别代理(默认行为)
|
||||||
|
proxy = Column(JSON, nullable=True, default=None)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
provider = relationship("Provider", back_populates="api_keys")
|
||||||
461
src/models/stats.py
Normal file
461
src/models/stats.py
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
"""
|
||||||
|
统计数据相关数据库模型
|
||||||
|
|
||||||
|
包含: StatsBaseMixin, StatsHourly, StatsHourlyUser, StatsHourlyModel, StatsHourlyProvider,
|
||||||
|
StatsDaily, StatsDailyModel, StatsDailyProvider, StatsDailyApiKey, StatsDailyError,
|
||||||
|
StatsSummary, StatsUserDaily
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StatsBaseMixin:
|
||||||
|
"""统计表公共字段 Mixin"""
|
||||||
|
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsHourly(Base):
|
||||||
|
"""小时级统计快照 - 用于时间序列查询"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_hourly"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 小时起点 (UTC)
|
||||||
|
hour_utc = Column(DateTime(timezone=True), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# 请求统计
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 成本统计 (USD)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
actual_total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 性能统计
|
||||||
|
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 完成标记
|
||||||
|
is_complete = Column(Boolean, default=False, nullable=False)
|
||||||
|
aggregated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("idx_stats_hourly_hour", "hour_utc"),)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsHourlyUser(StatsBaseMixin, Base):
|
||||||
|
"""小时级用户维度统计"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_hourly_user"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
user_id = Column(String(36), nullable=False, index=True)
|
||||||
|
|
||||||
|
success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("hour_utc", "user_id", name="uq_stats_hourly_user"),
|
||||||
|
Index("idx_stats_hourly_user_hour", "hour_utc"),
|
||||||
|
Index("idx_stats_hourly_user_user_hour", "user_id", "hour_utc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsHourlyModel(StatsBaseMixin, Base):
|
||||||
|
"""小时级模型维度统计"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_hourly_model"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
model = Column(String(100), nullable=False, index=True)
|
||||||
|
|
||||||
|
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("hour_utc", "model", name="uq_stats_hourly_model"),
|
||||||
|
Index("idx_stats_hourly_model_hour", "hour_utc"),
|
||||||
|
Index("idx_stats_hourly_model_model_hour", "model", "hour_utc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsHourlyProvider(StatsBaseMixin, Base):
|
||||||
|
"""小时级提供商维度统计"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_hourly_provider"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
hour_utc = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
provider_name = Column(String(100), nullable=False, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
|
||||||
|
Index("idx_stats_hourly_provider_hour", "hour_utc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsDaily(Base):
|
||||||
|
"""每日统计快照 - 用于快速查询历史数据"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_daily"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 统计日期 (UTC)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
# 请求统计
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 成本统计 (USD)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
actual_total_cost = Column(Float, default=0.0, nullable=False) # 倍率后成本
|
||||||
|
input_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
output_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
cache_creation_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
cache_read_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 性能统计
|
||||||
|
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
||||||
|
p50_response_time_ms = Column(Integer, nullable=True)
|
||||||
|
p90_response_time_ms = Column(Integer, nullable=True)
|
||||||
|
p99_response_time_ms = Column(Integer, nullable=True)
|
||||||
|
p50_first_byte_time_ms = Column(Integer, nullable=True)
|
||||||
|
p90_first_byte_time_ms = Column(Integer, nullable=True)
|
||||||
|
p99_first_byte_time_ms = Column(Integer, nullable=True)
|
||||||
|
fallback_count = Column(Integer, default=0, nullable=False) # Provider 切换次数
|
||||||
|
|
||||||
|
# 使用维度统计
|
||||||
|
unique_models = Column(Integer, default=0, server_default="0", nullable=False)
|
||||||
|
unique_providers = Column(Integer, default=0, server_default="0", nullable=False)
|
||||||
|
|
||||||
|
# 完成标记
|
||||||
|
is_complete = Column(Boolean, default=False, nullable=False)
|
||||||
|
aggregated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsDailyModel(Base):
|
||||||
|
"""每日模型统计快照 - 用于快速查询每日模型维度数据"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_daily_model"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 统计日期 (UTC)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
# 模型名称
|
||||||
|
model = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# 请求统计
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 成本统计 (USD)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 性能统计
|
||||||
|
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 唯一约束:每个模型每天只有一条记录
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("date", "model", name="uq_stats_daily_model"),
|
||||||
|
Index("idx_stats_daily_model_date", "date"),
|
||||||
|
Index("idx_stats_daily_model_date_model", "date", "model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsDailyProvider(Base):
|
||||||
|
"""每日供应商统计快照 - 用于快速查询每日供应商维度数据"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_daily_provider"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 统计日期 (UTC)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
# 供应商名称
|
||||||
|
provider_name = Column(String(100), nullable=False)
|
||||||
|
|
||||||
|
# 请求统计
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 成本统计 (USD)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 唯一约束:每个供应商每天只有一条记录
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("date", "provider_name", name="uq_stats_daily_provider"),
|
||||||
|
Index("idx_stats_daily_provider_date", "date"),
|
||||||
|
Index("idx_stats_daily_provider_date_provider", "date", "provider_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsDailyApiKey(Base):
|
||||||
|
"""API Key 每日统计"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_daily_api_key"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
|
||||||
|
Index("idx_stats_daily_api_key_date", "date"),
|
||||||
|
Index("idx_stats_daily_api_key_key_date", "api_key_id", "date"),
|
||||||
|
Index("idx_stats_daily_api_key_date_requests", "date", "total_requests"),
|
||||||
|
Index("idx_stats_daily_api_key_date_cost", "date", "total_cost"),
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = relationship("ApiKey")
|
||||||
|
|
||||||
|
|
||||||
|
class StatsDailyError(Base):
|
||||||
|
"""每日错误统计"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_daily_error"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
error_category = Column(String(50), nullable=False)
|
||||||
|
provider_name = Column(String(100), nullable=True)
|
||||||
|
model = Column(String(100), nullable=True)
|
||||||
|
count = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"date",
|
||||||
|
"error_category",
|
||||||
|
"provider_name",
|
||||||
|
"model",
|
||||||
|
name="uq_stats_daily_error",
|
||||||
|
),
|
||||||
|
Index("idx_stats_daily_error_date", "date"),
|
||||||
|
Index("idx_stats_daily_error_category", "date", "error_category"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsSummary(Base):
|
||||||
|
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_summary"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 统计截止日期 (不含当天)
|
||||||
|
cutoff_date = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# 累计请求统计
|
||||||
|
all_time_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
all_time_success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
all_time_error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 累计 Token 统计
|
||||||
|
all_time_input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
all_time_output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
all_time_cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
all_time_cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 累计成本统计 (USD)
|
||||||
|
all_time_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
all_time_actual_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 累计用户/API Key 统计 (快照)
|
||||||
|
total_users = Column(Integer, default=0, nullable=False)
|
||||||
|
active_users = Column(Integer, default=0, nullable=False)
|
||||||
|
total_api_keys = Column(Integer, default=0, nullable=False)
|
||||||
|
active_api_keys = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsUserDaily(Base):
|
||||||
|
"""用户每日统计快照 - 用于用户仪表盘"""
|
||||||
|
|
||||||
|
__tablename__ = "stats_user_daily"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 用户关联
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
# 统计日期 (UTC)
|
||||||
|
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||||
|
|
||||||
|
# 请求统计
|
||||||
|
total_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
success_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
error_requests = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
# Token 统计
|
||||||
|
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||||
|
|
||||||
|
# 成本统计 (USD)
|
||||||
|
total_cost = Column(Float, default=0.0, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 唯一约束:每个用户每天只有一条记录
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||||
|
Index("idx_stats_user_daily_user_date", "user_id", "date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User")
|
||||||
243
src/models/usage.py
Normal file
243
src/models/usage.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
使用记录相关数据库模型
|
||||||
|
|
||||||
|
包含: Usage, RequestCandidate
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Usage(Base):
|
||||||
|
"""使用记录模型"""
|
||||||
|
|
||||||
|
__tablename__ = "usage"
|
||||||
|
__table_args__ = (
|
||||||
|
# Composite indexes for common query patterns (analytics / list pages)
|
||||||
|
Index("idx_usage_user_created", "user_id", "created_at"),
|
||||||
|
Index("idx_usage_apikey_created", "api_key_id", "created_at"),
|
||||||
|
Index("idx_usage_provider_model_created", "provider_name", "model", "created_at"),
|
||||||
|
Index("idx_usage_provider_created", "provider_name", "created_at"),
|
||||||
|
Index("idx_usage_model_created", "model", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
# 请求信息
|
||||||
|
request_id = Column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
provider_name = Column(String(100), nullable=False) # Provider 名称(非外键)
|
||||||
|
model = Column(String(100), nullable=False)
|
||||||
|
target_model = Column(
|
||||||
|
String(100), nullable=True, comment="映射后的目标模型名(若无映射则为空)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Provider 侧追踪信息(记录最终成功的 Provider/Endpoint/Key)
|
||||||
|
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
provider_endpoint_id = Column(
|
||||||
|
String(36), ForeignKey("provider_endpoints.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
provider_api_key_id = Column(
|
||||||
|
String(36), ForeignKey("provider_api_keys.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token统计
|
||||||
|
input_tokens = Column(Integer, default=0)
|
||||||
|
output_tokens = Column(Integer, default=0)
|
||||||
|
total_tokens = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# 缓存相关 tokens (for Claude models)
|
||||||
|
cache_creation_input_tokens = Column(Integer, default=0)
|
||||||
|
cache_read_input_tokens = Column(Integer, default=0)
|
||||||
|
|
||||||
|
# 成本计算
|
||||||
|
input_cost_usd = Column(Float, default=0.0)
|
||||||
|
output_cost_usd = Column(Float, default=0.0)
|
||||||
|
cache_cost_usd = Column(Float, default=0.0) # 总缓存成本(兼容旧数据)
|
||||||
|
cache_creation_cost_usd = Column(Float, default=0.0) # 缓存创建成本
|
||||||
|
cache_read_cost_usd = Column(Float, default=0.0) # 缓存读取成本
|
||||||
|
request_cost_usd = Column(Float, default=0.0) # 按次计费成本
|
||||||
|
total_cost_usd = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# 真实成本计算(表面成本 x 倍率)
|
||||||
|
actual_input_cost_usd = Column(Float, default=0.0) # 真实输入成本
|
||||||
|
actual_output_cost_usd = Column(Float, default=0.0) # 真实输出成本
|
||||||
|
actual_cache_creation_cost_usd = Column(Float, default=0.0) # 真实缓存创建成本
|
||||||
|
actual_cache_read_cost_usd = Column(Float, default=0.0) # 真实缓存读取成本
|
||||||
|
actual_request_cost_usd = Column(Float, default=0.0) # 真实按次计费成本
|
||||||
|
actual_total_cost_usd = Column(Float, default=0.0) # 真实总成本
|
||||||
|
rate_multiplier = Column(Float, default=1.0) # 使用的倍率(来自 ProviderAPIKey)
|
||||||
|
|
||||||
|
# 历史价格记录(每1M tokens的美元价格,记录请求时的实际价格)
|
||||||
|
input_price_per_1m = Column(Float, nullable=True) # 输入单价
|
||||||
|
output_price_per_1m = Column(Float, nullable=True) # 输出单价
|
||||||
|
cache_creation_price_per_1m = Column(Float, nullable=True) # 缓存创建单价
|
||||||
|
cache_read_price_per_1m = Column(Float, nullable=True) # 缓存读取单价
|
||||||
|
price_per_request = Column(Float, nullable=True) # 按次计费单价(历史记录)
|
||||||
|
|
||||||
|
# 请求详情
|
||||||
|
request_type = Column(String(50)) # chat, completion, embedding等
|
||||||
|
api_format = Column(String(50), nullable=True) # API 格式: CLAUDE, OPENAI 等(用户请求格式)
|
||||||
|
endpoint_api_format = Column(String(50), nullable=True) # 端点原生 API 格式
|
||||||
|
has_format_conversion = Column(Boolean, nullable=True, default=False) # 是否发生了格式转换
|
||||||
|
is_stream = Column(Boolean, default=False) # 是否为流式请求
|
||||||
|
status_code = Column(Integer)
|
||||||
|
error_message = Column(Text, nullable=True)
|
||||||
|
error_category = Column(String(50), nullable=True, index=True)
|
||||||
|
response_time_ms = Column(Integer) # 总响应时间(毫秒)
|
||||||
|
first_byte_time_ms = Column(Integer, nullable=True) # 首字时间/TTFB(毫秒)
|
||||||
|
|
||||||
|
# 请求状态追踪
|
||||||
|
# pending: 请求开始处理中
|
||||||
|
# streaming: 流式响应进行中
|
||||||
|
# completed: 请求成功完成
|
||||||
|
# failed: 请求失败
|
||||||
|
# cancelled: 客户端主动断开连接
|
||||||
|
status = Column(String(20), default="completed", nullable=False, index=True)
|
||||||
|
|
||||||
|
# 结算状态(与 status 解耦)
|
||||||
|
# - pending: 等待结算(任务未完成 / 流式未结束)
|
||||||
|
# - settled: 已结算(cost 已写入,可能 > 0 或 = 0)
|
||||||
|
# - void: 作废(不收费,如任务未开始就取消)
|
||||||
|
billing_status = Column(String(20), default="settled", nullable=False, index=True)
|
||||||
|
finalized_at = Column(DateTime(timezone=True), nullable=True) # 结算完成时间(可选)
|
||||||
|
|
||||||
|
# 完整请求和响应记录
|
||||||
|
request_headers = Column(JSON, nullable=True) # 客户端请求头
|
||||||
|
request_body = Column(JSON, nullable=True) # 请求体(7天内未压缩)
|
||||||
|
provider_request_headers = Column(JSON, nullable=True) # 向提供商发送的请求头
|
||||||
|
response_headers = Column(JSON, nullable=True) # 提供商响应头
|
||||||
|
client_response_headers = Column(JSON, nullable=True) # 返回给客户端的响应头
|
||||||
|
response_body = Column(JSON, nullable=True) # 响应体(7天内未压缩)
|
||||||
|
|
||||||
|
# 压缩存储字段(7天后自动压缩到这里)
|
||||||
|
request_body_compressed = Column(LargeBinary, nullable=True) # gzip压缩的请求体
|
||||||
|
response_body_compressed = Column(LargeBinary, nullable=True) # gzip压缩的响应体
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
request_metadata = Column(JSON, nullable=True) # 存储额外信息
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="usage_records")
|
||||||
|
api_key = relationship("ApiKey", back_populates="usage_records")
|
||||||
|
provider_obj = relationship("Provider") # 使用 provider_obj 避免与 provider 字段名冲突
|
||||||
|
provider_endpoint = relationship("ProviderEndpoint")
|
||||||
|
provider_api_key = relationship("ProviderAPIKey")
|
||||||
|
|
||||||
|
def get_request_body(self) -> Any:
|
||||||
|
"""获取请求体(自动解压)"""
|
||||||
|
if self.request_body is not None:
|
||||||
|
return self.request_body
|
||||||
|
if self.request_body_compressed is not None:
|
||||||
|
from src.utils.compression import decompress_json
|
||||||
|
|
||||||
|
return decompress_json(self.request_body_compressed)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_response_body(self) -> Any:
|
||||||
|
"""获取响应体(自动解压)"""
|
||||||
|
if self.response_body is not None:
|
||||||
|
return self.response_body
|
||||||
|
if self.response_body_compressed is not None:
|
||||||
|
from src.utils.compression import decompress_json
|
||||||
|
|
||||||
|
return decompress_json(self.response_body_compressed)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RequestCandidate(Base):
|
||||||
|
"""请求候选记录 - 追踪所有候选(包括未使用的)"""
|
||||||
|
|
||||||
|
__tablename__ = "request_candidates"
|
||||||
|
|
||||||
|
# 主键
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
|
||||||
|
# 关联字段
|
||||||
|
request_id = Column(String(100), nullable=False, index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
|
||||||
|
# 候选信息
|
||||||
|
candidate_index = Column(Integer, nullable=False) # 候选序号(从0开始)
|
||||||
|
retry_index = Column(Integer, nullable=False, default=0) # 重试序号(从0开始)
|
||||||
|
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
endpoint_id = Column(
|
||||||
|
String(36), ForeignKey("provider_endpoints.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
key_id = Column(
|
||||||
|
String(36), ForeignKey("provider_api_keys.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# 状态信息
|
||||||
|
status = Column(
|
||||||
|
String(20), nullable=False
|
||||||
|
) # 'pending', 'streaming', 'success', 'failed', 'cancelled', 'skipped'
|
||||||
|
skip_reason = Column(Text, nullable=True) # 跳过/失败原因
|
||||||
|
is_cached = Column(Boolean, default=False) # 是否为缓存亲和性候选
|
||||||
|
|
||||||
|
# 执行结果信息(当 status = success/failed 时)
|
||||||
|
status_code = Column(Integer, nullable=True) # HTTP 状态码
|
||||||
|
error_type = Column(String(50), nullable=True) # 错误类型
|
||||||
|
error_message = Column(Text, nullable=True) # 错误消息
|
||||||
|
latency_ms = Column(Integer, nullable=True) # 延迟(毫秒)
|
||||||
|
concurrent_requests = Column(Integer, nullable=True) # 并发请求数
|
||||||
|
|
||||||
|
# 元数据
|
||||||
|
extra_data = Column(JSON, nullable=True)
|
||||||
|
required_capabilities = Column(JSON, nullable=True) # 请求实际需要的能力标签
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
started_at = Column(DateTime(timezone=True), nullable=True) # 开始执行时间
|
||||||
|
finished_at = Column(DateTime(timezone=True), nullable=True) # 完成时间
|
||||||
|
|
||||||
|
# 唯一约束和索引
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||||
|
),
|
||||||
|
Index("idx_request_candidates_request_id", "request_id"),
|
||||||
|
Index("idx_request_candidates_status", "status"),
|
||||||
|
Index("idx_request_candidates_provider_id", "provider_id"),
|
||||||
|
Index("idx_request_candidates_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User")
|
||||||
|
api_key = relationship("ApiKey")
|
||||||
|
provider = relationship("Provider")
|
||||||
|
endpoint = relationship("ProviderEndpoint")
|
||||||
|
key = relationship("ProviderAPIKey")
|
||||||
522
src/models/user.py
Normal file
522
src/models/user.py
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
"""
|
||||||
|
用户相关数据库模型
|
||||||
|
|
||||||
|
包含: User, ApiKey, UserQuota, UserPreference, ManagementToken
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from src.config import config
|
||||||
|
from src.core.enums import AuthSource, UserRole
|
||||||
|
|
||||||
|
from ._base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""用户模型"""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
# OAuth 用户可能没有邮箱;Postgres unique 允许多个 NULL
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=True)
|
||||||
|
# 注意:所有创建用户的入口必须显式写入 true/false,禁止依赖默认值
|
||||||
|
email_verified = Column(Boolean, nullable=False)
|
||||||
|
username = Column(String(100), unique=True, index=True, nullable=False)
|
||||||
|
# OAuth 用户可能没有本地密码(v1 仅做字段兼容)
|
||||||
|
password_hash = Column(String(255), nullable=True)
|
||||||
|
role = Column(
|
||||||
|
Enum(
|
||||||
|
UserRole,
|
||||||
|
name="userrole",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda x: [e.value for e in x],
|
||||||
|
),
|
||||||
|
default=UserRole.USER,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
auth_source = Column(
|
||||||
|
Enum(
|
||||||
|
AuthSource,
|
||||||
|
name="authsource",
|
||||||
|
create_type=False,
|
||||||
|
values_callable=lambda x: [e.value for e in x],
|
||||||
|
),
|
||||||
|
default=AuthSource.LOCAL,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# LDAP 标识(仅 auth_source=ldap 时使用,用于在邮箱变更/用户名冲突时稳定关联本地账户)
|
||||||
|
ldap_dn = Column(String(512), nullable=True, index=True)
|
||||||
|
ldap_username = Column(String(255), nullable=True, index=True)
|
||||||
|
|
||||||
|
# 访问限制(NULL 表示不限制,允许访问所有资源)
|
||||||
|
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
|
||||||
|
allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表
|
||||||
|
allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表
|
||||||
|
|
||||||
|
# Key 能力配置
|
||||||
|
model_capability_settings = Column(JSON, nullable=True) # 用户针对特定模型的能力配置
|
||||||
|
# 示例: {"claude-sonnet-4-20250514": {"cache_1h": true}}
|
||||||
|
|
||||||
|
# 配额管理
|
||||||
|
quota_usd = Column(Float, nullable=True) # 美元配额(NULL 表示无限制)
|
||||||
|
used_usd = Column(Float, default=0.0) # 当前周期已使用美元
|
||||||
|
total_usd = Column(Float, default=0.0) # 累积消费总额
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
is_deleted = Column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
last_login_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# 关系 - CASCADE delete: 让数据库处理级联删除
|
||||||
|
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
management_tokens = relationship(
|
||||||
|
"ManagementToken", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
preferences = relationship(
|
||||||
|
"UserPreference", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
|
||||||
|
)
|
||||||
|
quotas = relationship(
|
||||||
|
"UserQuota", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
|
||||||
|
)
|
||||||
|
announcement_reads = relationship(
|
||||||
|
"AnnouncementRead",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系 - SET NULL: 保留历史记录,让数据库处理 SET NULL
|
||||||
|
usage_records = relationship("Usage", back_populates="user", passive_deletes=True)
|
||||||
|
authored_announcements = relationship(
|
||||||
|
"Announcement",
|
||||||
|
back_populates="author",
|
||||||
|
foreign_keys="Announcement.author_id",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
audit_logs = relationship("AuditLog", back_populates="user", passive_deletes=True)
|
||||||
|
|
||||||
|
def set_password(self, password: str) -> None:
|
||||||
|
"""设置密码"""
|
||||||
|
self.password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_password(self, password: str) -> bool:
|
||||||
|
"""验证密码"""
|
||||||
|
if not self.password_hash:
|
||||||
|
return False
|
||||||
|
return bcrypt.checkpw(password.encode("utf-8"), self.password_hash.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(Base):
|
||||||
|
"""API密钥模型"""
|
||||||
|
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
key_hash = Column(String(64), unique=True, index=True, nullable=False) # API密钥的SHA256哈希
|
||||||
|
key_encrypted = Column(Text, nullable=True) # 加密后的完整密钥,用于查看
|
||||||
|
name = Column(String(100), nullable=True) # 密钥名称,便于用户管理
|
||||||
|
|
||||||
|
# 使用统计
|
||||||
|
total_requests = Column(Integer, default=0)
|
||||||
|
total_cost_usd = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# 余额管理(仅用于独立余额 Key)
|
||||||
|
balance_used_usd = Column(Float, default=0.0) # 已使用余额(USD),用于统计
|
||||||
|
current_balance_usd = Column(Float, nullable=True) # 当前余额(USD),NULL 表示无限制
|
||||||
|
is_standalone = Column(
|
||||||
|
Boolean, default=False, nullable=False
|
||||||
|
) # 是否为独立余额 Key(给非注册用户使用)
|
||||||
|
|
||||||
|
# 访问限制(NULL 表示不限制,允许访问所有资源)
|
||||||
|
allowed_providers = Column(JSON, nullable=True) # 允许使用的提供商 ID 列表
|
||||||
|
allowed_api_formats = Column(JSON, nullable=True) # 允许使用的 API 格式列表
|
||||||
|
allowed_models = Column(JSON, nullable=True) # 允许使用的模型名称列表
|
||||||
|
rate_limit = Column(Integer, default=None, nullable=True) # 每分钟请求限制,None = 无限制
|
||||||
|
concurrent_limit = Column(Integer, default=5, nullable=True) # 并发请求限制
|
||||||
|
|
||||||
|
# Key 能力配置
|
||||||
|
force_capabilities = Column(JSON, nullable=True) # 强制开启的能力
|
||||||
|
# 示例: {"cache_1h": true} - 强制所有支持的模型都用 1h 缓存
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
is_locked = Column(Boolean, default=False, nullable=False) # 管理员锁定,用户无法使用/操作
|
||||||
|
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
|
||||||
|
auto_delete_on_expiry = Column(Boolean, default=False, nullable=False) # 过期后是否自动删除
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="api_keys")
|
||||||
|
usage_records = relationship("Usage", back_populates="api_key")
|
||||||
|
provider_mappings = relationship(
|
||||||
|
"ApiKeyProviderMapping", back_populates="api_key", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_key() -> str:
|
||||||
|
"""生成API密钥(使用加密安全的随机数生成器)"""
|
||||||
|
import string
|
||||||
|
|
||||||
|
# 只使用字母和数字,避免特殊字符
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
random_part = "".join(secrets.choice(alphabet) for _ in range(32))
|
||||||
|
return f"{config.api_key_prefix}-{random_part}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_key(api_key: str) -> str:
|
||||||
|
"""对API密钥进行哈希"""
|
||||||
|
return hashlib.sha256(api_key.encode()).hexdigest()
|
||||||
|
|
||||||
|
def set_key(self, api_key: str) -> None:
|
||||||
|
"""
|
||||||
|
设置API密钥(用于测试和数据初始化)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: 明文API密钥
|
||||||
|
|
||||||
|
注意: 此方法会设置 key_hash 和 key_encrypted
|
||||||
|
"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
# 设置哈希(用于验证)
|
||||||
|
self.key_hash = self.hash_key(api_key)
|
||||||
|
|
||||||
|
# 设置加密的完整密钥(用于显示和管理)
|
||||||
|
self.key_encrypted = crypto_service.encrypt(api_key)
|
||||||
|
|
||||||
|
def verify_key(self, api_key: str) -> bool:
|
||||||
|
"""
|
||||||
|
验证API密钥是否匹配(用于测试)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: 明文API密钥
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 密钥是否匹配
|
||||||
|
"""
|
||||||
|
return self.key_hash == self.hash_key(api_key)
|
||||||
|
|
||||||
|
def get_display_key(self) -> str:
|
||||||
|
"""获取用于显示的脱敏密钥(前缀...后4位)"""
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
if self.key_encrypted:
|
||||||
|
try:
|
||||||
|
# 使用静默模式,避免在显示场景打印错误日志
|
||||||
|
full_key = crypto_service.decrypt(self.key_encrypted, silent=True)
|
||||||
|
# 格式:sk-SpJ3y...sdf4
|
||||||
|
prefix = full_key[:10] if len(full_key) >= 10 else full_key[: len(full_key) // 2]
|
||||||
|
suffix = full_key[-4:] if len(full_key) >= 4 else ""
|
||||||
|
return f"{prefix}...{suffix}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 降级:无法解密时返回占位符
|
||||||
|
return "sk-****"
|
||||||
|
|
||||||
|
|
||||||
|
class UserQuota(Base):
|
||||||
|
"""用户配额历史记录"""
|
||||||
|
|
||||||
|
__tablename__ = "user_quotas"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
# 配额类型
|
||||||
|
quota_type = Column(String(50), nullable=False) # monthly, daily, custom
|
||||||
|
|
||||||
|
# 配额值
|
||||||
|
quota_usd = Column(Float, nullable=False)
|
||||||
|
|
||||||
|
# 时间范围
|
||||||
|
period_start = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
period_end = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
# 使用情况
|
||||||
|
used_usd = Column(Float, default=0.0)
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="quotas")
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreference(Base):
|
||||||
|
"""用户偏好设置表"""
|
||||||
|
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 个人信息
|
||||||
|
avatar_url = Column(String(500), nullable=True) # 头像URL
|
||||||
|
bio = Column(Text, nullable=True) # 个人简介
|
||||||
|
|
||||||
|
# 偏好设置
|
||||||
|
default_provider_id = Column(String(36), ForeignKey("providers.id"), nullable=True)
|
||||||
|
theme = Column(String(20), default="light") # light/dark/auto
|
||||||
|
language = Column(String(10), default="zh-CN")
|
||||||
|
timezone = Column(String(50), default="Asia/Shanghai")
|
||||||
|
|
||||||
|
# 通知设置
|
||||||
|
email_notifications = Column(Boolean, default=True)
|
||||||
|
usage_alerts = Column(Boolean, default=True)
|
||||||
|
announcement_notifications = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="preferences")
|
||||||
|
default_provider = relationship("Provider")
|
||||||
|
|
||||||
|
|
||||||
|
class ManagementToken(Base):
|
||||||
|
"""Management Token 模型 - 用于程序化管理 API 调用"""
|
||||||
|
|
||||||
|
__tablename__ = "management_tokens"
|
||||||
|
|
||||||
|
# Token 格式常量
|
||||||
|
TOKEN_PREFIX = "ae_"
|
||||||
|
TOKEN_RANDOM_LENGTH = 40
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
|
||||||
|
# Token 信息
|
||||||
|
token_hash = Column(String(64), unique=True, index=True, nullable=False) # SHA256 哈希
|
||||||
|
token_prefix = Column(String(12), nullable=True) # Token 前缀用于显示(如 ae_xxxxxxxx)
|
||||||
|
name = Column(String(100), nullable=False) # Token 名称
|
||||||
|
description = Column(Text, nullable=True) # 描述
|
||||||
|
|
||||||
|
# IP 白名单(可选)
|
||||||
|
allowed_ips = Column(JSON, nullable=True) # 允许的 IP 列表,NULL = 不限制
|
||||||
|
# 格式: ["192.168.1.1", "10.0.0.0/24"]
|
||||||
|
|
||||||
|
# 有效期
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=True) # NULL = 永不过期
|
||||||
|
|
||||||
|
# 使用统计
|
||||||
|
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_used_ip = Column(String(45), nullable=True)
|
||||||
|
usage_count = Column(Integer, default=0) # 使用次数
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
# 时间戳
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 关系
|
||||||
|
user = relationship("User", back_populates="management_tokens")
|
||||||
|
|
||||||
|
# 索引和约束
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_management_tokens_user_id", "user_id"),
|
||||||
|
Index("idx_management_tokens_is_active", "is_active"),
|
||||||
|
UniqueConstraint("user_id", "name", name="uq_management_tokens_user_name"),
|
||||||
|
# IP 白名单必须为 NULL(不限制)或非空数组,禁止空数组
|
||||||
|
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||||
|
CheckConstraint(
|
||||||
|
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||||
|
name="check_allowed_ips_not_empty",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""生成 Management Token(使用加密安全的随机数)"""
|
||||||
|
import string
|
||||||
|
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
random_part = "".join(
|
||||||
|
secrets.choice(alphabet) for _ in range(ManagementToken.TOKEN_RANDOM_LENGTH)
|
||||||
|
)
|
||||||
|
return f"{ManagementToken.TOKEN_PREFIX}{random_part}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""对 Token 进行 SHA256 哈希
|
||||||
|
|
||||||
|
安全性说明(当前方案是安全的):
|
||||||
|
- Token 熵为 62^40(约 2^238),暴力破解在计算上不可行
|
||||||
|
- 结合速率限制(默认 30 次/分钟/IP),在线攻击不可行
|
||||||
|
- 不需要盐值:盐值用于防止彩虹表攻击,但 Token 是高熵随机值,
|
||||||
|
不存在可预计算的"常见值",因此彩虹表攻击不适用
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
def set_token(self, token: str) -> None:
|
||||||
|
"""设置 Token(只存储哈希和前缀用于显示)"""
|
||||||
|
self.token_hash = self.hash_token(token)
|
||||||
|
# 存储前缀用于显示(ae_ + 4 个字符,共 7 个字符)
|
||||||
|
self.token_prefix = token[:7] if len(token) > 7 else token
|
||||||
|
|
||||||
|
def get_display_token(self) -> str:
|
||||||
|
"""获取用于显示的脱敏 Token(显示前缀 + 掩码)"""
|
||||||
|
if self.token_prefix:
|
||||||
|
return f"{self.token_prefix}...****"
|
||||||
|
return "ae_****"
|
||||||
|
|
||||||
|
def is_ip_allowed(self, client_ip: str) -> bool:
|
||||||
|
"""检查 IP 是否在白名单中
|
||||||
|
|
||||||
|
安全策略:
|
||||||
|
- None 或不设置表示不限制(允许所有 IP)
|
||||||
|
- 非空列表表示只允许列表中的 IP
|
||||||
|
- 无效的白名单条目会被记录并跳过
|
||||||
|
- 无效的客户端 IP 直接拒绝
|
||||||
|
- 支持 IPv4 映射的 IPv6 地址规范化
|
||||||
|
"""
|
||||||
|
if self.allowed_ips is None:
|
||||||
|
return True # 未设置白名单,不限制
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
# 防御性检查:空列表应该在数据库层被拒绝,但这里再检查一次
|
||||||
|
if not self.allowed_ips:
|
||||||
|
logger.critical(f"Management Token {self.id} - allowed_ips 为空列表(违反数据库约束)")
|
||||||
|
return False # fail-safe
|
||||||
|
|
||||||
|
def normalize_ip(ip_str: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||||
|
"""规范化 IP 地址,将 IPv4 映射的 IPv6 转换为 IPv4"""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
||||||
|
return ip.ipv4_mapped
|
||||||
|
return ip
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 规范化客户端 IP
|
||||||
|
client = normalize_ip(client_ip)
|
||||||
|
if client is None:
|
||||||
|
logger.error(f"Management Token {self.id} - 拒绝无效的客户端 IP: {client_ip}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
valid_entries = 0
|
||||||
|
for allowed in self.allowed_ips:
|
||||||
|
try:
|
||||||
|
if "/" in allowed:
|
||||||
|
# CIDR 格式
|
||||||
|
network = ipaddress.ip_network(allowed, strict=False)
|
||||||
|
valid_entries += 1
|
||||||
|
if client in network:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 精确 IP
|
||||||
|
allowed_ip = normalize_ip(allowed)
|
||||||
|
if allowed_ip is None:
|
||||||
|
logger.error(f"Management Token {self.id} - 白名单包含无效条目: {allowed}")
|
||||||
|
continue
|
||||||
|
valid_entries += 1
|
||||||
|
if client == allowed_ip:
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Management Token {self.id} - 白名单包含无效条目: {allowed}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 如果白名单全部无效,记录严重错误并拒绝
|
||||||
|
if valid_entries == 0:
|
||||||
|
logger.critical(f"Management Token {self.id} - 白名单全部无效,拒绝所有访问")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""检查 Token 是否已过期(时区安全)"""
|
||||||
|
if not self.expires_at:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires = self.expires_at
|
||||||
|
if expires.tzinfo is None:
|
||||||
|
# 数据库中的时间应该有时区信息,如果没有则表示数据完整性问题
|
||||||
|
from src.core.logger import logger
|
||||||
|
|
||||||
|
logger.error(f"Management Token {self.id} expires_at 缺少时区信息(数据完整性问题)")
|
||||||
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return expires < datetime.now(timezone.utc)
|
||||||
@@ -62,6 +62,7 @@ def update_user_agent_version(version: str) -> None:
|
|||||||
# Backward compat: keep module-level constant in sync.
|
# Backward compat: keep module-level constant in sync.
|
||||||
HTTP_USER_AGENT = f"antigravity/{_ua_version} {_PLATFORM_TAG}"
|
HTTP_USER_AGENT = f"antigravity/{_ua_version} {_PLATFORM_TAG}"
|
||||||
|
|
||||||
|
|
||||||
def parse_version_string(text: str) -> str | None:
|
def parse_version_string(text: str) -> str | None:
|
||||||
"""从任意文本中提取 X.Y.Z 格式的版本号。"""
|
"""从任意文本中提取 X.Y.Z 格式的版本号。"""
|
||||||
m = _VERSION_RE.search(text)
|
m = _VERSION_RE.search(text)
|
||||||
@@ -72,7 +73,9 @@ def parse_version_string(text: str) -> str | None:
|
|||||||
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
|
URL_UNAVAILABLE_TTL_SECONDS = 300 # 5 分钟
|
||||||
|
|
||||||
# ============== Thinking Signature ==============
|
# ============== Thinking Signature ==============
|
||||||
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
|
# 统一从 core 层导入,避免多处定义
|
||||||
|
from src.core.api_format.conversion.constants import DUMMY_THOUGHT_SIGNATURE # noqa: E402
|
||||||
|
|
||||||
MIN_SIGNATURE_LENGTH = 50 # 与 Antigravity-Manager 对齐
|
MIN_SIGNATURE_LENGTH = 50 # 与 Antigravity-Manager 对齐
|
||||||
|
|
||||||
# ============== Thinking Budget ==============
|
# ============== Thinking Budget ==============
|
||||||
|
|||||||
@@ -3,22 +3,52 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from src.config.settings import config
|
from src.config.settings import config
|
||||||
|
from src.core.error_utils import extract_error_message
|
||||||
|
from src.core.exceptions import (
|
||||||
|
ConcurrencyLimitError,
|
||||||
|
EmbeddedErrorException,
|
||||||
|
ProviderNotAvailableException,
|
||||||
|
ProxyNodeUnavailableError,
|
||||||
|
ThinkingSignatureException,
|
||||||
|
UpstreamClientException,
|
||||||
|
)
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.core.provider_types import ProviderType
|
from src.core.provider_types import ProviderType
|
||||||
from src.models.database import ApiKey
|
from src.models.database import (
|
||||||
|
ApiKey,
|
||||||
|
Provider,
|
||||||
|
ProviderAPIKey,
|
||||||
|
ProviderEndpoint,
|
||||||
|
RequestCandidate,
|
||||||
|
Usage,
|
||||||
|
User,
|
||||||
|
VideoTask,
|
||||||
|
)
|
||||||
|
from src.services.cache.aware_scheduler import (
|
||||||
|
CacheAwareScheduler,
|
||||||
|
get_cache_aware_scheduler,
|
||||||
|
)
|
||||||
from src.services.candidate.failover import FailoverEngine
|
from src.services.candidate.failover import FailoverEngine
|
||||||
from src.services.candidate.policy import RetryPolicy, SkipPolicy
|
from src.services.candidate.policy import RetryPolicy, SkipPolicy
|
||||||
from src.services.candidate.recorder import CandidateRecorder
|
from src.services.candidate.recorder import CandidateRecorder
|
||||||
|
from src.services.candidate.resolver import CandidateResolver
|
||||||
|
from src.services.orchestration.error_classifier import ErrorClassifier
|
||||||
|
from src.services.orchestration.request_dispatcher import RequestDispatcher
|
||||||
from src.services.provider.format import normalize_endpoint_signature
|
from src.services.provider.format import normalize_endpoint_signature
|
||||||
from src.services.request.candidate import RequestCandidateService
|
from src.services.request.candidate import RequestCandidateService
|
||||||
|
from src.services.request.result import RequestMetadata
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
from src.services.task.context import TaskMode
|
from src.services.task.context import TaskMode
|
||||||
from src.services.task.exceptions import TaskNotFoundError
|
from src.services.task.exceptions import TaskNotFoundError
|
||||||
from src.services.task.protocol import AttemptKind, AttemptResult
|
from src.services.task.protocol import AttemptKind, AttemptResult
|
||||||
from src.services.task.schema import ExecutionResult, TaskStatusResult
|
from src.services.task.schema import ExecutionResult, TaskStatusResult
|
||||||
|
from src.services.usage.service import UsageService
|
||||||
|
|
||||||
_SENSITIVE_PATTERN = re.compile(
|
_SENSITIVE_PATTERN = re.compile(
|
||||||
r"(api[_-]?key|token|bearer|authorization)[=:\s]+\S+",
|
r"(api[_-]?key|token|bearer|authorization)[=:\s]+\S+",
|
||||||
@@ -173,21 +203,9 @@ class TaskService:
|
|||||||
- RequestDispatcher execution
|
- RequestDispatcher execution
|
||||||
- Error classification/rectify logic ported from the previous SYNC implementation
|
- Error classification/rectify logic ported from the previous SYNC implementation
|
||||||
"""
|
"""
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from src.models.database import User
|
|
||||||
from src.services.cache.aware_scheduler import (
|
|
||||||
CacheAwareScheduler,
|
|
||||||
get_cache_aware_scheduler,
|
|
||||||
)
|
|
||||||
from src.services.candidate.resolver import CandidateResolver
|
|
||||||
from src.services.orchestration.error_classifier import ErrorClassifier
|
|
||||||
from src.services.orchestration.request_dispatcher import RequestDispatcher
|
|
||||||
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||||
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||||
from src.services.request.executor import RequestExecutor
|
from src.services.request.executor import RequestExecutor
|
||||||
from src.services.system.config import SystemConfigService
|
|
||||||
from src.services.usage.service import UsageService
|
|
||||||
|
|
||||||
if not request_id:
|
if not request_id:
|
||||||
request_id = str(uuid4())
|
request_id = str(uuid4())
|
||||||
@@ -432,8 +450,6 @@ class TaskService:
|
|||||||
if not error or not candidate:
|
if not error or not candidate:
|
||||||
return
|
return
|
||||||
|
|
||||||
from src.services.request.result import RequestMetadata
|
|
||||||
|
|
||||||
existing_metadata = getattr(error, "request_metadata", None)
|
existing_metadata = getattr(error, "request_metadata", None)
|
||||||
if existing_metadata and getattr(existing_metadata, "api_format", None):
|
if existing_metadata and getattr(existing_metadata, "api_format", None):
|
||||||
return
|
return
|
||||||
@@ -469,10 +485,6 @@ class TaskService:
|
|||||||
last_error: Exception | None = None,
|
last_error: Exception | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Raise a unified 'all candidates failed' exception."""
|
"""Raise a unified 'all candidates failed' exception."""
|
||||||
import httpx
|
|
||||||
|
|
||||||
from src.core.exceptions import ProviderNotAvailableException
|
|
||||||
|
|
||||||
logger.error(" [{}] 所有 {} 个组合均失败", request_id, max_attempts)
|
logger.error(" [{}] 所有 {} 个组合均失败", request_id, max_attempts)
|
||||||
|
|
||||||
request_metadata = None
|
request_metadata = None
|
||||||
@@ -530,8 +542,6 @@ class TaskService:
|
|||||||
extra_data: dict[str, Any],
|
extra_data: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark ThinkingSignatureException as failed for the candidate."""
|
"""Mark ThinkingSignatureException as failed for the candidate."""
|
||||||
from src.core.exceptions import ThinkingSignatureException
|
|
||||||
|
|
||||||
if not isinstance(error, ThinkingSignatureException):
|
if not isinstance(error, ThinkingSignatureException):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -560,7 +570,6 @@ class TaskService:
|
|||||||
request_body_ref: dict[str, Any] | None,
|
request_body_ref: dict[str, Any] | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Try to rectify thinking signature errors and request a retry."""
|
"""Try to rectify thinking signature errors and request a retry."""
|
||||||
from src.core.exceptions import ThinkingSignatureException
|
|
||||||
from src.services.message.thinking_rectifier import ThinkingRectifier
|
from src.services.message.thinking_rectifier import ThinkingRectifier
|
||||||
|
|
||||||
if not isinstance(converted_error, ThinkingSignatureException):
|
if not isinstance(converted_error, ThinkingSignatureException):
|
||||||
@@ -697,17 +706,7 @@ class TaskService:
|
|||||||
- "break": move to next candidate
|
- "break": move to next candidate
|
||||||
- "raise": raise the underlying exception
|
- "raise": raise the underlying exception
|
||||||
"""
|
"""
|
||||||
import httpx
|
|
||||||
|
|
||||||
from src.core.api_format.conversion.exceptions import FormatConversionError
|
from src.core.api_format.conversion.exceptions import FormatConversionError
|
||||||
from src.core.error_utils import extract_error_message
|
|
||||||
from src.core.exceptions import (
|
|
||||||
ConcurrencyLimitError,
|
|
||||||
EmbeddedErrorException,
|
|
||||||
ProxyNodeUnavailableError,
|
|
||||||
ThinkingSignatureException,
|
|
||||||
UpstreamClientException,
|
|
||||||
)
|
|
||||||
from src.services.proxy_node.resolver import resolve_effective_proxy, resolve_proxy_info
|
from src.services.proxy_node.resolver import resolve_effective_proxy, resolve_proxy_info
|
||||||
from src.services.request.executor import ExecutionError
|
from src.services.request.executor import ExecutionError
|
||||||
|
|
||||||
@@ -980,20 +979,14 @@ class TaskService:
|
|||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
|
|
||||||
from src.models.database import RequestCandidate
|
|
||||||
from src.services.billing.rule_service import BillingRuleLookupResult, BillingRuleService
|
from src.services.billing.rule_service import BillingRuleLookupResult, BillingRuleService
|
||||||
from src.services.cache.aware_scheduler import ProviderCandidate, get_cache_aware_scheduler
|
|
||||||
from src.services.candidate.resolver import CandidateResolver
|
|
||||||
from src.services.candidate.submit import (
|
from src.services.candidate.submit import (
|
||||||
AllCandidatesFailedError,
|
AllCandidatesFailedError,
|
||||||
SubmitOutcome,
|
SubmitOutcome,
|
||||||
UpstreamClientRequestError,
|
UpstreamClientRequestError,
|
||||||
)
|
)
|
||||||
from src.services.orchestration.error_classifier import ErrorClassifier
|
|
||||||
from src.services.system.config import SystemConfigService
|
|
||||||
|
|
||||||
def _sanitize(message: str, max_length: int = 200) -> str:
|
def _sanitize(message: str, max_length: int = 200) -> str:
|
||||||
if not message:
|
if not message:
|
||||||
@@ -1131,8 +1124,6 @@ class TaskService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. global switch (from database config)
|
# 2. global switch (from database config)
|
||||||
from src.services.system.config import SystemConfigService
|
|
||||||
|
|
||||||
if not SystemConfigService.is_format_conversion_enabled(self.db):
|
if not SystemConfigService.is_format_conversion_enabled(self.db):
|
||||||
skip_reason = "format_conversion_disabled"
|
skip_reason = "format_conversion_disabled"
|
||||||
candidate_info.update(
|
candidate_info.update(
|
||||||
@@ -1398,8 +1389,6 @@ class TaskService:
|
|||||||
- internal UUID (VideoTask.id)
|
- internal UUID (VideoTask.id)
|
||||||
- external operation id (VideoTask.short_id)
|
- external operation id (VideoTask.short_id)
|
||||||
"""
|
"""
|
||||||
from src.models.database import VideoTask
|
|
||||||
|
|
||||||
task = (
|
task = (
|
||||||
self.db.query(VideoTask)
|
self.db.query(VideoTask)
|
||||||
.filter(VideoTask.id == task_id, VideoTask.user_id == user_id)
|
.filter(VideoTask.id == task_id, VideoTask.user_id == user_id)
|
||||||
@@ -1498,9 +1487,7 @@ class TaskService:
|
|||||||
)
|
)
|
||||||
from src.core.api_format.conversion.internal_video import VideoStatus
|
from src.core.api_format.conversion.internal_video import VideoStatus
|
||||||
from src.core.crypto import crypto_service
|
from src.core.crypto import crypto_service
|
||||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
|
||||||
from src.services.provider.transport import build_provider_url
|
from src.services.provider.transport import build_provider_url
|
||||||
from src.services.usage.service import UsageService
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task = self._get_video_task_for_user(task_id, user_id=user_id)
|
task = self._get_video_task_for_user(task_id, user_id=user_id)
|
||||||
@@ -1617,9 +1604,6 @@ class TaskService:
|
|||||||
|
|
||||||
This keeps behavior compatible with the old Phase2 finalize logic.
|
This keeps behavior compatible with the old Phase2 finalize logic.
|
||||||
"""
|
"""
|
||||||
from src.models.database import ApiKey, Provider, User
|
|
||||||
from src.services.usage.service import UsageService
|
|
||||||
|
|
||||||
user_obj = self.db.query(User).filter(User.id == task.user_id).first()
|
user_obj = self.db.query(User).filter(User.id == task.user_id).first()
|
||||||
api_key_obj = (
|
api_key_obj = (
|
||||||
self.db.query(ApiKey).filter(ApiKey.id == task.api_key_id).first()
|
self.db.query(ApiKey).filter(ApiKey.id == task.api_key_id).first()
|
||||||
@@ -1707,11 +1691,9 @@ class TaskService:
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from src.core.api_format.conversion.internal_video import VideoStatus
|
from src.core.api_format.conversion.internal_video import VideoStatus
|
||||||
from src.models.database import Usage
|
|
||||||
from src.services.billing.dimension_collector_service import DimensionCollectorService
|
from src.services.billing.dimension_collector_service import DimensionCollectorService
|
||||||
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
|
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
|
||||||
from src.services.billing.rule_service import BillingRuleService
|
from src.services.billing.rule_service import BillingRuleService
|
||||||
from src.services.usage.service import UsageService
|
|
||||||
|
|
||||||
request_id = getattr(task, "request_id", None) or getattr(task, "id", None)
|
request_id = getattr(task, "request_id", None) or getattr(task, "id", None)
|
||||||
if not request_id:
|
if not request_id:
|
||||||
@@ -1916,8 +1898,6 @@ class TaskService:
|
|||||||
|
|
||||||
async def finalize(self, task_id: str) -> bool:
|
async def finalize(self, task_id: str) -> bool:
|
||||||
"""Finalize a task by internal id (best-effort)."""
|
"""Finalize a task by internal id (best-effort)."""
|
||||||
from src.models.database import VideoTask
|
|
||||||
|
|
||||||
task = self.db.query(VideoTask).filter(VideoTask.id == task_id).first()
|
task = self.db.query(VideoTask).filter(VideoTask.id == task_id).first()
|
||||||
if not task:
|
if not task:
|
||||||
return False
|
return False
|
||||||
|
|||||||
81
src/services/usage/_types.py
Normal file
81
src/services/usage/_types.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.models.database import ApiKey, User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UsageRecordParams:
|
||||||
|
"""用量记录参数数据类,用于在内部方法间传递数据"""
|
||||||
|
|
||||||
|
db: Session
|
||||||
|
user: User | None
|
||||||
|
api_key: ApiKey | None
|
||||||
|
provider: str
|
||||||
|
model: str
|
||||||
|
input_tokens: int
|
||||||
|
output_tokens: int
|
||||||
|
cache_creation_input_tokens: int
|
||||||
|
cache_read_input_tokens: int
|
||||||
|
request_type: str
|
||||||
|
api_format: str | None
|
||||||
|
endpoint_api_format: str | None # 端点原生 API 格式
|
||||||
|
has_format_conversion: bool # 是否发生了格式转换
|
||||||
|
is_stream: bool
|
||||||
|
response_time_ms: int | None
|
||||||
|
first_byte_time_ms: int | None
|
||||||
|
status_code: int
|
||||||
|
error_message: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
request_headers: dict[str, Any] | None
|
||||||
|
request_body: Any | None
|
||||||
|
provider_request_headers: dict[str, Any] | None
|
||||||
|
response_headers: dict[str, Any] | None
|
||||||
|
client_response_headers: dict[str, Any] | None
|
||||||
|
response_body: Any | None
|
||||||
|
request_id: str
|
||||||
|
provider_id: str | None
|
||||||
|
provider_endpoint_id: str | None
|
||||||
|
provider_api_key_id: str | None
|
||||||
|
status: str
|
||||||
|
cache_ttl_minutes: int | None
|
||||||
|
use_tiered_pricing: bool
|
||||||
|
target_model: str | None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""验证关键字段,确保数据完整性"""
|
||||||
|
# Token 数量不能为负数
|
||||||
|
if self.input_tokens < 0:
|
||||||
|
raise ValueError(f"input_tokens 不能为负数: {self.input_tokens}")
|
||||||
|
if self.output_tokens < 0:
|
||||||
|
raise ValueError(f"output_tokens 不能为负数: {self.output_tokens}")
|
||||||
|
if self.cache_creation_input_tokens < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"cache_creation_input_tokens 不能为负数: {self.cache_creation_input_tokens}"
|
||||||
|
)
|
||||||
|
if self.cache_read_input_tokens < 0:
|
||||||
|
raise ValueError(f"cache_read_input_tokens 不能为负数: {self.cache_read_input_tokens}")
|
||||||
|
|
||||||
|
# 响应时间不能为负数
|
||||||
|
if self.response_time_ms is not None and self.response_time_ms < 0:
|
||||||
|
raise ValueError(f"response_time_ms 不能为负数: {self.response_time_ms}")
|
||||||
|
if self.first_byte_time_ms is not None and self.first_byte_time_ms < 0:
|
||||||
|
raise ValueError(f"first_byte_time_ms 不能为负数: {self.first_byte_time_ms}")
|
||||||
|
|
||||||
|
# HTTP 状态码范围校验
|
||||||
|
if not (100 <= self.status_code <= 599):
|
||||||
|
raise ValueError(f"无效的 HTTP 状态码: {self.status_code}")
|
||||||
|
|
||||||
|
# 状态值校验
|
||||||
|
# - pending: 请求已创建,等待处理
|
||||||
|
# - streaming: 流式响应进行中
|
||||||
|
# - completed: 请求成功完成
|
||||||
|
# - failed: 请求失败(上游错误、超时等)
|
||||||
|
# - cancelled: 客户端主动断开连接
|
||||||
|
valid_statuses = {"pending", "streaming", "completed", "failed", "cancelled"}
|
||||||
|
if self.status not in valid_statuses:
|
||||||
|
raise ValueError(f"无效的状态值: {self.status},有效值: {valid_statuses}")
|
||||||
331
src/services/usage/active_requests.py
Normal file
331
src/services/usage/active_requests.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.api_format.metadata import can_passthrough_endpoint
|
||||||
|
from src.core.api_format.signature import normalize_signature_key
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.models.database import RequestCandidate, Usage
|
||||||
|
|
||||||
|
|
||||||
|
class UsageActiveRequestsMixin:
|
||||||
|
"""活跃请求管理方法"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_requests(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[Usage]:
|
||||||
|
"""
|
||||||
|
获取活跃的请求(pending 或 streaming 状态)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 用户ID(可选,用于过滤)
|
||||||
|
limit: 最大返回数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
活跃请求的 Usage 列表
|
||||||
|
"""
|
||||||
|
query = db.query(Usage).filter(Usage.status.in_(["pending", "streaming"]))
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
|
||||||
|
return query.order_by(Usage.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cleanup_stale_pending_requests(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
timeout_minutes: int = 10,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
清理超时的 pending/streaming 请求
|
||||||
|
|
||||||
|
将超过指定时间仍处于 pending 或 streaming 状态的请求标记为 failed。
|
||||||
|
这些请求可能是由于网络问题、服务重启或其他异常导致未能正常完成。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
timeout_minutes: 超时时间(分钟),默认 10 分钟
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
清理的记录数
|
||||||
|
"""
|
||||||
|
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||||
|
|
||||||
|
# 查找超时的请求
|
||||||
|
stale_requests = (
|
||||||
|
db.query(Usage)
|
||||||
|
.filter(
|
||||||
|
Usage.status.in_(["pending", "streaming"]),
|
||||||
|
Usage.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for usage in stale_requests:
|
||||||
|
old_status = usage.status
|
||||||
|
usage.status = "failed"
|
||||||
|
usage.error_message = f"请求超时: 状态 '{old_status}' 超过 {timeout_minutes} 分钟未完成"
|
||||||
|
usage.status_code = 504 # Gateway Timeout
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"清理超时请求: 将 {count} 条超过 {timeout_minutes} 分钟的 pending/streaming 请求标记为 failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_stale_pending_count(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
timeout_minutes: int = 10,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
获取超时的 pending/streaming 请求数量(用于监控)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
timeout_minutes: 超时时间(分钟)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
超时请求数量
|
||||||
|
"""
|
||||||
|
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
|
||||||
|
|
||||||
|
return (
|
||||||
|
db.query(Usage)
|
||||||
|
.filter(
|
||||||
|
Usage.status.in_(["pending", "streaming"]),
|
||||||
|
Usage.created_at < cutoff_time,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_active_requests_status(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
ids: list[str] | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
default_timeout_seconds: int = 300,
|
||||||
|
*,
|
||||||
|
include_admin_fields: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取活跃请求状态(用于前端轮询),并自动清理超时的 pending/streaming 请求
|
||||||
|
|
||||||
|
与 get_active_requests 不同,此方法:
|
||||||
|
1. 返回轻量级的状态字典而非完整 Usage 对象
|
||||||
|
2. 自动检测并清理超时的 pending/streaming 请求
|
||||||
|
3. 支持按 ID 列表查询特定请求
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
ids: 指定要查询的请求 ID 列表(可选)
|
||||||
|
user_id: 限制只查询该用户的请求(可选,用于普通用户接口)
|
||||||
|
default_timeout_seconds: 默认超时时间(秒),当端点未配置时使用
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
请求状态列表
|
||||||
|
"""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 构建基础查询
|
||||||
|
query = db.query(
|
||||||
|
Usage.id,
|
||||||
|
Usage.status,
|
||||||
|
Usage.input_tokens,
|
||||||
|
Usage.output_tokens,
|
||||||
|
Usage.cache_creation_input_tokens,
|
||||||
|
Usage.cache_read_input_tokens,
|
||||||
|
Usage.total_cost_usd,
|
||||||
|
Usage.actual_total_cost_usd,
|
||||||
|
Usage.rate_multiplier,
|
||||||
|
Usage.response_time_ms,
|
||||||
|
Usage.first_byte_time_ms, # 首字时间 (TTFB)
|
||||||
|
Usage.created_at,
|
||||||
|
Usage.provider_endpoint_id,
|
||||||
|
# API 格式 / 格式转换(streaming 状态时已可确定)
|
||||||
|
Usage.api_format,
|
||||||
|
Usage.endpoint_api_format,
|
||||||
|
Usage.has_format_conversion,
|
||||||
|
# 模型映射(streaming 时已可确定)
|
||||||
|
Usage.target_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 管理员轮询:可附带 provider 与上游 key 名称(注意:不要在普通用户接口暴露上游 key 信息)
|
||||||
|
if include_admin_fields:
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
|
||||||
|
query = query.add_columns(
|
||||||
|
Usage.provider_name,
|
||||||
|
ProviderAPIKey.name.label("api_key_name"),
|
||||||
|
).outerjoin(ProviderAPIKey, Usage.provider_api_key_id == ProviderAPIKey.id)
|
||||||
|
|
||||||
|
if ids:
|
||||||
|
query = query.filter(Usage.id.in_(ids))
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
else:
|
||||||
|
# 查询所有活跃请求
|
||||||
|
query = query.filter(Usage.status.in_(["pending", "streaming"]))
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
query = query.order_by(Usage.created_at.desc()).limit(50)
|
||||||
|
|
||||||
|
records = query.all()
|
||||||
|
|
||||||
|
# 检查超时的 pending/streaming 请求
|
||||||
|
# 收集可能超时的 usage_id 列表
|
||||||
|
timeout_candidates: list[str] = []
|
||||||
|
for r in records:
|
||||||
|
if r.status in ("pending", "streaming") and r.created_at:
|
||||||
|
# 使用全局配置的超时时间
|
||||||
|
timeout_seconds = default_timeout_seconds
|
||||||
|
|
||||||
|
# 处理时区:如果 created_at 没有时区信息,假定为 UTC
|
||||||
|
created_at = r.created_at
|
||||||
|
if created_at.tzinfo is None:
|
||||||
|
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||||
|
elapsed = (now - created_at).total_seconds()
|
||||||
|
if elapsed > timeout_seconds:
|
||||||
|
# 需要获取 request_id 以便检查 RequestCandidate 表
|
||||||
|
# r.id 是 usage_id,需要查询 request_id
|
||||||
|
timeout_candidates.append(r.id)
|
||||||
|
|
||||||
|
# 批量更新超时的请求(排除已有成功完成记录的请求)
|
||||||
|
timeout_ids = []
|
||||||
|
if timeout_candidates:
|
||||||
|
# 检查 RequestCandidate 表是否有成功完成的记录
|
||||||
|
# 如果流已经成功完成(stream_completed: true),不应该标记为超时
|
||||||
|
# 先获取这些 Usage 的 request_id
|
||||||
|
usage_request_ids = (
|
||||||
|
db.query(Usage.id, Usage.request_id).filter(Usage.id.in_(timeout_candidates)).all()
|
||||||
|
)
|
||||||
|
usage_id_to_request_id = {u.id: u.request_id for u in usage_request_ids}
|
||||||
|
request_id_to_usage_id = {u.request_id: u.id for u in usage_request_ids}
|
||||||
|
request_ids = list(request_id_to_usage_id.keys())
|
||||||
|
|
||||||
|
# 查询这些请求中已有成功完成记录的 request_id
|
||||||
|
# 包括两种情况:
|
||||||
|
# 1. status='success' 且 stream_completed=True(正常完成)
|
||||||
|
# 2. status='streaming' 且 status_code=200(流传输中但 Provider 已返回 200,可能是服务重启导致回调丢失)
|
||||||
|
completed_usage_ids = set()
|
||||||
|
if request_ids:
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
db.query(
|
||||||
|
RequestCandidate.request_id,
|
||||||
|
RequestCandidate.status,
|
||||||
|
RequestCandidate.status_code,
|
||||||
|
RequestCandidate.extra_data,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
RequestCandidate.request_id.in_(request_ids),
|
||||||
|
or_(
|
||||||
|
RequestCandidate.status == "success",
|
||||||
|
# streaming 状态且 status_code=200,说明 Provider 响应成功
|
||||||
|
# 但流传输可能因服务重启而中断
|
||||||
|
(RequestCandidate.status == "streaming")
|
||||||
|
& (RequestCandidate.status_code == 200),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
extra_data = candidate.extra_data or {}
|
||||||
|
# 情况1:status='success' 且 stream_completed=True
|
||||||
|
if candidate.status == "success" and extra_data.get("stream_completed", False):
|
||||||
|
usage_id = request_id_to_usage_id.get(candidate.request_id)
|
||||||
|
if usage_id:
|
||||||
|
completed_usage_ids.add(usage_id)
|
||||||
|
# 情况2:status='streaming' 且 status_code=200
|
||||||
|
# 这表示 Provider 返回了 200,但流传输可能因服务重启而未正常结束
|
||||||
|
# 此时应该恢复为 completed 而不是标记为 failed
|
||||||
|
elif candidate.status == "streaming" and candidate.status_code == 200:
|
||||||
|
usage_id = request_id_to_usage_id.get(candidate.request_id)
|
||||||
|
if usage_id:
|
||||||
|
completed_usage_ids.add(usage_id)
|
||||||
|
|
||||||
|
# 只对没有成功完成记录的请求标记超时
|
||||||
|
timeout_ids = [uid for uid in timeout_candidates if uid not in completed_usage_ids]
|
||||||
|
|
||||||
|
if timeout_ids:
|
||||||
|
db.query(Usage).filter(Usage.id.in_(timeout_ids)).update(
|
||||||
|
{"status": "failed", "error_message": "请求超时(服务器可能已重启)"},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 对于已完成但状态未更新的请求,主动恢复状态为 completed
|
||||||
|
# 这处理了遥测回调丢失的情况(例如服务重启、后台任务未执行等)
|
||||||
|
if completed_usage_ids:
|
||||||
|
db.query(Usage).filter(Usage.id.in_(list(completed_usage_ids))).update(
|
||||||
|
{"status": "completed"},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[Usage] 恢复 {len(completed_usage_ids)} 个已完成请求的状态(遥测回调丢失)"
|
||||||
|
)
|
||||||
|
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for r in records:
|
||||||
|
api_format = getattr(r, "api_format", None)
|
||||||
|
endpoint_api_format = getattr(r, "endpoint_api_format", None)
|
||||||
|
has_format_conversion = getattr(r, "has_format_conversion", None)
|
||||||
|
|
||||||
|
# 兼容历史数据:当 streaming 状态已拿到两个格式但 has_format_conversion 为空时,回填推断结果
|
||||||
|
if has_format_conversion is None and api_format and endpoint_api_format:
|
||||||
|
client_raw = str(api_format).strip()
|
||||||
|
endpoint_raw = str(endpoint_api_format).strip()
|
||||||
|
if ":" in client_raw and ":" in endpoint_raw:
|
||||||
|
client_fmt = normalize_signature_key(client_raw)
|
||||||
|
endpoint_fmt = normalize_signature_key(endpoint_raw)
|
||||||
|
has_format_conversion = not can_passthrough_endpoint(client_fmt, endpoint_fmt)
|
||||||
|
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"id": r.id,
|
||||||
|
"status": "failed" if r.id in timeout_ids else r.status,
|
||||||
|
"input_tokens": r.input_tokens,
|
||||||
|
"output_tokens": r.output_tokens,
|
||||||
|
"cache_creation_input_tokens": r.cache_creation_input_tokens,
|
||||||
|
"cache_read_input_tokens": r.cache_read_input_tokens,
|
||||||
|
"cost": float(r.total_cost_usd) if r.total_cost_usd else 0,
|
||||||
|
"actual_cost": (
|
||||||
|
float(r.actual_total_cost_usd) if r.actual_total_cost_usd is not None else None
|
||||||
|
),
|
||||||
|
"rate_multiplier": (
|
||||||
|
float(r.rate_multiplier) if r.rate_multiplier is not None else None
|
||||||
|
),
|
||||||
|
"response_time_ms": r.response_time_ms,
|
||||||
|
"first_byte_time_ms": r.first_byte_time_ms, # 首字时间 (TTFB)
|
||||||
|
}
|
||||||
|
if api_format:
|
||||||
|
item["api_format"] = api_format
|
||||||
|
if endpoint_api_format:
|
||||||
|
item["endpoint_api_format"] = endpoint_api_format
|
||||||
|
if has_format_conversion is not None:
|
||||||
|
item["has_format_conversion"] = bool(has_format_conversion)
|
||||||
|
# 模型映射(streaming 时已可确定)
|
||||||
|
if r.target_model:
|
||||||
|
item["target_model"] = r.target_model
|
||||||
|
if include_admin_fields:
|
||||||
|
item["provider"] = r.provider_name
|
||||||
|
item["api_key_name"] = r.api_key_name
|
||||||
|
result.append(item)
|
||||||
|
|
||||||
|
return result
|
||||||
529
src/services/usage/cache_analysis.py
Normal file
529
src/services/usage/cache_analysis.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.models.database import Usage, User
|
||||||
|
|
||||||
|
|
||||||
|
class UsageCacheAnalysisMixin:
|
||||||
|
"""缓存分析方法"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_cache_affinity_ttl(
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
api_key_id: str | None = None,
|
||||||
|
hours: int = 168,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
分析用户请求间隔分布,推荐合适的缓存亲和性 TTL
|
||||||
|
|
||||||
|
通过分析同一用户连续请求之间的时间间隔,判断用户的使用模式:
|
||||||
|
- 高频用户(间隔短):5 分钟 TTL 足够
|
||||||
|
- 中频用户:15-30 分钟 TTL
|
||||||
|
- 低频用户(间隔长):需要 60 分钟 TTL
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 指定用户 ID(可选,为空则分析所有用户)
|
||||||
|
api_key_id: 指定 API Key ID(可选)
|
||||||
|
hours: 分析最近多少小时的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含分析结果的字典
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# 计算时间范围
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 构建 SQL 查询 - 使用窗口函数计算请求间隔
|
||||||
|
# 按 user_id 或 api_key_id 分组,计算同一组内连续请求的时间差
|
||||||
|
group_by_field = "api_key_id" if api_key_id else "user_id"
|
||||||
|
|
||||||
|
# 构建过滤条件
|
||||||
|
filter_clause = ""
|
||||||
|
if user_id or api_key_id:
|
||||||
|
filter_clause = f"AND {group_by_field} = :filter_id"
|
||||||
|
|
||||||
|
sql = text(f"""
|
||||||
|
WITH user_requests AS (
|
||||||
|
SELECT
|
||||||
|
{group_by_field} as group_id,
|
||||||
|
created_at,
|
||||||
|
LAG(created_at) OVER (
|
||||||
|
PARTITION BY {group_by_field}
|
||||||
|
ORDER BY created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage
|
||||||
|
WHERE status = 'completed'
|
||||||
|
AND created_at > :start_date
|
||||||
|
AND {group_by_field} IS NOT NULL
|
||||||
|
{filter_clause}
|
||||||
|
),
|
||||||
|
intervals AS (
|
||||||
|
SELECT
|
||||||
|
group_id,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
|
FROM user_requests
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
),
|
||||||
|
user_stats AS (
|
||||||
|
SELECT
|
||||||
|
group_id,
|
||||||
|
COUNT(*) as request_count,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes <= 5) as within_5min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 5 AND interval_minutes <= 15) as within_15min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 15 AND interval_minutes <= 30) as within_30min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 30 AND interval_minutes <= 60) as within_60min,
|
||||||
|
COUNT(*) FILTER (WHERE interval_minutes > 60) as over_60min,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY interval_minutes) as median_interval,
|
||||||
|
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY interval_minutes) as p75_interval,
|
||||||
|
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY interval_minutes) as p90_interval,
|
||||||
|
AVG(interval_minutes) as avg_interval,
|
||||||
|
MIN(interval_minutes) as min_interval,
|
||||||
|
MAX(interval_minutes) as max_interval
|
||||||
|
FROM intervals
|
||||||
|
GROUP BY group_id
|
||||||
|
HAVING COUNT(*) >= 2
|
||||||
|
)
|
||||||
|
SELECT * FROM user_stats
|
||||||
|
ORDER BY request_count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"start_date": start_date,
|
||||||
|
}
|
||||||
|
if user_id:
|
||||||
|
params["filter_id"] = user_id
|
||||||
|
elif api_key_id:
|
||||||
|
params["filter_id"] = api_key_id
|
||||||
|
|
||||||
|
result = db.execute(sql, params)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# 收集所有 user_id 以便批量查询用户信息
|
||||||
|
group_ids = [row[0] for row in rows]
|
||||||
|
|
||||||
|
# 如果是按 user_id 分组,查询用户信息
|
||||||
|
user_info_map: dict[str, dict[str, str]] = {}
|
||||||
|
if group_by_field == "user_id" and group_ids:
|
||||||
|
users = db.query(User).filter(User.id.in_(group_ids)).all()
|
||||||
|
for user in users:
|
||||||
|
user_info_map[str(user.id)] = {
|
||||||
|
"username": str(user.username),
|
||||||
|
"email": str(user.email) if user.email else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 处理结果
|
||||||
|
users_analysis = []
|
||||||
|
for row in rows:
|
||||||
|
# row 是一个 tuple,按查询顺序访问
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
request_count,
|
||||||
|
within_5min,
|
||||||
|
within_15min,
|
||||||
|
within_30min,
|
||||||
|
within_60min,
|
||||||
|
over_60min,
|
||||||
|
median_interval,
|
||||||
|
p75_interval,
|
||||||
|
p90_interval,
|
||||||
|
avg_interval,
|
||||||
|
min_interval,
|
||||||
|
max_interval,
|
||||||
|
) = row
|
||||||
|
|
||||||
|
# 计算推荐 TTL
|
||||||
|
recommended_ttl = UsageCacheAnalysisMixin._calculate_recommended_ttl(
|
||||||
|
p75_interval, p90_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
user_info = user_info_map.get(str(group_id), {})
|
||||||
|
|
||||||
|
# 计算各区间占比
|
||||||
|
total_intervals = request_count
|
||||||
|
users_analysis.append(
|
||||||
|
{
|
||||||
|
"group_id": group_id,
|
||||||
|
"username": user_info.get("username"),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"request_count": request_count,
|
||||||
|
"interval_distribution": {
|
||||||
|
"within_5min": within_5min,
|
||||||
|
"within_15min": within_15min,
|
||||||
|
"within_30min": within_30min,
|
||||||
|
"within_60min": within_60min,
|
||||||
|
"over_60min": over_60min,
|
||||||
|
},
|
||||||
|
"interval_percentages": {
|
||||||
|
"within_5min": round(within_5min / total_intervals * 100, 1),
|
||||||
|
"within_15min": round(within_15min / total_intervals * 100, 1),
|
||||||
|
"within_30min": round(within_30min / total_intervals * 100, 1),
|
||||||
|
"within_60min": round(within_60min / total_intervals * 100, 1),
|
||||||
|
"over_60min": round(over_60min / total_intervals * 100, 1),
|
||||||
|
},
|
||||||
|
"percentiles": {
|
||||||
|
"p50": round(float(median_interval), 2) if median_interval else None,
|
||||||
|
"p75": round(float(p75_interval), 2) if p75_interval else None,
|
||||||
|
"p90": round(float(p90_interval), 2) if p90_interval else None,
|
||||||
|
},
|
||||||
|
"avg_interval_minutes": (
|
||||||
|
round(float(avg_interval), 2) if avg_interval else None
|
||||||
|
),
|
||||||
|
"min_interval_minutes": (
|
||||||
|
round(float(min_interval), 2) if min_interval else None
|
||||||
|
),
|
||||||
|
"max_interval_minutes": (
|
||||||
|
round(float(max_interval), 2) if max_interval else None
|
||||||
|
),
|
||||||
|
"recommended_ttl_minutes": recommended_ttl,
|
||||||
|
"recommendation_reason": UsageCacheAnalysisMixin._get_ttl_recommendation_reason(
|
||||||
|
recommended_ttl, p75_interval, p90_interval
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 汇总统计
|
||||||
|
ttl_distribution = {"5min": 0, "15min": 0, "30min": 0, "60min": 0}
|
||||||
|
for analysis in users_analysis:
|
||||||
|
ttl = analysis["recommended_ttl_minutes"]
|
||||||
|
if ttl <= 5:
|
||||||
|
ttl_distribution["5min"] += 1
|
||||||
|
elif ttl <= 15:
|
||||||
|
ttl_distribution["15min"] += 1
|
||||||
|
elif ttl <= 30:
|
||||||
|
ttl_distribution["30min"] += 1
|
||||||
|
else:
|
||||||
|
ttl_distribution["60min"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_users_analyzed": len(users_analysis),
|
||||||
|
"ttl_distribution": ttl_distribution,
|
||||||
|
"users": users_analysis,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_recommended_ttl(
|
||||||
|
p75_interval: float | None,
|
||||||
|
p90_interval: float | None,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
根据请求间隔分布计算推荐的缓存 TTL
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 如果 90% 的请求间隔都在 5 分钟内 -> 5 分钟 TTL
|
||||||
|
- 如果 75% 的请求间隔在 15 分钟内 -> 15 分钟 TTL
|
||||||
|
- 如果 75% 的请求间隔在 30 分钟内 -> 30 分钟 TTL
|
||||||
|
- 否则 -> 60 分钟 TTL
|
||||||
|
"""
|
||||||
|
if p90_interval is None or p75_interval is None:
|
||||||
|
return 5 # 默认值
|
||||||
|
|
||||||
|
# 如果 90% 的间隔都在 5 分钟内
|
||||||
|
if p90_interval <= 5:
|
||||||
|
return 5
|
||||||
|
|
||||||
|
# 如果 75% 的间隔在 15 分钟内
|
||||||
|
if p75_interval <= 15:
|
||||||
|
return 15
|
||||||
|
|
||||||
|
# 如果 75% 的间隔在 30 分钟内
|
||||||
|
if p75_interval <= 30:
|
||||||
|
return 30
|
||||||
|
|
||||||
|
# 低频用户,需要更长的 TTL
|
||||||
|
return 60
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_ttl_recommendation_reason(
|
||||||
|
ttl: int,
|
||||||
|
p75_interval: float | None,
|
||||||
|
p90_interval: float | None,
|
||||||
|
) -> str:
|
||||||
|
"""生成 TTL 推荐理由"""
|
||||||
|
if p75_interval is None or p90_interval is None:
|
||||||
|
return "数据不足,使用默认值"
|
||||||
|
|
||||||
|
if ttl == 5:
|
||||||
|
return f"高频用户:90% 的请求间隔在 {p90_interval:.1f} 分钟内"
|
||||||
|
elif ttl == 15:
|
||||||
|
return f"中高频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内"
|
||||||
|
elif ttl == 30:
|
||||||
|
return f"中频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内"
|
||||||
|
else:
|
||||||
|
return f"低频用户:75% 的请求间隔为 {p75_interval:.1f} 分钟,建议使用长 TTL"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_cache_hit_analysis(
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
api_key_id: str | None = None,
|
||||||
|
hours: int = 168,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
分析缓存命中情况
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 指定用户 ID(可选)
|
||||||
|
api_key_id: 指定 API Key ID(可选)
|
||||||
|
hours: 分析最近多少小时的数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
缓存命中分析结果
|
||||||
|
"""
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 基础查询
|
||||||
|
query = db.query(
|
||||||
|
func.count(Usage.id).label("total_requests"),
|
||||||
|
func.sum(Usage.input_tokens).label("total_input_tokens"),
|
||||||
|
func.sum(Usage.cache_read_input_tokens).label("total_cache_read_tokens"),
|
||||||
|
func.sum(Usage.cache_creation_input_tokens).label("total_cache_creation_tokens"),
|
||||||
|
func.sum(Usage.cache_read_cost_usd).label("total_cache_read_cost"),
|
||||||
|
func.sum(Usage.cache_creation_cost_usd).label("total_cache_creation_cost"),
|
||||||
|
).filter(
|
||||||
|
Usage.status == "completed",
|
||||||
|
Usage.created_at >= start_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
query = query.filter(Usage.api_key_id == api_key_id)
|
||||||
|
|
||||||
|
result = query.first()
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
total_requests = 0
|
||||||
|
total_input_tokens = 0
|
||||||
|
total_cache_read_tokens = 0
|
||||||
|
total_cache_creation_tokens = 0
|
||||||
|
total_cache_read_cost = 0.0
|
||||||
|
total_cache_creation_cost = 0.0
|
||||||
|
else:
|
||||||
|
total_requests = result.total_requests or 0
|
||||||
|
total_input_tokens = result.total_input_tokens or 0
|
||||||
|
total_cache_read_tokens = result.total_cache_read_tokens or 0
|
||||||
|
total_cache_creation_tokens = result.total_cache_creation_tokens or 0
|
||||||
|
total_cache_read_cost = float(result.total_cache_read_cost or 0)
|
||||||
|
total_cache_creation_cost = float(result.total_cache_creation_cost or 0)
|
||||||
|
|
||||||
|
# 计算缓存命中率(按 token 数)
|
||||||
|
# 总输入上下文 = input_tokens + cache_read_tokens(因为 input_tokens 不含 cache_read)
|
||||||
|
# 或者如果 input_tokens 已经包含 cache_read,则直接用 input_tokens
|
||||||
|
# 这里假设 cache_read_tokens 是额外的,命中率 = cache_read / (input + cache_read)
|
||||||
|
total_context_tokens = total_input_tokens + total_cache_read_tokens
|
||||||
|
cache_hit_rate = 0.0
|
||||||
|
if total_context_tokens > 0:
|
||||||
|
cache_hit_rate = total_cache_read_tokens / total_context_tokens * 100
|
||||||
|
|
||||||
|
# 计算节省的费用
|
||||||
|
# 缓存读取价格是正常输入价格的 10%,所以节省了 90%
|
||||||
|
# 节省 = cache_read_tokens * (正常价格 - 缓存价格) = cache_read_cost * 9
|
||||||
|
# 因为 cache_read_cost 是按 10% 价格算的,如果按 100% 算就是 10 倍
|
||||||
|
estimated_savings = total_cache_read_cost * 9 # 节省了 90%
|
||||||
|
|
||||||
|
# 统计有缓存命中的请求数
|
||||||
|
requests_with_cache_hit = db.query(func.count(Usage.id)).filter(
|
||||||
|
Usage.status == "completed",
|
||||||
|
Usage.created_at >= start_date,
|
||||||
|
Usage.cache_read_input_tokens > 0,
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
requests_with_cache_hit = requests_with_cache_hit.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
requests_with_cache_hit = requests_with_cache_hit.filter(Usage.api_key_id == api_key_id)
|
||||||
|
requests_with_cache_hit_count = int(requests_with_cache_hit.scalar() or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_requests": total_requests,
|
||||||
|
"requests_with_cache_hit": requests_with_cache_hit_count,
|
||||||
|
"request_cache_hit_rate": (
|
||||||
|
round(requests_with_cache_hit_count / total_requests * 100, 2)
|
||||||
|
if total_requests > 0
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
"total_input_tokens": total_input_tokens,
|
||||||
|
"total_cache_read_tokens": total_cache_read_tokens,
|
||||||
|
"total_cache_creation_tokens": total_cache_creation_tokens,
|
||||||
|
"token_cache_hit_rate": round(cache_hit_rate, 2),
|
||||||
|
"total_cache_read_cost_usd": round(total_cache_read_cost, 4),
|
||||||
|
"total_cache_creation_cost_usd": round(total_cache_creation_cost, 4),
|
||||||
|
"estimated_savings_usd": round(estimated_savings, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_interval_timeline(
|
||||||
|
db: Session,
|
||||||
|
hours: int = 24,
|
||||||
|
limit: int = 10000,
|
||||||
|
user_id: str | None = None,
|
||||||
|
include_user_info: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取请求间隔时间线数据,用于散点图展示
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
hours: 分析最近多少小时的数据(默认24小时)
|
||||||
|
limit: 最大返回数据点数量(默认10000)
|
||||||
|
user_id: 指定用户 ID(可选,为空则返回所有用户)
|
||||||
|
include_user_info: 是否包含用户信息(用于管理员多用户视图)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含时间线数据点的字典,每个数据点包含 model 字段用于按模型区分颜色
|
||||||
|
"""
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
start_date = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# 构建用户过滤条件
|
||||||
|
user_filter = "AND u.user_id = :user_id" if user_id else ""
|
||||||
|
|
||||||
|
# 根据是否需要用户信息选择不同的查询
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
# 管理员视图:返回带用户信息的数据点
|
||||||
|
# 使用按比例采样,保持每个用户的数据量比例不变
|
||||||
|
sql = text(f"""
|
||||||
|
WITH request_intervals AS (
|
||||||
|
SELECT
|
||||||
|
u.created_at,
|
||||||
|
u.user_id,
|
||||||
|
u.model,
|
||||||
|
usr.username,
|
||||||
|
LAG(u.created_at) OVER (
|
||||||
|
PARTITION BY u.user_id
|
||||||
|
ORDER BY u.created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage u
|
||||||
|
LEFT JOIN users usr ON u.user_id = usr.id
|
||||||
|
WHERE u.status = 'completed'
|
||||||
|
AND u.created_at > :start_date
|
||||||
|
AND u.user_id IS NOT NULL
|
||||||
|
{user_filter}
|
||||||
|
),
|
||||||
|
filtered_intervals AS (
|
||||||
|
SELECT
|
||||||
|
created_at,
|
||||||
|
user_id,
|
||||||
|
model,
|
||||||
|
username,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) as rn
|
||||||
|
FROM request_intervals
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120
|
||||||
|
),
|
||||||
|
total_count AS (
|
||||||
|
SELECT COUNT(*) as cnt FROM filtered_intervals
|
||||||
|
),
|
||||||
|
user_totals AS (
|
||||||
|
SELECT user_id, COUNT(*) as user_cnt FROM filtered_intervals GROUP BY user_id
|
||||||
|
),
|
||||||
|
user_limits AS (
|
||||||
|
SELECT
|
||||||
|
ut.user_id,
|
||||||
|
CASE WHEN tc.cnt <= :limit THEN ut.user_cnt
|
||||||
|
ELSE GREATEST(CEIL(ut.user_cnt::float * :limit / tc.cnt), 1)::int
|
||||||
|
END as user_limit
|
||||||
|
FROM user_totals ut, total_count tc
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
fi.created_at,
|
||||||
|
fi.user_id,
|
||||||
|
fi.model,
|
||||||
|
fi.username,
|
||||||
|
fi.interval_minutes
|
||||||
|
FROM filtered_intervals fi
|
||||||
|
JOIN user_limits ul ON fi.user_id = ul.user_id
|
||||||
|
WHERE fi.rn <= ul.user_limit
|
||||||
|
ORDER BY fi.created_at
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
# 普通视图:返回时间、间隔和模型信息
|
||||||
|
sql = text(f"""
|
||||||
|
WITH request_intervals AS (
|
||||||
|
SELECT
|
||||||
|
u.created_at,
|
||||||
|
u.user_id,
|
||||||
|
u.model,
|
||||||
|
LAG(u.created_at) OVER (
|
||||||
|
PARTITION BY u.user_id
|
||||||
|
ORDER BY u.created_at
|
||||||
|
) as prev_request_at
|
||||||
|
FROM usage u
|
||||||
|
WHERE u.status = 'completed'
|
||||||
|
AND u.created_at > :start_date
|
||||||
|
AND u.user_id IS NOT NULL
|
||||||
|
{user_filter}
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
created_at,
|
||||||
|
model,
|
||||||
|
EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes
|
||||||
|
FROM request_intervals
|
||||||
|
WHERE prev_request_at IS NOT NULL
|
||||||
|
AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120
|
||||||
|
ORDER BY created_at
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
|
||||||
|
params: dict[str, Any] = {"start_date": start_date, "limit": limit}
|
||||||
|
if user_id:
|
||||||
|
params["user_id"] = user_id
|
||||||
|
|
||||||
|
result = db.execute(sql, params)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
# 转换为时间线数据点
|
||||||
|
points = []
|
||||||
|
users_map: dict[str, str] = {} # user_id -> username
|
||||||
|
models_set: set = set() # 收集所有出现的模型
|
||||||
|
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
for row in rows:
|
||||||
|
created_at, row_user_id, model, username, interval_minutes = row
|
||||||
|
point_data: dict[str, Any] = {
|
||||||
|
"x": created_at.isoformat(),
|
||||||
|
"y": round(float(interval_minutes), 2),
|
||||||
|
"user_id": str(row_user_id),
|
||||||
|
}
|
||||||
|
if model:
|
||||||
|
point_data["model"] = model
|
||||||
|
models_set.add(model)
|
||||||
|
points.append(point_data)
|
||||||
|
if row_user_id and username:
|
||||||
|
users_map[str(row_user_id)] = username
|
||||||
|
else:
|
||||||
|
for row in rows:
|
||||||
|
created_at, model, interval_minutes = row
|
||||||
|
point_data = {"x": created_at.isoformat(), "y": round(float(interval_minutes), 2)}
|
||||||
|
if model:
|
||||||
|
point_data["model"] = model
|
||||||
|
models_set.add(model)
|
||||||
|
points.append(point_data)
|
||||||
|
|
||||||
|
response: dict[str, Any] = {
|
||||||
|
"analysis_period_hours": hours,
|
||||||
|
"total_points": len(points),
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_user_info and not user_id:
|
||||||
|
response["users"] = users_map
|
||||||
|
|
||||||
|
# 如果有模型信息,返回模型列表
|
||||||
|
if models_set:
|
||||||
|
response["models"] = sorted(models_set)
|
||||||
|
|
||||||
|
return response
|
||||||
524
src/services/usage/lifecycle.py
Normal file
524
src/services/usage/lifecycle.py
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.models.database import ApiKey, Usage, User
|
||||||
|
from src.services.system.config import SystemConfigService
|
||||||
|
|
||||||
|
|
||||||
|
class UsageLifecycleMixin:
|
||||||
|
"""使用记录生命周期管理方法"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def begin_pending_usage(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
user: User | None,
|
||||||
|
api_key: ApiKey | None,
|
||||||
|
model: str,
|
||||||
|
*,
|
||||||
|
is_stream: bool = False,
|
||||||
|
request_type: str = "chat",
|
||||||
|
api_format: str | None = None,
|
||||||
|
request_headers: dict[str, Any] | None = None,
|
||||||
|
request_body: Any | None = None,
|
||||||
|
) -> Usage:
|
||||||
|
"""
|
||||||
|
创建(或返回已有)pending Usage 记录,但**不提交事务**。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
- ApplicationService 在同一事务内创建 pending usage + task + candidates
|
||||||
|
- submit 幂等:重复调用同一 request_id 时返回已有记录
|
||||||
|
"""
|
||||||
|
existing = db.query(Usage).filter(Usage.request_id == request_id).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# 根据配置决定是否记录请求详情
|
||||||
|
should_log_headers = SystemConfigService.should_log_headers(db)
|
||||||
|
should_log_body = SystemConfigService.should_log_body(db)
|
||||||
|
|
||||||
|
# 处理请求头
|
||||||
|
processed_request_headers = None
|
||||||
|
if should_log_headers and request_headers:
|
||||||
|
processed_request_headers = SystemConfigService.mask_sensitive_headers(
|
||||||
|
db, request_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# 处理请求体
|
||||||
|
processed_request_body = None
|
||||||
|
if should_log_body and request_body:
|
||||||
|
processed_request_body = SystemConfigService.truncate_body(
|
||||||
|
db, request_body, is_request=True
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = Usage(
|
||||||
|
user_id=user.id if user else None,
|
||||||
|
api_key_id=api_key.id if api_key else None,
|
||||||
|
request_id=request_id,
|
||||||
|
provider_name="pending", # 尚未确定 provider
|
||||||
|
model=model,
|
||||||
|
input_tokens=0,
|
||||||
|
output_tokens=0,
|
||||||
|
total_tokens=0,
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
request_type=request_type,
|
||||||
|
api_format=api_format,
|
||||||
|
is_stream=is_stream,
|
||||||
|
status="pending",
|
||||||
|
billing_status="pending",
|
||||||
|
request_headers=processed_request_headers,
|
||||||
|
request_body=processed_request_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(usage)
|
||||||
|
db.flush()
|
||||||
|
return usage
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_pending_usage(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
user: User | None,
|
||||||
|
api_key: ApiKey | None,
|
||||||
|
model: str,
|
||||||
|
is_stream: bool = False,
|
||||||
|
request_type: str = "chat",
|
||||||
|
api_format: str | None = None,
|
||||||
|
request_headers: dict[str, Any] | None = None,
|
||||||
|
request_body: Any | None = None,
|
||||||
|
) -> Usage:
|
||||||
|
"""
|
||||||
|
创建 pending 状态的使用记录(在请求开始时调用)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
request_id: 请求ID
|
||||||
|
user: 用户对象
|
||||||
|
api_key: API Key 对象
|
||||||
|
model: 模型名称
|
||||||
|
is_stream: 是否流式请求
|
||||||
|
api_format: API 格式
|
||||||
|
request_headers: 请求头
|
||||||
|
request_body: 请求体
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的 Usage 记录
|
||||||
|
"""
|
||||||
|
usage = cls.begin_pending_usage(
|
||||||
|
db,
|
||||||
|
request_id=request_id,
|
||||||
|
user=user,
|
||||||
|
api_key=api_key,
|
||||||
|
model=model,
|
||||||
|
is_stream=is_stream,
|
||||||
|
request_type=request_type,
|
||||||
|
api_format=api_format,
|
||||||
|
request_headers=request_headers,
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.debug("创建 pending 使用记录: request_id={}, model={}", request_id, model)
|
||||||
|
|
||||||
|
return usage
|
||||||
|
|
||||||
|
# ========== billing_status 并发幂等 finalize ==========
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def finalize_settled(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
total_cost_usd: float,
|
||||||
|
request_cost_usd: float | None = None,
|
||||||
|
status: str = "completed",
|
||||||
|
status_code: int = 200,
|
||||||
|
error_message: str | None = None,
|
||||||
|
response_time_ms: int | None = None,
|
||||||
|
billing_snapshot: dict[str, Any] | None = None,
|
||||||
|
extra_metadata: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
并发安全的幂等 finalize(settled)。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
|
||||||
|
- 不在本方法内 commit,由调用方决定事务提交时机
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cost = float(total_cost_usd)
|
||||||
|
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
|
||||||
|
|
||||||
|
result = db.execute(
|
||||||
|
update(Usage)
|
||||||
|
.where(
|
||||||
|
Usage.request_id == request_id,
|
||||||
|
Usage.billing_status == "pending",
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
billing_status="settled",
|
||||||
|
finalized_at=now,
|
||||||
|
total_cost_usd=cost,
|
||||||
|
request_cost_usd=request_cost,
|
||||||
|
status=status,
|
||||||
|
status_code=status_code,
|
||||||
|
error_message=error_message,
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.rowcount != 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 写入审计快照(只在本次 finalize 生效时执行)
|
||||||
|
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
|
||||||
|
if usage:
|
||||||
|
metadata = usage.request_metadata or {}
|
||||||
|
if billing_snapshot is not None:
|
||||||
|
metadata["billing_snapshot"] = billing_snapshot
|
||||||
|
if extra_metadata:
|
||||||
|
metadata.update(extra_metadata)
|
||||||
|
usage.request_metadata = cls._sanitize_request_metadata(metadata)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def finalize_void(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
reason: str | None = None,
|
||||||
|
status_code: int = 499,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
并发安全的幂等 finalize(void,不收费)。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
|
||||||
|
- 不在本方法内 commit,由调用方决定事务提交时机
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = db.execute(
|
||||||
|
update(Usage)
|
||||||
|
.where(
|
||||||
|
Usage.request_id == request_id,
|
||||||
|
Usage.billing_status == "pending",
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
billing_status="void",
|
||||||
|
finalized_at=now,
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
request_cost_usd=0.0,
|
||||||
|
status="cancelled",
|
||||||
|
status_code=status_code,
|
||||||
|
error_message=reason,
|
||||||
|
response_time_ms=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def finalize_submitted(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
provider_name: str,
|
||||||
|
provider_id: str | None = None,
|
||||||
|
provider_endpoint_id: str | None = None,
|
||||||
|
provider_api_key_id: str | None = None,
|
||||||
|
response_time_ms: int | None = None,
|
||||||
|
status_code: int = 200,
|
||||||
|
endpoint_api_format: str | None = None,
|
||||||
|
provider_request_headers: dict[str, Any] | None = None,
|
||||||
|
response_headers: dict[str, Any] | None = None,
|
||||||
|
response_body: Any | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
异步任务提交成功时的幂等结算。
|
||||||
|
|
||||||
|
将 pending 使用记录标记为 settled,费用暂时为 0。
|
||||||
|
后续轮询完成后通过 update_settled_billing 更新实际费用。
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
|
||||||
|
- 不在本方法内 commit,由调用方决定事务提交时机
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# 处理响应头和响应体
|
||||||
|
should_log_headers = SystemConfigService.should_log_headers(db)
|
||||||
|
should_log_body = SystemConfigService.should_log_body(db)
|
||||||
|
|
||||||
|
processed_provider_headers = None
|
||||||
|
if should_log_headers and provider_request_headers:
|
||||||
|
processed_provider_headers = SystemConfigService.mask_sensitive_headers(
|
||||||
|
db, provider_request_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_response_headers = None
|
||||||
|
if should_log_headers and response_headers:
|
||||||
|
processed_response_headers = dict(response_headers)
|
||||||
|
|
||||||
|
processed_response_body = None
|
||||||
|
if should_log_body and response_body:
|
||||||
|
processed_response_body = SystemConfigService.truncate_body(
|
||||||
|
db, response_body, is_request=False
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
"billing_status": "settled",
|
||||||
|
"finalized_at": now,
|
||||||
|
"total_cost_usd": 0.0,
|
||||||
|
"request_cost_usd": 0.0,
|
||||||
|
"status": "completed",
|
||||||
|
"status_code": status_code,
|
||||||
|
"response_time_ms": response_time_ms,
|
||||||
|
"provider_name": provider_name,
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"provider_endpoint_id": provider_endpoint_id,
|
||||||
|
"provider_api_key_id": provider_api_key_id,
|
||||||
|
"endpoint_api_format": endpoint_api_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed_provider_headers is not None:
|
||||||
|
values["provider_request_headers"] = processed_provider_headers
|
||||||
|
if processed_response_headers is not None:
|
||||||
|
values["response_headers"] = processed_response_headers
|
||||||
|
if processed_response_body is not None:
|
||||||
|
values["response_body"] = processed_response_body
|
||||||
|
|
||||||
|
result = db.execute(
|
||||||
|
update(Usage)
|
||||||
|
.where(
|
||||||
|
Usage.request_id == request_id,
|
||||||
|
Usage.billing_status == "pending",
|
||||||
|
)
|
||||||
|
.values(**values)
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_settled_billing(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
total_cost_usd: float,
|
||||||
|
request_cost_usd: float | None = None,
|
||||||
|
status: str = "completed",
|
||||||
|
status_code: int = 200,
|
||||||
|
error_message: str | None = None,
|
||||||
|
response_time_ms: int | None = None,
|
||||||
|
billing_snapshot: dict[str, Any] | None = None,
|
||||||
|
extra_metadata: dict[str, Any] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
更新已结算记录的计费信息(用于异步任务轮询完成后)。
|
||||||
|
|
||||||
|
与 finalize_settled 不同:
|
||||||
|
- finalize_settled: pending -> settled(首次结算)
|
||||||
|
- update_settled_billing: settled -> settled(更新费用)
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 仅当 billing_status='settled' 时才会生效
|
||||||
|
- 不在本方法内 commit,由调用方决定事务提交时机
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cost = float(total_cost_usd)
|
||||||
|
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
|
||||||
|
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
"total_cost_usd": cost,
|
||||||
|
"request_cost_usd": request_cost,
|
||||||
|
"status": status,
|
||||||
|
"status_code": status_code,
|
||||||
|
}
|
||||||
|
if error_message is not None:
|
||||||
|
values["error_message"] = error_message
|
||||||
|
if response_time_ms is not None:
|
||||||
|
values["response_time_ms"] = response_time_ms
|
||||||
|
|
||||||
|
result = db.execute(
|
||||||
|
update(Usage)
|
||||||
|
.where(
|
||||||
|
Usage.request_id == request_id,
|
||||||
|
Usage.billing_status == "settled",
|
||||||
|
)
|
||||||
|
.values(**values)
|
||||||
|
)
|
||||||
|
if result.rowcount != 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 写入审计快照
|
||||||
|
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
|
||||||
|
if usage:
|
||||||
|
metadata = usage.request_metadata or {}
|
||||||
|
if billing_snapshot is not None:
|
||||||
|
metadata["billing_snapshot"] = billing_snapshot
|
||||||
|
if extra_metadata:
|
||||||
|
metadata.update(extra_metadata)
|
||||||
|
metadata["billing_updated_at"] = now.isoformat()
|
||||||
|
usage.request_metadata = cls._sanitize_request_metadata(metadata)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def void_settled(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
*,
|
||||||
|
reason: str | None = None,
|
||||||
|
status_code: int = 499,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
将已结算的记录作废(用于异步任务取消)。
|
||||||
|
|
||||||
|
与 finalize_void 不同:
|
||||||
|
- finalize_void: pending -> void(未结算时作废)
|
||||||
|
- void_settled: settled -> void(已结算后取消,费用归零)
|
||||||
|
|
||||||
|
约定:
|
||||||
|
- 仅当 billing_status='settled' 时才会生效
|
||||||
|
- 不在本方法内 commit,由调用方决定事务提交时机
|
||||||
|
"""
|
||||||
|
from sqlalchemy import update
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = db.execute(
|
||||||
|
update(Usage)
|
||||||
|
.where(
|
||||||
|
Usage.request_id == request_id,
|
||||||
|
Usage.billing_status == "settled",
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
billing_status="void",
|
||||||
|
finalized_at=now,
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
request_cost_usd=0.0,
|
||||||
|
status="cancelled",
|
||||||
|
status_code=status_code,
|
||||||
|
error_message=reason,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.rowcount == 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_usage_status(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
request_id: str,
|
||||||
|
status: str,
|
||||||
|
error_message: str | None = None,
|
||||||
|
provider: str | None = None,
|
||||||
|
target_model: str | None = None,
|
||||||
|
first_byte_time_ms: int | None = None,
|
||||||
|
provider_id: str | None = None,
|
||||||
|
provider_endpoint_id: str | None = None,
|
||||||
|
provider_api_key_id: str | None = None,
|
||||||
|
api_format: str | None = None,
|
||||||
|
endpoint_api_format: str | None = None,
|
||||||
|
has_format_conversion: bool | None = None,
|
||||||
|
status_code: int | None = None,
|
||||||
|
) -> Usage | None:
|
||||||
|
"""
|
||||||
|
快速更新使用记录状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
request_id: 请求ID
|
||||||
|
status: 新状态 (pending, streaming, completed, failed)
|
||||||
|
error_message: 错误消息(仅在 failed 状态时使用)
|
||||||
|
provider: 提供商名称(可选,streaming 状态时更新)
|
||||||
|
target_model: 映射后的目标模型名(可选)
|
||||||
|
first_byte_time_ms: 首字时间/TTFB(可选,streaming 状态时更新)
|
||||||
|
provider_id: Provider ID(可选,streaming 状态时更新)
|
||||||
|
provider_endpoint_id: Endpoint ID(可选,streaming 状态时更新)
|
||||||
|
provider_api_key_id: Provider API Key ID(可选,streaming 状态时更新)
|
||||||
|
api_format: API 格式(可选,用于获取按格式配置的倍率)
|
||||||
|
endpoint_api_format: 端点原生 API 格式(可选)
|
||||||
|
has_format_conversion: 是否发生了格式转换(可选)
|
||||||
|
status_code: HTTP 状态码(可选)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新后的 Usage 记录,如果未找到则返回 None
|
||||||
|
"""
|
||||||
|
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
|
||||||
|
if not usage:
|
||||||
|
logger.warning("未找到 request_id={} 的使用记录,无法更新状态", request_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 避免状态回退:streaming 只能从 pending/streaming 进入
|
||||||
|
if status == "streaming" and usage.status not in ("pending", "streaming"):
|
||||||
|
logger.debug(
|
||||||
|
f"跳过 streaming 状态更新(避免回退): request_id={request_id}, "
|
||||||
|
f"{usage.status} -> {status}"
|
||||||
|
)
|
||||||
|
return usage
|
||||||
|
|
||||||
|
old_status = usage.status
|
||||||
|
usage.status = status
|
||||||
|
if error_message:
|
||||||
|
usage.error_message = error_message
|
||||||
|
if provider:
|
||||||
|
usage.provider_name = provider
|
||||||
|
elif status == "streaming" and usage.provider_name == "pending":
|
||||||
|
# 状态变为 streaming 但 provider_name 仍为 pending,记录警告
|
||||||
|
logger.warning(
|
||||||
|
f"状态更新为 streaming 但 provider_name 为空: request_id={request_id}, "
|
||||||
|
f"当前 provider_name={usage.provider_name}"
|
||||||
|
)
|
||||||
|
if target_model:
|
||||||
|
usage.target_model = target_model
|
||||||
|
if first_byte_time_ms is not None:
|
||||||
|
usage.first_byte_time_ms = first_byte_time_ms
|
||||||
|
if provider_id is not None:
|
||||||
|
usage.provider_id = provider_id
|
||||||
|
if provider_endpoint_id is not None:
|
||||||
|
usage.provider_endpoint_id = provider_endpoint_id
|
||||||
|
if provider_api_key_id is not None:
|
||||||
|
usage.provider_api_key_id = provider_api_key_id
|
||||||
|
# 当设置 provider_api_key_id 时,同步获取并更新 rate_multiplier
|
||||||
|
# 这样前端在 streaming 状态就能显示倍率
|
||||||
|
rate_multiplier = cls._get_rate_multiplier_sync(
|
||||||
|
db, provider_api_key_id, api_format or usage.api_format
|
||||||
|
)
|
||||||
|
if rate_multiplier is not None:
|
||||||
|
usage.rate_multiplier = rate_multiplier
|
||||||
|
if endpoint_api_format is not None:
|
||||||
|
usage.endpoint_api_format = endpoint_api_format
|
||||||
|
if has_format_conversion is not None:
|
||||||
|
usage.has_format_conversion = has_format_conversion
|
||||||
|
if status_code is not None:
|
||||||
|
usage.status_code = status_code
|
||||||
|
|
||||||
|
# 结算状态:当请求进入终态时,将 billing_status 标记为 settled
|
||||||
|
# 注意:取消是否应 VOID/部分结算由更高层策略决定;这里默认终态均视为已结算。
|
||||||
|
if status in ("completed", "failed", "cancelled"):
|
||||||
|
if getattr(usage, "billing_status", None) == "pending":
|
||||||
|
usage.billing_status = "settled"
|
||||||
|
if getattr(usage, "finalized_at", None) is None:
|
||||||
|
usage.finalized_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.debug("更新使用记录状态: request_id={}, {} -> {}", request_id, old_status, status)
|
||||||
|
|
||||||
|
return usage
|
||||||
176
src/services/usage/pricing.py
Normal file
176
src/services/usage/pricing.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
from src.services.model.cost import ModelCostService
|
||||||
|
|
||||||
|
|
||||||
|
class UsagePricingMixin:
|
||||||
|
"""定价相关方法"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_model_price_async(
|
||||||
|
cls, db: Session, provider: str, model: str
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""异步获取模型价格(输入价格,输出价格)每1M tokens
|
||||||
|
|
||||||
|
查找逻辑:
|
||||||
|
1. 直接通过 GlobalModel.name 匹配
|
||||||
|
2. 查找该 Provider 的 Model 实现并获取价格
|
||||||
|
3. 如果找不到则使用系统默认价格
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return await service.get_model_price_async(provider, model)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_model_price(cls, db: Session, provider: str, model: str) -> tuple[float, float]:
|
||||||
|
"""获取模型价格(输入价格,输出价格)每1M tokens
|
||||||
|
|
||||||
|
查找逻辑:
|
||||||
|
1. 直接通过 GlobalModel.name 匹配
|
||||||
|
2. 查找该 Provider 的 Model 实现并获取价格
|
||||||
|
3. 如果找不到则使用系统默认价格
|
||||||
|
"""
|
||||||
|
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return service.get_model_price(provider, model)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_cache_prices_async(
|
||||||
|
cls, db: Session, provider: str, model: str, input_price: float
|
||||||
|
) -> tuple[float | None, float | None]:
|
||||||
|
"""异步获取模型缓存价格(缓存创建价格,缓存读取价格)每1M tokens"""
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return await service.get_cache_prices_async(provider, model, input_price)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_cache_prices(
|
||||||
|
cls, db: Session, provider: str, model: str, input_price: float
|
||||||
|
) -> tuple[float | None, float | None]:
|
||||||
|
"""获取模型缓存价格(缓存创建价格,缓存读取价格)每1M tokens"""
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return service.get_cache_prices(provider, model, input_price)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_request_price_async(cls, db: Session, provider: str, model: str) -> float | None:
|
||||||
|
"""异步获取模型按次计费价格"""
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return await service.get_request_price_async(provider, model)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_request_price(cls, db: Session, provider: str, model: str) -> float | None:
|
||||||
|
"""获取模型按次计费价格"""
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return service.get_request_price(provider, model)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_cost(
|
||||||
|
input_tokens: int,
|
||||||
|
output_tokens: int,
|
||||||
|
input_price_per_1m: float,
|
||||||
|
output_price_per_1m: float,
|
||||||
|
cache_creation_input_tokens: int = 0,
|
||||||
|
cache_read_input_tokens: int = 0,
|
||||||
|
cache_creation_price_per_1m: float | None = None,
|
||||||
|
cache_read_price_per_1m: float | None = None,
|
||||||
|
price_per_request: float | None = None,
|
||||||
|
) -> tuple[float, float, float, float, float, float, float]:
|
||||||
|
"""计算成本(价格是每百万tokens)- 固定价格模式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (input_cost, output_cost, cache_creation_cost,
|
||||||
|
cache_read_cost, cache_cost, request_cost, total_cost)
|
||||||
|
"""
|
||||||
|
return ModelCostService.compute_cost(
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
input_price_per_1m=input_price_per_1m,
|
||||||
|
output_price_per_1m=output_price_per_1m,
|
||||||
|
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens=cache_read_input_tokens,
|
||||||
|
cache_creation_price_per_1m=cache_creation_price_per_1m,
|
||||||
|
cache_read_price_per_1m=cache_read_price_per_1m,
|
||||||
|
price_per_request=price_per_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def calculate_cost_with_strategy_async(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
provider: str,
|
||||||
|
model: str,
|
||||||
|
input_tokens: int,
|
||||||
|
output_tokens: int,
|
||||||
|
cache_creation_input_tokens: int = 0,
|
||||||
|
cache_read_input_tokens: int = 0,
|
||||||
|
api_format: str | None = None,
|
||||||
|
cache_ttl_minutes: int | None = None,
|
||||||
|
) -> tuple[float, float, float, float, float, float, float, int | None]:
|
||||||
|
"""使用策略模式计算成本(支持阶梯计费)
|
||||||
|
|
||||||
|
根据 api_format 选择对应的计费策略,支持阶梯计费和 TTL 差异化。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (input_cost, output_cost, cache_creation_cost,
|
||||||
|
cache_read_cost, cache_cost, request_cost, total_cost, tier_index)
|
||||||
|
"""
|
||||||
|
service = ModelCostService(db)
|
||||||
|
return await service.compute_cost_with_strategy_async(
|
||||||
|
provider=provider,
|
||||||
|
model=model,
|
||||||
|
input_tokens=input_tokens,
|
||||||
|
output_tokens=output_tokens,
|
||||||
|
cache_creation_input_tokens=cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens=cache_read_input_tokens,
|
||||||
|
api_format=api_format,
|
||||||
|
cache_ttl_minutes=cache_ttl_minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _get_rate_multiplier_and_free_tier(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
provider_api_key_id: str | None,
|
||||||
|
provider_id: str | None,
|
||||||
|
api_format: str | None = None,
|
||||||
|
) -> tuple[float, bool]:
|
||||||
|
"""获取费率倍数和是否免费套餐(使用缓存)"""
|
||||||
|
from src.services.cache.provider_cache import ProviderCacheService
|
||||||
|
|
||||||
|
return await ProviderCacheService.get_rate_multiplier_and_free_tier(
|
||||||
|
db, provider_api_key_id, provider_id, api_format
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_rate_multiplier_sync(
|
||||||
|
db: Session,
|
||||||
|
provider_api_key_id: str,
|
||||||
|
api_format: str | None = None,
|
||||||
|
) -> float | None:
|
||||||
|
"""
|
||||||
|
同步获取 ProviderAPIKey 的 rate_multiplier
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
provider_api_key_id: ProviderAPIKey ID
|
||||||
|
api_format: API 格式(可选),如 "CLAUDE"、"OPENAI"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
rate_multiplier 或 None
|
||||||
|
"""
|
||||||
|
from src.services.cache.provider_cache import ProviderCacheService
|
||||||
|
|
||||||
|
provider_key = (
|
||||||
|
db.query(ProviderAPIKey.rate_multipliers)
|
||||||
|
.filter(ProviderAPIKey.id == provider_api_key_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not provider_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ProviderCacheService.compute_rate_multiplier(
|
||||||
|
provider_key.rate_multipliers, api_format
|
||||||
|
)
|
||||||
531
src/services/usage/query.py
Normal file
531
src/services/usage/query.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.models.database import ApiKey, Usage, User, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class UsageQueryMixin:
|
||||||
|
"""查询/统计相关方法"""
|
||||||
|
|
||||||
|
# 热力图缓存键前缀(依赖 TTL 自动过期,用户角色变更时主动清除)
|
||||||
|
HEATMAP_CACHE_KEY_PREFIX = "activity_heatmap"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_heatmap_cache_key(cls, user_id: str | None, include_actual_cost: bool) -> str:
|
||||||
|
"""生成热力图缓存键"""
|
||||||
|
cost_suffix = "with_cost" if include_actual_cost else "no_cost"
|
||||||
|
if user_id:
|
||||||
|
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:user:{user_id}:{cost_suffix}"
|
||||||
|
else:
|
||||||
|
return f"{cls.HEATMAP_CACHE_KEY_PREFIX}:admin:all:{cost_suffix}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def clear_user_heatmap_cache(cls, user_id: str) -> None:
|
||||||
|
"""
|
||||||
|
清除用户的热力图缓存(用户角色变更时调用)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: 用户ID
|
||||||
|
"""
|
||||||
|
from src.clients.redis_client import get_redis_client
|
||||||
|
|
||||||
|
redis_client = await get_redis_client(require_redis=False)
|
||||||
|
if not redis_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 清除该用户的所有热力图缓存(with_cost 和 no_cost)
|
||||||
|
keys_to_delete = [
|
||||||
|
cls._get_heatmap_cache_key(user_id, include_actual_cost=True),
|
||||||
|
cls._get_heatmap_cache_key(user_id, include_actual_cost=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in keys_to_delete:
|
||||||
|
try:
|
||||||
|
await redis_client.delete(key)
|
||||||
|
logger.debug("已清除热力图缓存: {}", key)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("清除热力图缓存失败: {}, error={}", key, e)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_cached_heatmap(
|
||||||
|
cls,
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
include_actual_cost: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取带缓存的热力图数据
|
||||||
|
|
||||||
|
缓存策略:
|
||||||
|
- TTL: 10分钟(CacheTTL.ACTIVITY_HEATMAP = 600)
|
||||||
|
- 仅依赖 TTL 自动过期,新使用记录最多延迟 10 分钟出现
|
||||||
|
- 用户角色变更时通过 clear_user_heatmap_cache() 主动清除
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_id: 用户ID,None 表示获取全局热力图(管理员)
|
||||||
|
include_actual_cost: 是否包含实际成本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
热力图数据字典
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from src.clients.redis_client import get_redis_client
|
||||||
|
from src.config.constants import CacheTTL
|
||||||
|
|
||||||
|
cache_key = cls._get_heatmap_cache_key(user_id, include_actual_cost)
|
||||||
|
|
||||||
|
cache_ttl = CacheTTL.ACTIVITY_HEATMAP
|
||||||
|
redis_client = await get_redis_client(require_redis=False)
|
||||||
|
|
||||||
|
# 尝试从缓存获取
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
cached = await redis_client.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
try:
|
||||||
|
return json.loads(cached) # type: ignore[no-any-return]
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(
|
||||||
|
"热力图缓存解析失败,删除损坏缓存: {}, error={}", cache_key, e
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await redis_client.delete(cache_key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("读取热力图缓存出错: {}, error={}", cache_key, e)
|
||||||
|
|
||||||
|
# 从数据库查询
|
||||||
|
result = cls.get_daily_activity(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
window_days=365,
|
||||||
|
include_actual_cost=include_actual_cost,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存到缓存(失败不影响返回结果)
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
await redis_client.setex(
|
||||||
|
cache_key,
|
||||||
|
cache_ttl,
|
||||||
|
json.dumps(result, ensure_ascii=False, default=str),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("保存热力图缓存失败: {}, error={}", cache_key, e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def check_user_quota(
|
||||||
|
db: Session,
|
||||||
|
user: User,
|
||||||
|
estimated_tokens: int = 0,
|
||||||
|
estimated_cost: float = 0,
|
||||||
|
api_key: ApiKey | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""检查用户配额或独立Key余额
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user: 用户对象
|
||||||
|
estimated_tokens: 预估token数
|
||||||
|
estimated_cost: 预估费用
|
||||||
|
api_key: API Key对象(用于检查独立余额Key)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否通过, 消息)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 如果是独立余额Key,检查Key的余额而不是用户配额
|
||||||
|
if api_key and api_key.is_standalone:
|
||||||
|
# 导入 ApiKeyService 以使用统一的余额计算方法
|
||||||
|
from src.services.user.apikey import ApiKeyService
|
||||||
|
|
||||||
|
# NULL 表示无限制
|
||||||
|
if api_key.current_balance_usd is None:
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
# 使用统一的余额计算方法
|
||||||
|
remaining_balance = ApiKeyService.get_remaining_balance(api_key)
|
||||||
|
if remaining_balance is None:
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
# 检查余额是否充足
|
||||||
|
if remaining_balance < estimated_cost:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"Key余额不足(剩余: ${remaining_balance:.2f},需要: ${estimated_cost:.2f})",
|
||||||
|
)
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
# 普通Key:检查用户配额
|
||||||
|
# 管理员无限制
|
||||||
|
if user.role == UserRole.ADMIN:
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
# NULL 表示无限制
|
||||||
|
if user.quota_usd is None:
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
# 有配额限制,检查是否超额
|
||||||
|
used_usd = float(user.used_usd or 0)
|
||||||
|
quota_usd = float(user.quota_usd)
|
||||||
|
if used_usd + estimated_cost > quota_usd:
|
||||||
|
remaining = quota_usd - used_usd
|
||||||
|
return False, f"配额不足(剩余: ${remaining:.2f})"
|
||||||
|
|
||||||
|
return True, "OK"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_usage_summary(
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
api_key_id: str | None = None,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
group_by: str = "day", # day, week, month
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""获取使用汇总"""
|
||||||
|
|
||||||
|
query = db.query(Usage)
|
||||||
|
# 过滤掉 pending/streaming 状态的请求(尚未完成的请求不应计入统计)
|
||||||
|
query = query.filter(Usage.status.notin_(["pending", "streaming"]))
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query = query.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
query = query.filter(Usage.api_key_id == api_key_id)
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(Usage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(Usage.created_at < end_date)
|
||||||
|
|
||||||
|
# 使用跨数据库兼容的日期函数
|
||||||
|
from src.utils.database_helpers import date_trunc_portable
|
||||||
|
|
||||||
|
# 检测数据库方言
|
||||||
|
bind = db.bind
|
||||||
|
dialect = bind.dialect.name if bind is not None else "sqlite"
|
||||||
|
|
||||||
|
# 根据分组类型选择日期函数(兼容多种数据库)
|
||||||
|
if group_by == "day":
|
||||||
|
date_func = date_trunc_portable(dialect, "day", Usage.created_at)
|
||||||
|
elif group_by == "week":
|
||||||
|
date_func = date_trunc_portable(dialect, "week", Usage.created_at)
|
||||||
|
elif group_by == "month":
|
||||||
|
date_func = date_trunc_portable(dialect, "month", Usage.created_at)
|
||||||
|
else:
|
||||||
|
# 默认按天分组
|
||||||
|
date_func = date_trunc_portable(dialect, "day", Usage.created_at)
|
||||||
|
|
||||||
|
# 汇总查询
|
||||||
|
summary = db.query(
|
||||||
|
date_func.label("period"),
|
||||||
|
Usage.provider_name,
|
||||||
|
Usage.model,
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.input_tokens).label("input_tokens"),
|
||||||
|
func.sum(Usage.output_tokens).label("output_tokens"),
|
||||||
|
func.sum(Usage.total_tokens).label("total_tokens"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
|
||||||
|
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 过滤掉 pending/streaming 状态的请求(与上方明细查询一致)
|
||||||
|
summary = summary.filter(Usage.status.notin_(["pending", "streaming"]))
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
summary = summary.filter(Usage.user_id == user_id)
|
||||||
|
if api_key_id:
|
||||||
|
summary = summary.filter(Usage.api_key_id == api_key_id)
|
||||||
|
if start_date:
|
||||||
|
summary = summary.filter(Usage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
summary = summary.filter(Usage.created_at < end_date)
|
||||||
|
|
||||||
|
summary = summary.group_by(date_func, Usage.provider_name, Usage.model).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"period": row.period,
|
||||||
|
"provider": row.provider_name,
|
||||||
|
"model": row.model,
|
||||||
|
"requests": row.requests,
|
||||||
|
"input_tokens": row.input_tokens,
|
||||||
|
"output_tokens": row.output_tokens,
|
||||||
|
"total_tokens": row.total_tokens,
|
||||||
|
"total_cost_usd": float(row.total_cost_usd),
|
||||||
|
"avg_response_time_ms": (
|
||||||
|
float(row.avg_response_time) if row.avg_response_time else 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for row in summary
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_daily_activity(
|
||||||
|
db: Session,
|
||||||
|
user_id: str | None = None,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
window_days: int = 365,
|
||||||
|
include_actual_cost: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""按天统计请求活跃度,用于渲染热力图。
|
||||||
|
|
||||||
|
优化策略:
|
||||||
|
- 历史数据从预计算的 StatsDaily/StatsUserDaily 表读取
|
||||||
|
- 只有"今天"的数据才实时查询 Usage 表
|
||||||
|
"""
|
||||||
|
|
||||||
|
def ensure_timezone(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
# 如果调用方未指定时间范围,则默认统计最近 window_days 天
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
end_dt = ensure_timezone(end_date) if end_date else now
|
||||||
|
start_dt = (
|
||||||
|
ensure_timezone(start_date) if start_date else end_dt - timedelta(days=window_days - 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 对齐到自然日的开始/结束
|
||||||
|
start_dt = datetime.combine(start_dt.date(), datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
end_dt = datetime.combine(end_dt.date(), datetime.max.time(), tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
today = now.date()
|
||||||
|
today_start_dt = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
aggregated: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# 1. 从预计算表读取历史数据(不包括今天)
|
||||||
|
if user_id:
|
||||||
|
from src.models.database import StatsUserDaily
|
||||||
|
|
||||||
|
hist_query = db.query(StatsUserDaily).filter(
|
||||||
|
StatsUserDaily.user_id == user_id,
|
||||||
|
StatsUserDaily.date >= start_dt,
|
||||||
|
StatsUserDaily.date < today_start_dt,
|
||||||
|
)
|
||||||
|
for row in hist_query.all():
|
||||||
|
key = (
|
||||||
|
row.date.date().isoformat()
|
||||||
|
if isinstance(row.date, datetime)
|
||||||
|
else str(row.date)[:10]
|
||||||
|
)
|
||||||
|
aggregated[key] = {
|
||||||
|
"requests": row.total_requests or 0,
|
||||||
|
"total_tokens": (
|
||||||
|
(row.input_tokens or 0)
|
||||||
|
+ (row.output_tokens or 0)
|
||||||
|
+ (row.cache_creation_tokens or 0)
|
||||||
|
+ (row.cache_read_tokens or 0)
|
||||||
|
),
|
||||||
|
"total_cost_usd": float(row.total_cost or 0.0),
|
||||||
|
}
|
||||||
|
# StatsUserDaily 没有 actual_total_cost 字段,用户视图不需要倍率成本
|
||||||
|
else:
|
||||||
|
from src.models.database import StatsDaily
|
||||||
|
|
||||||
|
hist_query = db.query(StatsDaily).filter(
|
||||||
|
StatsDaily.date >= start_dt,
|
||||||
|
StatsDaily.date < today_start_dt,
|
||||||
|
)
|
||||||
|
for row in hist_query.all():
|
||||||
|
key = (
|
||||||
|
row.date.date().isoformat()
|
||||||
|
if isinstance(row.date, datetime)
|
||||||
|
else str(row.date)[:10]
|
||||||
|
)
|
||||||
|
aggregated[key] = {
|
||||||
|
"requests": row.total_requests or 0,
|
||||||
|
"total_tokens": (
|
||||||
|
(row.input_tokens or 0)
|
||||||
|
+ (row.output_tokens or 0)
|
||||||
|
+ (row.cache_creation_tokens or 0)
|
||||||
|
+ (row.cache_read_tokens or 0)
|
||||||
|
),
|
||||||
|
"total_cost_usd": float(row.total_cost or 0.0),
|
||||||
|
}
|
||||||
|
if include_actual_cost:
|
||||||
|
aggregated[key]["actual_total_cost_usd"] = float(
|
||||||
|
row.actual_total_cost or 0.0 # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 实时查询今天的数据(如果在查询范围内)
|
||||||
|
if today >= start_dt.date() and today <= end_dt.date():
|
||||||
|
today_start = datetime.combine(today, datetime.min.time(), tzinfo=timezone.utc)
|
||||||
|
today_end = datetime.combine(today, datetime.max.time(), tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if include_actual_cost:
|
||||||
|
today_query = db.query(
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_tokens).label("total_tokens"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
|
||||||
|
func.sum(Usage.actual_total_cost_usd).label("actual_total_cost_usd"),
|
||||||
|
).filter(
|
||||||
|
Usage.created_at >= today_start,
|
||||||
|
Usage.created_at <= today_end,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
today_query = db.query(
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_tokens).label("total_tokens"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("total_cost_usd"),
|
||||||
|
).filter(
|
||||||
|
Usage.created_at >= today_start,
|
||||||
|
Usage.created_at <= today_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
today_query = today_query.filter(Usage.user_id == user_id)
|
||||||
|
|
||||||
|
today_row = today_query.first()
|
||||||
|
if today_row and today_row.requests:
|
||||||
|
aggregated[today.isoformat()] = {
|
||||||
|
"requests": int(today_row.requests or 0),
|
||||||
|
"total_tokens": int(today_row.total_tokens or 0),
|
||||||
|
"total_cost_usd": float(today_row.total_cost_usd or 0.0),
|
||||||
|
}
|
||||||
|
if include_actual_cost:
|
||||||
|
aggregated[today.isoformat()]["actual_total_cost_usd"] = float(
|
||||||
|
today_row.actual_total_cost_usd or 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 构建返回结果
|
||||||
|
days: list[dict[str, Any]] = []
|
||||||
|
cursor = start_dt.date()
|
||||||
|
end_date_only = end_dt.date()
|
||||||
|
max_requests = 0
|
||||||
|
|
||||||
|
while cursor <= end_date_only:
|
||||||
|
iso_date = cursor.isoformat()
|
||||||
|
stats = aggregated.get(iso_date, {})
|
||||||
|
requests = stats.get("requests", 0)
|
||||||
|
total_tokens = stats.get("total_tokens", 0)
|
||||||
|
total_cost = stats.get("total_cost_usd", 0.0)
|
||||||
|
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"date": iso_date,
|
||||||
|
"requests": requests,
|
||||||
|
"total_tokens": total_tokens,
|
||||||
|
"total_cost": total_cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_actual_cost:
|
||||||
|
entry["actual_total_cost"] = stats.get("actual_total_cost_usd", 0.0)
|
||||||
|
|
||||||
|
days.append(entry)
|
||||||
|
max_requests = max(max_requests, requests)
|
||||||
|
cursor += timedelta(days=1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_date": start_dt.date().isoformat(),
|
||||||
|
"end_date": end_dt.date().isoformat(),
|
||||||
|
"total_days": len(days),
|
||||||
|
"max_requests": max_requests,
|
||||||
|
"days": days,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_top_users(
|
||||||
|
db: Session,
|
||||||
|
limit: int = 10,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
order_by: str = "cost", # cost, tokens, requests
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""获取使用量最高的用户"""
|
||||||
|
|
||||||
|
query = (
|
||||||
|
db.query(
|
||||||
|
User.id,
|
||||||
|
User.email,
|
||||||
|
User.username,
|
||||||
|
func.count(Usage.id).label("requests"),
|
||||||
|
func.sum(Usage.total_tokens).label("tokens"),
|
||||||
|
func.sum(Usage.total_cost_usd).label("cost_usd"),
|
||||||
|
)
|
||||||
|
.join(Usage, User.id == Usage.user_id)
|
||||||
|
.filter(Usage.user_id.isnot(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query = query.filter(Usage.created_at >= start_date)
|
||||||
|
if end_date:
|
||||||
|
query = query.filter(Usage.created_at <= end_date)
|
||||||
|
|
||||||
|
query = query.group_by(User.id, User.email, User.username)
|
||||||
|
|
||||||
|
# 排序
|
||||||
|
if order_by == "cost":
|
||||||
|
query = query.order_by(func.sum(Usage.total_cost_usd).desc())
|
||||||
|
elif order_by == "tokens":
|
||||||
|
query = query.order_by(func.sum(Usage.total_tokens).desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(func.count(Usage.id).desc())
|
||||||
|
|
||||||
|
results = query.limit(limit).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": row.id,
|
||||||
|
"email": row.email,
|
||||||
|
"username": row.username,
|
||||||
|
"requests": row.requests,
|
||||||
|
"tokens": row.tokens,
|
||||||
|
"cost_usd": float(row.cost_usd),
|
||||||
|
}
|
||||||
|
for row in results
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cleanup_old_usage_records(
|
||||||
|
db: Session, days_to_keep: int = 90, batch_size: int = 1000
|
||||||
|
) -> int:
|
||||||
|
"""清理旧的使用记录(分批删除避免长事务锁定)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
days_to_keep: 保留天数,默认 90 天
|
||||||
|
batch_size: 每批删除数量,默认 1000 条
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除的总记录数
|
||||||
|
"""
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
||||||
|
total_deleted = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 查询待删除的 ID(使用新索引 idx_usage_user_created)
|
||||||
|
batch_ids = (
|
||||||
|
db.query(Usage.id).filter(Usage.created_at < cutoff_date).limit(batch_size).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not batch_ids:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 批量删除
|
||||||
|
deleted_count = (
|
||||||
|
db.query(Usage)
|
||||||
|
.filter(Usage.id.in_([row.id for row in batch_ids]))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
total_deleted += deleted_count
|
||||||
|
|
||||||
|
logger.debug("清理使用记录: 本批删除 {} 条", deleted_count)
|
||||||
|
|
||||||
|
logger.info("清理使用记录: 共删除 {} 条超过 {} 天的记录", total_deleted, days_to_keep)
|
||||||
|
|
||||||
|
return total_deleted
|
||||||
1470
src/services/usage/recording.py
Normal file
1470
src/services/usage/recording.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ def test_convert_sse_line_unwraps_before_convert_stream_chunk() -> None:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"src.api.handlers.base.cli_handler_base.get_format_converter_registry",
|
"src.api.handlers.base.cli_event_mixin.get_format_converter_registry",
|
||||||
return_value=_DummyRegistry(),
|
return_value=_DummyRegistry(),
|
||||||
):
|
):
|
||||||
_lines, _events = handler._convert_sse_line(ctx, v1_line, [])
|
_lines, _events = handler._convert_sse_line(ctx, v1_line, [])
|
||||||
@@ -189,7 +189,6 @@ async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> N
|
|||||||
]
|
]
|
||||||
byte_iter = _AsyncIter([]) # no more bytes after prefetch
|
byte_iter = _AsyncIter([]) # no more bytes after prefetch
|
||||||
response_ctx = SimpleNamespace(__aexit__=AsyncMock(return_value=None))
|
response_ctx = SimpleNamespace(__aexit__=AsyncMock(return_value=None))
|
||||||
http_client = SimpleNamespace(aclose=AsyncMock(return_value=None))
|
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
handler,
|
handler,
|
||||||
@@ -198,7 +197,7 @@ async def test_antigravity_forces_conversion_path_in_stream_with_prefetch() -> N
|
|||||||
) as mock_convert:
|
) as mock_convert:
|
||||||
out = []
|
out = []
|
||||||
async for chunk in handler._create_response_stream_with_prefetch(
|
async for chunk in handler._create_response_stream_with_prefetch(
|
||||||
ctx, byte_iter, response_ctx, http_client, prefetched # type: ignore[arg-type]
|
ctx, byte_iter, response_ctx, prefetched # type: ignore[arg-type]
|
||||||
):
|
):
|
||||||
out.append(chunk)
|
out.append(chunk)
|
||||||
|
|
||||||
|
|||||||
@@ -84,12 +84,7 @@ def test_process_line_handles_openai_usage_chunk_followed_by_done_without_blank_
|
|||||||
|
|
||||||
|
|
||||||
class _DummyResponseCtx:
|
class _DummyResponseCtx:
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
async def __aexit__(self, exc_type: type | None, exc: BaseException | None, tb: object) -> None:
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _DummyHTTPClient:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +110,6 @@ async def test_create_response_stream_flushes_usage_on_remote_protocol_error() -
|
|||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
byte_iterator=_iter_bytes_then_remote_protocol_error(),
|
byte_iterator=_iter_bytes_then_remote_protocol_error(),
|
||||||
response_ctx=_DummyResponseCtx(),
|
response_ctx=_DummyResponseCtx(),
|
||||||
http_client=_DummyHTTPClient(), # type: ignore[arg-type]
|
|
||||||
prefetched_chunks=[],
|
prefetched_chunks=[],
|
||||||
start_time=None,
|
start_time=None,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class DummyParser(ResponseParser):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def _empty_async_iter():
|
async def _empty_async_iter() -> Any:
|
||||||
if False: # pragma: no cover
|
if False: # pragma: no cover
|
||||||
yield b""
|
yield b""
|
||||||
|
|
||||||
@@ -43,9 +43,6 @@ async def test_create_response_stream_converts_claude_to_openai() -> None:
|
|||||||
response_ctx = AsyncMock()
|
response_ctx = AsyncMock()
|
||||||
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
response_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
http_client = AsyncMock()
|
|
||||||
http_client.aclose = AsyncMock(return_value=None)
|
|
||||||
|
|
||||||
message_start = {
|
message_start = {
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": {
|
"message": {
|
||||||
@@ -79,7 +76,6 @@ async def test_create_response_stream_converts_claude_to_openai() -> None:
|
|||||||
ctx,
|
ctx,
|
||||||
byte_iterator=_empty_async_iter(),
|
byte_iterator=_empty_async_iter(),
|
||||||
response_ctx=response_ctx,
|
response_ctx=response_ctx,
|
||||||
http_client=http_client,
|
|
||||||
prefetched_chunks=prefetched_chunks,
|
prefetched_chunks=prefetched_chunks,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
from typing import AsyncIterator, Optional
|
from typing import AsyncIterator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -10,12 +10,7 @@ from src.core.api_format.conversion import register_default_normalizers
|
|||||||
|
|
||||||
|
|
||||||
class _DummyResponseCtx:
|
class _DummyResponseCtx:
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
async def __aexit__(self, exc_type: type | None, exc: BaseException | None, tb: object) -> None:
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _DummyHTTPClient:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +65,6 @@ async def test_stream_processor_converts_gemini_json_lines_without_data_prefix()
|
|||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
byte_iterator=_iter_bytes(upstream_lines),
|
byte_iterator=_iter_bytes(upstream_lines),
|
||||||
response_ctx=_DummyResponseCtx(),
|
response_ctx=_DummyResponseCtx(),
|
||||||
http_client=_DummyHTTPClient(), # type: ignore[arg-type]
|
|
||||||
prefetched_chunks=[],
|
prefetched_chunks=[],
|
||||||
start_time=None,
|
start_time=None,
|
||||||
):
|
):
|
||||||
|
|||||||
Reference in New Issue
Block a user