feat: 添加格式转换追踪、模型过滤规则和 Provider 超时配置

1. Usage 格式转换追踪
   - 新增 endpoint_api_format 和 has_format_conversion 字段
   - 在用量记录中显示格式转换信息(请求格式 → 端点格式)
   - 兼容历史数据的回填逻辑

2. Provider API Key 模型过滤规则
   - 新增 model_include_patterns 和 model_exclude_patterns 字段
   - 支持 * 和 ? 通配符,不区分大小写
   - 自动获取模型时应用过滤规则

3. Provider 超时配置
   - 新增 stream_first_byte_timeout 和 request_timeout 字段
   - 支持每个 Provider 单独配置超时时间
   - 优先使用 Provider 配置,否则回退到全局配置

Close #122, Close #123

Co-Authored-By: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
fawney19
2026-01-28 00:04:47 +08:00
parent 3c0bf5fdae
commit c732a03263
23 changed files with 652 additions and 97 deletions

View File

@@ -224,6 +224,10 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# 记录 allowed_models 变化前的值
allowed_models_before = set(key.allowed_models or [])
# 记录过滤规则变化前的值(用于检测是否需要重新应用过滤)
include_patterns_before = key.model_include_patterns
exclude_patterns_before = key.model_exclude_patterns
update_data = self.key_data.model_dump(exclude_unset=True)
if "api_key" in update_data:
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
@@ -247,6 +251,17 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if isinstance(lm, list) and len(lm) == 0:
update_data["locked_models"] = None
# 处理模型过滤规则:空字符串 -> None
if "model_include_patterns" in update_data:
patterns = update_data["model_include_patterns"]
if isinstance(patterns, list) and len(patterns) == 0:
update_data["model_include_patterns"] = None
if "model_exclude_patterns" in update_data:
patterns = update_data["model_exclude_patterns"]
if isinstance(patterns, list) and len(patterns) == 0:
update_data["model_exclude_patterns"] = None
for field, value in update_data.items():
setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc)
@@ -285,6 +300,27 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
)
db.commit()
db.refresh(key)
elif auto_fetch_enabled_after:
# auto_fetch_models 保持开启状态,检查过滤规则是否变更
include_patterns_after = key.model_include_patterns
exclude_patterns_after = key.model_exclude_patterns
patterns_changed = (
include_patterns_before != include_patterns_after
or exclude_patterns_before != exclude_patterns_after
)
if patterns_changed:
# 过滤规则变更,重新应用过滤(使用缓存的上游模型数据)
logger.info(
"[AUTO_FETCH] Key %s 过滤规则变更,重新应用过滤",
self.key_id,
)
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
scheduler = get_model_fetch_scheduler()
await scheduler._fetch_models_for_key_by_id(self.key_id)
except Exception as e:
logger.error(f"重新应用过滤规则失败: {e}")
# 任何字段更新都清除缓存,确保缓存一致性
# 包括 is_active、allowed_models、capabilities 等影响权限和行为的字段
@@ -622,6 +658,12 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
max_probe_interval_minutes=self.key_data.max_probe_interval_minutes,
auto_fetch_models=self.key_data.auto_fetch_models,
locked_models=self.key_data.locked_models if self.key_data.locked_models else None,
model_include_patterns=(
self.key_data.model_include_patterns if self.key_data.model_include_patterns else None
),
model_exclude_patterns=(
self.key_data.model_exclude_patterns if self.key_data.model_exclude_patterns else None
),
request_count=0,
success_count=0,
error_count=0,

View File

@@ -295,6 +295,9 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
concurrent_limit=validated_data.concurrent_limit,
max_retries=validated_data.max_retries,
proxy=validated_data.proxy.model_dump() if validated_data.proxy else None,
# 超时配置
stream_first_byte_timeout=validated_data.stream_first_byte_timeout,
request_timeout=validated_data.request_timeout,
config=validated_data.config,
)

View File

@@ -310,6 +310,8 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
quota_expires_at=provider.quota_expires_at,
max_retries=provider.max_retries,
proxy=provider.proxy,
stream_first_byte_timeout=provider.stream_first_byte_timeout,
request_timeout=provider.request_timeout,
total_endpoints=total_endpoints,
active_endpoints=active_endpoints,
total_keys=total_keys,

View File

@@ -802,6 +802,22 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
if usage.provider_id and str(usage.provider_id) in provider_map:
provider_name = provider_map[str(usage.provider_id)]
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
api_format = usage.api_format or (
endpoint.api_format if endpoint and endpoint.api_format else None
)
endpoint_api_format = usage.endpoint_api_format or (
endpoint.api_format if endpoint else None
)
has_format_conversion = usage.has_format_conversion
if has_format_conversion is None:
client_fmt = str(api_format or "").upper()
endpoint_fmt = str(endpoint_api_format or "").upper()
has_format_conversion = bool(
client_fmt and endpoint_fmt and client_fmt != endpoint_fmt
)
data.append(
{
"id": usage.id,
@@ -842,8 +858,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"has_fallback": fallback_map.get(usage.request_id, False),
"has_retry": retry_map.get(usage.request_id, False),
"has_rectified": rectified_map.get(usage.request_id, False),
"api_format": usage.api_format
or (endpoint.api_format if endpoint and endpoint.api_format else None),
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": bool(has_format_conversion),
"api_key_name": provider_api_key.name if provider_api_key else None,
"request_metadata": usage.request_metadata, # Provider 响应元数据
}

View File

@@ -109,6 +109,9 @@ class MessageTelemetry:
provider_endpoint_id: Optional[str] = None,
provider_api_key_id: Optional[str] = None,
api_format: Optional[str] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None, # 端点原生 API 格式
has_format_conversion: bool = False, # 是否发生了格式转换
# 模型映射信息
target_model: Optional[str] = None,
# Provider 响应元数据(如 Gemini 的 modelVersion
@@ -135,6 +138,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens,
request_type="chat",
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms, # 传递首字时间
@@ -195,6 +200,9 @@ class MessageTelemetry:
response_body: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, Any]] = None,
client_response_headers: Optional[Dict[str, Any]] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
# 模型映射信息
target_model: Optional[str] = None,
) -> None:
@@ -230,6 +238,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens,
request_type="chat",
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms,
status_code=status_code,
@@ -265,6 +275,9 @@ class MessageTelemetry:
response_body: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, Any]] = None,
client_response_headers: Optional[Dict[str, Any]] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
target_model: Optional[str] = None,
) -> None:
"""
@@ -286,6 +299,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens,
request_type="chat",
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms,
@@ -488,6 +503,9 @@ class BaseMessageHandler:
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.needs_conversion
# 如果 provider 为空,记录警告(不应该发生,但用于调试)
if not provider:
@@ -512,6 +530,8 @@ class BaseMessageHandler:
provider_api_key_id=key_id,
first_byte_time_ms=first_byte_time_ms,
api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
)
finally:
db.close()

View File

@@ -713,7 +713,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool
@@ -851,6 +852,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
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.needs_conversion,
target_model=ctx.mapped_model,
)
@@ -987,7 +991,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
from src.clients.http_client import HTTPClientPool
# 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)
@@ -1140,6 +1145,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
is_stream=False,
provider_request_headers=provider_request_headers,
api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id,
@@ -1203,6 +1211,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_request_headers=provider_request_headers,
response_headers=response_headers,
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
target_model=mapped_model_result,
)
client_format = (client_api_format_for_error or "").upper()
@@ -1249,6 +1260,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
# 模型映射信息
target_model=mapped_model_result,
)

View File

@@ -739,7 +739,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
# 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
logger.debug(
f" └─ [{self.request_id}] 发送流式请求: "
@@ -1068,7 +1069,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_parser = self.parser
# 使用共享的 TTFB 超时函数读取首字节
ttfb_timeout = config.stream_first_byte_timeout
# 优先使用 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,
@@ -1804,6 +1806,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
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.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
)
@@ -1845,6 +1850,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
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.needs_conversion,
# Provider 侧追踪信息(用于记录真实成本)
provider_id=ctx.provider_id,
provider_endpoint_id=ctx.endpoint_id,
@@ -1977,6 +1985,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
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.needs_conversion,
# 模型映射信息
target_model=ctx.mapped_model,
)
@@ -2101,7 +2112,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
from src.clients.http_client import HTTPClientPool
# 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
)
@@ -2274,6 +2286,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=False,
provider_request_headers=provider_request_headers,
api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
# Provider 侧追踪信息(用于记录真实成本)
provider_id=provider_id,
provider_endpoint_id=endpoint_id,
@@ -2346,6 +2361,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
# 模型映射信息
target_model=mapped_model_result,
)

View File

@@ -213,7 +213,8 @@ class StreamProcessor:
try:
# 使用共享的 TTFB 超时函数读取首字节
ttfb_timeout = config.stream_first_byte_timeout
# 优先使用 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,
@@ -422,6 +423,8 @@ class StreamProcessor:
f"[{self.request_id}] needs_conversion=True 但 provider_format 为空,回退到透传模式"
)
needs_conversion = False
# 保持 ctx 与实际行为一致,避免 Usage 记录误标记为转换
ctx.needs_conversion = False
def _mark_stream_started() -> None:
nonlocal start_time, streaming_started

View File

@@ -91,6 +91,9 @@ class CreateProviderRequest(BaseModel):
# 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
@field_validator("name", "description")
@@ -161,6 +164,9 @@ class UpdateProviderRequest(BaseModel):
# 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
config: Optional[Dict[str, Any]] = None
# 复用相同的验证器

View File

@@ -405,6 +405,10 @@ class ProviderCreate(BaseModel):
config: Optional[dict] = Field(None, description="额外配置")
is_active: bool = Field(False, description="是否启用默认false需要配置API密钥后才能启用")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderUpdate(BaseModel):
"""更新提供商请求"""
@@ -423,6 +427,10 @@ class ProviderUpdate(BaseModel):
config: Optional[dict] = None
is_active: Optional[bool] = None
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderResponse(BaseModel):
"""提供商响应"""
@@ -447,6 +455,10 @@ class ProviderResponse(BaseModel):
active_models_count: int = 0
api_keys_count: int = 0
# 超时配置
stream_first_byte_timeout: Optional[float] = None
request_timeout: Optional[float] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -327,7 +327,9 @@ class Usage(Base):
# 请求详情
request_type = Column(String(50)) # chat, completion, embedding等
api_format = Column(String(50), nullable=True) # API 格式: CLAUDE, OPENAI 等
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)
@@ -653,6 +655,10 @@ class Provider(Base):
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等
@@ -1178,6 +1184,9 @@ class ProviderAPIKey(Base):
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) # 排除规则列表,空表示不排除
# 时间戳
created_at = Column(

View File

@@ -211,6 +211,14 @@ class EndpointAPIKeyCreate(BaseModel):
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
# 模型过滤规则(仅当 auto_fetch_models=True 时生效)
model_include_patterns: Optional[List[str]] = Field(
default=None, description="模型包含规则(支持 * 和 ? 通配符),空表示包含所有"
)
model_exclude_patterns: Optional[List[str]] = Field(
default=None, description="模型排除规则(支持 * 和 ? 通配符),空表示不排除"
)
@field_validator("api_formats")
@classmethod
def validate_api_formats(cls, v: Optional[List[str]]) -> Optional[List[str]]:
@@ -345,6 +353,13 @@ class EndpointAPIKeyUpdate(BaseModel):
locked_models: Optional[List[str]] = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
# 模型过滤规则(仅当 auto_fetch_models=True 时生效)
model_include_patterns: Optional[List[str]] = Field(
default=None, description="模型包含规则(支持 * 和 ? 通配符),空表示包含所有"
)
model_exclude_patterns: Optional[List[str]] = Field(
default=None, description="模型排除规则(支持 * 和 ? 通配符),空表示不排除"
)
@field_validator("api_formats")
@classmethod
@@ -502,6 +517,9 @@ class EndpointAPIKeyResponse(BaseModel):
last_models_fetch_at: Optional[datetime] = Field(None, description="最后获取模型时间")
last_models_fetch_error: Optional[str] = Field(None, description="最后获取模型错误信息")
locked_models: Optional[List[str]] = Field(None, description="被锁定的模型列表")
# 模型过滤规则
model_include_patterns: Optional[List[str]] = Field(None, description="模型包含规则")
model_exclude_patterns: Optional[List[str]] = Field(None, description="模型排除规则")
# 时间戳
last_used_at: Optional[datetime] = None
@@ -605,6 +623,9 @@ class ProviderUpdateRequest(BaseModel):
# 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderWithEndpointsSummary(BaseModel):
@@ -629,6 +650,9 @@ class ProviderWithEndpointsSummary(BaseModel):
# 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(default=2, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(default=None, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(default=None, description="非流式请求整体超时(秒)")
# Endpoint 统计
total_endpoints: int = Field(default=0, description="总 Endpoint 数量")

View File

@@ -7,13 +7,15 @@
- 扫描所有启用了 auto_fetch_models 的 ProviderAPIKey
- 调用 Adapter.fetch_models() 获取模型列表
- 更新 Key 的 allowed_models保留 locked_models 中的模型)
- 支持包含/排除规则过滤模型
- 记录获取结果和错误信息
"""
import asyncio
import fnmatch
import os
from datetime import datetime, timezone
from typing import Optional
from typing import List, Optional, Set
from sqlalchemy.orm import Session, joinedload
@@ -42,6 +44,68 @@ KEY_FETCH_TIMEOUT_SECONDS = 120
UPSTREAM_MODELS_CACHE_TTL_SECONDS = MODEL_FETCH_INTERVAL_MINUTES * 60
def _match_pattern(model_id: str, pattern: str) -> bool:
"""
检查模型 ID 是否匹配模式
支持的通配符:
- * 匹配任意字符(包括空)
- ? 匹配单个字符
Args:
model_id: 模型 ID
pattern: 匹配模式
Returns:
是否匹配
"""
return fnmatch.fnmatch(model_id.lower(), pattern.lower())
def _filter_models_by_patterns(
model_ids: Set[str],
include_patterns: Optional[List[str]],
exclude_patterns: Optional[List[str]],
) -> Set[str]:
"""
根据包含/排除规则过滤模型列表
规则优先级:
1. 如果 include_patterns 为空或 None则包含所有模型
2. 如果 include_patterns 不为空,则只包含匹配的模型
3. exclude_patterns 总是会排除匹配的模型(优先级高于 include
Args:
model_ids: 原始模型 ID 集合
include_patterns: 包含规则列表(支持 * 和 ? 通配符)
exclude_patterns: 排除规则列表(支持 * 和 ? 通配符)
Returns:
过滤后的模型 ID 集合
"""
result = set()
for model_id in model_ids:
# 步骤1: 检查是否应该包含
should_include = True
if include_patterns:
# 有包含规则时,必须匹配至少一个规则
should_include = any(_match_pattern(model_id, p) for p in include_patterns)
if not should_include:
continue
# 步骤2: 检查是否应该排除
should_exclude = False
if exclude_patterns:
should_exclude = any(_match_pattern(model_id, p) for p in exclude_patterns)
if not should_exclude:
result.add(model_id)
return result
def _get_upstream_models_cache_key(provider_id: str, api_key_id: str) -> str:
"""生成上游模型缓存的 key"""
return f"upstream_models:{provider_id}:{api_key_id}"
@@ -341,7 +405,7 @@ class ModelFetchScheduler:
def _update_key_allowed_models(self, key: ProviderAPIKey, fetched_model_ids: set[str]) -> bool:
"""
更新 Key 的 allowed_models保留 locked_models
更新 Key 的 allowed_models保留 locked_models,应用过滤规则
Returns:
bool: 是否有变化
@@ -349,9 +413,26 @@ class ModelFetchScheduler:
# 获取当前锁定的模型
locked_models = set(key.locked_models or [])
# 新的 allowed_models = 获取到的模型 + 锁定的模型
# 应用包含/排除过滤规则
include_patterns = key.model_include_patterns
exclude_patterns = key.model_exclude_patterns
filtered_model_ids = _filter_models_by_patterns(
fetched_model_ids, include_patterns, exclude_patterns
)
# 记录过滤结果
if include_patterns or exclude_patterns:
filtered_count = len(fetched_model_ids) - len(filtered_model_ids)
if filtered_count > 0:
logger.info(
f"Key {key.id} 过滤规则生效: 原始 {len(fetched_model_ids)} 个模型, "
f"过滤后 {len(filtered_model_ids)} 个 (排除 {filtered_count} 个)"
)
# 新的 allowed_models = 过滤后的模型 + 锁定的模型
# 锁定模型无论上游是否返回都会保留
new_allowed_models = list(fetched_model_ids | locked_models)
new_allowed_models = list(filtered_model_ids | locked_models)
new_allowed_models.sort() # 保持顺序稳定
# 检查是否有变化

View File

@@ -30,6 +30,8 @@ class UsageRecordParams:
cache_read_input_tokens: int
request_type: str
api_format: Optional[str]
endpoint_api_format: Optional[str] # 端点原生 API 格式
has_format_conversion: bool # 是否发生了格式转换
is_stream: bool
response_time_ms: Optional[int]
first_byte_time_ms: Optional[int]
@@ -214,6 +216,8 @@ class UsageService:
cache_read_input_tokens: int,
request_type: str,
api_format: Optional[str],
endpoint_api_format: Optional[str],
has_format_conversion: bool,
is_stream: bool,
response_time_ms: Optional[int],
first_byte_time_ms: Optional[int],
@@ -349,6 +353,8 @@ class UsageService:
"price_per_request": request_price,
"request_type": request_type,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": has_format_conversion,
"is_stream": is_stream,
"status_code": status_code,
"error_message": error_message,
@@ -706,6 +712,8 @@ class UsageService:
cache_read_input_tokens=params.cache_read_input_tokens,
request_type=params.request_type,
api_format=params.api_format,
endpoint_api_format=params.endpoint_api_format,
has_format_conversion=params.has_format_conversion,
is_stream=params.is_stream,
response_time_ms=params.response_time_ms,
first_byte_time_ms=params.first_byte_time_ms,
@@ -756,6 +764,8 @@ class UsageService:
cache_read_input_tokens: int = 0,
request_type: str = "chat",
api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
is_stream: bool = False,
response_time_ms: Optional[int] = None,
first_byte_time_ms: Optional[int] = None,
@@ -794,7 +804,9 @@ class UsageService:
input_tokens=input_tokens, output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
request_type=request_type, api_format=api_format, is_stream=is_stream,
request_type=request_type, api_format=api_format,
endpoint_api_format=endpoint_api_format, has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms,
status_code=status_code, error_message=error_message, metadata=metadata,
request_headers=request_headers, request_body=request_body,
@@ -849,6 +861,8 @@ class UsageService:
cache_read_input_tokens: int = 0,
request_type: str = "chat",
api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
is_stream: bool = False,
response_time_ms: Optional[int] = None,
first_byte_time_ms: Optional[int] = None,
@@ -889,7 +903,9 @@ class UsageService:
input_tokens=input_tokens, output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens,
request_type=request_type, api_format=api_format, is_stream=is_stream,
request_type=request_type, api_format=api_format,
endpoint_api_format=endpoint_api_format, has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms,
status_code=status_code, error_message=error_message, metadata=metadata,
request_headers=request_headers, request_body=request_body,
@@ -1486,6 +1502,8 @@ class UsageService:
provider_endpoint_id: Optional[str] = None,
provider_api_key_id: Optional[str] = None,
api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: Optional[bool] = None,
) -> Optional[Usage]:
"""
快速更新使用记录状态
@@ -1502,6 +1520,8 @@ class UsageService:
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: 是否发生了格式转换(可选)
Returns:
更新后的 Usage 记录,如果未找到则返回 None
@@ -1540,6 +1560,10 @@ class UsageService:
)
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
db.commit()

View File

@@ -41,6 +41,9 @@ class StreamUsageTracker:
provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
):
"""
初始化流式用量跟踪器
@@ -60,6 +63,8 @@ class StreamUsageTracker:
provider_endpoint_id: Endpoint ID用于记录真实成本
provider_api_key_id: API Key ID用于记录真实成本
api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI
endpoint_api_format: 端点原生 API 格式
has_format_conversion: 是否发生了格式转换
"""
self.db = db
# 只存储ID避免会话绑定问题
@@ -79,6 +84,8 @@ class StreamUsageTracker:
# API 格式和响应解析器
self.api_format = api_format or "CLAUDE"
self.endpoint_api_format = endpoint_api_format
self.has_format_conversion = has_format_conversion
self.response_parser = get_parser_for_format(self.api_format)
self.stream_stats = StreamStats() # 解析器统计信息
@@ -488,6 +495,8 @@ class StreamUsageTracker:
provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id,
api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
)
except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
@@ -710,6 +719,8 @@ class StreamUsageTracker:
cache_read_input_tokens=self.cache_read_input_tokens,
request_type="chat",
api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
is_stream=True,
response_time_ms=response_time_ms,
status_code=self.status_code, # 使用实际的状态码
@@ -801,6 +812,9 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
):
super().__init__(
db,
@@ -817,6 +831,8 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_endpoint_id,
provider_api_key_id,
api_format,
endpoint_api_format,
has_format_conversion,
)
# 用于更准确的token计算
self._init_tokenizer()
@@ -950,6 +966,8 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id,
api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
)
except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
@@ -1054,6 +1072,9 @@ def create_stream_tracker(
provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
) -> StreamUsageTracker:
"""
创建流式用量跟踪器
@@ -1074,6 +1095,8 @@ def create_stream_tracker(
provider_endpoint_id: Endpoint ID用于记录真实成本
provider_api_key_id: API Key ID用于记录真实成本
api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI
endpoint_api_format: 端点原生 API 格式
has_format_conversion: 是否发生了格式转换
Returns:
流式用量跟踪器实例
@@ -1094,6 +1117,8 @@ def create_stream_tracker(
provider_endpoint_id,
provider_api_key_id,
api_format,
endpoint_api_format,
has_format_conversion,
)
else:
return StreamUsageTracker(
@@ -1111,4 +1136,6 @@ def create_stream_tracker(
provider_endpoint_id,
provider_api_key_id,
api_format,
endpoint_api_format,
has_format_conversion,
)