refactor: 全局适配 ApiFamily/EndpointKind 结构化标识体系

将新的 (ApiFamily, EndpointKind) / `family:kind` 签名体系应用到整个代码库:
- API Handlers: 所有 adapter/handler 使用新的签名格式
- Services: provider, model, usage, cache, auth 等服务层适配
- Database: ProviderEndpoint 新增 api_family/endpoint_kind 字段
- Frontend: Provider 管理、Usage 表格等组件适配
- Tests: 更新所有相关测试用例
This commit is contained in:
fawney19
2026-02-01 17:28:00 +08:00
parent c246ccfc91
commit 7b66505634
219 changed files with 4732 additions and 2545 deletions

View File

@@ -11,20 +11,20 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.database import get_db
from src.models.database import ProviderAPIKey
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/adaptive", tags=["Adaptive RPM"])
pipeline = ApiRequestPipeline()

View File

@@ -5,15 +5,16 @@
from __future__ import annotations
from typing import Any
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
@@ -21,8 +22,6 @@ from src.database import get_db
from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey
from src.services.user.apikey import ApiKeyService
from src.api.base.context import ApiRequestContext
# 应用时区配置,默认为 Asia/Shanghai
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
@@ -432,7 +431,9 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
)
logger.info(f"管理员创建独立余额Key: ID {api_key.id}, 初始余额 ${self.key_data.initial_balance_usd}")
logger.info(
f"管理员创建独立余额Key: ID {api_key.id}, 初始余额 ${self.key_data.initial_balance_usd}"
)
context.add_audit_metadata(
action="create_standalone_api_key",
@@ -548,7 +549,9 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
db.commit()
db.refresh(api_key)
logger.info(f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if api_key.is_active else '禁用'}")
logger.info(
f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if api_key.is_active else '禁用'}"
)
context.add_audit_metadata(
action="toggle_api_key",
@@ -581,7 +584,9 @@ class AdminToggleLockApiKeyAdapter(AdminApiAdapter):
db.commit()
db.refresh(api_key)
logger.info(f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}")
logger.info(
f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}"
)
context.add_audit_metadata(
action="toggle_lock_api_key",
@@ -611,7 +616,9 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
db.delete(api_key)
db.commit()
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user.email if user else '未知'}")
logger.info(
f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user.email if user else '未知'}"
)
context.add_audit_metadata(
action="delete_api_key",

View File

@@ -2,20 +2,20 @@
Key RPM 限制管理 API
"""
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import NotFoundException
from src.database import get_db
from src.models.database import ProviderAPIKey
from src.models.endpoint_models import KeyRpmStatusResponse
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
from src.api.base.context import ApiRequestContext
router = APIRouter(tags=["RPM Control"])
pipeline = ApiRequestPipeline()

View File

@@ -4,16 +4,17 @@ Endpoint 健康监控 API
from __future__ import annotations
from typing import Any
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import NotFoundException
from src.core.logger import logger
@@ -27,10 +28,16 @@ from src.models.endpoint_models import (
HealthSummaryResponse,
)
from src.services.health.endpoint import EndpointHealthService
from src.services.health.monitor import health_monitor
from src.api.base.context import ApiRequestContext
from src.services.health.monitor import HealthMonitor, health_monitor
router = APIRouter(tags=["Endpoint Health"])
def _format_str(api_format_enum: Any) -> str:
"""将 DB 查询返回的 api_format可能是 enum 或 str统一转为 str。"""
return api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
pipeline = ApiRequestPipeline()
@@ -234,8 +241,6 @@ class AdminEndpointHealthStatusAdapter(AdminApiAdapter):
lookback_hours: int
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.services.health.endpoint import EndpointHealthService
db = context.db
# 使用共享服务获取健康状态(管理员视图)
@@ -265,30 +270,7 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
now = datetime.now(timezone.utc)
since = now - timedelta(hours=self.lookback_hours)
# 1. 获取所有活跃的 API 格式及其 Provider 数量
active_formats = (
db.query(
ProviderEndpoint.api_format,
func.count(func.distinct(ProviderEndpoint.provider_id)).label("provider_count"),
)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
)
.group_by(ProviderEndpoint.api_format)
.all()
)
# 构建所有格式的 provider_count 映射
all_formats: dict[str, int] = {}
for api_format_enum, provider_count in active_formats:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
all_formats[api_format] = provider_count
# 1.1 建立每个 API 格式对应的 Endpoint ID 列表(用于时间线生成),并收集活跃的 provider+format 组合
# 1. 单次查询获取所有活跃 endpoint 行,在内存中聚合 provider_count / endpoint_map
endpoint_rows = (
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id, ProviderEndpoint.provider_id)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
@@ -298,14 +280,20 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
)
.all()
)
all_formats: dict[str, int] = {} # api_format -> distinct provider count
endpoint_map: dict[str, list[str]] = defaultdict(list)
active_provider_formats: set[tuple[str, str]] = set()
_provider_sets: dict[str, set[str]] = defaultdict(set)
for api_format_enum, endpoint_id, provider_id in endpoint_rows:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
endpoint_map[api_format].append(endpoint_id)
active_provider_formats.add((str(provider_id), api_format))
fmt = _format_str(api_format_enum)
endpoint_map[fmt].append(endpoint_id)
_provider_sets[fmt].add(str(provider_id))
active_provider_formats.add((str(provider_id), fmt))
for fmt, pids in _provider_sets.items():
all_formats[fmt] = len(pids)
# 1.2 统计每个 API 格式可用的活跃 Key 数量Key 属于 Provider通过 api_formats 关联格式)
key_counts: dict[str, int] = {}
@@ -321,7 +309,7 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
)
for provider_id, api_formats in active_provider_keys:
pid = str(provider_id)
for fmt in (api_formats or []):
for fmt in api_formats or []:
if (pid, fmt) not in active_provider_formats:
continue
key_counts[fmt] = key_counts.get(fmt, 0) + 1
@@ -347,12 +335,10 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
# 构建每个格式的状态统计
status_counts: dict[str, dict[str, int]] = {}
for api_format_enum, status, count in status_counts_query:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
if api_format not in status_counts:
status_counts[api_format] = {"success": 0, "failed": 0, "skipped": 0}
status_counts[api_format][status] = count
fmt = _format_str(api_format_enum)
if fmt not in status_counts:
status_counts[fmt] = {"success": 0, "failed": 0, "skipped": 0}
status_counts[fmt][status] = count
# 3. 获取最近一段时间的 RequestCandidate限制数量
# 使用上面定义的 final_statuses排除中间状态
@@ -376,15 +362,13 @@ class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
grouped_attempts: dict[str, list[RequestCandidate]] = {}
for attempt, api_format_enum, provider_id in rows:
api_format = (
api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
)
if api_format not in grouped_attempts:
grouped_attempts[api_format] = []
fmt = _format_str(api_format_enum)
if fmt not in grouped_attempts:
grouped_attempts[fmt] = []
# 只保留每个 API 格式最近 per_format_limit 条记录
if len(grouped_attempts[api_format]) < self.per_format_limit:
grouped_attempts[api_format].append(attempt)
if len(grouped_attempts[fmt]) < self.per_format_limit:
grouped_attempts[fmt].append(attempt)
# 4. 为所有活跃格式生成监控数据(包括没有请求记录的)
monitors: list[ApiFormatHealthMonitor] = []
@@ -551,17 +535,22 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 查找所有有熔断格式的 Key检查 circuit_breaker_by_format JSON 字段)
all_keys = db.query(ProviderAPIKey).all()
# 粗过滤:仅加载 circuit_breaker_by_format 非空的 Key避免全表扫描
candidates = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.circuit_breaker_by_format.isnot(None),
ProviderAPIKey.circuit_breaker_by_format != "{}",
)
.all()
)
# 筛选有任何格式熔断的 Key
circuit_open_keys = []
for key in all_keys:
circuit_by_format = key.circuit_breaker_by_format or {}
for fmt, circuit_data in circuit_by_format.items():
if circuit_data.get("open"):
circuit_open_keys.append(key)
break
# 精确筛选有任何格式熔断的 Key
circuit_open_keys = [
key
for key in candidates
if any(cb.get("open") for cb in (key.circuit_breaker_by_format or {}).values())
]
if not circuit_open_keys:
return {
@@ -586,11 +575,8 @@ class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
db.commit()
# 重置健康监控器的计数
from src.services.health.monitor import HealthMonitor, health_open_circuits
HealthMonitor._open_circuit_keys = 0
health_open_circuits.set(0)
# 重置健康监控器的熔断计数
HealthMonitor.reset_open_circuit_count()
logger.info(f"管理员批量恢复 {len(recovered_keys)} 个 Key 的健康状态")

View File

@@ -4,16 +4,17 @@ Provider API Keys 管理
from __future__ import annotations
from typing import Any
import json
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import crypto_service
@@ -28,7 +29,6 @@ from src.models.endpoint_models import (
EndpointAPIKeyUpdate,
)
from src.services.cache.provider_cache import ProviderCacheService
from src.api.base.context import ApiRequestContext
router = APIRouter(tags=["Provider Keys"])
pipeline = ApiRequestPipeline()
@@ -261,7 +261,9 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
# 加密 auth_config包含敏感的 Service Account 凭证)
if "auth_config" in update_data and update_data["auth_config"]:
update_data["auth_config"] = crypto_service.encrypt(json.dumps(update_data["auth_config"]))
update_data["auth_config"] = crypto_service.encrypt(
json.dumps(update_data["auth_config"])
)
# 特殊处理 rpm_limit需要区分"未提供"和"显式设置为 null"
if "rpm_limit" in self.key_data.model_fields_set:
@@ -410,10 +412,10 @@ class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
# 检查是否是新格式的占位符(表示 auth_config 丢失)
if decrypted_key == "__placeholder__":
logger.error(f"Vertex AI Key 缺少 auth_config: ID={self.key_id}")
raise InvalidRequestException(
"认证配置丢失,请重新添加该密钥。"
)
logger.info(f"[REVEAL] 查看完整 Key (legacy vertex_ai): ID={self.key_id}, Name={key.name}")
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
logger.info(
f"[REVEAL] 查看完整 Key (legacy vertex_ai): ID={self.key_id}, Name={key.name}"
)
return {"auth_type": "vertex_ai", "auth_config": decrypted_key}
except InvalidRequestException:
raise
@@ -760,10 +762,14 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
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
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
self.key_data.model_exclude_patterns
if self.key_data.model_exclude_patterns
else None
),
request_count=0,
success_count=0,

View File

@@ -4,10 +4,10 @@ ProviderEndpoint CRUD 管理 API
from __future__ import annotations
from typing import Any
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import and_
@@ -15,13 +15,14 @@ from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline
from src.core.api_format.signature import parse_signature_key
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.api.base.context import ApiRequestContext
from src.models.endpoint_models import (
ProviderEndpointCreate,
ProviderEndpointResponse,
@@ -243,7 +244,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
total_keys_map: dict[str, int] = {}
active_keys_map: dict[str, int] = {}
for api_formats, is_active in keys:
for fmt in (api_formats or []):
for fmt in api_formats or []:
total_keys_map[fmt] = total_keys_map.get(fmt, 0) + 1
if is_active:
active_keys_map[fmt] = active_keys_map.get(fmt, 0) + 1
@@ -299,11 +300,18 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
)
now = datetime.now(timezone.utc)
sig = parse_signature_key(self.endpoint_data.api_format)
api_family = sig.api_family.value
endpoint_kind = sig.endpoint_kind.value
# 使用归一化后的 signature key确保格式一致性
normalized_api_format = sig.key
new_endpoint = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=self.provider_id,
api_format=self.endpoint_data.api_format,
api_format=normalized_api_format,
api_family=api_family,
endpoint_kind=endpoint_kind,
base_url=self.endpoint_data.base_url,
custom_path=self.endpoint_data.custom_path,
header_rules=self.endpoint_data.header_rules,
@@ -323,7 +331,9 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
# 清除 /v1/models 列表缓存
await invalidate_models_list_cache()
logger.info(f"[OK] 创建 Endpoint: Provider={provider.name}, Format={self.endpoint_data.api_format}, ID={new_endpoint.id}")
logger.info(
f"[OK] 创建 Endpoint: Provider={provider.name}, Format={self.endpoint_data.api_format}, ID={new_endpoint.id}"
)
endpoint_dict = {
k: v
@@ -417,6 +427,11 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
# proxy 为 None 时保留,用于清除代理配置
for field, value in update_data.items():
setattr(endpoint, field, value)
# Phase 3/4: 自动维护新架构字段,确保新增/历史数据都能被调度器按 family/kind 查询
sig = parse_signature_key(endpoint.api_format)
endpoint.api_family = sig.api_family.value
endpoint.endpoint_kind = sig.endpoint_kind.value
endpoint.updated_at = datetime.now(timezone.utc)
db.commit()
@@ -426,10 +441,14 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
await invalidate_models_list_cache()
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
logger.info(f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}")
logger.info(
f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}"
)
endpoint_format = (
endpoint.api_format if isinstance(endpoint.api_format, str) else endpoint.api_format.value
endpoint.api_format
if isinstance(endpoint.api_format, str)
else endpoint.api_format.value
)
keys = (
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
@@ -472,7 +491,9 @@ class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
endpoint_format = (
endpoint.api_format if isinstance(endpoint.api_format, str) else endpoint.api_format.value
endpoint.api_format
if isinstance(endpoint.api_format, str)
else endpoint.api_format.value
)
# 查询包含该格式的所有 Key并从 api_formats 中移除该格式
@@ -488,7 +509,7 @@ class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
# 移除该格式
new_formats = [f for f in key.api_formats if f != endpoint_format]
key.api_formats = new_formats if new_formats else []
flag_modified(key, 'api_formats')
flag_modified(key, "api_formats")
db.delete(endpoint)
db.commit()

View File

@@ -10,6 +10,7 @@ from pydantic import BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import crypto_service
from src.core.enums import AuthSource
@@ -17,7 +18,6 @@ from src.core.exceptions import InvalidRequestException, translate_pydantic_erro
from src.core.logger import logger
from src.database import get_db
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
from src.api.base.context import ApiRequestContext
from src.services.system.audit import AuditService
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])

View File

@@ -2,8 +2,8 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
@@ -222,9 +222,7 @@ class AdminGetManagementTokenAdapter(AdminManagementTokenApiAdapter):
token_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id
)
token = ManagementTokenService.get_token_by_id(db=context.db, token_id=self.token_id)
if not token:
raise NotFoundException("Management Token 不存在")
@@ -245,9 +243,7 @@ class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any:
# 先获取 token 信息用于审计
token = ManagementTokenService.get_token_by_id(
db=context.db, token_id=self.token_id
)
token = ManagementTokenService.get_token_by_id(db=context.db, token_id=self.token_id)
if not token:
raise NotFoundException("Management Token 不存在")
@@ -258,9 +254,7 @@ class AdminDeleteManagementTokenAdapter(AdminManagementTokenApiAdapter):
owner_user_id=token.user_id,
)
success = ManagementTokenService.delete_token(
db=context.db, token_id=self.token_id
)
success = ManagementTokenService.delete_token(db=context.db, token_id=self.token_id)
if not success:
raise NotFoundException("Management Token 不存在")
@@ -277,9 +271,7 @@ class AdminToggleManagementTokenAdapter(AdminManagementTokenApiAdapter):
audit_success_event = AuditEventType.MANAGEMENT_TOKEN_UPDATED
async def handle(self, context: ApiRequestContext) -> Any:
token = ManagementTokenService.toggle_status(
db=context.db, token_id=self.token_id
)
token = ManagementTokenService.toggle_status(db=context.db, token_id=self.token_id)
if not token:
raise NotFoundException("Management Token 不存在")

View File

@@ -4,17 +4,17 @@
基于 GlobalModel 的聚合视图
"""
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session, joinedload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.database import get_db
from src.models.database import GlobalModel, Model
from src.api.base.context import ApiRequestContext
from src.models.pydantic_models import (
ModelCapabilities,
ModelCatalogItem,

View File

@@ -6,13 +6,14 @@ GlobalModel Admin API
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request, Response
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline
from src.core.logger import logger
@@ -29,7 +30,6 @@ from src.models.pydantic_models import (
ModelCatalogProviderDetail,
)
from src.services.model.global_model import GlobalModelService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/global", tags=["Admin - Global Models"])
pipeline = ApiRequestPipeline()

View File

@@ -17,6 +17,7 @@ from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session, selectinload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import CryptoService
from src.core.model_permissions import (
@@ -32,7 +33,6 @@ from src.models.database import (
ProviderEndpoint,
)
from src.services.cache.aware_scheduler import CacheAwareScheduler
from src.api.base.context import ApiRequestContext
from src.services.system.config import SystemConfigService
router = APIRouter(prefix="/global", tags=["Admin - Global Models"])
@@ -49,7 +49,9 @@ class RoutingKeyInfo(BaseModel):
name: str
masked_key: str = Field("", description="脱敏的 API Key")
internal_priority: int = Field(..., description="Key 内部优先级")
global_priority_by_format: dict[str, int] | None = Field(None, description="按 API 格式的全局优先级")
global_priority_by_format: dict[str, int] | None = Field(
None, description="按 API 格式的全局优先级"
)
rpm_limit: int | None = Field(None, description="RPM 限制null 表示自适应")
is_adaptive: bool = Field(False, description="是否为自适应 RPM 模式")
effective_rpm: int | None = Field(None, description="有效 RPM 限制")
@@ -320,11 +322,13 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
# 按优先级排序(使用当前格式的全局优先级)
api_format = ep.api_format or ""
def get_key_priority(k: ProviderAPIKey) -> tuple[int, int]:
format_priority = 999
if k.global_priority_by_format and api_format in k.global_priority_by_format:
format_priority = k.global_priority_by_format[api_format]
return (format_priority, k.internal_priority or 0)
ep_keys.sort(key=get_key_priority)
key_infos = []
@@ -414,11 +418,21 @@ class AdminGetModelRoutingPreviewAdapter(AdminApiAdapter):
)
)
# 按 APIFormat 枚举定义的顺序排序 Endpoints
from src.core.api_format import APIFormat
format_order = {fmt.value: i for i, fmt in enumerate(APIFormat)}
endpoint_infos.sort(key=lambda e: format_order.get(e.api_format, 999))
# 按 endpoint signature 的推荐顺序排序 Endpoints(与前端展示保持一致)
preferred_order = [
"openai:chat",
"openai:cli",
"openai:video",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
"gemini:video",
]
order_map = {key: i for i, key in enumerate(preferred_order)}
endpoint_infos.sort(
key=lambda e: order_map.get(str(e.api_format or "").strip().lower(), 999)
)
active_endpoints = sum(1 for e in endpoint_infos if e.is_active)
provider_infos.append(

View File

@@ -1,6 +1,7 @@
"""模块管理 API 端点"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@@ -9,10 +10,10 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.modules import ModuleStatus, get_module_registry
from src.api.base.context import ApiRequestContext
from src.database import get_db
router = APIRouter(prefix="/api/admin/modules", tags=["Admin - Modules"])

View File

@@ -2,15 +2,16 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pagination import PaginationMeta, build_pagination_payload, paginate_query
from src.api.base.pipeline import ApiRequestPipeline
from src.core.logger import logger
@@ -26,8 +27,6 @@ from src.models.database import User as DBUser
from src.services.health.monitor import HealthMonitor
from src.services.system.audit import audit_service
from src.utils.database_helpers import escape_like_pattern
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"])
pipeline = ApiRequestPipeline()

View File

@@ -776,9 +776,9 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
global_model_map: dict[str, GlobalModel] = {}
if global_model_ids:
# model_name 可能是 UUID 格式的 global_model_id也可能是原始模型名称
global_models = db.query(GlobalModel).filter(
GlobalModel.id.in_(list(global_model_ids))
).all()
global_models = (
db.query(GlobalModel).filter(GlobalModel.id.in_(list(global_model_ids))).all()
)
global_model_map = {str(gm.id): gm for gm in global_models}
keyword_lower = self.keyword.lower() if self.keyword else None
@@ -830,12 +830,14 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
"global_model_id": affinity.get("model_name"), # 原始的 global_model_id
"model_name": (
global_model_map.get(affinity.get("model_name")).name
if affinity.get("model_name") and global_model_map.get(affinity.get("model_name"))
if affinity.get("model_name")
and global_model_map.get(affinity.get("model_name"))
else affinity.get("model_name") # 如果找不到 GlobalModel显示原始值
),
"model_display_name": (
global_model_map.get(affinity.get("model_name")).display_name
if affinity.get("model_name") and global_model_map.get(affinity.get("model_name"))
if affinity.get("model_name")
and global_model_map.get(affinity.get("model_name"))
else None
),
"api_format": affinity.get("api_format"),
@@ -916,7 +918,9 @@ class AdminClearUserCacheAdapter(AdminApiAdapter):
)
count += 1
logger.info(f"已清除API Key缓存亲和性: api_key_name={api_key.name}, affinity_key={affinity_key[:8]}..., 清除数量={count}")
logger.info(
f"已清除API Key缓存亲和性: api_key_name={api_key.name}, affinity_key={affinity_key[:8]}..., 清除数量={count}"
)
response = {
"status": "ok",
@@ -969,7 +973,9 @@ class AdminClearUserCacheAdapter(AdminApiAdapter):
)
count += 1
logger.info(f"已清除用户缓存亲和性: username={user.username}, user_id={user_id[:8]}..., 清除数量={count}")
logger.info(
f"已清除用户缓存亲和性: username={user.username}, user_id={user_id[:8]}..., 清除数量={count}"
)
response = {
"status": "ok",
@@ -1075,7 +1081,9 @@ class AdminClearProviderCacheAdapter(AdminApiAdapter):
redis_client = get_redis_client_sync()
affinity_mgr = await get_affinity_manager(redis_client)
count = await affinity_mgr.invalidate_all_for_provider(self.provider_id)
logger.info(f"已清除Provider缓存亲和性: provider_id={self.provider_id[:8]}..., count={count}")
logger.info(
f"已清除Provider缓存亲和性: provider_id={self.provider_id[:8]}..., count={count}"
)
context.add_audit_metadata(
action="cache_clear_provider",
provider_id=self.provider_id,
@@ -1094,8 +1102,8 @@ class AdminClearProviderCacheAdapter(AdminApiAdapter):
class AdminCacheConfigAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
from src.services.cache.affinity_manager import CacheAffinityManager
from src.config.constants import ConcurrencyDefaults
from src.services.cache.affinity_manager import CacheAffinityManager
from src.services.rate_limit.adaptive_reservation import get_adaptive_reservation_manager
# 获取动态预留管理器的配置
@@ -1334,11 +1342,13 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
)
if cached_str == "NOT_FOUND":
unmapped_entries.append({
"mapping_name": mapping_name,
"status": "not_found",
"ttl": ttl if ttl > 0 else None,
})
unmapped_entries.append(
{
"mapping_name": mapping_name,
"status": "not_found",
"ttl": ttl if ttl > 0 else None,
}
)
else:
try:
cached_data = json.loads(cached_str)
@@ -1380,27 +1390,33 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
provider_names.append(provider.name)
provider_names = sorted(list(set(provider_names)))
mappings.append({
"mapping_name": mapping_name,
"global_model_name": global_model_name,
"global_model_display_name": global_model_display_name,
"providers": provider_names,
"ttl": ttl if ttl > 0 else None,
})
mappings.append(
{
"mapping_name": mapping_name,
"global_model_name": global_model_name,
"global_model_display_name": global_model_display_name,
"providers": provider_names,
"ttl": ttl if ttl > 0 else None,
}
)
except json.JSONDecodeError:
unmapped_entries.append({
"mapping_name": mapping_name,
"status": "invalid",
"ttl": ttl if ttl > 0 else None,
})
unmapped_entries.append(
{
"mapping_name": mapping_name,
"status": "invalid",
"ttl": ttl if ttl > 0 else None,
}
)
except Exception as e:
logger.warning(f"解析缓存键 {key} 失败: {e}")
unmapped_entries.append({
"mapping_name": mapping_name,
"status": "error",
"ttl": None,
})
unmapped_entries.append(
{
"mapping_name": mapping_name,
"status": "error",
"ttl": None,
}
)
# 按 mapping_name 排序
mappings.sort(key=lambda x: x["mapping_name"])
@@ -1408,8 +1424,13 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
# 3. 解析 provider_global 缓存Provider 级别的模型解析缓存)
provider_model_mappings = []
# 预加载 Provider 和 GlobalModel 数据
provider_map = {str(p.id): p for p in db.query(Provider).filter(Provider.is_active.is_(True)).all()}
global_model_map = {str(gm.id): gm for gm in db.query(GlobalModel).filter(GlobalModel.is_active.is_(True)).all()}
provider_map = {
str(p.id): p for p in db.query(Provider).filter(Provider.is_active.is_(True)).all()
}
global_model_map = {
str(gm.id): gm
for gm in db.query(GlobalModel).filter(GlobalModel.is_active.is_(True)).all()
}
for key in provider_global_keys[:100]: # 最多处理 100 个
# key 格式: model:provider_global:{provider_id}:{global_model_id}
@@ -1447,7 +1468,9 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
mapping_names = []
if cached_model_mappings:
for mapping_entry in cached_model_mappings:
if isinstance(mapping_entry, dict) and mapping_entry.get("name"):
if isinstance(mapping_entry, dict) and mapping_entry.get(
"name"
):
mapping_names.append(mapping_entry["name"])
# provider_model_name 为空时跳过
@@ -1463,19 +1486,23 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
if has_name_mapping or has_mappings:
# 构建用于展示的映射列表
# 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称"
display_mappings = mapping_names if mapping_names else [global_model.name]
display_mappings = (
mapping_names if mapping_names else [global_model.name]
)
provider_model_mappings.append({
"provider_id": provider_id,
"provider_name": provider.name,
"global_model_id": global_model_id,
"global_model_name": global_model.name,
"global_model_display_name": global_model.display_name,
"provider_model_name": provider_model_name,
"aliases": display_mappings,
"ttl": ttl if ttl > 0 else None,
"hit_count": hit_count,
})
provider_model_mappings.append(
{
"provider_id": provider_id,
"provider_name": provider.name,
"global_model_id": global_model_id,
"global_model_name": global_model.name,
"global_model_display_name": global_model.display_name,
"provider_model_name": provider_model_name,
"aliases": display_mappings,
"ttl": ttl if ttl > 0 else None,
"hit_count": hit_count,
}
)
except json.JSONDecodeError:
pass
except Exception as e:
@@ -1496,7 +1523,9 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
"global_model_resolve": len(global_model_resolve_keys),
},
"mappings": mappings,
"provider_model_mappings": provider_model_mappings if provider_model_mappings else None,
"provider_model_mappings": (
provider_model_mappings if provider_model_mappings else None
),
"unmapped": unmapped_entries if unmapped_entries else None,
}

View File

@@ -4,21 +4,21 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.pipeline import ApiRequestPipeline
from src.database import get_db
from src.models.database import Provider, ProviderEndpoint, ProviderAPIKey
from src.core.crypto import crypto_service
from src.services.request.candidate import RequestCandidateService
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import crypto_service
from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.request.candidate import RequestCandidateService
router = APIRouter(prefix="/api/admin/monitoring/trace", tags=["Admin - Monitoring: Trace"])
pipeline = ApiRequestPipeline()
@@ -184,8 +184,7 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
# 4. status="pending" 表示请求尚未开始执行
# 5. status="cancelled" 表示客户端主动断开连接(不算失败)
has_success = any(
c.status == "success"
or (c.status_code is not None and 200 <= c.status_code < 300)
c.status == "success" or (c.status_code is not None and 200 <= c.status_code < 300)
for c in candidates
)
has_streaming = any(c.status == "streaming" for c in candidates)
@@ -221,7 +220,9 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
endpoint_ids = {c.endpoint_id for c in candidates if c.endpoint_id}
endpoint_map = {}
if endpoint_ids:
endpoints = db.query(ProviderEndpoint).filter(ProviderEndpoint.id.in_(endpoint_ids)).all()
endpoints = (
db.query(ProviderEndpoint).filter(ProviderEndpoint.id.in_(endpoint_ids)).all()
)
endpoint_map = {e.id: e.api_format for e in endpoints}
# 批量加载 key 信息
@@ -245,7 +246,9 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
prefix_end = len(prefix)
break
if prefix_end > 0:
key_preview_map[k.id] = f"{decrypted_key[:prefix_end]}***{decrypted_key[-4:]}"
key_preview_map[k.id] = (
f"{decrypted_key[:prefix_end]}***{decrypted_key[-4:]}"
)
else:
key_preview_map[k.id] = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
elif len(decrypted_key) > 4:
@@ -267,12 +270,8 @@ class AdminGetRequestTraceAdapter(AdminApiAdapter):
endpoint_name = (
endpoint_map.get(candidate.endpoint_id) if candidate.endpoint_id else None
)
key_name = (
key_map.get(candidate.key_id) if candidate.key_id else None
)
key_preview = (
key_preview_map.get(candidate.key_id) if candidate.key_id else None
)
key_name = key_map.get(candidate.key_id) if candidate.key_id else None
key_preview = key_preview_map.get(candidate.key_id) if candidate.key_id else None
key_capabilities = (
key_capabilities_map.get(candidate.key_id) if candidate.key_id else None
)

View File

@@ -5,8 +5,8 @@ Provider Query API 端点
from __future__ import annotations
from typing import Any
import asyncio
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException
@@ -14,11 +14,15 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session, joinedload
from src.config.constants import TimeoutDefaults
from src.core.crypto import crypto_service
from src.core.api_format import get_extra_headers_from_endpoint
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.database.database import get_db
from src.models.database import Provider, ProviderEndpoint, User
from src.services.model.fetch_scheduler import (
get_upstream_models_from_cache,
set_upstream_models_to_cache,
)
from src.services.model.upstream_fetcher import (
_get_adapter_for_format,
build_all_format_configs,
@@ -26,11 +30,6 @@ from src.services.model.upstream_fetcher import (
)
from src.utils.auth_utils import get_current_user
from src.utils.ssl_utils import get_ssl_context
from src.services.model.fetch_scheduler import (
get_upstream_models_from_cache,
set_upstream_models_to_cache,
)
router = APIRouter(prefix="/api/admin/provider-query", tags=["Provider Query"])
@@ -124,9 +123,7 @@ async def query_available_models(
async def fetch_for_key(api_key: Any) -> Any:
# 非强制刷新时,先检查缓存
if not request.force_refresh:
cached_models = await get_upstream_models_from_cache(
request.provider_id, api_key.id
)
cached_models = await get_upstream_models_from_cache(request.provider_id, api_key.id)
if cached_models is not None:
return cached_models, None, True # models, error, from_cache
@@ -252,10 +249,7 @@ async def _fetch_models_for_single_key(
) -> Any:
"""获取单个 Key 的模型列表"""
# 查找指定的 Key
api_key = next(
(key for key in provider.api_keys if key.id == api_key_id),
None
)
api_key = next((key for key in provider.api_keys if key.id == api_key_id), None)
if not api_key:
raise HTTPException(status_code=404, detail="API Key not found")
@@ -347,19 +341,22 @@ async def test_model(
if not endpoint:
raise HTTPException(
status_code=404,
detail=f"No active endpoint found for API format: {request.api_format}"
detail=f"No active endpoint found for API format: {request.api_format}",
)
if request.api_key_id:
# 使用指定的 Key但需要校验是否支持该格式
api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
None
(
key
for key in provider.api_keys
if key.id == request.api_key_id and key.is_active
),
None,
)
if api_key and request.api_format not in (api_key.api_formats or []):
raise HTTPException(
status_code=400,
detail=f"API Key does not support format: {request.api_format}"
status_code=400, detail=f"API Key does not support format: {request.api_format}"
)
else:
# 找支持该格式的第一个可用 Key
@@ -378,13 +375,17 @@ async def test_model(
if request.api_key_id:
# 同时指定了 Key需要校验是否支持该端点格式
api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
None
(
key
for key in provider.api_keys
if key.id == request.api_key_id and key.is_active
),
None,
)
if api_key and endpoint.api_format not in (api_key.api_formats or []):
raise HTTPException(
status_code=400,
detail=f"API Key does not support endpoint format: {endpoint.api_format}"
detail=f"API Key does not support endpoint format: {endpoint.api_format}",
)
else:
# 找支持该端点格式的第一个可用 Key
@@ -398,11 +399,11 @@ async def test_model(
# 使用指定的 API Key
api_key = next(
(key for key in provider.api_keys if key.id == request.api_key_id and key.is_active),
None
None,
)
if api_key:
# 找到该 Key 支持的第一个活跃 Endpoint
for fmt in (api_key.api_formats or []):
for fmt in api_key.api_formats or []:
if fmt in format_to_endpoint:
endpoint = format_to_endpoint[fmt]
break
@@ -469,7 +470,9 @@ async def test_model(
}
# 发送测试请求
async with httpx.AsyncClient(timeout=endpoint_config["timeout"], verify=get_ssl_context()) as client:
async with httpx.AsyncClient(
timeout=endpoint_config["timeout"], verify=get_ssl_context()
) as client:
# 非流式测试
logger.debug(f"[test-model] 开始非流式测试...")
@@ -492,46 +495,47 @@ async def test_model(
logger.debug(f"[test-model] 非流式测试结果:")
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
response_data = response.get('response', {})
response_body = response_data.get('response_body', {})
response_data = response.get("response", {})
response_body = response_data.get("response_body", {})
logger.debug(f"[test-model] Response Data: {response_data}")
logger.debug(f"[test-model] Response Body: {response_body}")
# 尝试解析 response_body (通常是 JSON 字符串)
parsed_body = response_body
import json
if isinstance(response_body, str):
try:
parsed_body = json.loads(response_body)
except json.JSONDecodeError:
pass
if isinstance(parsed_body, dict) and 'error' in parsed_body:
error_obj = parsed_body['error']
if isinstance(parsed_body, dict) and "error" in parsed_body:
error_obj = parsed_body["error"]
# 兼容 error 可能是字典或字符串的情况
if isinstance(error_obj, dict):
logger.debug(f"[test-model] Error Message: {error_obj.get('message')}")
raise HTTPException(status_code=500, detail=error_obj.get('message'))
raise HTTPException(status_code=500, detail=error_obj.get("message"))
else:
logger.debug(f"[test-model] Error: {error_obj}")
raise HTTPException(status_code=500, detail=error_obj)
elif 'error' in response:
elif "error" in response:
logger.debug(f"[test-model] Error: {response['error']}")
raise HTTPException(status_code=500, detail=response['error'])
raise HTTPException(status_code=500, detail=response["error"])
else:
# 如果有选择或消息,记录内容预览
if isinstance(response_data, dict):
if 'choices' in response_data and response_data['choices']:
choice = response_data['choices'][0]
if 'message' in choice:
content = choice['message'].get('content', '')
if "choices" in response_data and response_data["choices"]:
choice = response_data["choices"][0]
if "message" in choice:
content = choice["message"].get("content", "")
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
elif 'content' in response_data and response_data['content']:
content = str(response_data['content'])
elif "content" in response_data and response_data["content"]:
content = str(response_data["content"])
logger.debug(f"[test-model] Content Preview: {content[:200]}...")
# 检查测试是否成功基于HTTP状态码
status_code = response.get('status_code', 0)
is_success = status_code == 200 and 'error' not in response
status_code = response.get("status_code", 0)
is_success = status_code == 200 and "error" not in response
return {
"success": is_success,
@@ -561,9 +565,13 @@ async def test_model(
"name": provider.name,
},
"model": request.model_name,
"endpoint": {
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
} if endpoint else None,
"endpoint": (
{
"id": endpoint.id,
"api_format": endpoint.api_format,
"base_url": endpoint.base_url,
}
if endpoint
else None
),
}

View File

@@ -4,8 +4,8 @@
from __future__ import annotations
from typing import Any
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
@@ -13,13 +13,13 @@ from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.core.logger import logger
from src.database import get_db
from src.models.database import Provider
from src.api.base.context import ApiRequestContext
from src.models.database_extensions import ProviderUsageTracking
router = APIRouter(prefix="/api/admin/provider-strategy", tags=["Provider Strategy"])
@@ -187,7 +187,9 @@ class AdminProviderBillingAdapter(AdminApiAdapter):
.scalar()
)
provider.monthly_used_usd = float(period_usage or 0)
logger.info(f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}")
logger.info(
f"Synced usage for provider {provider.name}: ${period_usage:.4f} since {new_reset_at}"
)
if config.quota_expires_at:
expires_at = datetime.fromisoformat(config.quota_expires_at)

View File

@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session, joinedload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
@@ -21,23 +22,22 @@ from src.models.api import (
ModelResponse,
ModelUpdate,
)
from src.models.pydantic_models import (
BatchAssignModelsToProviderRequest,
BatchAssignModelsToProviderResponse,
ImportFromUpstreamRequest,
ImportFromUpstreamResponse,
ImportFromUpstreamSuccessItem,
ImportFromUpstreamErrorItem,
ProviderAvailableSourceModel,
ProviderAvailableSourceModelsResponse,
)
from src.models.database import (
GlobalModel,
Model,
Provider,
)
from src.models.pydantic_models import (
BatchAssignModelsToProviderRequest,
BatchAssignModelsToProviderResponse,
ImportFromUpstreamErrorItem,
ImportFromUpstreamRequest,
ImportFromUpstreamResponse,
ImportFromUpstreamSuccessItem,
ProviderAvailableSourceModel,
ProviderAvailableSourceModelsResponse,
)
from src.services.model.service import ModelService
from src.api.base.context import ApiRequestContext
router = APIRouter(tags=["Model Management"])
pipeline = ApiRequestPipeline()
@@ -322,9 +322,7 @@ async def batch_assign_global_models_to_provider(
- `global_model_name`: 全局模型名称(如果可用)
- `error`: 错误信息
"""
adapter = AdminBatchAssignModelsToProviderAdapter(
provider_id=provider_id, payload=payload
)
adapter = AdminBatchAssignModelsToProviderAdapter(provider_id=provider_id, payload=payload)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -407,7 +405,9 @@ class AdminCreateProviderModelAdapter(AdminApiAdapter):
try:
model = ModelService.create_model(db, self.provider_id, self.model_data)
logger.info(f"Model created: {model.provider_model_name} for provider {provider.name} by {context.user.username}")
logger.info(
f"Model created: {model.provider_model_name} for provider {provider.name} by {context.user.username}"
)
# 缓存失效已在 ModelService.create_model 中处理
return ModelService.convert_to_response(model)
except Exception as exc:
@@ -450,7 +450,9 @@ class AdminUpdateProviderModelAdapter(AdminApiAdapter):
try:
updated_model = ModelService.update_model(db, self.model_id, self.model_data)
logger.info(f"Model updated: {updated_model.provider_model_name} by {context.user.username}")
logger.info(
f"Model updated: {updated_model.provider_model_name} by {context.user.username}"
)
# 缓存失效已在 ModelService.update_model 中处理
return ModelService.convert_to_response(updated_model)
except Exception as exc:
@@ -495,7 +497,9 @@ class AdminBatchCreateModelsAdapter(AdminApiAdapter):
try:
models = ModelService.batch_create_models(db, self.provider_id, self.models_data)
logger.info(f"Batch created {len(models)} models for provider {provider.name} by {context.user.username}")
logger.info(
f"Batch created {len(models)} models for provider {provider.name} by {context.user.username}"
)
# 缓存失效已在 ModelService.batch_create_models 中处理
return [ModelService.convert_to_response(model) for model in models]
except Exception as exc:
@@ -642,6 +646,7 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
if success:
# Provider 新增模型实现后,清除同进程的 ModelMapper 缓存,避免 TTL 内仍返回 None
from src.services.cache.invalidation import get_cache_invalidation_service
cache_service = get_cache_invalidation_service()
cache_service.on_model_changed(self.provider_id, success[0].get("global_model_id", ""))
@@ -669,9 +674,12 @@ class AdminImportFromUpstreamAdapter(AdminApiAdapter):
# 获取价格覆盖配置
tiered_pricing = None
price_per_request = None
if hasattr(self.payload, 'tiered_pricing') and self.payload.tiered_pricing:
if hasattr(self.payload, "tiered_pricing") and self.payload.tiered_pricing:
tiered_pricing = self.payload.tiered_pricing
if hasattr(self.payload, 'price_per_request') and self.payload.price_per_request is not None:
if (
hasattr(self.payload, "price_per_request")
and self.payload.price_per_request is not None
):
price_per_request = self.payload.price_per_request
for model_id in self.payload.model_ids:
@@ -679,7 +687,11 @@ class AdminImportFromUpstreamAdapter(AdminApiAdapter):
if not model_id or len(model_id) > 100:
errors.append(
ImportFromUpstreamErrorItem(
model_id=model_id[:50] + "..." if model_id and len(model_id) > 50 else model_id or "<empty>",
model_id=(
model_id[:50] + "..."
if model_id and len(model_id) > 50
else model_id or "<empty>"
),
error="Invalid model_id: must be 1-100 characters",
)
)
@@ -705,7 +717,9 @@ class AdminImportFromUpstreamAdapter(AdminApiAdapter):
ImportFromUpstreamSuccessItem(
model_id=model_id,
global_model_id=existing.global_model_id or "",
global_model_name=existing.global_model.name if existing.global_model else "",
global_model_name=(
existing.global_model.name if existing.global_model else ""
),
provider_model_id=existing.id,
created_global_model=False,
)

View File

@@ -2,17 +2,17 @@
from __future__ import annotations
from typing import Any
import asyncio
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache
from src.services.cache.model_cache import ModelCacheService
from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, NotFoundException
@@ -21,8 +21,8 @@ from src.core.model_permissions import match_model_with_pattern, parse_allowed_m
from src.database import get_db
from src.models.admin_requests import CreateProviderRequest, UpdateProviderRequest
from src.models.database import GlobalModel, Provider, ProviderAPIKey
from src.services.cache.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService
from src.api.base.context import ApiRequestContext
router = APIRouter(tags=["Provider CRUD"])
pipeline = ApiRequestPipeline()
@@ -159,7 +159,9 @@ async def create_provider(request: Request, db: Session = Depends(get_db)) -> An
@router.put("/{provider_id}")
async def update_provider(provider_id: str, request: Request, db: Session = Depends(get_db)) -> None:
async def update_provider(
provider_id: str, request: Request, db: Session = Depends(get_db)
) -> None:
"""
更新提供商配置
@@ -195,7 +197,9 @@ async def update_provider(provider_id: str, request: Request, db: Session = Depe
@router.delete("/{provider_id}")
async def delete_provider(provider_id: str, request: Request, db: Session = Depends(get_db)) -> None:
async def delete_provider(
provider_id: str, request: Request, db: Session = Depends(get_db)
) -> None:
"""
删除提供商

View File

@@ -2,15 +2,16 @@
Provider 摘要与健康监控 API
"""
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import case, func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import ProviderBillingType
from src.core.exceptions import NotFoundException
@@ -23,7 +24,6 @@ from src.models.database import (
ProviderEndpoint,
RequestCandidate,
)
from src.api.base.context import ApiRequestContext
from src.models.endpoint_models import (
EndpointHealthEvent,
EndpointHealthMonitor,
@@ -229,10 +229,14 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
active_keys = int(key_stats.active or 0)
# Model 统计(合并为单个查询)
model_stats = db.query(
func.count(Model.id).label("total"),
func.sum(case((Model.is_active == True, 1), else_=0)).label("active"),
).filter(Model.provider_id == provider.id).first()
model_stats = (
db.query(
func.count(Model.id).label("total"),
func.sum(case((Model.is_active == True, 1), else_=0)).label("active"),
)
.filter(Model.provider_id == provider.id)
.first()
)
total_models = model_stats.total or 0
active_models = int(model_stats.active or 0)
@@ -294,7 +298,9 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
# 检查是否配置了 Provider Ops余额监控等
provider_ops_config = (provider.config or {}).get("provider_ops")
ops_configured = bool(provider_ops_config)
ops_architecture_id = provider_ops_config.get("architecture_id") if provider_ops_config else None
ops_architecture_id = (
provider_ops_config.get("architecture_id") if provider_ops_config else None
)
return ProviderWithEndpointsSummary(
id=provider.id,

View File

@@ -7,17 +7,18 @@ IP 安全管理接口
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiMode
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.database import get_db
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/security/ip", tags=["Admin - Security"])
pipeline = ApiRequestPipeline()
@@ -78,7 +79,9 @@ async def add_to_blacklist(request: Request, db: Session = Depends(get_db)) -> N
@router.delete("/blacklist/{ip_address}")
async def remove_from_blacklist(ip_address: str, request: Request, db: Session = Depends(get_db)) -> None:
async def remove_from_blacklist(
ip_address: str, request: Request, db: Session = Depends(get_db)
) -> None:
"""
从黑名单移除 IP
@@ -133,7 +136,9 @@ async def add_to_whitelist(request: Request, db: Session = Depends(get_db)) -> N
@router.delete("/whitelist/{ip_address}")
async def remove_from_whitelist(ip_address: str, request: Request, db: Session = Depends(get_db)) -> None:
async def remove_from_whitelist(
ip_address: str, request: Request, db: Session = Depends(get_db)
) -> None:
"""
从白名单移除 IP

View File

@@ -1,17 +1,17 @@
"""系统设置API端点。"""
from __future__ import annotations
from typing import Any
import copy
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger
@@ -20,7 +20,6 @@ from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
@@ -179,7 +178,9 @@ async def check_update() -> Any:
tag_commit_sha = latest_tag_info.get("commit", {}).get("sha")
if tag_commit_sha:
# 尝试获取 annotated tag 的信息
tag_ref_url = f"https://api.github.com/repos/{github_repo}/git/refs/tags/{latest_tag_name}"
tag_ref_url = (
f"https://api.github.com/repos/{github_repo}/git/refs/tags/{latest_tag_name}"
)
ref_response = await client.get(
tag_ref_url,
headers={
@@ -210,7 +211,9 @@ async def check_update() -> Any:
# 如果没有获取到时间,从 commit 获取
if not published_at:
commit_url = f"https://api.github.com/repos/{github_repo}/commits/{tag_commit_sha}"
commit_url = (
f"https://api.github.com/repos/{github_repo}/commits/{tag_commit_sha}"
)
commit_response = await client.get(
commit_url,
headers={
@@ -220,7 +223,9 @@ async def check_update() -> Any:
)
if commit_response.status_code == 200:
commit_data = commit_response.json()
published_at = commit_data.get("commit", {}).get("committer", {}).get("date")
published_at = (
commit_data.get("commit", {}).get("committer", {}).get("date")
)
return {
"current_version": current_version,
@@ -599,6 +604,7 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
# 对敏感配置进行加密
if self.key in self.ENCRYPTED_KEYS and value:
from src.core.crypto import crypto_service
value = crypto_service.encrypt(value)
config = SystemConfigService.set_config(
@@ -653,7 +659,6 @@ class AdminTriggerCleanupAdapter(AdminApiAdapter):
"""手动触发清理任务"""
from datetime import datetime, timezone
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
db = context.db
@@ -714,21 +719,41 @@ class AdminTriggerCleanupAdapter(AdminApiAdapter):
class AdminGetApiFormatsAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""获取所有可用的API格式"""
from src.core.api_format import API_FORMAT_DEFINITIONS, APIFormat
from src.core.api_format import list_endpoint_definitions
_ = context # 参数保留以符合接口规范
formats = []
for api_format in APIFormat:
definition = API_FORMAT_DEFINITIONS.get(api_format)
formats.append(
{
"value": api_format.value,
"label": api_format.value,
"default_path": definition.default_path if definition else "/",
"aliases": list(definition.aliases) if definition else [],
}
)
def _label_for(sig: str) -> str:
fam, kind = (sig.split(":", 1) + [""])[:2]
fam_title = {"claude": "Claude", "openai": "OpenAI", "gemini": "Gemini"}.get(fam, fam)
if kind == "chat":
return fam_title
kind_title = {"cli": "CLI", "video": "Video", "image": "Image"}.get(kind, kind)
return f"{fam_title} {kind_title}".strip()
endpoint_defs = list_endpoint_definitions()
preferred_order = [
"openai:chat",
"openai:cli",
"openai:video",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
"gemini:video",
]
order_map = {key: i for i, key in enumerate(preferred_order)}
endpoint_defs.sort(key=lambda d: order_map.get(d.signature_key, 999))
formats = [
{
"value": d.signature_key,
"label": _label_for(d.signature_key),
"default_path": d.default_path,
"aliases": list(d.aliases or []),
}
for d in endpoint_defs
]
return {"formats": formats}
@@ -738,8 +763,14 @@ class AdminExportConfigAdapter(AdminApiAdapter):
# Provider Ops 中需要解密的敏感字段
SENSITIVE_CREDENTIALS = {
"api_key", "password", "session_token", "session_cookie",
"token_cookie", "auth_cookie", "cookie_string", "cookie"
"api_key",
"password",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
}
def _decrypt_provider_config(self, config: dict, crypto_service: Any) -> dict:
@@ -797,9 +828,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
for provider in providers:
# 导出 Endpoints
endpoints = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.provider_id == provider.id)
.all()
db.query(ProviderEndpoint).filter(ProviderEndpoint.provider_id == provider.id).all()
)
endpoints_data = []
for ep in endpoints:
@@ -903,6 +932,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
# 导出 LDAP 配置
from src.models.database import LDAPConfig
ldap_config = db.query(LDAPConfig).first()
ldap_data = None
if ldap_config:
@@ -931,6 +961,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
# 导出 OAuth Providers 配置
from src.models.database import OAuthProvider
oauth_providers = db.query(OAuthProvider).all()
oauth_data = []
for oauth in oauth_providers:
@@ -942,21 +973,23 @@ class AdminExportConfigAdapter(AdminApiAdapter):
except Exception as e:
logger.debug(f"解密 OAuth '{oauth.provider_type}' client_secret 失败: {e}")
oauth_data.append({
"provider_type": oauth.provider_type,
"display_name": oauth.display_name,
"client_id": oauth.client_id,
"client_secret": client_secret,
"authorization_url_override": oauth.authorization_url_override,
"token_url_override": oauth.token_url_override,
"userinfo_url_override": oauth.userinfo_url_override,
"scopes": oauth.scopes,
"redirect_uri": oauth.redirect_uri,
"frontend_callback_url": oauth.frontend_callback_url,
"attribute_mapping": oauth.attribute_mapping,
"extra_config": oauth.extra_config,
"is_enabled": oauth.is_enabled,
})
oauth_data.append(
{
"provider_type": oauth.provider_type,
"display_name": oauth.display_name,
"client_id": oauth.client_id,
"client_secret": client_secret,
"authorization_url_override": oauth.authorization_url_override,
"token_url_override": oauth.token_url_override,
"userinfo_url_override": oauth.userinfo_url_override,
"scopes": oauth.scopes,
"redirect_uri": oauth.redirect_uri,
"frontend_callback_url": oauth.frontend_callback_url,
"attribute_mapping": oauth.attribute_mapping,
"extra_config": oauth.extra_config,
"is_enabled": oauth.is_enabled,
}
)
return {
"version": "2.1",
@@ -976,8 +1009,14 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# Provider Ops 中需要加密的敏感字段
SENSITIVE_CREDENTIALS = {
"api_key", "password", "session_token", "session_cookie",
"token_cookie", "auth_cookie", "cookie_string", "cookie"
"api_key",
"password",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
}
def _encrypt_provider_config(self, config: dict, crypto_service: Any) -> dict:
@@ -1045,9 +1084,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# 导入 GlobalModels
global_model_map = {} # name -> id 映射
for gm_data in global_models_data:
existing = (
db.query(GlobalModel).filter(GlobalModel.name == gm_data["name"]).first()
)
existing = db.query(GlobalModel).filter(GlobalModel.name == gm_data["name"]).first()
if existing:
global_model_map[gm_data["name"]] = existing.id
@@ -1055,23 +1092,17 @@ class AdminImportConfigAdapter(AdminApiAdapter):
stats["global_models"]["skipped"] += 1
continue
elif merge_mode == "error":
raise InvalidRequestException(
f"GlobalModel '{gm_data['name']}' 已存在"
)
raise InvalidRequestException(f"GlobalModel '{gm_data['name']}' 已存在")
elif merge_mode == "overwrite":
# 更新现有记录
existing.display_name = gm_data.get(
"display_name", existing.display_name
)
existing.display_name = gm_data.get("display_name", existing.display_name)
existing.default_price_per_request = gm_data.get(
"default_price_per_request"
)
existing.default_tiered_pricing = gm_data.get(
"default_tiered_pricing", existing.default_tiered_pricing
)
existing.supported_capabilities = gm_data.get(
"supported_capabilities"
)
existing.supported_capabilities = gm_data.get("supported_capabilities")
existing.config = gm_data.get("config")
existing.is_active = gm_data.get("is_active", True)
existing.updated_at = datetime.now(timezone.utc)
@@ -1085,7 +1116,15 @@ class AdminImportConfigAdapter(AdminApiAdapter):
default_price_per_request=gm_data.get("default_price_per_request"),
default_tiered_pricing=gm_data.get(
"default_tiered_pricing",
{"tiers": [{"up_to": None, "input_price_per_1m": 0, "output_price_per_1m": 0}]},
{
"tiers": [
{
"up_to": None,
"input_price_per_1m": 0,
"output_price_per_1m": 0,
}
]
},
),
supported_capabilities=gm_data.get("supported_capabilities"),
config=gm_data.get("config"),
@@ -1108,33 +1147,23 @@ class AdminImportConfigAdapter(AdminApiAdapter):
stats["providers"]["skipped"] += 1
# 仍然需要处理 endpoints 和 models如果存在
elif merge_mode == "error":
raise InvalidRequestException(
f"Provider '{prov_data['name']}' 已存在"
)
raise InvalidRequestException(f"Provider '{prov_data['name']}' 已存在")
elif merge_mode == "overwrite":
# 更新现有记录
existing_provider.name = prov_data.get(
"name", existing_provider.name
)
existing_provider.name = prov_data.get("name", existing_provider.name)
existing_provider.description = prov_data.get("description")
existing_provider.website = prov_data.get("website")
if prov_data.get("billing_type"):
existing_provider.billing_type = ProviderBillingType(
prov_data["billing_type"]
)
existing_provider.monthly_quota_usd = prov_data.get(
"monthly_quota_usd"
)
existing_provider.quota_reset_day = prov_data.get(
"quota_reset_day", 30
)
existing_provider.monthly_quota_usd = prov_data.get("monthly_quota_usd")
existing_provider.quota_reset_day = prov_data.get("quota_reset_day", 30)
existing_provider.provider_priority = prov_data.get(
"provider_priority", 100
)
existing_provider.is_active = prov_data.get("is_active", True)
existing_provider.concurrent_limit = prov_data.get(
"concurrent_limit"
)
existing_provider.concurrent_limit = prov_data.get("concurrent_limit")
existing_provider.max_retries = prov_data.get(
"max_retries", existing_provider.max_retries
)
@@ -1178,11 +1207,17 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# 导入 Endpoints
for ep_data in prov_data.get("endpoints", []):
from src.core.api_format.signature import (
normalize_signature_key,
parse_signature_key,
)
ep_format = normalize_signature_key(ep_data["api_format"])
existing_ep = (
db.query(ProviderEndpoint)
.filter(
ProviderEndpoint.provider_id == provider_id,
ProviderEndpoint.api_format == ep_data["api_format"],
ProviderEndpoint.api_format == ep_format,
)
.first()
)
@@ -1192,25 +1227,32 @@ class AdminImportConfigAdapter(AdminApiAdapter):
stats["endpoints"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"Endpoint '{ep_data['api_format']}' 已存在于 Provider '{prov_data['name']}'"
f"Endpoint '{ep_format}' 已存在于 Provider '{prov_data['name']}'"
)
elif merge_mode == "overwrite":
existing_ep.base_url = ep_data.get(
"base_url", existing_ep.base_url
)
existing_ep.base_url = ep_data.get("base_url", existing_ep.base_url)
existing_ep.header_rules = ep_data.get("header_rules")
existing_ep.max_retries = ep_data.get("max_retries", 2)
existing_ep.is_active = ep_data.get("is_active", True)
existing_ep.custom_path = ep_data.get("custom_path")
existing_ep.config = ep_data.get("config")
existing_ep.proxy = ep_data.get("proxy")
sig = parse_signature_key(ep_format)
existing_ep.api_format = sig.key # 使用归一化后的格式
existing_ep.api_family = sig.api_family.value
existing_ep.endpoint_kind = sig.endpoint_kind.value
existing_ep.updated_at = datetime.now(timezone.utc)
stats["endpoints"]["updated"] += 1
else:
sig = parse_signature_key(ep_format)
api_family = sig.api_family.value
endpoint_kind = sig.endpoint_kind.value
new_ep = ProviderEndpoint(
id=str(uuid.uuid4()),
provider_id=provider_id,
api_format=ep_data["api_format"],
api_format=sig.key, # 使用归一化后的格式
api_family=api_family,
endpoint_kind=endpoint_kind,
base_url=ep_data["base_url"],
header_rules=ep_data.get("header_rules"),
max_retries=ep_data.get("max_retries", 2),
@@ -1232,11 +1274,11 @@ class AdminImportConfigAdapter(AdminApiAdapter):
endpoint_formats: set[str] = set()
for (api_format,) in endpoint_format_rows:
fmt = api_format.value if hasattr(api_format, "value") else str(api_format)
endpoint_formats.add(fmt.strip().upper())
from src.core.api_format.signature import normalize_signature_key
endpoint_formats.add(normalize_signature_key(fmt))
existing_keys = (
db.query(ProviderAPIKey)
.filter(ProviderAPIKey.provider_id == provider_id)
.all()
db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == provider_id).all()
)
existing_key_values = set()
for ek in existing_keys:
@@ -1248,9 +1290,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
for key_data in prov_data.get("api_keys", []):
if not key_data.get("api_key"):
stats["errors"].append(
f"跳过空 API Key (Provider: {prov_data['name']})"
)
stats["errors"].append(f"跳过空 API Key (Provider: {prov_data['name']})")
continue
plaintext_key = key_data["api_key"]
@@ -1368,21 +1408,13 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_model.provider_model_mappings = model_data.get(
"provider_model_mappings"
)
existing_model.price_per_request = model_data.get(
"price_per_request"
)
existing_model.tiered_pricing = model_data.get(
"tiered_pricing"
)
existing_model.supports_vision = model_data.get(
"supports_vision"
)
existing_model.price_per_request = model_data.get("price_per_request")
existing_model.tiered_pricing = model_data.get("tiered_pricing")
existing_model.supports_vision = model_data.get("supports_vision")
existing_model.supports_function_calling = model_data.get(
"supports_function_calling"
)
existing_model.supports_streaming = model_data.get(
"supports_streaming"
)
existing_model.supports_streaming = model_data.get("supports_streaming")
existing_model.supports_extended_thinking = model_data.get(
"supports_extended_thinking"
)
@@ -1399,22 +1431,14 @@ class AdminImportConfigAdapter(AdminApiAdapter):
provider_id=provider_id,
global_model_id=global_model_id,
provider_model_name=model_data["provider_model_name"],
provider_model_mappings=model_data.get(
"provider_model_mappings"
),
provider_model_mappings=model_data.get("provider_model_mappings"),
price_per_request=model_data.get("price_per_request"),
tiered_pricing=model_data.get("tiered_pricing"),
supports_vision=model_data.get("supports_vision"),
supports_function_calling=model_data.get(
"supports_function_calling"
),
supports_function_calling=model_data.get("supports_function_calling"),
supports_streaming=model_data.get("supports_streaming"),
supports_extended_thinking=model_data.get(
"supports_extended_thinking"
),
supports_image_generation=model_data.get(
"supports_image_generation"
),
supports_extended_thinking=model_data.get("supports_extended_thinking"),
supports_image_generation=model_data.get("supports_image_generation"),
is_active=model_data.get("is_active", True),
config=model_data.get("config"),
)
@@ -1439,7 +1463,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
elif merge_mode == "error":
raise InvalidRequestException("LDAP 配置已存在")
elif merge_mode == "overwrite":
existing_ldap.server_url = ldap_data.get("server_url", existing_ldap.server_url)
existing_ldap.server_url = ldap_data.get(
"server_url", existing_ldap.server_url
)
existing_ldap.bind_dn = ldap_data.get("bind_dn", existing_ldap.bind_dn)
# 加密绑定密码
if ldap_data.get("bind_password"):
@@ -1453,11 +1479,15 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_ldap.username_attr = ldap_data.get(
"username_attr", existing_ldap.username_attr
)
existing_ldap.email_attr = ldap_data.get("email_attr", existing_ldap.email_attr)
existing_ldap.email_attr = ldap_data.get(
"email_attr", existing_ldap.email_attr
)
existing_ldap.display_name_attr = ldap_data.get(
"display_name_attr", existing_ldap.display_name_attr
)
existing_ldap.is_enabled = ldap_data.get("is_enabled", existing_ldap.is_enabled)
existing_ldap.is_enabled = ldap_data.get(
"is_enabled", existing_ldap.is_enabled
)
existing_ldap.is_exclusive = ldap_data.get(
"is_exclusive", existing_ldap.is_exclusive
)
@@ -1476,7 +1506,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
bind_dn=ldap_data["bind_dn"],
bind_password_encrypted=(
crypto_service.encrypt(ldap_data["bind_password"])
if ldap_data.get("bind_password") else None
if ldap_data.get("bind_password")
else None
),
base_dn=ldap_data["base_dn"],
user_search_filter=ldap_data.get("user_search_filter", "(uid={username})"),
@@ -1494,6 +1525,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# 导入 OAuth Providers2.1 新增)
if oauth_data:
from src.models.database import OAuthProvider
for oauth_item in oauth_data:
provider_type = oauth_item.get("provider_type")
if not provider_type:
@@ -1548,7 +1580,11 @@ class AdminImportConfigAdapter(AdminApiAdapter):
stats["oauth"]["updated"] += 1
else:
# 创建新的 OAuth Provider - 校验必填字段
required_oauth_fields = ["client_id", "redirect_uri", "frontend_callback_url"]
required_oauth_fields = [
"client_id",
"redirect_uri",
"frontend_callback_url",
]
missing = [f for f in required_oauth_fields if not oauth_item.get(f)]
if missing:
stats["errors"].append(
@@ -1562,7 +1598,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
client_id=oauth_item["client_id"],
client_secret_encrypted=(
crypto_service.encrypt(oauth_item["client_secret"])
if oauth_item.get("client_secret") else None
if oauth_item.get("client_secret")
else None
),
authorization_url_override=oauth_item.get("authorization_url_override"),
token_url_override=oauth_item.get("token_url_override"),
@@ -1588,10 +1625,14 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# 触发开启了 auto_fetch_models 的 Key 的模型获取
keys_to_fetch = stats.get("keys_to_fetch", [])
if keys_to_fetch:
logger.info(f"[AUTO_FETCH] 导入了 {len(keys_to_fetch)} 个开启自动获取模型的 Key触发模型获取")
logger.info(
f"[AUTO_FETCH] 导入了 {len(keys_to_fetch)} 个开启自动获取模型的 Key触发模型获取"
)
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
import asyncio
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
scheduler = get_model_fetch_scheduler()
for key_id in keys_to_fetch:
asyncio.create_task(scheduler._fetch_models_for_key_by_id(key_id))
@@ -1649,18 +1690,18 @@ class AdminExportUsersAdapter(AdminApiAdapter):
return data
# 导出 Users排除管理员
users = db.query(User).filter(
User.is_deleted.is_(False),
User.role != UserRole.ADMIN
).all()
users = db.query(User).filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN).all()
users_data = []
for user in users:
# 导出用户的 API Keys排除独立余额Key独立Key单独导出
api_keys = db.query(ApiKey).filter(
ApiKey.user_id == user.id,
ApiKey.is_standalone.is_(False)
).all()
api_keys_data = [_serialize_api_key(key, include_is_standalone=True) for key in api_keys]
api_keys = (
db.query(ApiKey)
.filter(ApiKey.user_id == user.id, ApiKey.is_standalone.is_(False))
.all()
)
api_keys_data = [
_serialize_api_key(key, include_is_standalone=True) for key in api_keys
]
users_data.append(
{
@@ -1751,27 +1792,30 @@ class AdminImportUsersAdapter(AdminApiAdapter):
f"API Key '{key_data.get('name', key_hash[:8])}' 的 expires_at 格式无效"
)
return ApiKey(
id=str(uuid.uuid4()),
user_id=owner_id,
key_hash=key_hash,
key_encrypted=key_data.get("key_encrypted"),
name=key_data.get("name"),
is_standalone=is_standalone or key_data.get("is_standalone", False),
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
rate_limit=key_data.get("rate_limit"),
concurrent_limit=key_data.get("concurrent_limit", 5),
force_capabilities=key_data.get("force_capabilities"),
is_active=key_data.get("is_active", True),
expires_at=expires_at,
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
total_requests=key_data.get("total_requests", 0),
total_cost_usd=key_data.get("total_cost_usd", 0.0),
), "created"
return (
ApiKey(
id=str(uuid.uuid4()),
user_id=owner_id,
key_hash=key_hash,
key_encrypted=key_data.get("key_encrypted"),
name=key_data.get("name"),
is_standalone=is_standalone or key_data.get("is_standalone", False),
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
rate_limit=key_data.get("rate_limit"),
concurrent_limit=key_data.get("concurrent_limit", 5),
force_capabilities=key_data.get("force_capabilities"),
is_active=key_data.get("is_active", True),
expires_at=expires_at,
auto_delete_on_expiry=key_data.get("auto_delete_on_expiry", False),
total_requests=key_data.get("total_requests", 0),
total_cost_usd=key_data.get("total_cost_usd", 0.0),
),
"created",
)
try:
for user_data in users_data:
@@ -1785,29 +1829,21 @@ class AdminImportUsersAdapter(AdminApiAdapter):
# 导入必须有邮箱email 是导入的主键)
import_email = user_data.get("email")
if not import_email:
stats["errors"].append(
f"跳过无邮箱用户: {user_data.get('username', '未知')}"
)
stats["errors"].append(f"跳过无邮箱用户: {user_data.get('username', '未知')}")
stats["users"]["skipped"] += 1
continue
existing_user = (
db.query(User).filter(User.email == import_email).first()
)
existing_user = db.query(User).filter(User.email == import_email).first()
if existing_user:
user_id = existing_user.id
if merge_mode == "skip":
stats["users"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"用户 '{import_email}' 已存在"
)
raise InvalidRequestException(f"用户 '{import_email}' 已存在")
elif merge_mode == "overwrite":
# 更新现有用户
existing_user.username = user_data.get(
"username", existing_user.username
)
existing_user.username = user_data.get("username", existing_user.username)
if user_data.get("password_hash"):
existing_user.password_hash = user_data["password_hash"]
if user_data.get("role"):
@@ -1898,8 +1934,8 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""测试 SMTP 连接"""
from src.core.crypto import crypto_service
from src.services.system.config import SystemConfigService
from src.services.email.email_sender import EmailSenderService
from src.services.system.config import SystemConfigService
db = context.db
payload = context.ensure_json_body() or {}
@@ -1917,16 +1953,23 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
config = {
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
"smtp_host": payload.get("smtp_host")
or SystemConfigService.get_config(db, "smtp_host"),
"smtp_port": payload.get("smtp_port")
or SystemConfigService.get_config(db, "smtp_port", default=587),
"smtp_user": payload.get("smtp_user")
or SystemConfigService.get_config(db, "smtp_user"),
"smtp_password": smtp_password,
"smtp_use_tls": payload.get("smtp_use_tls")
if payload.get("smtp_use_tls") is not None
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
"smtp_use_ssl": payload.get("smtp_use_ssl")
if payload.get("smtp_use_ssl") is not None
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
"smtp_use_tls": (
payload.get("smtp_use_tls")
if payload.get("smtp_use_tls") is not None
else SystemConfigService.get_config(db, "smtp_use_tls", default=True)
),
"smtp_use_ssl": (
payload.get("smtp_use_ssl")
if payload.get("smtp_use_ssl") is not None
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False)
),
"smtp_from_email": payload.get("smtp_from_email")
or SystemConfigService.get_config(db, "smtp_from_email"),
"smtp_from_name": payload.get("smtp_from_name")
@@ -1935,12 +1978,14 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
# 验证必要配置
missing_fields = [
field for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"] if not config.get(field)
field
for field in ["smtp_host", "smtp_user", "smtp_password", "smtp_from_email"]
if not config.get(field)
]
if missing_fields:
return {
"success": False,
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}"
"message": f"SMTP 配置不完整,请检查 {', '.join(missing_fields)}",
}
# 测试连接
@@ -1950,20 +1995,11 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
)
if success:
return {
"success": True,
"message": "SMTP 连接测试成功"
}
return {"success": True, "message": "SMTP 连接测试成功"}
else:
return {
"success": False,
"message": error_msg
}
return {"success": False, "message": error_msg}
except Exception as e:
return {
"success": False,
"message": str(e)
}
return {"success": False, "message": str(e)}
# -------- 邮件模板适配器 --------

View File

@@ -2,16 +2,17 @@
from __future__ import annotations
from typing import Any
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.database import get_db
from src.models.database import (
@@ -24,7 +25,6 @@ from src.models.database import (
User,
)
from src.services.usage.service import UsageService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/usage", tags=["Admin - Usage"])
pipeline = ApiRequestPipeline()
@@ -36,7 +36,9 @@ pipeline = ApiRequestPipeline()
@router.get("/aggregation/stats")
async def get_usage_aggregation(
request: Request,
group_by: str = Query(..., description="Aggregation dimension: model, user, provider, or api_format"),
group_by: str = Query(
..., description="Aggregation dimension: model, user, provider, or api_format"
),
start_date: datetime | None = None,
end_date: datetime | None = None,
limit: int = Query(20, ge=1, le=100),
@@ -66,11 +68,13 @@ async def get_usage_aggregation(
elif group_by == "provider":
adapter = AdminUsageByProviderAdapter(start_date=start_date, end_date=end_date, limit=limit)
elif group_by == "api_format":
adapter = AdminUsageByApiFormatAdapter(start_date=start_date, end_date=end_date, limit=limit)
adapter = AdminUsageByApiFormatAdapter(
start_date=start_date, end_date=end_date, limit=limit
)
else:
raise HTTPException(
status_code=400,
detail=f"Invalid group_by value: {group_by}. Must be one of: model, user, provider, api_format"
detail=f"Invalid group_by value: {group_by}. Must be one of: model, user, provider, api_format",
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -454,12 +458,10 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
attempt_query = db.query(
RequestCandidate.provider_id,
func.count(RequestCandidate.id).label("attempt_count"),
func.sum(
case((RequestCandidate.status == "success", 1), else_=0)
).label("success_count"),
func.sum(
case((RequestCandidate.status == "failed", 1), else_=0)
).label("failed_count"),
func.sum(case((RequestCandidate.status == "success", 1), else_=0)).label(
"success_count"
),
func.sum(case((RequestCandidate.status == "failed", 1), else_=0)).label("failed_count"),
func.avg(RequestCandidate.latency_ms).label("avg_latency_ms"),
).filter(
RequestCandidate.provider_id.isnot(None),
@@ -537,17 +539,19 @@ class AdminUsageByProviderAdapter(AdminApiAdapter):
# 从 usage_map 获取 token 和费用信息
usage_stat = usage_map.get(provider_id_str)
result.append({
"provider_id": provider_id_str,
"provider": provider_map.get(provider_id_str, "Unknown"),
"request_count": attempt_count, # 尝试次数
"total_tokens": int(usage_stat.total_tokens or 0) if usage_stat else 0,
"total_cost": float(usage_stat.total_cost or 0) if usage_stat else 0,
"actual_cost": float(usage_stat.actual_cost or 0) if usage_stat else 0,
"avg_response_time_ms": float(stat.avg_latency_ms or 0),
"success_rate": round(success_rate, 2),
"error_count": failed_count,
})
result.append(
{
"provider_id": provider_id_str,
"provider": provider_map.get(provider_id_str, "Unknown"),
"request_count": attempt_count, # 尝试次数
"total_tokens": int(usage_stat.total_tokens or 0) if usage_stat else 0,
"total_cost": float(usage_stat.total_cost or 0) if usage_stat else 0,
"actual_cost": float(usage_stat.actual_cost or 0) if usage_stat else 0,
"avg_response_time_ms": float(stat.avg_latency_ms or 0),
"success_rate": round(success_rate, 2),
"error_count": failed_count,
}
)
return result
@@ -581,9 +585,7 @@ class AdminUsageByApiFormatAdapter(AdminApiAdapter):
query = query.filter(Usage.created_at <= self.end_date)
query = (
query.group_by(Usage.api_format)
.order_by(func.count(Usage.id).desc())
.limit(self.limit)
query.group_by(Usage.api_format).order_by(func.count(Usage.id).desc()).limit(self.limit)
)
stats = query.all()
@@ -691,9 +693,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
elif self.status == "standard":
query = query.filter(Usage.is_stream == False) # noqa: E712
elif self.status == "error":
query = query.filter(
(Usage.status_code >= 400) | (Usage.error_message.isnot(None))
)
query = query.filter((Usage.status_code >= 400) | (Usage.error_message.isnot(None)))
elif self.status in ("pending", "streaming", "completed"):
# 新的状态筛选:直接按 status 字段过滤
query = query.filter(Usage.status == self.status)
@@ -702,9 +702,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
# 1. 新方式status = "failed"
# 2. 旧方式status_code >= 400 或 error_message 不为空
query = query.filter(
(Usage.status == "failed") |
(Usage.status_code >= 400) |
(Usage.error_message.isnot(None))
(Usage.status == "failed")
| (Usage.status_code >= 400)
| (Usage.error_message.isnot(None))
)
elif self.status == "active":
# 活跃请求pending 或 streaming 状态
@@ -761,9 +761,7 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
retry_map[req_id] = has_retry
# 检查是否有整流:任意候选的 extra_data 中有 rectified=True
rectified_map[req_id] = any(
c[2].get("rectified", False) for c in candidates
)
rectified_map[req_id] = any(c[2].get("rectified", False) for c in candidates)
context.add_audit_metadata(
action="usage_records",

View File

@@ -2,14 +2,15 @@
from __future__ import annotations
from typing import Any
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger
@@ -20,8 +21,6 @@ from src.models.database import ApiKey, User, UserRole
from src.services.system.config import SystemConfigService
from src.services.user.apikey import ApiKeyService
from src.services.user.service import UserService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/admin/users", tags=["Admin - Users"])
pipeline = ApiRequestPipeline()

View File

@@ -2,8 +2,8 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import ValidationError
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
from src.database import get_db
@@ -19,8 +20,6 @@ from src.models.api import CreateAnnouncementRequest, UpdateAnnouncementRequest
from src.models.database import User
from src.services.auth.service import AuthService
from src.services.system.announcement import AnnouncementService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/announcements", tags=["Announcements"])
pipeline = ApiRequestPipeline()

View File

@@ -2,10 +2,10 @@
认证相关API端点
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer
from pydantic import ValidationError
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException
from src.core.logger import logger
@@ -34,15 +35,14 @@ from src.models.api import (
VerifyEmailResponse,
)
from src.models.database import AuditEventType, User, UserRole
from src.services.auth.service import AuthService
from src.services.auth.ldap import LDAPService
from src.services.auth.service import AuthService
from src.services.email import EmailSenderService, EmailVerificationService
from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
from src.services.user.service import UserService
from src.services.email import EmailSenderService, EmailVerificationService
from src.utils.request_utils import get_client_ip, get_user_agent
from src.api.base.context import ApiRequestContext
def validate_email_suffix(db: Session, email: str) -> tuple[bool, str | None]:
@@ -307,7 +307,10 @@ class AuthLoginAdapter(AuthPublicAdapter):
}
)
refresh_token = AuthService.create_refresh_token(
data={"user_id": user.id, "created_at": user.created_at.isoformat() if user.created_at else None}
data={
"user_id": user.id,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
)
response = LoginResponse(
access_token=access_token,
@@ -348,10 +351,14 @@ class AuthRefreshAdapter(AuthPublicAdapter):
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户已禁用")
if user.is_deleted:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户不存在或已禁用")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="用户不存在或已禁用"
)
if not AuthService.token_identity_matches_user(token_payload, user):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的刷新令牌"
)
new_access_token = AuthService.create_access_token(
data={
@@ -361,7 +368,10 @@ class AuthRefreshAdapter(AuthPublicAdapter):
}
)
new_refresh_token = AuthService.create_refresh_token(
data={"user_id": user.id, "created_at": user.created_at.isoformat() if user.created_at else None}
data={
"user_id": user.id,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
)
logger.info(f"令牌刷新成功: user_id={user.id}")
return RefreshTokenResponse(
@@ -382,8 +392,12 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
"""公开返回注册相关配置"""
db = context.db
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
enable_registration = SystemConfigService.get_config(
db, "enable_registration", default=False
)
require_verification = SystemConfigService.get_config(
db, "require_email_verification", default=False
)
email_configured = EmailSenderService.is_smtp_configured(db)
# 如果邮箱服务未配置,强制 require_email_verification 为 False
@@ -434,7 +448,8 @@ class AuthRegisterAdapter(AuthPublicAdapter):
# 仅允许 LDAP 登录时拒绝本地注册
if LDAPService.is_ldap_exclusive(db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="系统已启用 LDAP 专属登录,禁止本地注册"
status_code=status.HTTP_403_FORBIDDEN,
detail="系统已启用 LDAP 专属登录,禁止本地注册",
)
allow_registration = db.query(SystemConfig).filter_by(key="enable_registration").first()
@@ -452,7 +467,9 @@ class AuthRegisterAdapter(AuthPublicAdapter):
email = register_request.email
email_configured = EmailSenderService.is_smtp_configured(db)
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
require_verification = SystemConfigService.get_config(
db, "require_email_verification", default=False
)
# 如果邮箱服务未配置,强制不要求邮箱验证
if not email_configured:
@@ -495,7 +512,9 @@ class AuthRegisterAdapter(AuthPublicAdapter):
try:
# 读取系统配置的默认配额
default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
default_quota = SystemConfigService.get_config(
db, "default_user_quota_usd", default=10.0
)
# email_verified 逻辑:
# - 要求邮箱验证且已通过验证True
@@ -513,7 +532,8 @@ class AuthRegisterAdapter(AuthPublicAdapter):
AuditService.log_event(
db=db,
event_type=AuditEventType.USER_CREATED,
description=f"User registered: {user.username}" + (f" ({user.email})" if user.email else ""),
description=f"User registered: {user.username}"
+ (f" ({user.email})" if user.email else ""),
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
@@ -671,8 +691,8 @@ class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
)
# 生成并发送验证码(使用服务中的默认配置)
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
email
success, code_or_error, error_detail = (
await EmailVerificationService.send_verification_code(email)
)
if not success:

View File

@@ -1,6 +1,7 @@
from src.api.base.context import ApiRequestContext
from fastapi import HTTPException
from src.api.base.context import ApiRequestContext
from .adapter import ApiAdapter, ApiMode

View File

@@ -1,4 +1,5 @@
from __future__ import annotations
import json
import time
import uuid
@@ -13,7 +14,6 @@ from src.models.database import ApiKey, ManagementToken, User
from src.utils.request_utils import get_client_ip
@dataclass
class ApiRequestContext:
"""统一的API请求上下文贯穿Pipeline与格式适配器。"""

View File

@@ -11,17 +11,20 @@
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any
from sqlalchemy import tuple_
from sqlalchemy.orm import Session
from src.config.constants import CacheTTL
from src.core.cache_service import CacheService
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.cache_service import CacheService
from src.core.logger import logger
from src.services.model.availability import ModelAvailabilityQuery
from src.models.database import ApiKey, Model, Provider, ProviderEndpoint, User
from src.services.model.availability import ModelAvailabilityQuery
from src.services.provider.format import normalize_endpoint_signature
# 缓存 key 前缀
_CACHE_KEY_PREFIX = "models:list"
@@ -60,7 +63,9 @@ async def _set_cached_models(
try:
data = [asdict(m) for m in models]
await CacheService.set(cache_key, data, ttl_seconds=_CACHE_TTL)
logger.debug(f"[ModelsService] 已缓存: {cache_key}, {len(models)} 个模型, TTL={_CACHE_TTL}s")
logger.debug(
f"[ModelsService] 已缓存: {cache_key}, {len(models)} 个模型, TTL={_CACHE_TTL}s"
)
except Exception as e:
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
@@ -119,9 +124,7 @@ class AccessRestrictions:
allowed_api_formats: list[str] | None = None # 允许的 API 格式列表
@classmethod
def from_api_key_and_user(
cls, api_key: ApiKey | None, user: User | None
) -> AccessRestrictions:
def from_api_key_and_user(cls, api_key: ApiKey | None, user: User | None) -> AccessRestrictions:
"""
从 API Key 和 User 合并访问限制
@@ -164,14 +167,16 @@ class AccessRestrictions:
检查 API 格式是否被允许
Args:
api_format: API 格式 (如 "OPENAI", "CLAUDE", "GEMINI")
api_format: endpoint signature"openai:chat"
Returns:
True 如果格式被允许False 否则
"""
if self.allowed_api_formats is None:
return True
return api_format in self.allowed_api_formats
target = normalize_endpoint_signature(api_format)
allowed = {normalize_endpoint_signature(f) for f in self.allowed_api_formats if f}
return target in allowed
def is_model_allowed(self, model_id: str, provider_id: str) -> bool:
"""
@@ -201,14 +206,14 @@ def _normalize_api_formats(
api_formats: list[str] | None,
provider_to_formats: dict[str, set[str]] | None = None,
) -> list[str]:
"""规范化 API 格式列表(大写),必要时从 provider_to_formats 兜底"""
"""规范化 API 格式列表(endpoint signature小写 canonical),必要时从 provider_to_formats 兜底"""
if api_formats:
return [str(fmt).upper() for fmt in api_formats if fmt is not None]
return [normalize_endpoint_signature(str(fmt)) for fmt in api_formats if fmt]
if not provider_to_formats:
return []
all_formats: set[str] = set()
for formats in provider_to_formats.values():
all_formats.update(str(fmt).upper() for fmt in formats)
all_formats.update(normalize_endpoint_signature(str(fmt)) for fmt in formats if fmt)
return list(all_formats)
@@ -226,7 +231,9 @@ def _get_provider_model_names_for_formats(
if not isinstance(raw_mappings, list):
return names
usable_formats_upper = {f.upper() for f in usable_formats} if usable_formats else None
usable_formats_norm = (
{normalize_endpoint_signature(f) for f in usable_formats} if usable_formats else None
)
for raw in raw_mappings:
if not isinstance(raw, dict):
@@ -236,15 +243,12 @@ def _get_provider_model_names_for_formats(
continue
mapping_api_formats = raw.get("api_formats")
if usable_formats_upper and mapping_api_formats:
if isinstance(mapping_api_formats, list):
mapping_formats = {
str(fmt).upper()
for fmt in mapping_api_formats
if isinstance(fmt, str)
}
if not mapping_formats & usable_formats_upper:
continue
if usable_formats_norm and mapping_api_formats and isinstance(mapping_api_formats, list):
mapping_formats = {
normalize_endpoint_signature(str(fmt)) for fmt in mapping_api_formats if fmt
}
if not mapping_formats & usable_formats_norm:
continue
names.add(name.strip())
@@ -266,39 +270,52 @@ def get_compatible_provider_formats(
if not normalized_formats:
return {}
target_formats = set(normalized_formats)
client_format_upper = client_format.upper()
target_pairs: list[tuple[str, str]] = []
for fmt in normalized_formats:
try:
fam, kind = fmt.split(":", 1)
except ValueError:
continue
if fam and kind:
target_pairs.append((fam, kind))
if not target_pairs:
return {}
client_format_norm = normalize_endpoint_signature(client_format)
endpoint_rows = (
db.query(
ProviderEndpoint.provider_id,
ProviderEndpoint.api_format,
ProviderEndpoint.api_family,
ProviderEndpoint.endpoint_kind,
ProviderEndpoint.format_acceptance_config,
)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
Provider.is_active.is_(True),
ProviderEndpoint.is_active.is_(True),
ProviderEndpoint.api_format.in_(list(target_formats)),
ProviderEndpoint.api_family.isnot(None),
ProviderEndpoint.endpoint_kind.isnot(None),
tuple_(ProviderEndpoint.api_family, ProviderEndpoint.endpoint_kind).in_(target_pairs),
)
.all()
)
provider_to_formats: dict[str, set[str]] = {}
for provider_id, endpoint_format, format_acceptance_config in endpoint_rows:
if not provider_id or not endpoint_format:
for provider_id, api_family, endpoint_kind, format_acceptance_config in endpoint_rows:
if not provider_id or not api_family or not endpoint_kind:
continue
endpoint_format = normalize_endpoint_signature(f"{api_family}:{endpoint_kind}")
is_compatible, _needs_conversion, _reason = is_format_compatible(
client_format_upper,
str(endpoint_format),
client_format_norm,
endpoint_format,
format_acceptance_config,
is_stream=False,
global_conversion_enabled=global_conversion_enabled,
)
if not is_compatible:
continue
fmt_upper = str(endpoint_format).upper()
provider_to_formats.setdefault(provider_id, set()).add(fmt_upper)
provider_to_formats.setdefault(provider_id, set()).add(endpoint_format)
return provider_to_formats
@@ -420,7 +437,9 @@ def _extract_model_info(model: Any) -> ModelInfo | None:
"""
global_model = model.global_model
if global_model is None:
logger.warning(f"[ModelService] Model {getattr(model, 'id', 'unknown')} 缺少 global_model跳过")
logger.warning(
f"[ModelService] Model {getattr(model, 'id', 'unknown')} 缺少 global_model跳过"
)
return None
model_id: str = global_model.name

View File

@@ -1,6 +1,6 @@
from collections.abc import Sequence
from dataclasses import asdict, dataclass
from typing import Any, TypeVar
from collections.abc import Sequence
from sqlalchemy.orm import Query

View File

@@ -61,7 +61,10 @@ class ApiRequestPipeline:
logger.debug("[Pipeline] START | path=%s", http_request.url.path)
logger.debug(
"[Pipeline] Running with mode=%s, adapter=%s, adapter.mode=%s, path=%s",
mode, adapter.__class__.__name__, adapter.mode, http_request.url.path
mode,
adapter.__class__.__name__,
adapter.mode,
http_request.url.path,
)
if mode == ApiMode.ADMIN:
user, management_token = await self._authenticate_admin(http_request, db)
@@ -94,7 +97,10 @@ class ApiRequestPipeline:
http_request.body(), timeout=config.request_body_timeout
)
if not is_quiet:
logger.debug("[Pipeline] Raw body读取完成 | size=%d bytes", len(raw_body) if raw_body is not None else 0)
logger.debug(
"[Pipeline] Raw body读取完成 | size=%d bytes",
len(raw_body) if raw_body is not None else 0,
)
except TimeoutError:
timeout_sec = int(config.request_body_timeout)
logger.error(f"读取请求体超时({timeout_sec}s),可能客户端未发送完整请求体")
@@ -122,14 +128,22 @@ class ApiRequestPipeline:
# 存储 quiet 标志到 context用于审计日志判断
context.quiet_logging = is_quiet
if not is_quiet:
logger.debug("[Pipeline] Context构建完成 | adapter=%s | request_id=%s", adapter.name, context.request_id)
logger.debug(
"[Pipeline] Context构建完成 | adapter=%s | request_id=%s",
adapter.name,
context.request_id,
)
if mode != ApiMode.ADMIN and user:
context.quota_remaining = self._calculate_quota_remaining(user)
if not is_quiet:
logger.debug("[Pipeline] Adapter=%s | RequestID=%s", adapter.name, context.request_id)
logger.debug("[Pipeline] Calling authorize on %s, user=%s", adapter.__class__.__name__, context.user)
logger.debug(
"[Pipeline] Calling authorize on %s, user=%s",
adapter.__class__.__name__,
context.user,
)
# authorize 可能是异步的,需要检查并 await
authorize_result = adapter.authorize(context)
if hasattr(authorize_result, "__await__"):
@@ -172,7 +186,10 @@ class ApiRequestPipeline:
# 使用 adapter 的 extract_api_key 方法,支持不同 API 格式的认证头
client_api_key = adapter.extract_api_key(request)
if not quiet:
logger.debug("[Pipeline._authenticate_client] 提取API密钥完成 | key_prefix=%s...", client_api_key[:8] if client_api_key else None)
logger.debug(
"[Pipeline._authenticate_client] 提取API密钥完成 | key_prefix=%s...",
client_api_key[:8] if client_api_key else None,
)
if not client_api_key:
raise HTTPException(status_code=401, detail="请提供API密钥")

View File

@@ -2,9 +2,9 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func
@@ -12,15 +12,23 @@ from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.enums import UserRole
from src.database import get_db
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, StatsDailyProvider, Usage
from src.models.database import (
ApiKey,
Provider,
RequestCandidate,
StatsDaily,
StatsDailyModel,
StatsDailyProvider,
Usage,
)
from src.models.database import User as DBUser
from src.services.system.stats_aggregator import StatsAggregatorService
from src.utils.cache_decorator import cache_result
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
pipeline = ApiRequestPipeline()
@@ -181,10 +189,13 @@ class DashboardStatsAdapter(DashboardAdapter):
class AdminDashboardStatsAdapter(AdminApiAdapter):
@cache_result(key_prefix="dashboard:admin:stats", ttl=CacheTTL.DASHBOARD_STATS, user_specific=False)
@cache_result(
key_prefix="dashboard:admin:stats", ttl=CacheTTL.DASHBOARD_STATS, user_specific=False
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
"""管理员仪表盘统计 - 使用预聚合数据优化性能"""
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db
@@ -218,7 +229,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
active_users = combined_stats.get("active_users") or (
db.query(func.count(DBUser.id)).filter(DBUser.is_active.is_(True)).scalar()
)
total_api_keys = combined_stats.get("total_api_keys") or db.query(func.count(ApiKey.id)).scalar()
total_api_keys = (
combined_stats.get("total_api_keys") or db.query(func.count(ApiKey.id)).scalar()
)
active_api_keys = combined_stats.get("active_api_keys") or (
db.query(func.count(ApiKey.id)).filter(ApiKey.is_active.is_(True)).scalar()
)
@@ -250,12 +263,14 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
requests_yesterday = (
db.query(func.count(Usage.id))
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.scalar() or 0
.scalar()
or 0
)
cost_yesterday = (
db.query(func.sum(Usage.total_cost_usd))
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.scalar() or 0
.scalar()
or 0
)
yesterday_token_stats = (
db.query(
@@ -267,10 +282,20 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
.filter(Usage.created_at >= yesterday, Usage.created_at < today)
.first()
)
input_tokens_yesterday = int(yesterday_token_stats.input_tokens or 0) if yesterday_token_stats else 0
output_tokens_yesterday = int(yesterday_token_stats.output_tokens or 0) if yesterday_token_stats else 0
cache_creation_yesterday = int(yesterday_token_stats.cache_creation_tokens or 0) if yesterday_token_stats else 0
cache_read_yesterday = int(yesterday_token_stats.cache_read_tokens or 0) if yesterday_token_stats else 0
input_tokens_yesterday = (
int(yesterday_token_stats.input_tokens or 0) if yesterday_token_stats else 0
)
output_tokens_yesterday = (
int(yesterday_token_stats.output_tokens or 0) if yesterday_token_stats else 0
)
cache_creation_yesterday = (
int(yesterday_token_stats.cache_creation_tokens or 0)
if yesterday_token_stats
else 0
)
cache_read_yesterday = (
int(yesterday_token_stats.cache_read_tokens or 0) if yesterday_token_stats else 0
)
# ==================== 本月统计(从预聚合表聚合)====================
monthly_stats = (
@@ -279,8 +304,12 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
func.sum(StatsDaily.error_requests).label("error_requests"),
func.sum(StatsDaily.total_cost).label("total_cost"),
func.sum(StatsDaily.actual_total_cost).label("actual_total_cost"),
func.sum(StatsDaily.input_tokens + StatsDaily.output_tokens +
StatsDaily.cache_creation_tokens + StatsDaily.cache_read_tokens).label("total_tokens"),
func.sum(
StatsDaily.input_tokens
+ StatsDaily.output_tokens
+ StatsDaily.cache_creation_tokens
+ StatsDaily.cache_read_tokens
).label("total_tokens"),
func.sum(StatsDaily.cache_creation_tokens).label("cache_creation_tokens"),
func.sum(StatsDaily.cache_read_tokens).label("cache_read_tokens"),
func.sum(StatsDaily.cache_creation_cost).label("cache_creation_cost"),
@@ -298,7 +327,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
total_cost = float(monthly_stats.total_cost or 0) + cost_today
total_actual_cost = float(monthly_stats.actual_total_cost or 0) + actual_cost_today
total_tokens = int(monthly_stats.total_tokens or 0) + tokens_today
cache_creation_tokens = int(monthly_stats.cache_creation_tokens or 0) + cache_creation_today
cache_creation_tokens = (
int(monthly_stats.cache_creation_tokens or 0) + cache_creation_today
)
cache_read_tokens = int(monthly_stats.cache_read_tokens or 0) + cache_read_today
cache_creation_cost = float(monthly_stats.cache_creation_cost or 0)
cache_read_cost = float(monthly_stats.cache_read_cost or 0)
@@ -309,21 +340,31 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
db.query(func.count(Usage.id)).filter(Usage.created_at >= month_start).scalar() or 0
)
total_cost = (
db.query(func.sum(Usage.total_cost_usd)).filter(Usage.created_at >= month_start).scalar() or 0
db.query(func.sum(Usage.total_cost_usd))
.filter(Usage.created_at >= month_start)
.scalar()
or 0
)
total_actual_cost = (
db.query(func.sum(Usage.actual_total_cost_usd))
.filter(Usage.created_at >= month_start).scalar() or 0
.filter(Usage.created_at >= month_start)
.scalar()
or 0
)
error_requests = (
db.query(func.count(Usage.id))
.filter(
Usage.created_at >= month_start,
(Usage.status_code >= 400) | (Usage.error_message.isnot(None)),
).scalar() or 0
)
.scalar()
or 0
)
total_tokens = (
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= month_start).scalar() or 0
db.query(func.sum(Usage.total_tokens))
.filter(Usage.created_at >= month_start)
.scalar()
or 0
)
cache_stats = (
db.query(
@@ -335,7 +376,9 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
.filter(Usage.created_at >= month_start)
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_creation_tokens = (
int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
)
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
cache_creation_cost = float(cache_stats.cache_creation_cost or 0) if cache_stats else 0
cache_read_cost = float(cache_stats.cache_read_cost or 0) if cache_stats else 0
@@ -343,7 +386,8 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
# Fallback 统计
fallback_subquery = (
db.query(
RequestCandidate.request_id, func.count(RequestCandidate.id).label("executed_count")
RequestCandidate.request_id,
func.count(RequestCandidate.id).label("executed_count"),
)
.filter(
RequestCandidate.created_at >= month_start,
@@ -356,7 +400,8 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
db.query(func.count())
.select_from(fallback_subquery)
.filter(fallback_subquery.c.executed_count > 1)
.scalar() or 0
.scalar()
or 0
)
# ==================== 系统健康指标 ====================
@@ -370,7 +415,8 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
Usage.status_code == 200,
Usage.response_time_ms.isnot(None),
)
.scalar() or 0
.scalar()
or 0
)
avg_response_time_seconds = float(avg_response_time) / 1000.0
@@ -427,18 +473,53 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
"subValue": f"输入 {format_tokens(all_time_input_tokens)} / 输出 {format_tokens(all_time_output_tokens)}",
"change": (
f"+{format_tokens(input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)}"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
> (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
else format_tokens(input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
if (
input_tokens_today
+ output_tokens_today
+ cache_creation_today
+ cache_read_today
)
> (
input_tokens_yesterday
+ output_tokens_yesterday
+ cache_creation_yesterday
+ cache_read_yesterday
)
else format_tokens(
input_tokens_today
+ output_tokens_today
+ cache_creation_today
+ cache_read_today
)
),
"changeType": (
"increase"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
> (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
if (
input_tokens_today
+ output_tokens_today
+ cache_creation_today
+ cache_read_today
)
> (
input_tokens_yesterday
+ output_tokens_yesterday
+ cache_creation_yesterday
+ cache_read_yesterday
)
else (
"decrease"
if (input_tokens_today + output_tokens_today + cache_creation_today + cache_read_today)
< (input_tokens_yesterday + output_tokens_yesterday + cache_creation_yesterday + cache_read_yesterday)
if (
input_tokens_today
+ output_tokens_today
+ cache_creation_today
+ cache_read_today
)
< (
input_tokens_yesterday
+ output_tokens_yesterday
+ cache_creation_yesterday
+ cache_read_yesterday
)
else "neutral"
)
),
@@ -515,6 +596,7 @@ class UserDashboardStatsAdapter(DashboardAdapter):
@cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db
@@ -790,9 +872,12 @@ class DashboardProviderStatusAdapter(DashboardAdapter):
class DashboardDailyStatsAdapter(DashboardAdapter):
days: int
@cache_result(key_prefix="dashboard:daily:stats", ttl=CacheTTL.DASHBOARD_DAILY, user_specific=True)
@cache_result(
key_prefix="dashboard:daily:stats", ttl=CacheTTL.DASHBOARD_DAILY, user_specific=True
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db
@@ -807,7 +892,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
today = today_local.astimezone(timezone.utc)
end_date_local = now_local.replace(hour=23, minute=59, second=59, microsecond=999999)
end_date = end_date_local.astimezone(timezone.utc)
start_date_local = (today_local - timedelta(days=self.days - 1))
start_date_local = today_local - timedelta(days=self.days - 1)
start_date = start_date_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据优化 ====================
@@ -819,6 +904,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
.order_by(StatsDaily.date.asc())
.all()
)
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 需要转回业务时区再取日期,才能与日期序列匹配
def _to_business_date_str(value: datetime) -> str:
@@ -831,11 +917,16 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
stats_map = {
_to_business_date_str(stat.date): {
"requests": stat.total_requests,
"tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens,
"tokens": stat.input_tokens
+ stat.output_tokens
+ stat.cache_creation_tokens
+ stat.cache_read_tokens,
"cost": stat.total_cost,
"avg_response_time": stat.avg_response_time_ms / 1000.0 if stat.avg_response_time_ms else 0,
"unique_models": getattr(stat, 'unique_models', 0) or 0,
"unique_providers": getattr(stat, 'unique_providers', 0) or 0,
"avg_response_time": (
stat.avg_response_time_ms / 1000.0 if stat.avg_response_time_ms else 0
),
"unique_models": getattr(stat, "unique_models", 0) or 0,
"unique_providers": getattr(stat, "unique_providers", 0) or 0,
"fallback_count": stat.fallback_count or 0,
}
for stat in daily_stats
@@ -849,18 +940,21 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
today_avg_rt = (
db.query(func.avg(Usage.response_time_ms))
.filter(Usage.created_at >= today, Usage.response_time_ms.isnot(None))
.scalar() or 0
.scalar()
or 0
)
# 今日 unique_models 和 unique_providers
today_unique_models = (
db.query(func.count(func.distinct(Usage.model)))
.filter(Usage.created_at >= today)
.scalar() or 0
.scalar()
or 0
)
today_unique_providers = (
db.query(func.count(func.distinct(Usage.provider_name)))
.filter(Usage.created_at >= today)
.scalar() or 0
.scalar()
or 0
)
# 今日 fallback_count
today_fallback_count = (
@@ -875,12 +969,17 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
.having(func.count(RequestCandidate.id) > 1)
.subquery()
)
.scalar() or 0
.scalar()
or 0
)
stats_map[today_str] = {
"requests": today_stats["total_requests"],
"tokens": (today_stats["input_tokens"] + today_stats["output_tokens"] +
today_stats["cache_creation_tokens"] + today_stats["cache_read_tokens"]),
"tokens": (
today_stats["input_tokens"]
+ today_stats["output_tokens"]
+ today_stats["cache_creation_tokens"]
+ today_stats["cache_read_tokens"]
),
"cost": today_stats["total_cost"],
"avg_response_time": float(today_avg_rt) / 1000.0 if today_avg_rt else 0,
"unique_models": today_unique_models,
@@ -912,9 +1011,11 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
+ computed["cache_read_tokens"]
),
"cost": computed["total_cost"],
"avg_response_time": computed["avg_response_time_ms"] / 1000.0
if computed["avg_response_time_ms"]
else 0,
"avg_response_time": (
computed["avg_response_time_ms"] / 1000.0
if computed["avg_response_time_ms"]
else 0
),
"unique_models": computed["unique_models"],
"unique_providers": computed["unique_providers"],
"fallback_count": computed["fallback_count"],
@@ -947,7 +1048,9 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
"avg_response_time": float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0,
"avg_response_time": (
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
),
}
for stat in user_daily_stats
}
@@ -1007,16 +1110,25 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
"requests": 0,
"tokens": 0,
"cost": 0.0,
"total_response_time": 0.0,
"response_count": 0,
}
model_agg[model]["requests"] += stat.total_requests
tokens = (stat.input_tokens + stat.output_tokens +
stat.cache_creation_tokens + stat.cache_read_tokens)
tokens = (
stat.input_tokens
+ stat.output_tokens
+ stat.cache_creation_tokens
+ stat.cache_read_tokens
)
model_agg[model]["tokens"] += tokens
model_agg[model]["cost"] += stat.total_cost
if stat.avg_response_time_ms is not None:
model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests
model_agg[model]["total_response_time"] += (
stat.avg_response_time_ms * stat.total_requests
)
model_agg[model]["response_count"] += stat.total_requests
# 按日期分组
@@ -1026,12 +1138,14 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
date_utc = stat.date.astimezone(timezone.utc)
date_str = date_utc.astimezone(app_tz).date().isoformat()
daily_breakdown.setdefault(date_str, []).append({
"model": model,
"requests": stat.total_requests,
"tokens": tokens,
"cost": stat.total_cost,
})
daily_breakdown.setdefault(date_str, []).append(
{
"model": model,
"requests": stat.total_requests,
"tokens": tokens,
"cost": stat.total_cost,
}
)
# 今日实时模型统计
today_model_stats = (
@@ -1052,38 +1166,50 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
model = stat.model
if model not in model_agg:
model_agg[model] = {
"requests": 0, "tokens": 0, "cost": 0.0,
"total_response_time": 0.0, "response_count": 0
"requests": 0,
"tokens": 0,
"cost": 0.0,
"total_response_time": 0.0,
"response_count": 0,
}
model_agg[model]["requests"] += stat.requests or 0
model_agg[model]["tokens"] += int(stat.tokens or 0)
model_agg[model]["cost"] += float(stat.cost or 0)
if stat.avg_response_time is not None:
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0)
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (
stat.requests or 0
)
model_agg[model]["response_count"] += stat.requests or 0
# 今日 breakdown
daily_breakdown.setdefault(today_str, []).append({
"model": model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
})
daily_breakdown.setdefault(today_str, []).append(
{
"model": model,
"requests": stat.requests or 0,
"tokens": int(stat.tokens or 0),
"cost": float(stat.cost or 0),
}
)
# 构建 model_summary
model_summary = []
for model, agg in model_agg.items():
avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0
if agg["response_count"] > 0 else 0)
model_summary.append({
"model": model,
"requests": agg["requests"],
"tokens": agg["tokens"],
"cost": agg["cost"],
"avg_response_time": avg_rt,
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
})
avg_rt = (
agg["total_response_time"] / agg["response_count"] / 1000.0
if agg["response_count"] > 0
else 0
)
model_summary.append(
{
"model": model,
"requests": agg["requests"],
"tokens": agg["tokens"],
"cost": agg["cost"],
"avg_response_time": avg_rt,
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
}
)
model_summary.sort(key=lambda x: x["cost"], reverse=True)
# 填充 model_breakdown
@@ -1096,7 +1222,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
and_(
Usage.user_id == user.id,
Usage.created_at >= start_date,
Usage.created_at <= end_date
Usage.created_at <= end_date,
)
)
@@ -1166,7 +1292,9 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
# 历史数据从 stats_daily_provider 获取
historical_provider_stats = (
db.query(StatsDailyProvider)
.filter(and_(StatsDailyProvider.date >= start_date, StatsDailyProvider.date < today))
.filter(
and_(StatsDailyProvider.date >= start_date, StatsDailyProvider.date < today)
)
.all()
)
@@ -1177,8 +1305,12 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
if provider not in provider_agg:
provider_agg[provider] = {"requests": 0, "tokens": 0, "cost": 0.0}
provider_agg[provider]["requests"] += stat.total_requests
tokens = (stat.input_tokens + stat.output_tokens +
stat.cache_creation_tokens + stat.cache_read_tokens)
tokens = (
stat.input_tokens
+ stat.output_tokens
+ stat.cache_creation_tokens
+ stat.cache_read_tokens
)
provider_agg[provider]["tokens"] += tokens
provider_agg[provider]["cost"] += stat.total_cost

View File

@@ -27,9 +27,9 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable, Coroutine
from typing import (
TYPE_CHECKING,
Any,
@@ -38,18 +38,14 @@ from typing import (
runtime_checkable,
)
from collections.abc import Callable
from collections.abc import Awaitable, Coroutine
from fastapi import Request
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
from src.clients.redis_client import get_redis_client_sync
from src.core.api_format import APIFormat, resolve_api_format
from src.core.logger import logger
from src.services.orchestration.fallback_orchestrator import FallbackOrchestrator
from src.services.provider.format import normalize_api_format
from src.services.provider.format import normalize_endpoint_signature
from src.services.system.audit import audit_service
from src.services.usage.service import UsageService
@@ -238,7 +234,9 @@ class MessageTelemetry:
"""
provider_name = provider or "unknown"
if provider_name == "unknown":
logger.warning(f"[Telemetry] Recording failure with unknown provider (request_id={self.request_id})")
logger.warning(
f"[Telemetry] Recording failure with unknown provider (request_id={self.request_id})"
)
await UsageService.record_usage(
db=self.db,
@@ -393,8 +391,9 @@ class BaseMessageHandler:
self.client_ip = client_ip
self.user_agent = user_agent
self.start_time = start_time
self.allowed_api_formats = allowed_api_formats or [APIFormat.CLAUDE.value]
self.primary_api_format = normalize_api_format(self.allowed_api_formats[0])
# 新模式endpoint signature keyfamily:kind如 "claude:chat"
self.allowed_api_formats = allowed_api_formats or ["claude:chat"]
self.primary_api_format = normalize_endpoint_signature(self.allowed_api_formats[0])
self.adapter_detector = adapter_detector
redis_client = get_redis_client_sync()
@@ -446,13 +445,6 @@ class BaseMessageHandler:
"""可选的 Key 优先级解析钩子(默认不启用)。"""
return None
def get_api_format(self, provider_type: str | None = None) -> APIFormat:
"""根据 provider_type 解析 API 格式,未知类型默认 OPENAI"""
if provider_type:
result = resolve_api_format(provider_type, default=APIFormat.OPENAI)
return result or APIFormat.OPENAI
return self.primary_api_format
def build_provider_payload(
self,
original_body: dict[str, Any],
@@ -477,6 +469,7 @@ class BaseMessageHandler:
request_id: 请求 ID如果不传则使用 self.request_id
"""
import asyncio
from src.database.database import get_db
target_request_id = request_id or self.request_id
@@ -511,6 +504,7 @@ class BaseMessageHandler:
ctx: 流式上下文,包含 provider 相关信息
"""
import asyncio
from src.database.database import get_db
target_request_id = self.request_id
@@ -567,14 +561,23 @@ class BaseMessageHandler:
error: 异常对象
"""
from src.core.exceptions import (
ModelNotSupportedException,
ProviderException,
QuotaExceededException,
RateLimitException,
ModelNotSupportedException,
UpstreamClientException,
)
if isinstance(error, (ProviderException, QuotaExceededException, RateLimitException, ModelNotSupportedException, UpstreamClientException)):
if isinstance(
error,
(
ProviderException,
QuotaExceededException,
RateLimitException,
ModelNotSupportedException,
UpstreamClientException,
),
):
# 业务异常:简洁日志,不打印堆栈
logger.error(f"{message}: [{type(error).__name__}] {error}")
else:

View File

@@ -21,7 +21,7 @@ from __future__ import annotations
import time
import traceback
from abc import abstractmethod
from typing import Any
from typing import Any, ClassVar
import httpx
from fastapi import HTTPException, Request
@@ -32,12 +32,13 @@ from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import (
APIFormat,
build_adapter_base_headers,
build_adapter_headers,
get_adapter_protected_keys,
ApiFamily,
EndpointKind,
build_adapter_base_headers_for_endpoint,
build_adapter_headers_for_endpoint,
get_adapter_protected_keys_for_endpoint,
get_auth_handler,
get_default_auth_method,
get_default_auth_method_for_endpoint,
)
from src.core.exceptions import (
InvalidRequestException,
@@ -70,6 +71,10 @@ class ChatAdapterBase(ApiAdapter):
FORMAT_ID: str = "UNKNOWN"
HANDLER_CLASS: type[ChatHandlerBase]
# 新架构:结构化标识(逐步替代直接依赖 FORMAT_ID 的语义)
API_FAMILY: ClassVar[ApiFamily | None] = None
ENDPOINT_KIND: ClassVar[EndpointKind] = EndpointKind.CHAT
# 适配器配置
name: str = "chat.base"
mode = ApiMode.STANDARD
@@ -77,14 +82,6 @@ class ChatAdapterBase(ApiAdapter):
# 计费模板配置(子类可覆盖,如 "claude", "openai", "gemini"
BILLING_TEMPLATE: str = "claude"
@classmethod
def _get_api_format(cls) -> APIFormat:
"""获取 API 格式枚举,用于调用 headers.py 的统一函数"""
try:
return APIFormat[cls.FORMAT_ID]
except KeyError:
return APIFormat.OPENAI # 默认回退
# 子类可以配置的特殊方法用于check_endpoint
@classmethod
def build_endpoint_url(cls, base_url: str) -> str:
@@ -95,19 +92,19 @@ class ChatAdapterBase(ApiAdapter):
@classmethod
def build_base_headers(cls, api_key: str) -> dict[str, str]:
"""构建基础请求头,使用统一的 headers.py 实现"""
return build_adapter_base_headers(cls._get_api_format(), api_key)
return build_adapter_base_headers_for_endpoint(cls.FORMAT_ID, api_key)
@classmethod
def get_protected_header_keys(cls) -> tuple:
"""返回不应被extra_headers覆盖的头部key使用统一的 headers.py 实现"""
return get_adapter_protected_keys(cls._get_api_format())
return get_adapter_protected_keys_for_endpoint(cls.FORMAT_ID)
@classmethod
def build_headers_with_extra(
cls, api_key: str, extra_headers: dict[str, str] | None = None
) -> dict[str, str]:
"""构建完整请求头(包含 extra_headers使用统一的 headers.py 实现"""
return build_adapter_headers(cls._get_api_format(), api_key, extra_headers)
return build_adapter_headers_for_endpoint(cls.FORMAT_ID, api_key, extra_headers)
@classmethod
def build_request_body(cls, request_data: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -125,7 +122,7 @@ class ChatAdapterBase(ApiAdapter):
def extract_api_key(self, request: Request) -> str | None:
"""从请求中提取 API 密钥,使用 AuthHandler 新流程"""
auth_method = get_default_auth_method(self._get_api_format())
auth_method = get_default_auth_method_for_endpoint(self.FORMAT_ID)
handler = get_auth_handler(auth_method)
return handler.extract_credentials(request)
@@ -742,7 +739,7 @@ def get_adapter_class(api_format: str) -> type[ChatAdapterBase] | None:
根据 API format 获取 Adapter 类
Args:
api_format: API 格式标识(如 "CLAUDE", "OPENAI", "GEMINI"
api_format: API 格式标识(如 "openai:chat", "claude:chat", "gemini:chat"
Returns:
对应的 Adapter 类,如果未找到返回 None

View File

@@ -413,17 +413,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
mapped_model: 映射后的模型名
fallback_model: 兜底模型名(无映射时使用)
"""
from src.core.api_format import APIFormat
from src.core.api_format.metadata import API_FORMAT_DEFINITIONS
from src.core.api_format.metadata import resolve_endpoint_definition
try:
target_format = APIFormat(provider_api_format.upper())
target_meta = API_FORMAT_DEFINITIONS.get(target_format)
if target_meta and target_meta.model_in_body:
request_body["model"] = mapped_model or fallback_model
except (ValueError, KeyError):
# 未知格式,默认设置 model 字段
target_meta = resolve_endpoint_definition(provider_api_format)
if target_meta is None:
# 未知格式,保守处理:默认设置 model
request_body["model"] = mapped_model or fallback_model
return
if target_meta.model_in_body:
request_body["model"] = mapped_model or fallback_model
else:
request_body.pop("model", None)
def _set_stream_after_conversion(
self,
@@ -444,26 +445,26 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_api_format: Provider 侧 API 格式
is_stream: 是否为流式请求
"""
from src.core.api_format import APIFormat
from src.core.api_format.metadata import API_FORMAT_DEFINITIONS
from src.core.api_format.metadata import resolve_endpoint_definition
try:
client_format = APIFormat(client_api_format.upper())
provider_format = APIFormat(provider_api_format.upper())
client_meta = resolve_endpoint_definition(client_api_format)
provider_meta = resolve_endpoint_definition(provider_api_format)
client_meta = API_FORMAT_DEFINITIONS.get(client_format)
provider_meta = API_FORMAT_DEFINITIONS.get(provider_format)
# 默认stream_in_body=True如 OpenAI/Claude
client_uses_stream = client_meta.stream_in_body if client_meta else True
provider_uses_stream = provider_meta.stream_in_body if provider_meta else True
# 如果客户端格式不使用 stream 字段,但 Provider 格式需要
client_uses_stream = client_meta.stream_in_body if client_meta else True
provider_uses_stream = provider_meta.stream_in_body if provider_meta else True
# Provider 不使用 stream 字段(如 Gemini确保移除
if not provider_uses_stream:
request_body.pop("stream", None)
return
if not client_uses_stream and provider_uses_stream:
request_body["stream"] = is_stream
except (ValueError, KeyError):
# 未知格式,保守处理:如果请求体中没有 stream 字段则设置
if "stream" not in request_body:
request_body["stream"] = is_stream
# 如果客户端格式不使用 stream 字段,但 Provider 格式需要:补齐
if not client_uses_stream and provider_uses_stream:
request_body["stream"] = is_stream
elif "stream" not in request_body:
# 保守兜底:目标需要 stream 且当前缺失时写入
request_body["stream"] = is_stream
async def _get_mapped_model(
self,

View File

@@ -19,7 +19,7 @@ from __future__ import annotations
import time
import traceback
from typing import Any
from typing import Any, ClassVar
import httpx
from fastapi import HTTPException, Request
@@ -30,12 +30,13 @@ from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.core.api_format import (
APIFormat,
build_adapter_base_headers,
build_adapter_headers,
get_adapter_protected_keys,
ApiFamily,
EndpointKind,
build_adapter_base_headers_for_endpoint,
build_adapter_headers_for_endpoint,
get_adapter_protected_keys_for_endpoint,
get_auth_handler,
get_default_auth_method,
get_default_auth_method_for_endpoint,
)
from src.core.exceptions import (
InvalidRequestException,
@@ -68,6 +69,10 @@ class CliAdapterBase(ApiAdapter):
FORMAT_ID: str = "UNKNOWN"
HANDLER_CLASS: type[CliMessageHandlerBase]
# 新架构:结构化标识(逐步替代直接依赖 FORMAT_ID 的语义)
API_FAMILY: ClassVar[ApiFamily | None] = None
ENDPOINT_KIND: ClassVar[EndpointKind] = EndpointKind.CLI
# 适配器配置
name: str = "cli.base"
mode = ApiMode.PROXY
@@ -82,21 +87,13 @@ class CliAdapterBase(ApiAdapter):
# API 格式与头部处理 - 使用统一的 headers.py 函数
# =========================================================================
@classmethod
def _get_api_format(cls) -> APIFormat:
"""将 FORMAT_ID 转换为 APIFormat 枚举"""
try:
return APIFormat[cls.FORMAT_ID]
except KeyError:
return APIFormat.OPENAI
def extract_api_key(self, request: Request) -> str | None:
"""
从请求中提取 API 密钥
使用 AuthHandler 新流程,根据 API 格式选择认证方式。
"""
auth_method = get_default_auth_method(self._get_api_format())
auth_method = get_default_auth_method_for_endpoint(self.FORMAT_ID)
handler = get_auth_handler(auth_method)
return handler.extract_credentials(request)
@@ -107,7 +104,7 @@ class CliAdapterBase(ApiAdapter):
使用统一的头部处理函数。
"""
return build_adapter_base_headers(cls._get_api_format(), api_key)
return build_adapter_base_headers_for_endpoint(cls.FORMAT_ID, api_key)
@classmethod
def build_headers_with_extra(
@@ -118,7 +115,7 @@ class CliAdapterBase(ApiAdapter):
使用统一的头部处理函数,自动保护关键头部不被覆盖。
"""
return build_adapter_headers(cls._get_api_format(), api_key, extra_headers)
return build_adapter_headers_for_endpoint(cls.FORMAT_ID, api_key, extra_headers)
@classmethod
def get_protected_header_keys(cls) -> tuple[str, ...]:
@@ -127,7 +124,7 @@ class CliAdapterBase(ApiAdapter):
使用统一的头部处理函数。
"""
return get_adapter_protected_keys(cls._get_api_format())
return get_adapter_protected_keys_for_endpoint(cls.FORMAT_ID)
async def handle(self, context: ApiRequestContext) -> Any:
"""处理 CLI API 请求"""
@@ -778,7 +775,15 @@ def _ensure_cli_adapters_loaded() -> None:
def get_cli_adapter_class(api_format: str) -> type[CliAdapterBase] | None:
"""根据 API format 获取 CLI Adapter 类"""
"""
根据 API format 获取 CLI Adapter 类
Args:
api_format: API 格式标识(如 "openai:cli", "claude:cli", "gemini:cli"
Returns:
对应的 CLI Adapter 类,如果未找到返回 None
"""
_ensure_cli_adapters_loaded()
return _CLI_ADAPTER_REGISTRY.get(api_format.upper()) if api_format else None

View File

@@ -16,21 +16,19 @@ import asyncio
import codecs
import json
import time
from collections.abc import AsyncGenerator, Callable
from typing import (
TYPE_CHECKING,
Any,
)
from collections.abc import Callable
from collections.abc import AsyncGenerator
import httpx
from fastapi import BackgroundTasks, Request
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.orm import Session
if TYPE_CHECKING:
from src.core.api_format import ApiFormatDefinition
from src.core.api_format import EndpointDefinition
from src.api.handlers.base.base_handler import (
BaseMessageHandler,
@@ -78,7 +76,6 @@ from src.services.provider.transport import build_provider_url
from src.utils.sse_parser import SSEEventParser
from src.utils.timeout import read_first_chunk_with_ttfb_timeout
# ==============================================================================
# SSE 行解析辅助函数
# ==============================================================================
@@ -163,7 +160,7 @@ def _format_converted_events_to_sse(
SSE 行列表(每个元素是完整的 SSE 事件,包含尾部空行)
"""
result: list[str] = []
needs_event_line = client_format.upper() in ("CLAUDE", "CLAUDE_CLI")
needs_event_line = str(client_format or "").strip().lower().startswith("claude:")
for evt in converted_events:
payload = json.dumps(evt, ensure_ascii=False)
@@ -357,16 +354,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
return request_body
@staticmethod
def _get_format_metadata(format_id: str) -> ApiFormatDefinition | None:
"""获取格式元数据(解析失败返回 None"""
from src.core.api_format import APIFormat
from src.core.api_format.metadata import API_FORMAT_DEFINITIONS
def _get_format_metadata(format_id: str) -> "EndpointDefinition | None":
"""获取 endpoint 元数据(解析失败返回 None"""
from src.core.api_format.metadata import resolve_endpoint_definition
try:
fmt = APIFormat(format_id.upper())
return API_FORMAT_DEFINITIONS.get(fmt)
except (ValueError, KeyError):
return None
return resolve_endpoint_definition(format_id)
def _finalize_converted_request(
self,
@@ -447,7 +439,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
url_model = self.get_model_for_url(converted_body, mapped_model) or mapped_model or fallback_model
url_model = (
self.get_model_for_url(converted_body, mapped_model) or mapped_model or fallback_model
)
# 统一设置并清理 model/stream 字段
self._finalize_converted_request(
@@ -704,7 +698,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
else str(ctx.client_api_format)
)
provider_api_format = str(ctx.provider_api_format or "")
needs_conversion = bool(getattr(candidate, "needs_conversion", False)) if candidate else False
needs_conversion = (
bool(getattr(candidate, "needs_conversion", False)) if candidate else False
)
ctx.needs_conversion = needs_conversion
# 跨格式:先做请求体转换(失败触发 failover
@@ -720,7 +716,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
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 ctx.model
url_model = (
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
@@ -964,7 +962,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 格式转换或直接透传
if needs_conversion:
converted_lines, converted_events = self._convert_sse_line(ctx, line, events)
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:
@@ -1015,8 +1015,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
else:
logger.debug("流式数据转发完成")
# 为 OpenAI 客户端补齐 [DONE] 标记(非 CLI 格式)
client_fmt = (ctx.client_api_format or "").upper()
if needs_conversion and client_fmt == "OPENAI":
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:
@@ -1306,7 +1306,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 格式转换或直接透传
if needs_conversion:
converted_lines, converted_events = self._convert_sse_line(ctx, line, events)
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:
@@ -1383,7 +1385,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 格式转换或直接透传
if needs_conversion:
converted_lines, converted_events = self._convert_sse_line(ctx, line, events)
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:
@@ -1437,8 +1441,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
else:
logger.debug("流式数据转发完成")
# 为 OpenAI 客户端补齐 [DONE] 标记(非 CLI 格式)
client_fmt = (ctx.client_api_format or "").upper()
if needs_conversion and client_fmt == "OPENAI":
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:
@@ -1693,19 +1697,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
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
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(
f"[{ctx.request_id}] 从转换后事件更新 input_tokens: {new_input}"
)
logger.debug(f"[{ctx.request_id}] 从转换后事件更新 input_tokens: {new_input}")
if new_output > ctx.output_tokens:
ctx.output_tokens = new_output
logger.debug(
f"[{ctx.request_id}] 从转换后事件更新 output_tokens: {new_output}"
)
logger.debug(f"[{ctx.request_id}] 从转换后事件更新 output_tokens: {new_output}")
if new_cached > ctx.cached_tokens:
ctx.cached_tokens = new_cached
if new_cache_creation > ctx.cache_creation_tokens:
@@ -1969,9 +1971,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
# Provider 响应元数据(如 Gemini 的 modelVersion
response_metadata=ctx.response_metadata if ctx.response_metadata else None,
)
logger.debug(
f"[{ctx.request_id}] Usage 记录完成: cost=${total_cost:.6f}"
)
logger.debug(f"[{ctx.request_id}] Usage 记录完成: cost=${total_cost:.6f}")
# 简洁的请求完成摘要(两行格式)
line1 = f"[OK] {self.request_id[:8]} | {ctx.model} | {ctx.provider_name}"
if ctx.first_byte_time_ms:
@@ -2186,7 +2186,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
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
url_model = (
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
)
# 获取认证信息(处理 Service Account 等异步认证场景)
auth_info = await get_provider_auth(endpoint, key)
@@ -2532,7 +2534,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
- CLAUDE 和 CLAUDE_CLI、GEMINI 和 GEMINI_CLI格式相同只是认证不同可透传
- OPENAI 和 OPENAI_CLI格式不同Chat Completions vs Responses API需要转换
"""
from src.core.api_format.utils import get_base_format
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(
@@ -2541,8 +2544,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
return False
provider_format = str(ctx.provider_api_format).upper()
client_format = str(ctx.client_api_format).upper()
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:
@@ -2552,26 +2555,18 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
return False
# 2. 同族格式检查
provider_base = get_base_format(provider_format)
client_base = get_base_format(client_format)
if provider_base == client_base:
# OPENAI 和 OPENAI_CLI 的请求/响应格式不同,需要转换
# CLAUDE 和 CLAUDE_CLI、GEMINI 和 GEMINI_CLI 格式相同,可透传
result = provider_base == "OPENAI"
# 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}(base={provider_base}), "
f"client={client_format}(base={client_base}) -> {result} (same family, OPENAI needs conversion)"
f"provider={provider_format}, client={client_format} -> False (passthroughable)"
)
return result
return False
# 3. 跨格式 -> 需要转换
# 3. 其他情况 -> 需要转换
logger.debug(
f"[{getattr(ctx, 'request_id', 'unknown')}] _needs_format_conversion: "
f"provider={provider_format}(base={provider_base}), "
f"client={client_format}(base={client_base}) -> True (cross-format)"
f"provider={provider_format}, client={client_format} -> True"
)
return True
@@ -2617,17 +2612,17 @@ class CliMessageHandlerBase(BaseMessageHandler):
if not line or line.strip() == "":
return ([line] if line else [], [])
client_format = (ctx.client_api_format or "").upper()
client_format = (ctx.client_api_format or "").strip().lower()
# [DONE] 标记处理:只有 OpenAI 客户端需要Claude 客户端不需要
if line == "data: [DONE]":
if client_format.startswith("OPENAI"):
if client_format.startswith("openai"):
return [line], []
else:
# Claude/Gemini 客户端不需要 [DONE] 标记
return [], []
provider_format = (ctx.provider_api_format or "").upper()
provider_format = (ctx.provider_api_format or "").strip().lower()
# 过滤上游控制行id/retry避免与目标格式混淆
if line.startswith(("id:", "retry:")):
@@ -2682,9 +2677,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
logger.warning(f"格式转换失败,透传原始数据: {e}")
return [line], []
def _parse_sse_line_to_json(
self, line: str, provider_format: str
) -> tuple[Any | None, str]:
def _parse_sse_line_to_json(self, line: str, provider_format: str) -> tuple[Any | None, str]:
"""
解析 SSE 行为 JSON 对象
@@ -2718,9 +2711,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
return None, "skip"
# Gemini JSON-array 格式
if provider_format == "GEMINI":
if provider_format.startswith("gemini"):
return _parse_gemini_json_array_line(line)
# 其他格式:无法识别,透传
return None, "passthrough"

View File

@@ -15,18 +15,23 @@
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from collections.abc import Iterable
import asyncio
import json
import time
import uuid
import json
import asyncio
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import httpx
from src.core.api_format import (
CORE_REDACT_HEADERS,
merge_headers_with_protection,
redact_headers_for_log,
)
from src.core.logger import logger
from src.core.api_format import CORE_REDACT_HEADERS, merge_headers_with_protection, redact_headers_for_log
from src.utils.ssl_utils import get_ssl_context
@@ -88,7 +93,7 @@ async def run_endpoint_check(
provider_id=provider_id,
db=db,
user=user,
request_id=str(uuid.uuid4())[:8]
request_id=str(uuid.uuid4())[:8],
)
# 使用协调器执行检查
@@ -114,6 +119,7 @@ async def run_endpoint_check(
return response_data
async def _calculate_and_record_usage(
*,
db: Any,
@@ -146,9 +152,9 @@ async def _calculate_and_record_usage(
Returns:
Dict包含用量统计信息
"""
from src.services.usage.service import UsageService
from src.services.request.candidate import RequestCandidateService
from src.models.database import ApiKey, ProviderAPIKey
from src.services.request.candidate import RequestCandidateService
from src.services.usage.service import UsageService
# 获取Provider API Key对象不是用户API Key
provider_api_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == api_key_id).first()
@@ -160,6 +166,7 @@ async def _calculate_and_record_usage(
provider_endpoint = None
if api_format and provider_api_key.provider_id:
from src.models.database import Provider
provider = db.query(Provider).filter(Provider.id == provider_api_key.provider_id).first()
if provider:
for ep in provider.endpoints:
@@ -172,7 +179,9 @@ async def _calculate_and_record_usage(
if user:
try:
user_api_key = db.query(ApiKey).filter(ApiKey.user_id == user.id).first()
logger.info(f"[endpoint_check] User API Key found: {user_api_key.id if user_api_key else None}")
logger.info(
f"[endpoint_check] User API Key found: {user_api_key.id if user_api_key else None}"
)
except Exception as e:
logger.warning(f"[endpoint_check] Failed to get user API Key: {e}")
user_api_key = None
@@ -181,7 +190,12 @@ async def _calculate_and_record_usage(
# 用量记录会关联到执行测试的用户但实际的API调用使用Provider的配置
# Token计数 - 优先使用直接传递的数据,否则使用原有逻辑
if input_tokens is None or output_tokens is None or cache_creation_input_tokens is None or cache_read_input_tokens is None:
if (
input_tokens is None
or output_tokens is None
or cache_creation_input_tokens is None
or cache_read_input_tokens is None
):
# 使用原有逻辑计算token
logger.info(f"[endpoint_check] Calculating tokens from response data")
@@ -191,7 +205,7 @@ async def _calculate_and_record_usage(
usage_info = response_data.get("usage", {})
if not api_format:
api_format = "OPENAI"
api_format = "openai:chat"
logger.info(f"[endpoint_check] Detected API format: {api_format}")
@@ -199,8 +213,9 @@ async def _calculate_and_record_usage(
logger.info(f"[endpoint_check] Found usage field in response: {usage_info}")
# 使用提取函数获取token数据
api_identifier = provider_name # 在这个旧函数中我们只能使用provider_name
extracted_input, extracted_output, extracted_cache_creation, extracted_cache_read = \
extracted_input, extracted_output, extracted_cache_creation, extracted_cache_read = (
_extract_tokens_from_response(api_identifier, response_data)
)
input_tokens = input_tokens or extracted_input
output_tokens = output_tokens or extracted_output
@@ -209,10 +224,13 @@ async def _calculate_and_record_usage(
else:
# 如果没有usage字段使用fallback
logger.warning(f"[endpoint_check] No usage field found in response, using fallback counting")
logger.warning(
f"[endpoint_check] No usage field found in response, using fallback counting"
)
try:
fallback_input, fallback_output, fallback_cache_creation, fallback_cache_read = \
fallback_input, fallback_output, fallback_cache_creation, fallback_cache_read = (
_fallback_token_counting(request_data, response_data)
)
input_tokens = input_tokens or fallback_input
output_tokens = output_tokens or fallback_output
@@ -226,16 +244,20 @@ async def _calculate_and_record_usage(
cache_creation_input_tokens = cache_creation_input_tokens or 0
cache_read_input_tokens = cache_read_input_tokens or 0
logger.info(f"[endpoint_check] Final token count | input={input_tokens}, output={output_tokens}, "
f"cache_creation={cache_creation_input_tokens}, cache_read={cache_read_input_tokens}")
logger.info(
f"[endpoint_check] Final token count | input={input_tokens}, output={output_tokens}, "
f"cache_creation={cache_creation_input_tokens}, cache_read={cache_read_input_tokens}"
)
try:
# 使用UsageService记录用量
# 测试请求会关联到执行测试的用户API Key但实际使用Provider API Key
logger.info(f"[endpoint_check] Recording usage | provider={provider_name}, model={model_name}, "
f"tokens=({input_tokens}+{output_tokens}), status={status_code}, "
f"user_api_key_id={user_api_key.id if user_api_key else None}, "
f"provider_endpoint_id={provider_endpoint.id if provider_endpoint else None}")
logger.info(
f"[endpoint_check] Recording usage | provider={provider_name}, model={model_name}, "
f"tokens=({input_tokens}+{output_tokens}), status={status_code}, "
f"user_api_key_id={user_api_key.id if user_api_key else None}, "
f"provider_endpoint_id={provider_endpoint.id if provider_endpoint else None}"
)
usage_record = await UsageService.record_usage_async(
db=db,
@@ -269,7 +291,9 @@ async def _calculate_and_record_usage(
# 检查费用计算是否成功
total_cost = float(usage_record.total_cost_usd) if usage_record.total_cost_usd else 0.0
actual_cost = float(usage_record.actual_total_cost_usd) if usage_record.actual_total_cost_usd else 0.0
actual_cost = (
float(usage_record.actual_total_cost_usd) if usage_record.actual_total_cost_usd else 0.0
)
cache_cost = float(usage_record.cache_cost_usd) if usage_record.cache_cost_usd else 0.0
# 如果费用为0但Token不为0可能是价格配置缺失使用默认价格
@@ -280,9 +304,11 @@ async def _calculate_and_record_usage(
total_cost = ((input_tokens + output_tokens) / 1_000_000) * fallback_price_per_1m
actual_cost = total_cost # 测试请求使用实际成本
logger.info(f"[endpoint_check] Usage recorded successfully | "
f"usage_id={usage_record.id}, total_cost=${total_cost:.6f}, "
f"actual_cost=${actual_cost:.6f}")
logger.info(
f"[endpoint_check] Usage recorded successfully | "
f"usage_id={usage_record.id}, total_cost=${total_cost:.6f}, "
f"actual_cost=${actual_cost:.6f}"
)
# 创建RequestCandidate记录用于监控追踪API
try:
@@ -323,7 +349,9 @@ async def _calculate_and_record_usage(
extra_data={"model_name": model_name, "api_format": api_format},
)
logger.info(f"[endpoint_check] RequestCandidate created | request_id=test_{request_id}, candidate_id={candidate.id}")
logger.info(
f"[endpoint_check] RequestCandidate created | request_id=test_{request_id}, candidate_id={candidate.id}"
)
except Exception as e:
logger.warning(f"[endpoint_check] Failed to create RequestCandidate: {e}")
# 不影响主要功能
@@ -359,7 +387,9 @@ async def _calculate_and_record_usage(
}
def _extract_tokens_from_response(api_identifier: str, response_data: dict[str, Any] | None) -> tuple[int, int, int, int]:
def _extract_tokens_from_response(
api_identifier: str, response_data: dict[str, Any] | None
) -> tuple[int, int, int, int]:
"""
从响应中提取Token计数信息
@@ -395,6 +425,7 @@ def _extract_tokens_from_response(api_identifier: str, response_data: dict[str,
# 尝试提取cache creation tokens
try:
from src.api.handlers.base.utils import extract_cache_creation_tokens
cache_creation_input_tokens = extract_cache_creation_tokens(usage_info)
except Exception as e:
logger.warning(f"[endpoint_check] Failed to extract cache creation tokens: {e}")
@@ -402,14 +433,18 @@ def _extract_tokens_from_response(api_identifier: str, response_data: dict[str,
elif "openai" in api_identifier_lower:
# OpenAI格式
input_tokens = usage_info.get("prompt_tokens", 0) or usage_info.get("input_tokens", 0)
output_tokens = usage_info.get("completion_tokens", 0) or usage_info.get("output_tokens", 0)
output_tokens = usage_info.get("completion_tokens", 0) or usage_info.get(
"output_tokens", 0
)
cache_creation_input_tokens = 0
cache_read_input_tokens = 0
elif "gemini" in api_identifier_lower or "google" in api_identifier_lower:
# Gemini格式 - 使用与OpenAI类似的字段名
input_tokens = usage_info.get("prompt_tokens", 0) or usage_info.get("input_tokens", 0)
output_tokens = usage_info.get("completion_tokens", 0) or usage_info.get("output_tokens", 0)
output_tokens = usage_info.get("completion_tokens", 0) or usage_info.get(
"output_tokens", 0
)
cache_creation_input_tokens = 0
cache_read_input_tokens = 0
@@ -421,31 +456,38 @@ def _extract_tokens_from_response(api_identifier: str, response_data: dict[str,
cache_read_input_tokens = usage_info.get("cache_read_input_tokens", 0)
try:
from src.api.handlers.base.utils import extract_cache_creation_tokens
cache_creation_input_tokens = extract_cache_creation_tokens(usage_info)
except Exception as e:
logger.warning(f"[endpoint_check] Failed to extract cache creation tokens: {e}")
else:
# 默认情况:尝试通用提取
logger.warning(f"[endpoint_check] Unknown API identifier: {api_identifier}, using generic token extraction")
logger.warning(
f"[endpoint_check] Unknown API identifier: {api_identifier}, using generic token extraction"
)
input_tokens = usage_info.get("input_tokens", 0) or usage_info.get("prompt_tokens", 0)
output_tokens = usage_info.get("output_tokens", 0) or usage_info.get("completion_tokens", 0)
output_tokens = usage_info.get("output_tokens", 0) or usage_info.get(
"completion_tokens", 0
)
except Exception as e:
logger.warning(f"[endpoint_check] Error extracting tokens from response: {e}")
return 0, 0, 0, 0
logger.info(f"[endpoint_check] Tokens extracted from response | "
f"api_identifier={api_identifier}, "
f"input={input_tokens}, output={output_tokens}, "
f"cache_creation={cache_creation_input_tokens}, cache_read={cache_read_input_tokens}")
logger.info(
f"[endpoint_check] Tokens extracted from response | "
f"api_identifier={api_identifier}, "
f"input={input_tokens}, output={output_tokens}, "
f"cache_creation={cache_creation_input_tokens}, cache_read={cache_read_input_tokens}"
)
return input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens
def _fallback_token_counting(request_data: dict[str, Any], response_data: dict[str, Any] | None) -> tuple[int, int, int, int]:
def _fallback_token_counting(
request_data: dict[str, Any], response_data: dict[str, Any] | None
) -> tuple[int, int, int, int]:
"""
回退的Token计数方法简单估算
@@ -496,16 +538,21 @@ def _fallback_token_counting(request_data: dict[str, Any], response_data: dict[s
output_text += part["text"]
output_tokens = max(1, len(output_text.split()) // 4)
logger.info(f"[endpoint_check] Fallback token count | input={input_tokens}, output={output_tokens}")
logger.info(
f"[endpoint_check] Fallback token count | input={input_tokens}, output={output_tokens}"
)
return input_tokens, output_tokens, 0, 0
# =========================================================================
# 重构后的架构类 - 分离关注点
# =========================================================================
@dataclass
class EndpointCheckRequest:
"""端点检查请求数据类"""
url: str
headers: dict[str, str]
json_body: dict[str, Any]
@@ -523,6 +570,7 @@ class EndpointCheckRequest:
@dataclass
class EndpointCheckResult:
"""端点检查结果数据类"""
status_code: int
headers: dict[str, str]
response_time_ms: int
@@ -547,9 +595,7 @@ class HttpRequestExecutor:
# 使用httpx进行异步请求
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client:
response = await client.post(
url=request.url,
json=request.json_body,
headers=request.headers
url=request.url, json=request.json_body, headers=request.headers
)
end_time = time.time()
@@ -559,7 +605,9 @@ class HttpRequestExecutor:
if response.status_code == 200:
try:
response_data = response.json()
logger.debug(f"[{request.api_format}] check_endpoint | response | json={_truncate_repr(response_data)}")
logger.debug(
f"[{request.api_format}] check_endpoint | response | json={_truncate_repr(response_data)}"
)
except Exception:
response_data = None
logger.debug(f"[{request.api_format}] check_endpoint | response | invalid json")
@@ -569,18 +617,20 @@ class HttpRequestExecutor:
headers=dict(response.headers),
response_time_ms=response_time_ms,
request_id=request_id,
response_data=response_data
response_data=response_data,
)
else:
# 对于非200状态码使用错误处理器
error_body = response.text[:500] if response.text else "(empty)"
logger.debug(f"[{request.api_format}] check_endpoint | response | error={error_body}")
logger.debug(
f"[{request.api_format}] check_endpoint | response | error={error_body}"
)
# 创建HTTPStatusError让错误处理器处理
http_error = httpx.HTTPStatusError(
message=f"HTTP {response.status_code}: {error_body}",
request=None, # 我们不需要完整的request对象
response=response
response=response,
)
return await ErrorHandler.handle_error(http_error, request)
@@ -594,7 +644,9 @@ class UsageCalculator:
"""用量计算器 - 专门负责Token计数和费用计算"""
@staticmethod
def calculate_tokens(request: EndpointCheckRequest, result: EndpointCheckResult) -> tuple[int, int, int, int]:
def calculate_tokens(
request: EndpointCheckRequest, result: EndpointCheckResult
) -> tuple[int, int, int, int]:
"""
计算Token数量
@@ -612,7 +664,9 @@ class UsageCalculator:
return _extract_tokens_from_response(api_identifier, result.response_data)
@staticmethod
def _fallback_token_counting(request_data: dict[str, Any], response_data: dict[str, Any] | None) -> tuple[int, int, int, int]:
def _fallback_token_counting(
request_data: dict[str, Any], response_data: dict[str, Any] | None
) -> tuple[int, int, int, int]:
"""回退的Token计数方法简单估算"""
# 估算输入Token
messages = request_data.get("messages", request_data.get("contents", []))
@@ -658,6 +712,7 @@ class UsageCalculator:
return input_tokens, output_tokens, 0, 0
class AsyncBatchUsageRecorder:
"""异步用量记录器 - 批处理数据库操作"""
@@ -711,7 +766,9 @@ class AsyncBatchUsageRecorder:
# 目前保持简单的逐条插入,但减少了锁的竞争
for record in records_to_flush:
# 调用原有的用量记录逻辑(简化版)
logger.debug(f"[AsyncBatchUsageRecorder] Flushing usage record: {record.get('request_id', 'unknown')}")
logger.debug(
f"[AsyncBatchUsageRecorder] Flushing usage record: {record.get('request_id', 'unknown')}"
)
logger.info(f"[AsyncBatchUsageRecorder] Flushed {len(records_to_flush)} usage records")
except Exception as e:
@@ -741,6 +798,7 @@ class AsyncBatchUsageRecorder:
# 全局批处理器实例(单例)
_global_batch_recorder: AsyncBatchUsageRecorder | None = None
def get_batch_recorder() -> AsyncBatchUsageRecorder:
"""获取全局批处理器实例"""
global _global_batch_recorder
@@ -753,32 +811,48 @@ def get_batch_recorder() -> AsyncBatchUsageRecorder:
# 统一错误处理机制
# =========================================================================
class EndpointCheckError(Exception):
"""端点检查错误基类"""
def __init__(self, message: str, error_type: str, status_code: int = 500, details: dict[str, Any] | None = None):
def __init__(
self,
message: str,
error_type: str,
status_code: int = 500,
details: dict[str, Any] | None = None,
):
super().__init__(message)
self.message = message
self.error_type = error_type
self.status_code = status_code
self.details = details or {}
class NetworkError(EndpointCheckError):
"""网络请求错误"""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(message, "network_error", 0, details)
class AuthenticationError(EndpointCheckError):
"""认证错误"""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(message, "authentication_error", 401, details)
class RateLimitError(EndpointCheckError):
"""速率限制错误"""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(message, "rate_limit_error", 429, details)
class UpstreamError(EndpointCheckError):
"""上游服务错误"""
def __init__(self, message: str, status_code: int, details: dict[str, Any] | None = None):
super().__init__(message, "upstream_error", status_code, details)
@@ -803,7 +877,9 @@ class ErrorHandler:
return ErrorHandler._handle_unknown_error(error, request)
@staticmethod
def _handle_network_error(error: httpx.RequestError, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_network_error(
error: httpx.RequestError, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理网络错误"""
error_message = f"Network error: {str(error)}"
logger.warning(f"[{request.api_format}] Network error: {error}")
@@ -827,12 +903,14 @@ class ErrorHandler:
response_data={
"error_type": error_type,
"original_error": str(error),
"retryable": True
}
"retryable": True,
},
)
@staticmethod
def _handle_timeout_error(error: httpx.TimeoutException, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_timeout_error(
error: httpx.TimeoutException, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理超时错误"""
logger.warning(f"[{request.api_format}] Request timeout: {error}")
return EndpointCheckResult(
@@ -845,14 +923,18 @@ class ErrorHandler:
"error_type": "timeout",
"original_error": str(error),
"retryable": True,
"timeout_seconds": request.timeout
}
"timeout_seconds": request.timeout,
},
)
@staticmethod
def _handle_http_status_error(error: httpx.HTTPStatusError, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_http_status_error(
error: httpx.HTTPStatusError, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理HTTP状态错误"""
logger.warning(f"[{request.api_format}] HTTP error: {error.response.status_code} - {error.response.text[:200]}")
logger.warning(
f"[{request.api_format}] HTTP error: {error.response.status_code} - {error.response.text[:200]}"
)
# 根据状态码分类错误
status_code = error.response.status_code
@@ -887,14 +969,18 @@ class ErrorHandler:
"error_type": error_type,
"http_status": status_code,
"response_body": error.response.text[:500] if error.response.text else "",
"retryable": retryable
}
"retryable": retryable,
},
)
@staticmethod
def _handle_business_error(error: EndpointCheckError, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_business_error(
error: EndpointCheckError, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理业务逻辑错误"""
logger.warning(f"[{request.api_format}] Business error: {error.error_type} - {error.message}")
logger.warning(
f"[{request.api_format}] Business error: {error.error_type} - {error.message}"
)
return EndpointCheckResult(
status_code=error.status_code,
headers={},
@@ -904,12 +990,14 @@ class ErrorHandler:
response_data={
"error_type": error.error_type,
"details": error.details,
"retryable": error.status_code >= 500 or error.status_code == 429
}
"retryable": error.status_code >= 500 or error.status_code == 429,
},
)
@staticmethod
def _handle_validation_error(error: ValueError, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_validation_error(
error: ValueError, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理验证错误"""
logger.warning(f"[{request.api_format}] Validation error: {error}")
return EndpointCheckResult(
@@ -921,15 +1009,18 @@ class ErrorHandler:
response_data={
"error_type": "validation_error",
"original_error": str(error),
"retryable": False
}
"retryable": False,
},
)
@staticmethod
def _handle_unknown_error(error: Exception, request: EndpointCheckRequest) -> EndpointCheckResult:
def _handle_unknown_error(
error: Exception, request: EndpointCheckRequest
) -> EndpointCheckResult:
"""处理未知错误"""
logger.error(f"[{request.api_format}] Unknown error: {type(error).__name__}: {error}")
import traceback
logger.error(f"[{request.api_format}] Traceback: {traceback.format_exc()}")
return EndpointCheckResult(
@@ -941,8 +1032,8 @@ class ErrorHandler:
response_data={
"error_type": "internal_error",
"original_error": str(error),
"retryable": False
}
"retryable": False,
},
)
@@ -950,9 +1041,11 @@ class ErrorHandler:
# 配置化支持
# =========================================================================
@dataclass
class EndpointCheckConfig:
"""端点检查配置"""
# 性能配置
timeout: float = 30.0
max_retries: int = 3
@@ -986,20 +1079,31 @@ class EndpointCheckConfig:
import os
return cls(
timeout=float(os.getenv('ENDPOINT_CHECK_TIMEOUT', '30.0')),
max_retries=int(os.getenv('ENDPOINT_CHECK_MAX_RETRIES', '3')),
retry_delay=float(os.getenv('ENDPOINT_CHECK_RETRY_DELAY', '1.0')),
api_format_cache_size=int(os.getenv('ENDPOINT_CHECK_CACHE_SIZE', '512')),
enable_batch_recording=os.getenv('ENDPOINT_CHECK_BATCH_RECORDING', 'true').lower() == 'true',
batch_size=int(os.getenv('ENDPOINT_CHECK_BATCH_SIZE', '10')),
batch_flush_interval=float(os.getenv('ENDPOINT_CHECK_BATCH_INTERVAL', '2.0')),
enable_detailed_logging=os.getenv('ENDPOINT_CHECK_DETAILED_LOGGING', 'false').lower() == 'true',
enable_structured_logging=os.getenv('ENDPOINT_CHECK_STRUCTURED_LOGGING', 'true').lower() == 'true',
enable_usage_calculation=os.getenv('ENDPOINT_CHECK_USAGE_CALCULATION', 'true').lower() == 'true',
enable_fallback_token_counting=os.getenv('ENDPOINT_CHECK_FALLBACK_COUNTING', 'true').lower() == 'true',
enable_error_classification=os.getenv('ENDPOINT_CHECK_ERROR_CLASSIFICATION', 'true').lower() == 'true',
retry_on_server_errors=os.getenv('ENDPOINT_CHECK_RETRY_SERVER_ERRORS', 'true').lower() == 'true',
retry_on_timeouts=os.getenv('ENDPOINT_CHECK_RETRY_TIMEOUTS', 'true').lower() == 'true',
timeout=float(os.getenv("ENDPOINT_CHECK_TIMEOUT", "30.0")),
max_retries=int(os.getenv("ENDPOINT_CHECK_MAX_RETRIES", "3")),
retry_delay=float(os.getenv("ENDPOINT_CHECK_RETRY_DELAY", "1.0")),
api_format_cache_size=int(os.getenv("ENDPOINT_CHECK_CACHE_SIZE", "512")),
enable_batch_recording=os.getenv("ENDPOINT_CHECK_BATCH_RECORDING", "true").lower()
== "true",
batch_size=int(os.getenv("ENDPOINT_CHECK_BATCH_SIZE", "10")),
batch_flush_interval=float(os.getenv("ENDPOINT_CHECK_BATCH_INTERVAL", "2.0")),
enable_detailed_logging=os.getenv("ENDPOINT_CHECK_DETAILED_LOGGING", "false").lower()
== "true",
enable_structured_logging=os.getenv("ENDPOINT_CHECK_STRUCTURED_LOGGING", "true").lower()
== "true",
enable_usage_calculation=os.getenv("ENDPOINT_CHECK_USAGE_CALCULATION", "true").lower()
== "true",
enable_fallback_token_counting=os.getenv(
"ENDPOINT_CHECK_FALLBACK_COUNTING", "true"
).lower()
== "true",
enable_error_classification=os.getenv(
"ENDPOINT_CHECK_ERROR_CLASSIFICATION", "true"
).lower()
== "true",
retry_on_server_errors=os.getenv("ENDPOINT_CHECK_RETRY_SERVER_ERRORS", "true").lower()
== "true",
retry_on_timeouts=os.getenv("ENDPOINT_CHECK_RETRY_TIMEOUTS", "true").lower() == "true",
)
@classmethod
@@ -1016,8 +1120,7 @@ class ConfigurableEndpointChecker:
self.executor = HttpRequestExecutor(timeout=self.config.timeout)
self.usage_calculator = UsageCalculator()
self.orchestrator = EndpointCheckOrchestrator(
executor=self.executor,
usage_calculator=self.usage_calculator
executor=self.executor, usage_calculator=self.usage_calculator
)
# 应用配置到缓存大小
@@ -1027,7 +1130,9 @@ class ConfigurableEndpointChecker:
"""应用缓存配置"""
# 简化缓存配置 - 移除了有问题的缓存实现
# 未来如果需要缓存,可以重新设计缓存策略
logger.info(f"[ConfigurableEndpointChecker] Cache config applied: api_format_cache_size={self.config.api_format_cache_size}")
logger.info(
f"[ConfigurableEndpointChecker] Cache config applied: api_format_cache_size={self.config.api_format_cache_size}"
)
pass
async def check_endpoint(self, request: EndpointCheckRequest) -> EndpointCheckResult:
@@ -1063,19 +1168,24 @@ class ConfigurableEndpointChecker:
# 根据配置和错误类型判断是否重试
if error_type == "timeout" and self.config.retry_on_timeouts:
return True
elif error_type in ["server_error", "network_error", "connection_failed"] and self.config.retry_on_server_errors:
elif (
error_type in ["server_error", "network_error", "connection_failed"]
and self.config.retry_on_server_errors
):
return retryable
return False
async def _retry_check(self, request: EndpointCheckRequest, last_result: EndpointCheckResult) -> EndpointCheckResult:
async def _retry_check(
self, request: EndpointCheckRequest, last_result: EndpointCheckResult
) -> EndpointCheckResult:
"""重试端点检查"""
for attempt in range(self.config.max_retries):
if self.config.enable_structured_logging:
self._log_structured_retry(request, attempt + 1, last_result)
# 等待重试延迟
await asyncio.sleep(self.config.retry_delay * (2 ** attempt)) # 指数退避
await asyncio.sleep(self.config.retry_delay * (2**attempt)) # 指数退避
# 执行重试
result = await self.orchestrator.execute_check(request)
@@ -1105,11 +1215,13 @@ class ConfigurableEndpointChecker:
"max_retries": self.config.max_retries,
"enable_batch_recording": self.config.enable_batch_recording,
"enable_usage_calculation": self.config.enable_usage_calculation,
}
},
}
logger.info(f"[{request.api_format}] {json.dumps(log_entry)}")
def _log_structured_result(self, request: EndpointCheckRequest, result: EndpointCheckResult) -> None:
def _log_structured_result(
self, request: EndpointCheckRequest, result: EndpointCheckResult
) -> None:
"""记录结构化结果日志"""
log_entry = {
"event": "endpoint_check_complete",
@@ -1129,7 +1241,9 @@ class ConfigurableEndpointChecker:
logger.info(f"[{request.api_format}] {json.dumps(log_entry)}")
def _log_structured_retry(self, request: EndpointCheckRequest, attempt: int, last_result: EndpointCheckResult) -> None:
def _log_structured_retry(
self, request: EndpointCheckRequest, attempt: int, last_result: EndpointCheckResult
) -> None:
"""记录重试日志"""
log_entry = {
"event": "endpoint_check_retry",
@@ -1172,28 +1286,36 @@ class ConfigurableEndpointChecker:
# 全局配置检查器实例
_global_configured_checker: ConfigurableEndpointChecker | None = None
def get_configured_checker(config: EndpointCheckConfig | None = None) -> ConfigurableEndpointChecker:
def get_configured_checker(
config: EndpointCheckConfig | None = None,
) -> ConfigurableEndpointChecker:
"""获取全局配置检查器实例"""
global _global_configured_checker
if _global_configured_checker is None or config is not None:
_global_configured_checker = ConfigurableEndpointChecker(config or EndpointCheckConfig.from_env())
_global_configured_checker = ConfigurableEndpointChecker(
config or EndpointCheckConfig.from_env()
)
return _global_configured_checker
class EndpointCheckOrchestrator:
"""端点检查协调器 - 协调整个流程"""
def __init__(self, executor: HttpRequestExecutor | None = None,
usage_calculator: UsageCalculator | None = None):
def __init__(
self,
executor: HttpRequestExecutor | None = None,
usage_calculator: UsageCalculator | None = None,
):
self.executor = executor or HttpRequestExecutor()
self.usage_calculator = usage_calculator or UsageCalculator()
async def execute_check(self, request: EndpointCheckRequest) -> EndpointCheckResult:
"""执行端点检查的完整流程"""
logger.info(f"[{request.api_format}] Starting endpoint check | "
f"provider={request.provider_name}, model={request.model_name}")
logger.info(
f"[{request.api_format}] Starting endpoint check | "
f"provider={request.provider_name}, model={request.model_name}"
)
# 1. 执行HTTP请求
result = await self.executor.execute(request)
@@ -1201,8 +1323,12 @@ class EndpointCheckOrchestrator:
# 2. 计算用量
if request.db and request.user: # 只在有数据库连接和用户信息时才计算用量
try:
input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens = \
self.usage_calculator.calculate_tokens(request, result)
(
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
) = self.usage_calculator.calculate_tokens(request, result)
# 检测API格式
api_format = request.api_format
@@ -1229,10 +1355,15 @@ class EndpointCheckOrchestrator:
api_format=api_format,
)
logger.info(f"[{request.api_format}] Usage calculated successfully: {result.usage_data}")
logger.info(
f"[{request.api_format}] Usage calculated successfully: {result.usage_data}"
)
except Exception as e:
logger.error(f"[{request.api_format}] Failed to calculate usage: {e}")
import traceback
logger.error(f"[{request.api_format}] Usage calculation traceback: {traceback.format_exc()}")
logger.error(
f"[{request.api_format}] Usage calculation traceback: {traceback.format_exc()}"
)
return result

View File

@@ -134,8 +134,8 @@ class OpenAIResponseParser(ResponseParser):
from src.api.handlers.openai.stream_parser import OpenAIStreamParser
self._parser = OpenAIStreamParser()
self.name = "OPENAI"
self.api_format = "OPENAI"
self.name = "openai:chat"
self.api_format = "openai:chat"
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip():
@@ -245,8 +245,8 @@ class OpenAICliResponseParser(OpenAIResponseParser):
def __init__(self) -> None:
super().__init__()
self.name = "OPENAI_CLI"
self.api_format = "OPENAI_CLI"
self.name = "openai:cli"
self.api_format = "openai:cli"
class ClaudeResponseParser(ResponseParser):
@@ -256,8 +256,8 @@ class ClaudeResponseParser(ResponseParser):
from src.api.handlers.claude.stream_parser import ClaudeStreamParser
self._parser = ClaudeStreamParser()
self.name = "CLAUDE"
self.api_format = "CLAUDE"
self.name = "claude:chat"
self.api_format = "claude:chat"
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
if not line or not line.strip():
@@ -392,8 +392,8 @@ class ClaudeCliResponseParser(ClaudeResponseParser):
def __init__(self) -> None:
super().__init__()
self.name = "CLAUDE_CLI"
self.api_format = "CLAUDE_CLI"
self.name = "claude:cli"
self.api_format = "claude:cli"
class GeminiResponseParser(ResponseParser):
@@ -403,8 +403,8 @@ class GeminiResponseParser(ResponseParser):
from src.api.handlers.gemini.stream_parser import GeminiStreamParser
self._parser = GeminiStreamParser()
self.name = "GEMINI"
self.api_format = "GEMINI"
self.name = "gemini:chat"
self.api_format = "gemini:chat"
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
"""
@@ -557,18 +557,18 @@ class GeminiCliResponseParser(GeminiResponseParser):
def __init__(self) -> None:
super().__init__()
self.name = "GEMINI_CLI"
self.api_format = "GEMINI_CLI"
self.name = "gemini:cli"
self.api_format = "gemini:cli"
# 解析器注册表
_PARSERS: dict[str, type[ResponseParser]] = {
"CLAUDE": ClaudeResponseParser,
"CLAUDE_CLI": ClaudeCliResponseParser,
"OPENAI": OpenAIResponseParser,
"OPENAI_CLI": OpenAICliResponseParser,
"GEMINI": GeminiResponseParser,
"GEMINI_CLI": GeminiCliResponseParser,
"claude:chat": ClaudeResponseParser,
"claude:cli": ClaudeCliResponseParser,
"openai:chat": OpenAIResponseParser,
"openai:cli": OpenAICliResponseParser,
"gemini:chat": GeminiResponseParser,
"gemini:cli": GeminiCliResponseParser,
}
@@ -577,7 +577,7 @@ def get_parser_for_format(format_id: str) -> ResponseParser:
根据格式 ID 获取 ResponseParser
Args:
format_id: 格式 ID"CLAUDE", "OPENAI", "CLAUDE_CLI", "OPENAI_CLI"
format_id: endpoint signature"claude:chat", "openai:cli"
Returns:
ResponseParser 实例
@@ -585,10 +585,12 @@ def get_parser_for_format(format_id: str) -> ResponseParser:
Raises:
KeyError: 格式不存在
"""
format_id = format_id.upper()
if format_id not in _PARSERS:
raise KeyError(f"Unknown format: {format_id}")
return _PARSERS[format_id]()
from src.core.api_format.signature import normalize_signature_key
normalized = normalize_signature_key(format_id)
if normalized not in _PARSERS:
raise KeyError(f"Unknown format: {normalized}")
return _PARSERS[normalized]()
__all__ = [

View File

@@ -18,7 +18,12 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from src.core.api_format import UPSTREAM_DROP_HEADERS, HeaderBuilder
from src.core.api_format import (
UPSTREAM_DROP_HEADERS,
HeaderBuilder,
get_auth_config_for_endpoint,
make_signature_key,
)
from src.core.crypto import crypto_service
if TYPE_CHECKING:
@@ -93,7 +98,7 @@ def build_test_request_body(
使用格式转换注册表将 OpenAI 格式的测试请求转换为目标格式。
Args:
format_id: 目标 API 格式 ID"CLAUDE", "GEMINI", "OPENAI_CLI"
format_id: 目标 endpoint signature"claude:chat", "gemini:chat", "openai:cli"
request_data: 可选的请求数据,会与默认测试请求合并
Returns:
@@ -110,11 +115,15 @@ def build_test_request_body(
# 获取测试请求数据OpenAI 格式)
source_data = get_test_request_data(request_data)
# CLI 格式使用基础格式进行转换(CLAUDE_CLI -> CLAUDE
# CLI 格式使用基础格式进行转换(claude:cli -> claude:chat
target_format = get_base_format(format_id) or format_id
# 使用注册表进行格式转换 (OPENAI -> 目标基础格式)
return format_conversion_registry.convert_request(source_data, "OPENAI", target_format)
# 使用注册表进行格式转换 (openai:chat -> 目标基础格式)
return format_conversion_registry.convert_request(
source_data,
make_signature_key("openai", "chat"),
target_format,
)
# ==============================================================================
@@ -237,8 +246,6 @@ class PassthroughRequestBuilder(RequestBuilder):
pre_computed_auth: 预先计算的认证信息 (auth_header, auth_value)
用于 Service Account 等异步获取 token 的场景
"""
from src.core.api_format import get_auth_config, resolve_api_format
# 1. 根据 API 格式自动设置认证头
if pre_computed_auth:
# 使用预先计算的认证信息Service Account 等场景)
@@ -246,11 +253,23 @@ class PassthroughRequestBuilder(RequestBuilder):
else:
# 标准 API Key 认证
decrypted_key = crypto_service.decrypt(key.api_key)
api_format = getattr(endpoint, "api_format", None)
resolved_format = resolve_api_format(api_format)
auth_header, auth_type = (
get_auth_config(resolved_format) if resolved_format else ("Authorization", "bearer")
)
raw_family = getattr(endpoint, "api_family", None)
raw_kind = getattr(endpoint, "endpoint_kind", None)
endpoint_sig: str | None = None
if (
isinstance(raw_family, str)
and isinstance(raw_kind, str)
and raw_family
and raw_kind
):
endpoint_sig = make_signature_key(raw_family, raw_kind)
else:
# 兜底:允许 endpoint.api_format 已经是 signature key 的情况
raw_format = getattr(endpoint, "api_format", None)
if isinstance(raw_format, str) and ":" in raw_format:
endpoint_sig = raw_format
auth_header, auth_type = get_auth_config_for_endpoint(endpoint_sig or "openai:chat")
auth_value = f"Bearer {decrypted_key}" if auth_type == "bearer" else decrypted_key
# 认证头始终受保护,防止 header_rules 覆盖
protected_keys = {auth_header.lower(), "content-type"}

View File

@@ -260,8 +260,7 @@ class StreamContext:
# 第一行:基本信息 + 首字时间
line1 = (
f"[{status}] {request_id[:8]} | {self.model} | "
f"{self.provider_name or 'unknown'}"
f"[{status}] {request_id[:8]} | {self.model} | " f"{self.provider_name or 'unknown'}"
)
if self.first_byte_time_ms is not None:
line1 += f" | TTFB: {self.first_byte_time_ms}ms"

View File

@@ -14,12 +14,10 @@ from __future__ import annotations
import asyncio
import codecs
import json
from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from typing import Any
from collections.abc import Callable
from collections.abc import AsyncGenerator
import httpx
from src.api.handlers.base.content_extractors import (
@@ -310,8 +308,9 @@ class StreamProcessor:
# 预读阶段格式转换试验:首字节前可 failover
# 如果需要跨格式转换,对首个有效数据块做试转换
if ctx.needs_conversion and isinstance(data, dict):
client_format = (ctx.client_api_format or "").upper()
provider_format = (ctx.provider_api_format or "").upper()
# 新模式endpoint signature keyfamily:kind这里仅用于转换器选择不做 legacy 兼容
client_format = (ctx.client_api_format or "").strip().lower()
provider_format = (ctx.provider_api_format or "").strip().lower()
if client_format and provider_format:
try:
# 试转换:传 state=None不保留状态
@@ -414,14 +413,15 @@ class StreamProcessor:
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# ctx.api_format 可能是 APIFormat 枚举,需要取 value
_api_format_str = (
ctx.api_format.value
if hasattr(ctx.api_format, "value")
else str(ctx.api_format or "")
_api_format_str = str(ctx.api_format or "")
client_format = (ctx.client_api_format or _api_format_str).strip().lower()
provider_format = (ctx.provider_api_format or _api_format_str).strip().lower()
client_family = (
client_format.split(":", 1)[0] if ":" in client_format else client_format
)
provider_family = (
provider_format.split(":", 1)[0] if ":" in provider_format else provider_format
)
client_format = (ctx.client_api_format or _api_format_str).upper()
provider_format = (ctx.provider_api_format or _api_format_str).upper()
# 使用 handler 层预计算的 needs_conversion由 candidate 决定)
needs_conversion = ctx.needs_conversion
@@ -446,7 +446,7 @@ class StreamProcessor:
streaming_started = True
def _build_stream_error_payload(message: str) -> dict:
if client_format.startswith("OPENAI"):
if client_family == "openai":
return {
"error": {
"message": message,
@@ -502,7 +502,7 @@ class StreamProcessor:
and normalized_line[5:].strip() == "[DONE]"
):
skip_next_blank_line = True
if client_format.startswith("OPENAI"):
if client_family == "openai":
openai_done_sent = True
return [b"data: [DONE]\n\n"]
return []
@@ -510,7 +510,7 @@ class StreamProcessor:
# 默认只处理 SSE 的 data 行;但 Gemini 上游可能返回 JSON-array/chunks无 data 前缀)
is_data_line = normalized_line.startswith("data:")
if not is_data_line:
if provider_format != "GEMINI":
if provider_family != "gemini":
return []
data_content = normalized_line.strip()
else:
@@ -554,9 +554,7 @@ class StreamProcessor:
error_bytes = (
f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode()
)
done_bytes = (
b"data: [DONE]\n\n" if client_format.startswith("OPENAI") else b""
)
done_bytes = b"data: [DONE]\n\n" if client_family == "openai" else b""
if done_bytes:
openai_done_sent = True
return [error_bytes, done_bytes]
@@ -572,9 +570,7 @@ class StreamProcessor:
# 统一使用 SSE 格式输出Gemini streamGenerateContent 也使用 SSE
# 参考: https://ai.google.dev/api/generate-content
out.append(
f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode()
)
out.append(f"data: {json.dumps(evt, ensure_ascii=False)}\n\n".encode())
return out
# 统一处理 prefetched + iterator
@@ -669,7 +665,7 @@ class StreamProcessor:
return
# Provider 流结束后,为 OpenAI 客户端补齐 [DONE](许多上游不发送该哨兵)
if client_format.startswith("OPENAI") and not openai_done_sent:
if client_family == "openai" and not openai_done_sent:
_mark_stream_started()
yield b"data: [DONE]\n\n"
@@ -944,9 +940,7 @@ class StreamProcessor:
self._extractors[format_name] = extractor
return self._extractors.get(format_name)
def _detect_format_and_extract(
self, data: dict
) -> tuple[str | None, ContentExtractor | None]:
def _detect_format_and_extract(self, data: dict) -> tuple[str | None, ContentExtractor | None]:
"""
检测数据格式并提取内容
@@ -1042,9 +1036,7 @@ class _LightweightSmoother:
self._extractors[format_name] = extractor
return self._extractors.get(format_name)
def _detect_format_and_extract(
self, data: dict
) -> tuple[str | None, ContentExtractor | None]:
def _detect_format_and_extract(self, data: dict) -> tuple[str | None, ContentExtractor | None]:
for format_name in get_extractor_formats():
extractor = self._get_extractor(format_name)
if extractor:
@@ -1062,9 +1054,7 @@ class _LightweightSmoother:
return [content]
return [content[i : i + self.chunk_size] for i in range(0, text_length, self.chunk_size)]
async def smooth(
self, stream_generator: AsyncGenerator[bytes]
) -> AsyncGenerator[bytes]:
async def smooth(self, stream_generator: AsyncGenerator[bytes]) -> AsyncGenerator[bytes]:
buffer = b""
is_first_content = True

View File

@@ -20,7 +20,11 @@ from src.config.settings import config
from src.core.logger import logger
from src.database import get_db
from src.models.database import ApiKey, User
from src.services.usage.telemetry_writer import DbTelemetryWriter, QueueTelemetryWriter, TelemetryWriter
from src.services.usage.telemetry_writer import (
DbTelemetryWriter,
QueueTelemetryWriter,
TelemetryWriter,
)
class StreamTelemetryRecorder:
@@ -96,13 +100,20 @@ class StreamTelemetryRecorder:
return
actual_request_body = ctx.provider_request_body or original_request_body
response_body = None
if not isinstance(writer, QueueTelemetryWriter) or config.usage_queue_include_bodies:
if (
not isinstance(writer, QueueTelemetryWriter)
or config.usage_queue_include_bodies
):
response_body = ctx.build_response_body(response_time_ms)
try:
await self._dispatch_record(
writer, ctx, original_headers, actual_request_body,
response_body, response_time_ms,
writer,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
except Exception as writer_error:
if not isinstance(writer, QueueTelemetryWriter):
@@ -122,8 +133,12 @@ class StreamTelemetryRecorder:
if response_body is None:
response_body = ctx.build_response_body(response_time_ms)
await self._dispatch_record(
db_writer, ctx, original_headers, actual_request_body,
response_body, response_time_ms,
db_writer,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
# 更新候选记录状态
@@ -152,11 +167,13 @@ class StreamTelemetryRecorder:
"""记录成功的请求"""
# 流式成功时,返回给客户端的是提供商响应头 + 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",
})
client_response_headers.update(
{
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
"content-type": "text/event-stream",
}
)
await writer.record_success(
provider=ctx.provider_name or "unknown",
@@ -200,7 +217,9 @@ class StreamTelemetryRecorder:
) -> None:
"""记录失败的请求"""
# 失败时返回给客户端的是 JSON 错误响应,如果没有设置则使用默认值
client_response_headers = ctx.client_response_headers or {"content-type": "application/json"}
client_response_headers = ctx.client_response_headers or {
"content-type": "application/json"
}
await writer.record_failure(
provider=ctx.provider_name or "unknown",
@@ -242,7 +261,9 @@ class StreamTelemetryRecorder:
response_time_ms: int,
) -> None:
"""记录客户端取消的请求"""
client_response_headers = ctx.client_response_headers or {"content-type": "application/json"}
client_response_headers = ctx.client_response_headers or {
"content-type": "application/json"
}
await writer.record_cancelled(
provider=ctx.provider_name or "unknown",
@@ -319,7 +340,9 @@ class StreamTelemetryRecorder:
)
else:
# 请求链路追踪使用 upstream_response原始响应回退到 error_message友好消息
trace_error_message = ctx.upstream_response or ctx.error_message or f"HTTP {ctx.status_code}"
trace_error_message = (
ctx.upstream_response or ctx.error_message or f"HTTP {ctx.status_code}"
)
RequestCandidateService.mark_candidate_failed(
db=db,
candidate_id=ctx.attempt_id,
@@ -408,18 +431,30 @@ class StreamTelemetryRecorder:
"""根据上下文状态分发到对应的记录方法"""
if ctx.is_success():
await self._record_success(
writer, ctx, original_headers, actual_request_body,
response_body, response_time_ms,
writer,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
elif ctx.is_client_disconnected():
await self._record_cancelled(
writer, ctx, original_headers, actual_request_body,
response_body, response_time_ms,
writer,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
else:
await self._record_failure(
writer, ctx, original_headers, actual_request_body,
response_body, response_time_ms,
writer,
ctx,
original_headers,
actual_request_body,
response_body,
response_time_ms,
)
def _get_status_from_ctx(self, ctx: StreamContext) -> str:
@@ -440,7 +475,5 @@ class StreamTelemetryRecorder:
)
return None
bg_telemetry = MessageTelemetry(
bg_db, user, api_key_obj, self.request_id, self.client_ip
)
bg_telemetry = MessageTelemetry(bg_db, user, api_key_obj, self.request_id, self.client_ip)
return DbTelemetryWriter(bg_telemetry)

View File

@@ -4,12 +4,11 @@ Handler 基础工具函数
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
from src.core.api_format import filter_response_headers
from src.core.exceptions import EmbeddedErrorException, ProviderNotAvailableException
from src.core.logger import logger
if TYPE_CHECKING:
@@ -72,15 +71,12 @@ def extract_cache_creation_tokens(usage: dict[str, Any]) -> int:
cache_1h = int(cache_creation.get("ephemeral_1h_input_tokens", 0))
total = cache_5m + cache_1h
logger.debug(
f"Using nested cache_creation: 5m={cache_5m}, 1h={cache_1h}, total={total}"
)
logger.debug(f"Using nested cache_creation: 5m={cache_5m}, 1h={cache_1h}, total={total}")
return total
# 2. 检查扁平新格式
has_flat_format = (
"claude_cache_creation_5_m_tokens" in usage
or "claude_cache_creation_1_h_tokens" in usage
"claude_cache_creation_5_m_tokens" in usage or "claude_cache_creation_1_h_tokens" in usage
)
if has_flat_format:
@@ -88,9 +84,7 @@ def extract_cache_creation_tokens(usage: dict[str, Any]) -> int:
cache_1h = int(usage.get("claude_cache_creation_1_h_tokens", 0))
total = cache_5m + cache_1h
logger.debug(
f"Using flat new format: 5m={cache_5m}, 1h={cache_1h}, total={total}"
)
logger.debug(f"Using flat new format: 5m={cache_5m}, 1h={cache_1h}, total={total}")
return total
# 3. 回退到旧格式

View File

@@ -16,7 +16,7 @@ from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import get_header_value
from src.core.api_format import ApiFamily, get_header_value
from src.core.logger import logger
from src.core.optimization_utils import TokenCounter
from src.models.claude import ClaudeMessagesRequest, ClaudeTokenCountRequest
@@ -58,7 +58,8 @@ class ClaudeChatAdapter(ChatAdapterBase):
处理 Claude Chat 格式的请求(/v1/messages 端点,进行格式验证)。
"""
FORMAT_ID = "CLAUDE"
FORMAT_ID = "claude:chat"
API_FAMILY = ApiFamily.CLAUDE
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
name = "claude.chat"
@@ -70,7 +71,7 @@ class ClaudeChatAdapter(ChatAdapterBase):
return ClaudeChatHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["CLAUDE"])
super().__init__(allowed_api_formats)
logger.info(f"[{self.name}] 初始化Chat模式适配器 | API格式: {self.allowed_api_formats}")
def detect_capability_requirements(

View File

@@ -9,6 +9,7 @@ from typing import Any
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.api.handlers.base.utils import extract_cache_creation_tokens
from src.core.api_format import ApiFamily, EndpointKind
class ClaudeChatHandler(ChatHandlerBase):
@@ -21,7 +22,9 @@ class ClaudeChatHandler(ChatHandlerBase):
- 请求格式ClaudeMessagesRequest
"""
FORMAT_ID = "CLAUDE"
FORMAT_ID = "claude:chat"
API_FAMILY = ApiFamily.CLAUDE
ENDPOINT_KIND = EndpointKind.CHAT
def extract_model_from_request(
self,

View File

@@ -4,7 +4,6 @@ Claude SSE 流解析器
解析 Claude Messages API 的 Server-Sent Events 流。
"""
import json
from typing import Any

View File

@@ -14,6 +14,7 @@ from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.claude.adapter import ClaudeCapabilityDetector, ClaudeChatAdapter
from src.config.settings import config
from src.core.api_format import ApiFamily
@register_cli_adapter
@@ -24,7 +25,8 @@ class ClaudeCliAdapter(CliAdapterBase):
处理 Claude CLI 格式的请求(/v1/messages 端点,使用 Bearer 认证)。
"""
FORMAT_ID = "CLAUDE_CLI"
FORMAT_ID = "claude:cli"
API_FAMILY = ApiFamily.CLAUDE
BILLING_TEMPLATE = "claude" # 使用 Claude 计费模板
name = "claude.cli"
@@ -36,7 +38,7 @@ class ClaudeCliAdapter(CliAdapterBase):
return ClaudeCliMessageHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["CLAUDE_CLI"])
super().__init__(allowed_api_formats)
def detect_capability_requirements(
self,
@@ -113,16 +115,16 @@ class ClaudeCliAdapter(CliAdapterBase):
cli_headers = {"User-Agent": config.internal_user_agent_claude_cli}
if extra_headers:
cli_headers.update(extra_headers)
models, error = await ClaudeChatAdapter.fetch_models(
client, base_url, api_key, cli_headers
)
models, error = await ClaudeChatAdapter.fetch_models(client, base_url, api_key, cli_headers)
# 更新 api_format 为 CLI 格式
for m in models:
m["api_format"] = cls.FORMAT_ID
return models, error
@classmethod
def build_endpoint_url(cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None) -> str:
def build_endpoint_url(
cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None
) -> str:
"""构建Claude CLI API端点URL"""
base_url = base_url.rstrip("/")
if base_url.endswith("/v1"):

View File

@@ -11,6 +11,7 @@ from src.api.handlers.base.cli_handler_base import (
StreamContext,
)
from src.api.handlers.base.utils import extract_cache_creation_tokens
from src.core.api_format import ApiFamily, EndpointKind
class ClaudeCliMessageHandler(CliMessageHandlerBase):
@@ -29,7 +30,9 @@ class ClaudeCliMessageHandler(CliMessageHandlerBase):
模型字段:请求体顶级 model 字段
"""
FORMAT_ID = "CLAUDE_CLI"
FORMAT_ID = "claude:cli"
API_FAMILY = ApiFamily.CLAUDE
ENDPOINT_KIND = EndpointKind.CLI
def extract_model_from_request(
self,
@@ -197,4 +200,3 @@ class ClaudeCliMessageHandler(CliMessageHandlerBase):
# 记录模型名称
if ctx.model:
ctx.response_metadata["model"] = ctx.model

View File

@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import get_auth_handler
from src.core.api_format import ApiFamily, get_auth_handler
from src.core.api_format.enums import AuthMethod
from src.core.logger import logger
from src.models.gemini import GeminiRequest
@@ -31,7 +31,8 @@ class GeminiChatAdapter(ChatAdapterBase):
端点: /v1beta/models/{model}:generateContent
"""
FORMAT_ID = "GEMINI"
FORMAT_ID = "gemini:chat"
API_FAMILY = ApiFamily.GEMINI
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
name = "gemini.chat"
@@ -43,7 +44,7 @@ class GeminiChatAdapter(ChatAdapterBase):
return GeminiChatHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["GEMINI"])
super().__init__(allowed_api_formats)
logger.info(
f"[{self.name}] 初始化 Gemini Chat 适配器 | API格式: {self.allowed_api_formats}"
)

View File

@@ -6,10 +6,12 @@ Gemini Chat Handler
from __future__ import annotations
from starlette.requests import Request
from typing import Any
from starlette.requests import Request
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import ApiFamily, EndpointKind
class GeminiChatHandler(ChatHandlerBase):
@@ -23,7 +25,9 @@ class GeminiChatHandler(ChatHandlerBase):
- 响应格式: JSON 数组流(非 SSE
"""
FORMAT_ID = "GEMINI"
FORMAT_ID = "gemini:chat"
API_FAMILY = ApiFamily.GEMINI
ENDPOINT_KIND = EndpointKind.CHAT
async def _resolve_preferred_key_ids(
self,

View File

@@ -15,7 +15,7 @@ from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.gemini.adapter import GeminiChatAdapter
from src.config.settings import config
from src.core.api_format import get_auth_handler
from src.core.api_format import ApiFamily, get_auth_handler
from src.core.api_format.enums import AuthMethod
@@ -27,7 +27,8 @@ class GeminiCliAdapter(CliAdapterBase):
处理 Gemini CLI 格式的请求(透传模式,最小验证)。
"""
FORMAT_ID = "GEMINI_CLI"
FORMAT_ID = "gemini:cli"
API_FAMILY = ApiFamily.GEMINI
BILLING_TEMPLATE = "gemini" # 使用 Gemini 计费模板
name = "gemini.cli"
@@ -39,7 +40,7 @@ class GeminiCliAdapter(CliAdapterBase):
return GeminiCliMessageHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["GEMINI_CLI"])
super().__init__(allowed_api_formats)
def extract_api_key(self, request: Request) -> str | None:
"""

View File

@@ -10,6 +10,7 @@ from src.api.handlers.base.cli_handler_base import (
CliMessageHandlerBase,
StreamContext,
)
from src.core.api_format import ApiFamily, EndpointKind
class GeminiCliMessageHandler(CliMessageHandlerBase):
@@ -30,7 +31,9 @@ class GeminiCliMessageHandler(CliMessageHandlerBase):
- 请求体中的 model 字段用于内部路由,不发送给 API
"""
FORMAT_ID = "GEMINI_CLI"
FORMAT_ID = "gemini:cli"
API_FAMILY = ApiFamily.GEMINI
ENDPOINT_KIND = EndpointKind.CLI
def extract_model_from_request(
self,

View File

@@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse
from src.api.handlers.base.chat_adapter_base import ChatAdapterBase, register_adapter
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import ApiFamily
from src.core.logger import logger
from src.models.openai import OpenAIRequest
@@ -25,7 +26,8 @@ class OpenAIChatAdapter(ChatAdapterBase):
处理 OpenAI Chat 格式的请求(/v1/chat/completions 端点)。
"""
FORMAT_ID = "OPENAI"
FORMAT_ID = "openai:chat"
API_FAMILY = ApiFamily.OPENAI
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
name = "openai.chat"
@@ -37,9 +39,11 @@ class OpenAIChatAdapter(ChatAdapterBase):
return OpenAIChatHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["OPENAI"])
super().__init__(allowed_api_formats)
def _validate_request_body(self, original_request_body: dict, path_params: dict | None = None) -> None:
def _validate_request_body(
self, original_request_body: dict, path_params: dict | None = None
) -> None:
"""验证请求体"""
if not isinstance(original_request_body, dict):
return self._error_response(

View File

@@ -7,10 +7,12 @@ OpenAI Chat Handler - 基于通用 Chat Handler 基类的简化实现
from __future__ import annotations
from starlette.requests import Request
from typing import Any
from starlette.requests import Request
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.core.api_format import ApiFamily, EndpointKind
class OpenAIChatHandler(ChatHandlerBase):
@@ -23,7 +25,9 @@ class OpenAIChatHandler(ChatHandlerBase):
- 请求格式OpenAIRequest
"""
FORMAT_ID = "OPENAI"
FORMAT_ID = "openai:chat"
API_FAMILY = ApiFamily.OPENAI
ENDPOINT_KIND = EndpointKind.CHAT
def extract_model_from_request(
self,

View File

@@ -4,7 +4,6 @@ OpenAI SSE 流解析器
解析 OpenAI Chat Completions API 的 Server-Sent Events 流。
"""
import json
from typing import Any

View File

@@ -14,6 +14,7 @@ from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.openai.adapter import OpenAIChatAdapter
from src.config.settings import config
from src.core.api_format import ApiFamily
@register_cli_adapter
@@ -24,7 +25,8 @@ class OpenAICliAdapter(CliAdapterBase):
处理 /v1/responses 端点的请求。
"""
FORMAT_ID = "OPENAI_CLI"
FORMAT_ID = "openai:cli"
API_FAMILY = ApiFamily.OPENAI
BILLING_TEMPLATE = "openai" # 使用 OpenAI 计费模板
name = "openai.cli"
@@ -36,7 +38,7 @@ class OpenAICliAdapter(CliAdapterBase):
return OpenAICliMessageHandler
def __init__(self, allowed_api_formats: list[str] | None = None):
super().__init__(allowed_api_formats or ["OPENAI_CLI"])
super().__init__(allowed_api_formats)
# =========================================================================
# 模型列表查询
@@ -55,16 +57,16 @@ class OpenAICliAdapter(CliAdapterBase):
cli_headers = {"User-Agent": config.internal_user_agent_openai_cli}
if extra_headers:
cli_headers.update(extra_headers)
models, error = await OpenAIChatAdapter.fetch_models(
client, base_url, api_key, cli_headers
)
models, error = await OpenAIChatAdapter.fetch_models(client, base_url, api_key, cli_headers)
# 更新 api_format 为 CLI 格式
for m in models:
m["api_format"] = cls.FORMAT_ID
return models, error
@classmethod
def build_endpoint_url(cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None) -> str:
def build_endpoint_url(
cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None
) -> str:
"""构建OpenAI CLI API端点URL"""
base_url = base_url.rstrip("/")
if base_url.endswith("/v1"):

View File

@@ -11,6 +11,7 @@ from src.api.handlers.base.cli_handler_base import (
CliMessageHandlerBase,
StreamContext,
)
from src.core.api_format import ApiFamily, EndpointKind
class OpenAICliMessageHandler(CliMessageHandlerBase):
@@ -28,7 +29,9 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
模型字段:请求体顶级 model 字段
"""
FORMAT_ID = "OPENAI_CLI"
FORMAT_ID = "openai:cli"
API_FAMILY = ApiFamily.OPENAI
ENDPOINT_KIND = EndpointKind.CLI
def extract_model_from_request(
self,
@@ -203,9 +206,10 @@ class OpenAICliMessageHandler(CliMessageHandlerBase):
if "object" in ctx.final_response:
ctx.response_metadata["object"] = ctx.final_response["object"]
if "system_fingerprint" in ctx.final_response:
ctx.response_metadata["system_fingerprint"] = ctx.final_response["system_fingerprint"]
ctx.response_metadata["system_fingerprint"] = ctx.final_response[
"system_fingerprint"
]
# 如果没有从响应中获取到 model使用上下文中的
if "model" not in ctx.response_metadata and ctx.model:
ctx.response_metadata["model"] = ctx.model

View File

@@ -2,21 +2,21 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pagination import PaginationMeta, build_pagination_payload, paginate_query
from src.api.base.pipeline import ApiRequestPipeline
from src.core.logger import logger
from src.database import get_db
from src.models.database import ApiKey, AuditLog
from src.plugins.manager import get_plugin_manager
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/monitoring", tags=["Monitoring"])
pipeline = ApiRequestPipeline()

View File

@@ -12,4 +12,3 @@ router.include_router(user_router)
router.include_router(admin_router)
__all__ = ["router"]

View File

@@ -1,6 +1,5 @@
"""OAuth 管理端点(管理员)。"""
from __future__ import annotations
from typing import Any
@@ -97,25 +96,33 @@ async def list_provider_configs(request: Request, db: Session = Depends(get_db))
@router.get("/providers/{provider_type}", response_model=OAuthProviderAdminResponse)
async def get_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
async def get_provider_config(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = GetOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.put("/providers/{provider_type}", response_model=OAuthProviderAdminResponse)
async def upsert_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
async def upsert_provider_config(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = UpsertOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.delete("/providers/{provider_type}")
async def delete_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
async def delete_provider_config(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = DeleteOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/providers/{provider_type}/test", response_model=OAuthProviderTestResponse)
async def test_provider_config(provider_type: str, request: Request, db: Session = Depends(get_db)) -> Any:
async def test_provider_config(
provider_type: str, request: Request, db: Session = Depends(get_db)
) -> Any:
adapter = TestOAuthProviderConfigAdapter(provider_type=provider_type)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -165,7 +172,11 @@ class GetOAuthProviderConfigAdapter(AdminApiAdapter):
self.provider_type = provider_type
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
row = context.db.query(OAuthProvider).filter(OAuthProvider.provider_type == self.provider_type).first()
row = (
context.db.query(OAuthProvider)
.filter(OAuthProvider.provider_type == self.provider_type)
.first()
)
if not row:
raise InvalidRequestException("Provider 配置不存在")
return OAuthProviderAdminResponse(
@@ -248,9 +259,11 @@ class TestOAuthProviderConfigAdapter(AdminApiAdapter):
# 如果没有提供 client_secret尝试从数据库获取已保存的
client_secret = req.client_secret
if not client_secret:
existing = context.db.query(OAuthProvider).filter(
OAuthProvider.provider_type == self.provider_type
).first()
existing = (
context.db.query(OAuthProvider)
.filter(OAuthProvider.provider_type == self.provider_type)
.first()
)
if existing and existing.client_secret_encrypted:
client_secret = existing.get_client_secret()

View File

@@ -1,6 +1,5 @@
"""OAuth 公开端点(无需登录)。"""
from typing import Any
from fastapi import APIRouter, Depends, Query, status

View File

@@ -1,6 +1,5 @@
"""OAuth 用户端点(需登录)。"""
from typing import Any, cast
from fastapi import APIRouter, Depends, HTTPException, Request, status

View File

@@ -5,6 +5,7 @@
"""
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
@@ -132,13 +133,15 @@ async def get_model_supported_capabilities(
for cap_name in supported_caps:
if cap_name in all_caps:
cap = all_caps[cap_name]
capability_details.append({
"name": cap.name,
"display_name": cap.display_name,
"description": cap.description,
"match_mode": cap.match_mode.value,
"config_mode": cap.config_mode.value,
})
capability_details.append(
{
"name": cap.name,
"display_name": cap.display_name,
"description": cap.description,
"match_mode": cap.match_mode.value,
"config_mode": cap.config_mode.value,
}
)
return {
"model": model_name,

View File

@@ -5,16 +5,17 @@
from __future__ import annotations
from typing import Any
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, joinedload
from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.logger import logger
from src.database import get_db
@@ -38,7 +39,6 @@ from src.models.endpoint_models import (
PublicHealthEvent,
)
from src.services.health.endpoint import EndpointHealthService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = ApiRequestPipeline()
@@ -376,9 +376,17 @@ class PublicModelsAdapter(PublicApiAdapter):
provider_name=provider.name,
name=unified_name,
display_name=display_name,
description=global_model.config.get("description") if global_model and global_model.config else None,
description=(
global_model.config.get("description")
if global_model and global_model.config
else None
),
tags=None,
icon_url=global_model.config.get("icon_url") if global_model and global_model.config else None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
@@ -469,9 +477,17 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
provider_name=provider.name,
name=unified_name,
display_name=display_name,
description=global_model.config.get("description") if global_model and global_model.config else None,
description=(
global_model.config.get("description")
if global_model and global_model.config
else None
),
tags=None,
icon_url=global_model.config.get("icon_url") if global_model and global_model.config else None,
icon_url=(
global_model.config.get("icon_url")
if global_model and global_model.config
else None
),
input_price_per_1m=model.get_effective_input_price(),
output_price_per_1m=model.get_effective_output_price(),
cache_creation_price_per_1m=model.get_effective_cache_creation_price(),
@@ -612,13 +628,9 @@ class PublicApiFormatHealthMonitorAdapter(PublicApiAdapter):
)
# 获取本站入口路径
from src.core.api_format import APIFormat, get_local_path
from src.core.api_format import get_local_path_for_endpoint
try:
api_format_enum = APIFormat(api_format)
local_path = get_local_path(api_format_enum)
except ValueError:
local_path = "/"
local_path = get_local_path_for_endpoint(api_format)
monitors.append(
PublicApiFormatHealthMonitor(

View File

@@ -8,6 +8,7 @@ Claude API 端点
"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
@@ -16,11 +17,9 @@ from src.api.handlers.claude import (
ClaudeTokenCountAdapter,
build_claude_adapter,
)
from src.core.api_format import APIFormat, get_api_format_definition
from src.database import get_db
_claude_def = get_api_format_definition(APIFormat.CLAUDE)
router = APIRouter(tags=["Claude API"], prefix=_claude_def.path_prefix)
router = APIRouter(tags=["Claude API"])
pipeline = ApiRequestPipeline()

View File

@@ -11,19 +11,16 @@ Gemini API 专属端点
"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.pipeline import ApiRequestPipeline
from src.api.handlers.gemini import build_gemini_adapter
from src.api.handlers.gemini_cli import build_gemini_cli_adapter
from src.core.api_format import APIFormat, get_api_format_definition
from src.database import get_db
# 从配置获取路径前缀
_gemini_def = get_api_format_definition(APIFormat.GEMINI)
router = APIRouter(tags=["Gemini API"], prefix=_gemini_def.path_prefix)
router = APIRouter(tags=["Gemini API"])
pipeline = ApiRequestPipeline()

View File

@@ -27,8 +27,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from src.clients.http_client import HTTPClientPool
from src.core.api_format import APIFormat, get_auth_handler, get_default_auth_method
from src.core.api_format.metadata import get_api_format_definition
from src.core.api_format import get_auth_handler, get_default_auth_method_for_endpoint
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.database import get_db
@@ -38,10 +37,7 @@ from src.services.cache.aware_scheduler import CacheAwareScheduler, ProviderCand
from src.services.gemini_files_mapping import delete_file_key_mapping, store_file_key_mapping
from src.services.provider.transport import redact_url_for_log
# 从配置获取路径前缀
_gemini_def = get_api_format_definition(APIFormat.GEMINI)
router = APIRouter(tags=["Gemini Files API"], prefix=_gemini_def.path_prefix)
router = APIRouter(tags=["Gemini Files API"])
# Gemini Files API 基础 URL
GEMINI_FILES_BASE_URL = "https://generativelanguage.googleapis.com"
@@ -70,7 +66,7 @@ def _extract_gemini_api_key(request: Request) -> str | None:
1. URL 参数 ?key=
2. x-goog-api-key 请求头
"""
auth_method = get_default_auth_method(APIFormat.GEMINI)
auth_method = get_default_auth_method_for_endpoint("gemini:chat")
handler = get_auth_handler(auth_method)
return handler.extract_credentials(request)
@@ -175,7 +171,7 @@ def _resolve_files_model_name(
Model.is_active == True,
Provider.is_active == True,
ProviderEndpoint.is_active == True,
ProviderEndpoint.api_format == APIFormat.GEMINI.value,
ProviderEndpoint.api_family == "gemini",
)
.distinct()
.order_by(GlobalModel.name.asc())
@@ -193,7 +189,7 @@ async def _select_provider_candidate(
scheduler = CacheAwareScheduler()
candidates, _global_model_id = await scheduler.list_all_candidates(
db=db,
api_format=APIFormat.GEMINI,
api_format="gemini:chat",
model_name=model_name,
affinity_key=str(user_api_key.id),
user_api_key=user_api_key,

View File

@@ -20,12 +20,7 @@ from src.api.base.models_service import (
list_available_models,
)
from src.core.api_format import (
API_FORMAT_DEFINITIONS,
APIFormat,
ApiFormatDefinition,
detect_request_context,
get_auth_handler,
get_default_auth_method,
)
from src.core.api_format.conversion import (
format_conversion_registry,
@@ -35,32 +30,23 @@ from src.core.logger import logger
from src.database import get_db
from src.models.database import ApiKey, User
from src.services.auth.service import AuthService
from src.services.provider.format import normalize_endpoint_signature
router = APIRouter(tags=["System Catalog"])
# 各格式对应的 API 格式列表(包括对应的 CLI 格式)
_CLAUDE_FORMATS = [APIFormat.CLAUDE.value, APIFormat.CLAUDE_CLI.value]
_OPENAI_FORMATS = [APIFormat.OPENAI.value, APIFormat.OPENAI_CLI.value]
_GEMINI_FORMATS = [APIFormat.GEMINI.value, APIFormat.GEMINI_CLI.value]
_CLAUDE_FORMATS = ["claude:chat", "claude:cli"]
_OPENAI_FORMATS = ["openai:chat", "openai:cli"]
_GEMINI_FORMATS = ["gemini:chat", "gemini:cli"]
# 所有格式(用于格式转换时的查询)
_ALL_CHAT_FORMATS = [
APIFormat.CLAUDE.value,
APIFormat.CLAUDE_CLI.value,
APIFormat.OPENAI.value,
APIFormat.OPENAI_CLI.value,
APIFormat.GEMINI.value,
APIFormat.GEMINI_CLI.value,
*_CLAUDE_FORMATS,
*_OPENAI_FORMATS,
*_GEMINI_FORMATS,
]
def _extract_api_key_from_request(request: Request, definition: ApiFormatDefinition) -> str | None:
"""根据格式定义从请求中提取 API Key"""
auth_method = get_default_auth_method(definition.api_format)
handler = get_auth_handler(auth_method)
return handler.extract_credentials(request)
def _detect_api_format_and_key(request: Request) -> tuple[str, str | None]:
"""
根据请求头检测 API 格式并提取 API Key
@@ -74,17 +60,17 @@ def _detect_api_format_and_key(request: Request) -> tuple[str, str | None]:
(api_format, api_key) 元组
"""
context = detect_request_context(request)
return context.data_format.value.lower(), context.credentials
return context.endpoint.key, context.credentials
def _get_formats_for_api(api_format: str) -> list[str]:
"""获取对应 API 格式的端点格式列表"""
if api_format == "claude":
fam = (api_format.split(":", 1)[0] if api_format else "").strip().lower()
if fam == "claude":
return _CLAUDE_FORMATS
elif api_format == "gemini":
if fam == "gemini":
return _GEMINI_FORMATS
else:
return _OPENAI_FORMATS
return _OPENAI_FORMATS
def _is_format_conversion_enabled() -> bool:
@@ -101,30 +87,32 @@ def _get_convertible_formats(client_format: str, global_conversion_enabled: bool
当启用格式转换时,返回所有可以转换的格式;
否则只返回客户端格式本身(不包括同族的其他格式)。
"""
client_format_upper = client_format.upper()
client_format_norm = normalize_endpoint_signature(client_format)
# 格式转换关闭时,只返回客户端格式本身
if not global_conversion_enabled:
return [client_format_upper]
return [client_format_norm]
# 收集所有可转换的格式
register_default_normalizers()
convertible_formats = []
convertible_formats: list[str] = []
for target_format in _ALL_CHAT_FORMATS:
target_norm = normalize_endpoint_signature(target_format)
# 相同格式始终可用
if target_format == client_format_upper:
convertible_formats.append(target_format)
if target_norm == client_format_norm:
convertible_formats.append(target_norm)
continue
# 检查是否有双向转换器
if format_conversion_registry.can_convert_full(
client_format_upper,
target_format,
client_format_norm,
target_norm,
require_stream=False,
):
convertible_formats.append(target_format)
convertible_formats.append(target_norm)
return convertible_formats if convertible_formats else [client_format_upper]
# 去重并保持稳定顺序
return list(dict.fromkeys(convertible_formats)) if convertible_formats else [client_format_norm]
def _flatten_provider_formats(provider_to_formats: dict[str, set[str]]) -> list[str]:
@@ -137,11 +125,17 @@ def _flatten_provider_formats(provider_to_formats: dict[str, set[str]]) -> list[
return sorted(all_formats)
def _get_family(api_format: str) -> str:
"""从 endpoint signature 提取协议族(如 'openai:chat' -> 'openai')。"""
return (str(api_format).split(":", 1)[0] if api_format else "").strip().lower()
def _build_empty_list_response(api_format: str) -> dict:
"""根据 API 格式构建空列表响应"""
if api_format == "claude":
fam = _get_family(api_format)
if fam == "claude":
return {"data": [], "has_more": False, "first_id": None, "last_id": None}
elif api_format == "gemini":
elif fam == "gemini":
return {"models": []}
else:
return {"object": "list", "data": []}
@@ -159,9 +153,7 @@ def _filter_formats_by_restrictions(
"""
if restrictions.allowed_api_formats is None:
return formats, None
# 统一转为大写比较,兼容数据库中存储的大小写
allowed_upper = {f.upper() for f in restrictions.allowed_api_formats}
filtered = [f for f in formats if f.upper() in allowed_upper]
filtered = [f for f in formats if restrictions.is_api_format_allowed(f)]
if not filtered:
logger.info(f"[Models] API Key 不允许访问格式 {api_format}")
return [], _build_empty_list_response(api_format)
@@ -191,7 +183,8 @@ def _authenticate(db: Session, api_key: str | None) -> tuple[User | None, ApiKey
def _build_auth_error_response(api_format: str) -> JSONResponse:
"""根据 API 格式构建认证错误响应"""
if api_format == "claude":
fam = _get_family(api_format)
if fam == "claude":
return JSONResponse(
status_code=401,
content={
@@ -202,7 +195,7 @@ def _build_auth_error_response(api_format: str) -> JSONResponse:
},
},
)
elif api_format == "gemini":
elif fam == "gemini":
return JSONResponse(
status_code=401,
content={
@@ -383,7 +376,8 @@ def _build_gemini_model_response(model_info: ModelInfo) -> dict:
def _build_404_response(model_id: str, api_format: str) -> JSONResponse:
"""根据 API 格式构建 404 响应"""
if api_format == "claude":
fam = _get_family(api_format)
if fam == "claude":
return JSONResponse(
status_code=404,
content={
@@ -391,7 +385,7 @@ def _build_404_response(model_id: str, api_format: str) -> JSONResponse:
"error": {"type": "not_found_error", "message": f"Model '{model_id}' not found"},
},
)
elif api_format == "gemini":
elif fam == "gemini":
return JSONResponse(
status_code=404,
content={
@@ -533,9 +527,9 @@ async def list_models(
)
logger.debug(f"[Models] 返回 {len(models)} 个模型")
if api_format == "claude":
if _get_family(api_format) == "claude":
return _build_claude_list_response(models, before_id, after_id, limit)
elif api_format == "gemini":
elif _get_family(api_format) == "gemini":
return _build_gemini_list_response(models, page_size, page_token)
else:
return _build_openai_list_response(models)
@@ -596,7 +590,7 @@ async def retrieve_model(
api_format, api_key = _detect_api_format_and_key(request)
# Gemini 格式的 name 带 "models/" 前缀,需要移除
if api_format == "gemini" and model_id.startswith("models/"):
if _get_family(api_format) == "gemini" and model_id.startswith("models/"):
model_id = model_id[7:]
logger.info(f"[Models] GET /v1/models/{model_id} | format={api_format}")
@@ -635,9 +629,9 @@ async def retrieve_model(
if not model_info:
return _build_404_response(model_id, api_format)
if api_format == "claude":
if _get_family(api_format) == "claude":
return _build_claude_model_response(model_info)
elif api_format == "gemini":
elif _get_family(api_format) == "gemini":
return _build_gemini_model_response(model_info)
else:
return _build_openai_model_response(model_info)
@@ -682,29 +676,27 @@ async def list_models_gemini(
"""
logger.info("[Models] GET /v1beta/models | format=gemini")
# 从 x-goog-api-key 或 ?key= 提取 API Key
gemini_def = API_FORMAT_DEFINITIONS[APIFormat.GEMINI]
api_key = _extract_api_key_from_request(request, gemini_def)
api_format, api_key = _detect_api_format_and_key(request)
# 认证
user, key_record = _authenticate(db, api_key)
if not user:
return _build_auth_error_response("gemini")
return _build_auth_error_response(api_format)
# 构建访问限制
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats("gemini", global_conversion_enabled)
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
candidate_formats, empty_response = _filter_formats_by_restrictions(
candidate_formats, restrictions, "gemini"
candidate_formats, restrictions, api_format
)
if empty_response is not None:
return empty_response
provider_to_formats = get_compatible_provider_formats(
db, "gemini", candidate_formats, global_conversion_enabled
db, api_format, candidate_formats, global_conversion_enabled
)
formats = _flatten_provider_formats(provider_to_formats)
@@ -718,7 +710,7 @@ async def list_models_gemini(
formats,
restrictions,
provider_to_formats=provider_to_formats,
client_format="gemini",
client_format=api_format,
)
logger.debug(f"[Models] 返回 {len(models)} 个模型")
response = _build_gemini_list_response(models, page_size, page_token)
@@ -763,30 +755,28 @@ async def get_model_gemini(
model_id = model_name[7:] if model_name.startswith("models/") else model_name
logger.info(f"[Models] GET /v1beta/models/{model_id} | format=gemini")
# 从 x-goog-api-key 或 ?key= 提取 API Key
gemini_def = API_FORMAT_DEFINITIONS[APIFormat.GEMINI]
api_key = _extract_api_key_from_request(request, gemini_def)
api_format, api_key = _detect_api_format_and_key(request)
# 认证
user, key_record = _authenticate(db, api_key)
if not user:
return _build_auth_error_response("gemini")
return _build_auth_error_response(api_format)
# 构建访问限制
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats("gemini", global_conversion_enabled)
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
candidate_formats, _ = _filter_formats_by_restrictions(
candidate_formats, restrictions, "gemini"
candidate_formats, restrictions, api_format
)
provider_to_formats = get_compatible_provider_formats(
db, "gemini", candidate_formats, global_conversion_enabled
db, api_format, candidate_formats, global_conversion_enabled
)
formats = _flatten_provider_formats(provider_to_formats)
if not formats:
return _build_404_response(model_id, "gemini")
return _build_404_response(model_id, api_format)
available_provider_ids = get_available_provider_ids(db, formats, provider_to_formats)
model_info = find_model_by_id(
@@ -799,6 +789,6 @@ async def get_model_gemini(
)
if not model_info:
return _build_404_response(model_id, "gemini")
return _build_404_response(model_id, api_format)
return _build_gemini_model_response(model_info)

View File

@@ -1,7 +1,7 @@
"""公开模块状态 API供登录页等使用"""
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session

View File

@@ -8,17 +8,16 @@ OpenAI API 端点
"""
from typing import Any
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from src.api.base.pipeline import ApiRequestPipeline
from src.api.handlers.openai import OpenAIChatAdapter
from src.api.handlers.openai_cli import OpenAICliAdapter
from src.core.api_format import APIFormat, get_api_format_definition
from src.database import get_db
_openai_def = get_api_format_definition(APIFormat.OPENAI)
router = APIRouter(tags=["OpenAI API"], prefix=_openai_def.path_prefix)
router = APIRouter(tags=["OpenAI API"])
pipeline = ApiRequestPipeline()

View File

@@ -264,7 +264,7 @@ async def test_connection(
}
# 确定 API 格式
format_value = api_format or "CLAUDE"
format_value = api_format or "claude:chat"
# 创建 FallbackOrchestrator
redis_client = get_redis_client_sync()
@@ -279,7 +279,11 @@ async def test_connection(
request_builder = PassthroughRequestBuilder()
provider_payload, provider_headers = request_builder.build(
payload, {}, endpoint, key, is_stream=False,
payload,
{},
endpoint,
key,
is_stream=False,
pre_computed_auth=auth_info.as_tuple() if auth_info else None,
)

View File

@@ -2,9 +2,9 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse
@@ -486,9 +486,7 @@ class UpdateMyManagementTokenAdapter(ManagementTokenApiAdapter):
context.add_audit_metadata(token_id=token.id, token_name=token.name)
return JSONResponse(
content={"message": "更新成功", "data": token_to_dict(token)}
)
return JSONResponse(content={"message": "更新成功", "data": token_to_dict(token)})
@dataclass

View File

@@ -2,9 +2,9 @@
from __future__ import annotations
from typing import Any
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import ValidationError
@@ -12,9 +12,15 @@ from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.crypto import crypto_service
from src.core.exceptions import ForbiddenException, InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.exceptions import (
ForbiddenException,
InvalidRequestException,
NotFoundException,
translate_pydantic_error,
)
from src.core.logger import logger
from src.database import get_db
from src.models.api import (
@@ -30,8 +36,6 @@ from src.models.database import ApiKey, GlobalModel, Model, Provider, Usage, Use
from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService
from src.services.user.preference import PreferenceService
from src.api.base.context import ApiRequestContext
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = ApiRequestPipeline()
@@ -480,6 +484,7 @@ class ChangePasswordAdapter(AuthenticatedApiAdapter):
# LDAP 用户不能修改密码
from src.core.enums import AuthSource
if user.auth_source == AuthSource.LDAP:
raise ForbiddenException("LDAP 用户不能在此修改密码")
@@ -746,7 +751,8 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
# 过滤掉 unknown/pending provider 的记录(请求未到达任何提供商)
filtered_summary = [
item for item in summary_list
item
for item in summary_list
if item.get("provider") not in ("unknown", "pending", None)
]
@@ -757,8 +763,12 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
total_output_tokens = (
sum(item["output_tokens"] for item in filtered_summary) if filtered_summary else 0
)
total_tokens = sum(item["total_tokens"] for item in filtered_summary) if filtered_summary else 0
total_cost = sum(item["total_cost_usd"] for item in filtered_summary) if filtered_summary else 0.0
total_tokens = (
sum(item["total_tokens"] for item in filtered_summary) if filtered_summary else 0
)
total_cost = (
sum(item["total_cost_usd"] for item in filtered_summary) if filtered_summary else 0.0
)
# 管理员可以看到真实成本
total_actual_cost = 0.0
@@ -823,20 +833,22 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
for stats in provider_summary.values():
avg_response_time_ms = (
stats["total_response_time_ms"] / stats["response_time_count"]
if stats["response_time_count"] > 0 else 0
if stats["response_time_count"] > 0
else 0
)
success_rate = (
(stats["success_count"] / stats["requests"] * 100)
if stats["requests"] > 0 else 100
(stats["success_count"] / stats["requests"] * 100) if stats["requests"] > 0 else 100
)
summary_by_provider.append(
{
"provider": stats["provider"],
"requests": stats["requests"],
"total_tokens": stats["total_tokens"],
"total_cost_usd": stats["total_cost_usd"],
"success_rate": round(success_rate, 2),
"avg_response_time_ms": round(avg_response_time_ms, 2),
}
)
summary_by_provider.append({
"provider": stats["provider"],
"requests": stats["requests"],
"total_tokens": stats["total_tokens"],
"total_cost_usd": stats["total_cost_usd"],
"success_rate": round(success_rate, 2),
"avg_response_time_ms": round(avg_response_time_ms, 2),
})
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
query = (
@@ -866,7 +878,9 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
# 计算总数用于分页
total_records = query.count()
usage_records = query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
usage_records = (
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
)
avg_resp_query = db.query(func.avg(Usage.response_time_ms)).filter(
Usage.user_id == user.id,
@@ -918,12 +932,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
def _build_usage_records(self, usage_records: list, is_admin: bool = False) -> list:
"""构建使用记录列表,包含格式转换信息的回填逻辑
Args:
usage_records: 使用记录列表
is_admin: 是否为管理员,管理员可以看到模型映射信息
"""
from src.core.api_format.metadata import can_passthrough
from src.core.api_format.metadata import can_passthrough_endpoint
from src.core.api_format.signature import normalize_signature_key
records = []
for r, api_key, endpoint in usage_records:
@@ -935,49 +950,53 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
has_format_conversion = r.has_format_conversion
if has_format_conversion is None:
# 使用 can_passthrough 判断是否需要转换,与实际转换逻辑保持一致
client_fmt = str(api_format or "").upper()
endpoint_fmt = str(endpoint_api_format or "").upper()
if client_fmt and endpoint_fmt:
has_format_conversion = not can_passthrough(client_fmt, endpoint_fmt)
# 新模式:仅对 signature 进行推断(历史旧值保持 False避免解析失败
client_raw = str(api_format or "").strip()
endpoint_raw = str(endpoint_api_format or "").strip()
if client_raw and endpoint_raw and ":" 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)
else:
has_format_conversion = False
records.append({
"id": r.id,
"model": r.model,
# 只有管理员可以看到模型映射信息,普通用户只能看到请求的模型
"target_model": r.target_model if is_admin else None,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": bool(has_format_conversion),
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"total_tokens": r.total_tokens,
"cost": r.total_cost_usd,
"response_time_ms": r.response_time_ms,
"first_byte_time_ms": r.first_byte_time_ms,
"is_stream": r.is_stream,
"status": r.status, # 请求状态: pending, streaming, completed, failed
"created_at": r.created_at.isoformat(),
"cache_creation_input_tokens": r.cache_creation_input_tokens,
"cache_read_input_tokens": r.cache_read_input_tokens,
"status_code": r.status_code,
"error_message": r.error_message,
"input_price_per_1m": r.input_price_per_1m,
"output_price_per_1m": r.output_price_per_1m,
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
"cache_read_price_per_1m": r.cache_read_price_per_1m,
"api_key": (
{
"id": str(api_key.id),
"name": api_key.name,
"display": api_key.get_display_key(),
}
if api_key
else None
),
})
records.append(
{
"id": r.id,
"model": r.model,
# 只有管理员可以看到模型映射信息,普通用户只能看到请求的模型
"target_model": r.target_model if is_admin else None,
"api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": bool(has_format_conversion),
"input_tokens": r.input_tokens,
"output_tokens": r.output_tokens,
"total_tokens": r.total_tokens,
"cost": r.total_cost_usd,
"response_time_ms": r.response_time_ms,
"first_byte_time_ms": r.first_byte_time_ms,
"is_stream": r.is_stream,
"status": r.status, # 请求状态: pending, streaming, completed, failed
"created_at": r.created_at.isoformat(),
"cache_creation_input_tokens": r.cache_creation_input_tokens,
"cache_read_input_tokens": r.cache_read_input_tokens,
"status_code": r.status_code,
"error_message": r.error_message,
"input_price_per_1m": r.input_price_per_1m,
"output_price_per_1m": r.output_price_per_1m,
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
"cache_read_price_per_1m": r.cache_read_price_per_1m,
"api_key": (
{
"id": str(api_key.id),
"name": api_key.name,
"display": api_key.get_display_key(),
}
if api_key
else None
),
}
)
return records
@@ -1065,9 +1084,7 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
global_conversion_enabled = app_config.format_conversion_enabled
# 获取所有可用的 Provider ID考虑格式转换
available_provider_ids = self._get_all_available_provider_ids(
db, global_conversion_enabled
)
available_provider_ids = self._get_all_available_provider_ids(db, global_conversion_enabled)
if not available_provider_ids:
return {"models": [], "total": 0}
@@ -1154,30 +1171,42 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
- 在内存中进行格式兼容性过滤
- 一次性查询 Key 可用性
"""
from sqlalchemy import tuple_
from src.api.base.models_service import get_available_provider_ids
from src.core.api_format import APIFormat
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.api_format.signature import make_signature_key
from src.models.database import ProviderEndpoint
# 所有 API 格式列表(包括 CLI 格式
# 所有 Chat/CLI endpoint signature用于计算“可访问并集”
all_formats = [
APIFormat.OPENAI.value, APIFormat.OPENAI_CLI.value,
APIFormat.CLAUDE.value, APIFormat.CLAUDE_CLI.value,
APIFormat.GEMINI.value, APIFormat.GEMINI_CLI.value,
"openai:chat",
"openai:cli",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
]
target_pairs = [(f.split(":", 1)[0], f.split(":", 1)[1]) for f in all_formats]
# 步骤 1一次性查询所有活跃端点单次 DB 查询)
endpoint_rows = (
db.query(
ProviderEndpoint.provider_id,
ProviderEndpoint.api_format,
ProviderEndpoint.api_family,
ProviderEndpoint.endpoint_kind,
ProviderEndpoint.format_acceptance_config,
)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
Provider.is_active.is_(True),
ProviderEndpoint.is_active.is_(True),
ProviderEndpoint.api_format.in_(all_formats),
ProviderEndpoint.api_family.isnot(None),
ProviderEndpoint.endpoint_kind.isnot(None),
tuple_(ProviderEndpoint.api_family, ProviderEndpoint.endpoint_kind).in_(
target_pairs
),
)
.all()
)
@@ -1189,23 +1218,23 @@ class ListAvailableModelsAdapter(AuthenticatedApiAdapter):
# 只要端点能被任意一种客户端格式访问,就将其 Provider 加入结果
provider_to_formats: dict[str, set[str]] = {}
for provider_id, endpoint_format, format_acceptance_config in endpoint_rows:
if not provider_id or not endpoint_format:
for provider_id, api_family, endpoint_kind, format_acceptance_config in endpoint_rows:
if not provider_id or not api_family or not endpoint_kind:
continue
endpoint_format_upper = str(endpoint_format).upper()
endpoint_format = make_signature_key(str(api_family), str(endpoint_kind))
# 检查该端点是否能被任意客户端格式访问
for client_format in all_formats:
is_compatible, _, _ = is_format_compatible(
client_format,
endpoint_format_upper,
endpoint_format,
format_acceptance_config,
is_stream=False,
global_conversion_enabled=global_conversion_enabled,
)
if is_compatible:
provider_to_formats.setdefault(provider_id, set()).add(endpoint_format_upper)
provider_to_formats.setdefault(provider_id, set()).add(endpoint_format)
break # 只要有一种客户端格式能访问就够了
if not provider_to_formats:
@@ -1222,7 +1251,6 @@ class ListAvailableProvidersAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from sqlalchemy.orm import selectinload
db = context.db
# 使用 selectinload 预加载所有关联数据,避免 N+1 查询
@@ -1397,7 +1425,9 @@ class UpdateApiKeyCapabilitiesAdapter(AuthenticatedApiAdapter):
},
)
logger.debug(f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {force_capabilities}")
logger.debug(
f"用户 {user.id} 更新API密钥 {self.api_key_id} 的强制能力配置: {force_capabilities}"
)
return {
"message": "API密钥能力配置已更新",
"force_capabilities": api_key.force_capabilities,

View File

@@ -16,9 +16,10 @@ import time
from enum import Enum
import redis.asyncio as aioredis
from src.core.logger import logger
from redis.asyncio import sentinel as redis_sentinel
from src.core.logger import logger
class RedisState(Enum):
"""Redis 连接状态"""

View File

@@ -7,11 +7,12 @@
- 关键数据(计费)仍然立即 commit
"""
from typing import Any
import asyncio
from typing import Any
from sqlalchemy.orm import Session
from src.core.logger import logger
from sqlalchemy.orm import Session
class BatchCommitter:

View File

@@ -11,7 +11,6 @@ from src.clients.redis_client import get_redis_client
from src.core.logger import logger
class CacheService:
"""缓存服务"""

View File

@@ -9,6 +9,7 @@
"""
from __future__ import annotations
import base64
import hashlib
@@ -16,10 +17,10 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from ..config import config
from ..core.exceptions import DecryptionException
from src.core.logger import logger
from ..config import config
from ..core.exceptions import DecryptionException
class CryptoService:

View File

@@ -2,7 +2,7 @@
统一的枚举定义
避免重复定义造成的不一致
注意APIFormat 已移至 src/core/api_format/enums.py
注意APIFormat 架构已移除,统一使用 endpoint signaturefamily:kind
"""
from enum import Enum

View File

@@ -2,10 +2,9 @@
错误消息处理工具函数
"""
from __future__ import annotations
def extract_error_message(error: Exception, status_code: int | None = None) -> str:
"""
从异常中提取错误消息,优先使用上游原始响应(用于链路追踪/调试)

View File

@@ -9,7 +9,6 @@
from __future__ import annotations
from starlette.requests import Request
import asyncio
import re
import traceback
@@ -19,6 +18,7 @@ from typing import Any
import httpx
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from starlette.requests import Request
from src.core.logger import logger
@@ -280,9 +280,7 @@ class RateLimitException(ProxyException):
class ConcurrencyLimitError(ProxyException):
"""并发限制异常"""
def __init__(
self, message: str, endpoint_id: str | None = None, key_id: str | None = None
):
def __init__(self, message: str, endpoint_id: str | None = None, key_id: str | None = None):
details = {}
if endpoint_id:
details["endpoint_id"] = endpoint_id

View File

@@ -83,7 +83,9 @@ def get_all_capabilities() -> list[CapabilityDefinition]:
def get_user_configurable_capabilities() -> list[CapabilityDefinition]:
"""获取用户可配置的能力列表"""
return [c for c in _capabilities.values() if c.config_mode == CapabilityConfigMode.USER_CONFIGURABLE]
return [
c for c in _capabilities.values() if c.config_mode == CapabilityConfigMode.USER_CONFIGURABLE
]
# ============ 能力匹配检查 ============

View File

@@ -35,8 +35,7 @@ from loguru import logger
# ============================================================================
IS_DOCKER = (
os.path.exists("/.dockerenv")
or os.environ.get("DOCKER_CONTAINER", "false").lower() == "true"
os.path.exists("/.dockerenv") or os.environ.get("DOCKER_CONTAINER", "false").lower() == "true"
)
# 日志级别: 默认开发环境 DEBUG, 生产环境 INFO
@@ -53,9 +52,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# ============================================================================
CONSOLE_FORMAT_DEV = (
"<green>{time:HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{message}</cyan>"
"<green>{time:HH:mm:ss}</green> | " "<level>{level: <8}</level> | " "<cyan>{message}</cyan>"
)
CONSOLE_FORMAT_PROD = "{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}"

View File

@@ -6,13 +6,11 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any
from collections.abc import Callable
from collections.abc import Awaitable
if TYPE_CHECKING:
from fastapi import APIRouter
from sqlalchemy.orm import Session

View File

@@ -280,9 +280,7 @@ class ModuleRegistry:
logger.warning(f"Module [{name}] health check failed: {e}")
return ModuleHealth.UNHEALTHY
async def get_module_status_async(
self, name: str, db: Session
) -> ModuleStatus | None:
async def get_module_status_async(self, name: str, db: Session) -> ModuleStatus | None:
"""异步获取模块状态(包含健康检查)"""
if name not in self._modules:
return None

View File

@@ -11,16 +11,15 @@ import threading
import time
import traceback
import uuid
from collections.abc import Callable
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from collections.abc import Callable
from ..core.exceptions import ProxyException
from src.core.logger import logger
from ..core.exceptions import ProxyException
class ErrorSeverity(Enum):
@@ -383,7 +382,9 @@ async def safe_operation(operation_name: str, context: dict[str, Any] = None) ->
logger.warning(f"操作警告 [{error_result['error_id']}]: {error_result['user_message']}")
def graceful_degradation(fallback_func: Callable | None = None, fallback_value: Any | None = None) -> Any:
def graceful_degradation(
fallback_func: Callable | None = None, fallback_value: Any | None = None
) -> Any:
"""
优雅降级装饰器
当主要功能失败时,自动切换到备用方案

View File

@@ -163,7 +163,9 @@ class VertexAuthService:
except httpx.HTTPStatusError as e:
error_body = e.response.text[:500] if e.response.text else "(empty)"
raise VertexAuthError(f"Failed to get access token: HTTP {e.response.status_code}: {error_body}")
raise VertexAuthError(
f"Failed to get access token: HTTP {e.response.status_code}: {error_body}"
)
except Exception as e:
raise VertexAuthError(f"Failed to get access token: {e}")

View File

@@ -3,20 +3,20 @@
"""
import time
from typing import Any, cast
from collections.abc import Generator
from typing import Any, cast
from starlette.requests import Request
from sqlalchemy import create_engine, event
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool
from starlette.requests import Request
from src.core.logger import logger
from ..config import config
from src.core.logger import logger
from ..models.database import Base, SystemConfig, User, UserRole
# 延迟初始化的数据库引擎和会话工厂
_engine: Engine | None = None
_SessionLocal: sessionmaker[Session] | None = None
@@ -135,7 +135,9 @@ def _ensure_engine() -> Engine:
_log_pool_capacity()
logger.debug(f"数据库引擎已初始化: {DATABASE_URL.split('@')[-1] if '@' in DATABASE_URL else 'local'}")
logger.debug(
f"数据库引擎已初始化: {DATABASE_URL.split('@')[-1] if '@' in DATABASE_URL else 'local'}"
)
return _engine
@@ -280,6 +282,7 @@ def init_db() -> None:
注意:数据库表结构由 Alembic 管理,部署时请运行 ./migrate.sh
"""
import sys
from sqlalchemy.exc import OperationalError
logger.info("初始化数据库...")

View File

@@ -131,34 +131,38 @@ class PluginMiddleware:
if not exception_occurred and response_status_code > 0:
await self._call_post_request_plugins(request, response_status_code, start_time)
async def _send_rate_limit_response(
self, send: Send, result: RateLimitResult
) -> None:
async def _send_rate_limit_response(self, send: Send, result: RateLimitResult) -> None:
"""发送 429 限流响应"""
import json
body = json.dumps({
"type": "error",
"error": {
"type": "rate_limit_error",
"message": result.message or "Rate limit exceeded",
},
}).encode("utf-8")
body = json.dumps(
{
"type": "error",
"error": {
"type": "rate_limit_error",
"message": result.message or "Rate limit exceeded",
},
}
).encode("utf-8")
headers = [(b"content-type", b"application/json")]
if result.headers:
for key, value in result.headers.items():
headers.append((key.lower().encode(), str(value).encode()))
await send({
"type": "http.response.start",
"status": 429,
"headers": headers,
})
await send({
"type": "http.response.body",
"body": body,
})
await send(
{
"type": "http.response.start",
"status": 429,
"headers": headers,
}
)
await send(
{
"type": "http.response.body",
"body": body,
}
)
def _finalize_db_session(
self,
@@ -197,9 +201,7 @@ class PluginMiddleware:
except Exception as close_error:
logger.debug(f"{log_prefix}关闭数据库连接时出错(可忽略): {close_error}")
async def _cleanup_db_session(
self, request: Request, exception: Exception | None
) -> None:
async def _cleanup_db_session(self, request: Request, exception: Exception | None) -> None:
"""清理数据库会话
事务策略:
@@ -226,9 +228,7 @@ class PluginMiddleware:
should_rollback=exception is not None,
)
async def _maybe_release_streaming_db_session(
self, request: Request, message: Message
) -> None:
async def _maybe_release_streaming_db_session(self, request: Request, message: Message) -> None:
"""在 SSE 响应开始时提前释放请求级 DB session。"""
if getattr(request.state, "db_released_early", False):
return
@@ -401,7 +401,7 @@ class PluginMiddleware:
allowed=False,
remaining=0,
retry_after=30,
message="Rate limit service unavailable"
message="Rate limit service unavailable",
)
except TimeoutError as e:
# 超时错误:可能是负载过高,根据配置决定
@@ -410,10 +410,7 @@ class PluginMiddleware:
return None
else:
return RateLimitResult(
allowed=False,
remaining=0,
retry_after=30,
message="Rate limit service timeout"
allowed=False, remaining=0, retry_after=30, message="Rate limit service timeout"
)
except Exception as e:
logger.error(f"Rate limit error: {type(e).__name__}: {e}")
@@ -424,10 +421,7 @@ class PluginMiddleware:
else:
# fail-close: 异常时拒绝请求(优先安全性)
return RateLimitResult(
allowed=False,
remaining=0,
retry_after=60,
message="Rate limit service error"
allowed=False, remaining=0, retry_after=60, message="Rate limit service error"
)
async def _call_pre_request_plugins(self, request: Request) -> None:

View File

@@ -12,7 +12,6 @@ from typing import Any
from pydantic import BaseModel, Field, field_validator
from src.core.api_format import APIFormat
from src.core.enums import ProviderBillingType
@@ -71,8 +70,20 @@ class CreateProviderRequest(BaseModel):
# 检查 SQL 注入关键字(不区分大小写)
sql_keywords = [
"SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE",
"ALTER", "TRUNCATE", "UNION", "EXEC", "EXECUTE", "--", "/*", "*/"
"SELECT",
"INSERT",
"UPDATE",
"DELETE",
"DROP",
"CREATE",
"ALTER",
"TRUNCATE",
"UNION",
"EXEC",
"EXECUTE",
"--",
"/*",
"*/",
]
v_upper = v.upper()
for keyword in sql_keywords:
@@ -80,6 +91,7 @@ class CreateProviderRequest(BaseModel):
raise ValueError(f"名称包含非法关键字: {keyword}")
return v
billing_type: str | None = Field(
ProviderBillingType.PAY_AS_YOU_GO.value, description="计费类型"
)
@@ -87,15 +99,21 @@ class CreateProviderRequest(BaseModel):
quota_reset_day: int | None = Field(30, ge=1, le=365, description="配额重置周期(天数)")
quota_last_reset_at: datetime | None = Field(None, description="当前周期开始时间")
quota_expires_at: datetime | None = Field(None, description="配额过期时间")
provider_priority: int | None = Field(100, ge=0, le=1000, description="提供商优先级(数字越小越优先)")
provider_priority: int | None = Field(
100, ge=0, le=1000, description="提供商优先级(数字越小越优先)"
)
is_active: bool | None = Field(True, description="是否启用")
concurrent_limit: int | None = Field(None, ge=0, description="并发限制")
# 请求配置(从 Endpoint 迁移)
max_retries: int | None = Field(2, ge=0, le=10, description="最大重试次数")
proxy: ProxyConfig | None = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: float | None = Field(None, ge=1, le=600, description="流式请求整体超时(秒)")
stream_first_byte_timeout: float | None = Field(
None, ge=1, le=300, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
None, ge=1, le=600, description="非流式请求整体超时(秒)"
)
config: dict[str, Any] | None = Field(None, description="其他配置")
@field_validator("name", "description")
@@ -167,8 +185,12 @@ class UpdateProviderRequest(BaseModel):
max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数")
proxy: ProxyConfig | None = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: float | None = Field(None, ge=1, le=600, description="流式请求整体超时(秒)")
stream_first_byte_timeout: float | None = Field(
None, ge=1, le=300, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
None, ge=1, le=600, description="非流式请求整体超时(秒)"
)
config: dict[str, Any] | None = None
# 复用相同的验证器
@@ -187,7 +209,9 @@ class CreateEndpointRequest(BaseModel):
provider_id: str = Field(..., description="Provider ID")
name: str = Field(..., min_length=1, max_length=100, description="Endpoint 名称")
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
api_format: str = Field(..., description="API 格式CLAUDE 或 OPENAI")
api_format: str = Field(
..., description="Endpoint signature如 openai:chat, claude:cli, gemini:video"
)
custom_path: str | None = Field(None, max_length=200, description="自定义路径")
priority: int | None = Field(100, ge=0, le=1000, description="优先级")
is_active: bool | None = Field(True, description="是否启用")
@@ -216,12 +240,14 @@ class CreateEndpointRequest(BaseModel):
@classmethod
def validate_api_format(cls, v: str) -> str:
"""验证 API 格式"""
try:
APIFormat(v)
return v
except ValueError:
valid_formats = [f.value for f in APIFormat]
raise ValueError(f"无效的 API 格式,有效值为: {', '.join(valid_formats)}")
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
from src.core.api_format.signature import normalize_signature_key
normalized = normalize_signature_key(v)
if resolve_endpoint_definition(normalized) is None:
valid_formats = [d.signature_key for d in list_endpoint_definitions()]
raise ValueError(f"无效的 api_format有效值为: {', '.join(valid_formats)}")
return normalized
@field_validator("custom_path")
@classmethod
@@ -307,7 +333,9 @@ class UpdateUserRequest(BaseModel):
username: str | None = Field(None, min_length=1, max_length=50)
email: str | None = Field(None, max_length=100)
password: str | None = Field(None, min_length=6, max_length=128, description="新密码(留空保持不变)")
password: str | None = Field(
None, min_length=6, max_length=128, description="新密码(留空保持不变)"
)
quota_usd: float | None = Field(None, ge=0)
is_active: bool | None = None
role: str | None = None

View File

@@ -244,9 +244,15 @@ class CreateUserRequest(BaseModel):
quota_usd: float | None = Field(default=None, description="USD配额null表示使用系统默认配额")
unlimited: bool = Field(default=False, description="是否无限配额")
# 访问限制字段
allowed_providers: list[str] | None = Field(default=None, description="允许使用的提供商ID列表null表示无限制")
allowed_api_formats: list[str] | None = Field(default=None, description="允许使用的API格式列表null表示无限制")
allowed_models: list[str] | None = Field(default=None, description="允许使用的模型名称列表null表示无限制")
allowed_providers: list[str] | None = Field(
default=None, description="允许使用的提供商ID列表null表示无限制"
)
allowed_api_formats: list[str] | None = Field(
default=None, description="允许使用的API格式列表null表示无限制"
)
allowed_models: list[str] | None = Field(
default=None, description="允许使用的模型名称列表null表示无限制"
)
@field_validator("quota_usd", mode="before")
@classmethod
@@ -285,6 +291,30 @@ class CreateUserRequest(BaseModel):
raise ValueError("用户名只能包含字母、数字、下划线、连字符和点号")
return v
@field_validator("allowed_api_formats")
@classmethod
def validate_allowed_api_formats(cls, v: list[str] | None) -> list[str] | None:
"""校验并规范化 allowed_api_formatsendpoint signature: family:kind"""
if v is None:
return None
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
from src.core.api_format.signature import normalize_signature_key
allowed = [d.signature_key for d in list_endpoint_definitions()]
out: list[str] = []
seen: set[str] = set()
for fmt in v:
if not fmt:
continue
norm = normalize_signature_key(fmt)
if resolve_endpoint_definition(norm) is None:
raise ValueError(f"allowed_api_formats 必须是以下之一: {allowed},当前值: {fmt}")
if norm in seen:
continue
seen.add(norm)
out.append(norm)
return out
@classmethod
@field_validator("password")
def validate_password(cls, v: Any) -> Any:
@@ -313,6 +343,12 @@ class UpdateUserRequest(BaseModel):
quota_usd: float | None = None
is_active: bool | None = None
@field_validator("allowed_api_formats")
@classmethod
def validate_allowed_api_formats(cls, v: list[str] | None) -> list[str] | None:
# 与 CreateUserRequest 保持一致
return CreateUserRequest.validate_allowed_api_formats(v)
@field_validator("quota_usd", mode="before")
@classmethod
def validate_quota_usd(cls, v: Any) -> Any:
@@ -344,6 +380,12 @@ class CreateApiKeyRequest(BaseModel):
False, description="过期后是否自动删除True=物理删除False=仅禁用)"
)
@field_validator("allowed_api_formats")
@classmethod
def validate_allowed_api_formats(cls, v: list[str] | None) -> list[str] | None:
# 与 CreateUserRequest 保持一致
return CreateUserRequest.validate_allowed_api_formats(v)
class UserResponse(BaseModel):
"""用户响应"""
@@ -408,8 +450,12 @@ class ProviderCreate(BaseModel):
is_active: bool = Field(False, description="是否启用默认false需要配置API密钥后才能启用")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: float | None = Field(None, ge=1, le=600, description="流式请求整体超时(秒)")
stream_first_byte_timeout: float | None = Field(
None, ge=1, le=300, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
None, ge=1, le=600, description="非流式请求整体超时(秒)"
)
class ProviderUpdate(BaseModel):
@@ -430,8 +476,12 @@ class ProviderUpdate(BaseModel):
is_active: bool | None = None
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: float | None = Field(None, ge=1, le=600, description="流式请求整体超时(秒)")
stream_first_byte_timeout: float | None = Field(
None, ge=1, le=300, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
None, ge=1, le=600, description="非流式请求整体超时(秒)"
)
class ProviderResponse(BaseModel):

View File

@@ -707,7 +707,11 @@ class ProviderEndpoint(Base):
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False)
# API 格式和配置
api_format = Column(String(50), nullable=False) # 存储 APIFormat 枚举值的字符串
# 新模式:存储 endpoint signature keyfamily: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)
# 请求配置
@@ -754,6 +758,7 @@ class ProviderEndpoint(Base):
__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"),
)
@@ -1021,7 +1026,7 @@ class Model(Base):
Args:
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一映射
api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的映射
api_format: 当前请求的 endpoint signature"openai:chat"),用于过滤适用的映射
"""
import hashlib
@@ -1044,8 +1049,11 @@ class Model(Base):
mapping_api_formats = raw.get("api_formats")
if api_format and mapping_api_formats:
# 如果配置了作用域,只有匹配时才生效
if isinstance(mapping_api_formats, list) and api_format not in mapping_api_formats:
continue
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:
@@ -1228,7 +1236,7 @@ class ProviderAPIKey(Base):
# API 格式支持列表(核心字段)
# None 表示支持所有格式(兼容历史数据),空列表 [] 表示不支持任何格式
api_formats = Column(JSON, nullable=True, default=list) # ["CLAUDE", "CLAUDE_CLI"]
api_formats = Column(JSON, nullable=True, default=list) # ["claude:chat", "claude:cli"]
# 认证类型
# - "api_key": 标准 API Key 认证(默认)
@@ -1252,7 +1260,7 @@ class ProviderAPIKey(Base):
# 成本计算
rate_multipliers = Column(
JSON, nullable=True
) # 按 API 格式的成本倍率 {"CLAUDE_CLI": 1.0, "OPENAI_CLI": 0.8}
) # 按 endpoint signature 的成本倍率 {"claude:cli": 1.0, "openai:cli": 0.8}
# 优先级配置 (数字越小越优先)
internal_priority = Column(
@@ -1260,7 +1268,7 @@ class ProviderAPIKey(Base):
) # Endpoint 内部优先级(用于提供商优先模式,同 Endpoint 内 Keys 的排序,同优先级参与负载均衡)
global_priority_by_format = Column(
JSON, nullable=True
) # 按 API 格式的全局优先级 {"CLAUDE": 1, "CLAUDE_CLI": 2}
) # 按 endpoint signature 的全局优先级 {"claude:chat": 1, "claude:cli": 2}
# RPM 限制配置(自适应学习)
# rpm_limit 决定 RPM 控制模式:
@@ -1289,8 +1297,8 @@ class ProviderAPIKey(Base):
) # 利用率采样窗口 [{"ts": timestamp, "util": 0.8}, ...]
last_probe_increase_at = Column(DateTime(timezone=True), nullable=True) # 上次探测性扩容时间
# 健康度追踪(按 API 格式存储)
# 结构: {"CLAUDE": {"health_score": 1.0, "consecutive_failures": 0, "last_failure_at": null, "request_results_window": []}, ...}
# 健康度追踪(按 endpoint signature 存储)
# 结构: {"claude:chat": {"health_score": 1.0, "consecutive_failures": 0, ...}, ...}
health_by_format = Column(JSON, nullable=True, default=dict)
# 缓存与熔断配置
@@ -1301,8 +1309,8 @@ class ProviderAPIKey(Base):
Integer, default=32, nullable=False
) # 最大探测间隔(分钟)默认32分钟硬上限
# 熔断器状态(按 API 格式存储)
# 结构: {"CLAUDE": {"open": false, "open_at": null, "next_probe_at": null, "half_open_until": null, "half_open_successes": 0, "half_open_failures": 0}, ...}
# 熔断器状态(按 endpoint signature 存储)
# 结构: {"claude:chat": {"open": false, "open_at": null, ...}, ...}
circuit_breaker_by_format = Column(JSON, nullable=True, default=dict)
# 使用统计

View File

@@ -12,7 +12,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from src.models.admin_requests import ProxyConfig
# ========== Header Rule 类型定义 ==========
# 请求头规则支持三种操作:
# - set: 设置/覆盖请求头 {"action": "set", "key": "X-Custom", "value": "val"}
@@ -29,7 +28,12 @@ class ProviderEndpointCreate(BaseModel):
"""创建 Endpoint 请求"""
provider_id: str = Field(..., description="Provider ID")
api_format: str = Field(..., description="API 格式 (CLAUDE, OPENAI, CLAUDE_CLI, OPENAI_CLI)")
api_format: str = Field(
...,
description=(
"Endpoint signature例如: claude:chat/claude:cli, openai:chat/openai:cli/openai:video, gemini:chat/gemini:cli/gemini:video"
),
)
base_url: str = Field(..., min_length=1, max_length=500, description="API 基础 URL")
custom_path: str | None = Field(default=None, max_length=200, description="自定义请求路径")
@@ -57,13 +61,14 @@ class ProviderEndpointCreate(BaseModel):
@classmethod
def validate_api_format(cls, v: str) -> str:
"""验证 API 格式"""
from src.core.api_format import APIFormat
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
from src.core.api_format.signature import normalize_signature_key
allowed = [fmt.value for fmt in APIFormat]
v_upper = v.upper()
if v_upper not in allowed:
raise ValueError(f"API 格式必须是 {allowed} 之一")
return v_upper
normalized = normalize_signature_key(v)
if resolve_endpoint_definition(normalized) is None:
allowed = [d.signature_key for d in list_endpoint_definitions()]
raise ValueError(f"api_format 必须是以下之一: {allowed}")
return normalized
@field_validator("base_url")
@classmethod
@@ -125,9 +130,7 @@ class ProviderEndpointResponse(BaseModel):
custom_path: str | None = None
# 请求头配置
header_rules: list[HeaderRule] | None = Field(
default=None, description="请求头规则列表"
)
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
max_retries: int
@@ -165,23 +168,25 @@ class EndpointAPIKeyCreate(BaseModel):
provider_id: str | None = Field(default=None, description="Provider ID从 URL 获取)")
api_formats: list[str] | None = Field(
default=None, min_length=1, description="支持的 API 格式列表(必填,路由层校验)"
default=None, min_length=1, description="支持的 endpoint signature 列表(必填,路由层校验)"
)
api_key: str = Field(default="", max_length=500, description="API Key标准认证时必填将自动加密")
api_key: str = Field(
default="", max_length=500, description="API Key标准认证时必填将自动加密"
)
auth_type: Literal["api_key", "vertex_ai"] = Field(
default="api_key",
description="认证类型api_key标准 API Key或 vertex_aiVertex AI Service Account"
description="认证类型api_key标准 API Key或 vertex_aiVertex AI Service Account",
)
auth_config: dict[str, Any] | None = Field(
default=None,
description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
)
name: str = Field(..., min_length=1, max_length=100, description="密钥名称(必填,用于识别)")
# 成本计算
rate_multipliers: dict[str, float] | None = Field(
default=None, description="按 API 格式的成本倍率,如 {'CLAUDE_CLI': 1.0, 'OPENAI_CLI': 0.8}"
default=None,
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
)
# 优先级和限制(数字越小越优先)
@@ -236,19 +241,20 @@ class EndpointAPIKeyCreate(BaseModel):
if v is None:
return v
from src.core.api_format import APIFormat
from src.core.api_format import list_endpoint_definitions, resolve_endpoint_definition
from src.core.api_format.signature import normalize_signature_key
allowed = [fmt.value for fmt in APIFormat]
validated = []
seen = set()
allowed = [d.signature_key for d in list_endpoint_definitions()]
validated: list[str] = []
seen: set[str] = set()
for fmt in v:
fmt_upper = fmt.upper()
if fmt_upper not in allowed:
raise ValueError(f"API 格式必须是 {allowed} 之一,当前值: {fmt}")
if fmt_upper in seen:
normalized = normalize_signature_key(fmt)
if resolve_endpoint_definition(normalized) is None:
raise ValueError(f"api_formats 必须是以下之一: {allowed},当前值: {fmt}")
if normalized in seen:
continue # 静默去重
seen.add(fmt_upper)
validated.append(fmt_upper)
seen.add(normalized)
validated.append(normalized)
return validated
@field_validator("allowed_models")
@@ -323,25 +329,29 @@ class EndpointAPIKeyUpdate(BaseModel):
)
api_key: str | None = Field(
default=None, min_length=3, max_length=500, description="API Key标准认证时使用将自动加密"
default=None,
min_length=3,
max_length=500,
description="API Key标准认证时使用将自动加密",
)
auth_type: Literal["api_key", "vertex_ai"] | None = Field(
default=None,
description="认证类型api_key标准 API Key或 vertex_aiVertex AI Service Account"
description="认证类型api_key标准 API Key或 vertex_aiVertex AI Service Account",
)
auth_config: dict[str, Any] | None = Field(
default=None,
description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
default=None, description="认证配置JSONvertex_ai 时存储完整 Service Account JSON"
)
name: str | None = Field(default=None, min_length=1, max_length=100, description="密钥名称")
rate_multipliers: dict[str, float] | None = Field(
default=None, description="按 API 格式的成本倍率,如 {'CLAUDE_CLI': 1.0, 'OPENAI_CLI': 0.8}"
default=None,
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
)
internal_priority: int | None = Field(
default=None, description="Key 内部优先级(提供商优先模式,数字越小越优先)"
)
global_priority_by_format: dict[str, int] | None = Field(
default=None, description="按 API 格式的全局优先级,如 {'CLAUDE': 1, 'CLAUDE_CLI': 2}"
default=None,
description="按 endpoint signature 的全局优先级,如 {'claude:chat': 1, 'claude:cli': 2}",
)
# rpm_limit: 使用特殊标记区分"未提供"和"设置为 null自适应模式"
# - 不提供字段:不更新
@@ -365,9 +375,7 @@ class EndpointAPIKeyUpdate(BaseModel):
)
is_active: bool | None = Field(default=None, description="是否启用")
note: str | None = Field(default=None, max_length=500, description="备注说明")
auto_fetch_models: bool | None = Field(
default=None, description="是否启用自动获取模型"
)
auto_fetch_models: bool | None = Field(default=None, description="是否启用自动获取模型")
locked_models: list[str] | None = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
@@ -386,20 +394,7 @@ class EndpointAPIKeyUpdate(BaseModel):
if v is None:
return v
from src.core.api_format import APIFormat
allowed = [fmt.value for fmt in APIFormat]
validated = []
seen = set()
for fmt in v:
fmt_upper = fmt.upper()
if fmt_upper not in allowed:
raise ValueError(f"API 格式必须是 {allowed} 之一,当前值: {fmt}")
if fmt_upper in seen:
continue # 静默去重
seen.add(fmt_upper)
validated.append(fmt_upper)
return validated
return EndpointAPIKeyCreate.validate_api_formats(v)
@field_validator("allowed_models")
@classmethod
@@ -458,7 +453,9 @@ class EndpointAPIKeyResponse(BaseModel):
id: str
provider_id: str = Field(..., description="Provider ID")
api_formats: list[str] = Field(default=[], description="支持的 API 格式列表")
api_formats: list[str] = Field(
default=[], description="支持的 endpoint signature 列表(如 openai:chat, claude:cli"
)
# Key 信息(脱敏)
api_key_masked: str = Field(..., description="脱敏后的 Key")
@@ -469,13 +466,14 @@ class EndpointAPIKeyResponse(BaseModel):
# 成本计算
rate_multipliers: dict[str, float] | None = Field(
default=None, description="按 API 格式的成本倍率,如 {'CLAUDE_CLI': 1.0, 'OPENAI_CLI': 0.8}"
default=None,
description="按 endpoint signature 的成本倍率,如 {'claude:cli': 1.0, 'openai:cli': 0.8}",
)
# 优先级和限制
internal_priority: int = Field(default=50, description="Endpoint 内部优先级")
global_priority_by_format: dict[str, int] | None = Field(
default=None, description="API 格式的全局优先级"
default=None, description="endpoint signature 的全局优先级"
)
rpm_limit: int | None = None
allowed_models: list[str] | None = None
@@ -485,12 +483,12 @@ class EndpointAPIKeyResponse(BaseModel):
cache_ttl_minutes: int = Field(default=5, description="缓存 TTL分钟0=禁用")
max_probe_interval_minutes: int = Field(default=32, description="熔断探测间隔(分钟)")
# 按格式的健康度数据
# 按 endpoint signature 的健康度数据
health_by_format: dict[str, Any] | None = Field(
default=None, description="API 格式存储的健康度数据"
default=None, description="endpoint signature 存储的健康度数据"
)
circuit_breaker_by_format: dict[str, Any] | None = Field(
default=None, description="API 格式存储的熔断器状态"
default=None, description="endpoint signature 存储的熔断器状态"
)
# 聚合字段(从 health_by_format 计算,用于列表显示)
@@ -648,8 +646,12 @@ class ProviderUpdateRequest(BaseModel):
max_retries: int | None = Field(None, ge=0, le=10, description="最大重试次数")
proxy: dict[str, Any] | None = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: float | None = Field(None, ge=1, le=600, description="流式请求整体超时(秒)")
stream_first_byte_timeout: float | None = Field(
None, ge=1, le=300, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(
None, ge=1, le=600, description="非流式请求整体超时(秒)"
)
class ProviderWithEndpointsSummary(BaseModel):
@@ -679,7 +681,9 @@ class ProviderWithEndpointsSummary(BaseModel):
max_retries: int | None = Field(default=2, description="最大重试次数")
proxy: dict[str, Any] | None = Field(default=None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: float | None = Field(default=None, description="流式请求首字节超时(秒)")
stream_first_byte_timeout: float | None = Field(
default=None, description="流式请求首字节超时(秒)"
)
request_timeout: float | None = Field(default=None, description="非流式请求整体超时(秒)")
# Endpoint 统计
@@ -780,9 +784,7 @@ class ApiFormatHealthMonitor(BaseModel):
time_range_start: datetime | None = Field(
default=None, description="时间线所覆盖区间的开始时间"
)
time_range_end: datetime | None = Field(
default=None, description="时间线所覆盖区间的结束时间"
)
time_range_end: datetime | None = Field(default=None, description="时间线所覆盖区间的结束时间")
class ApiFormatHealthMonitorResponse(BaseModel):

View File

@@ -3,12 +3,12 @@ Pydantic 数据模型(阶段一统一模型管理)
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, model_validator
# ========== 阶梯计费相关模型 ==========
@@ -16,25 +16,23 @@ class CacheTTLPricing(BaseModel):
"""缓存时长定价配置"""
ttl_minutes: int = Field(..., ge=1, description="缓存时长(分钟)")
cache_creation_price_per_1m: float = Field(..., ge=0, description="该时长的缓存创建价格/M tokens")
cache_creation_price_per_1m: float = Field(
..., ge=0, description="该时长的缓存创建价格/M tokens"
)
class PricingTier(BaseModel):
"""单个价格阶梯配置"""
up_to: int | None = Field(
None,
ge=1,
description="阶梯上限tokensnull 表示无上限(最后一个阶梯)"
None, ge=1, description="阶梯上限tokensnull 表示无上限(最后一个阶梯)"
)
input_price_per_1m: float = Field(..., ge=0, description="输入价格/M tokens")
output_price_per_1m: float = Field(..., ge=0, description="输出价格/M tokens")
cache_creation_price_per_1m: float | None = Field(
None, ge=0, description="缓存创建价格/M tokens"
)
cache_read_price_per_1m: float | None = Field(
None, ge=0, description="缓存读取价格/M tokens"
)
cache_read_price_per_1m: float | None = Field(None, ge=0, description="缓存读取价格/M tokens")
cache_ttl_pricing: list[CacheTTLPricing] | None = Field(
None, description="按缓存时长分价格(可选)"
)
@@ -44,9 +42,7 @@ class TieredPricingConfig(BaseModel):
"""阶梯计费配置"""
tiers: list[PricingTier] = Field(
...,
min_length=1,
description="价格阶梯列表,按 up_to 升序排列"
..., min_length=1, description="价格阶梯列表,按 up_to 升序排列"
)
@model_validator(mode="after")
@@ -78,9 +74,7 @@ class TieredPricingConfig(BaseModel):
prev_ttl = 0
for ttl_pricing in tier.cache_ttl_pricing:
if ttl_pricing.ttl_minutes <= prev_ttl:
raise ValueError(
f"cache_ttl_pricing 必须按 ttl_minutes 升序排列"
)
raise ValueError(f"cache_ttl_pricing 必须按 ttl_minutes 升序排列")
prev_ttl = ttl_pricing.ttl_minutes
# 最后一个阶梯必须是无上限的
@@ -195,13 +189,10 @@ class GlobalModelCreate(BaseModel):
..., description="阶梯计费配置(固定价格用单阶梯表示)"
)
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"]
supported_capabilities: list[str] | None = Field(
None, description="支持的 Key 能力列表"
)
supported_capabilities: list[str] | None = Field(None, description="支持的 Key 能力列表")
# 模型配置JSON格式- 包含能力、规格、元信息等
config: dict[str, Any] | None = Field(
None,
description="模型配置streaming, vision, context_limit, description 等)"
None, description="模型配置streaming, vision, context_limit, description 等)"
)
is_active: bool | None = Field(True, description="是否激活")
@@ -214,17 +205,12 @@ class GlobalModelUpdate(BaseModel):
# 按次计费配置
default_price_per_request: float | None = Field(None, ge=0, description="每次请求固定费用")
# 阶梯计费配置
default_tiered_pricing: TieredPricingConfig | None = Field(
None, description="阶梯计费配置"
)
default_tiered_pricing: TieredPricingConfig | None = Field(None, description="阶梯计费配置")
# Key 能力配置 - 模型支持的能力列表(如 ["cache_1h", "context_1m"]
supported_capabilities: list[str] | None = Field(
None, description="支持的 Key 能力列表"
)
supported_capabilities: list[str] | None = Field(None, description="支持的 Key 能力列表")
# 模型配置JSON格式- 包含能力、规格、元信息等
config: dict[str, Any] | None = Field(
None,
description="模型配置streaming, vision, context_limit, description 等)"
None, description="模型配置streaming, vision, context_limit, description 等)"
)
@@ -247,8 +233,7 @@ class GlobalModelResponse(BaseModel):
)
# 模型配置JSON格式
config: dict[str, Any] | None = Field(
default=None,
description="模型配置streaming, vision, context_limit, description 等)"
default=None, description="模型配置streaming, vision, context_limit, description 等)"
)
# 统计数据(可选)
provider_count: int | None = Field(default=0, description="支持的 Provider 数量")
@@ -315,12 +300,10 @@ class ImportFromUpstreamRequest(BaseModel):
# 价格覆盖配置(应用于所有导入的模型)
tiered_pricing: dict | None = Field(
None,
description="阶梯计费配置(可选),格式: {tiers: [{up_to, input_price_per_1m, output_price_per_1m, ...}]}"
description="阶梯计费配置(可选),格式: {tiers: [{up_to, input_price_per_1m, output_price_per_1m, ...}]}",
)
price_per_request: float | None = Field(
None,
ge=0,
description="按次计费价格(可选,单位:美元)"
None, ge=0, description="按次计费价格(可选,单位:美元)"
)
@@ -331,7 +314,9 @@ class ImportFromUpstreamSuccessItem(BaseModel):
provider_model_id: str = Field(..., description="Provider Model ID")
global_model_id: str | None = Field("", description="GlobalModel ID如果已关联")
global_model_name: str | None = Field("", description="GlobalModel 名称(如果已关联)")
created_global_model: bool = Field(False, description="是否新创建了 GlobalModel始终为 false")
created_global_model: bool = Field(
False, description="是否新创建了 GlobalModel始终为 false"
)
class ImportFromUpstreamErrorItem(BaseModel):

View File

@@ -47,11 +47,7 @@ def _validate_config(db: Session) -> tuple[bool, str]:
from src.models.database import OAuthProvider
# 查找所有已启用的 Provider
enabled_providers = (
db.query(OAuthProvider)
.filter(OAuthProvider.is_enabled.is_(True))
.all()
)
enabled_providers = db.query(OAuthProvider).filter(OAuthProvider.is_enabled.is_(True)).all()
if not enabled_providers:
return False, "请先配置并启用至少一个 OAuth Provider"
@@ -87,4 +83,3 @@ oauth_module = ModuleDefinition(
health_check=_health_check,
validate_config=_validate_config,
)

View File

@@ -3,7 +3,6 @@ API Key认证插件
支持从header中提取API Key进行认证
"""
from __future__ import annotations
from fastapi import Request
@@ -16,7 +15,6 @@ from src.services.usage.service import UsageService
from .base import AuthContext, AuthPlugin
class ApiKeyAuthPlugin(AuthPlugin):
"""
API Key认证插件

View File

@@ -17,7 +17,6 @@ from src.services.auth.service import AuthService
from .base import AuthContext, AuthPlugin
class JwtAuthPlugin(AuthPlugin):
"""
JWT认证插件

View File

@@ -30,7 +30,6 @@ from src.core.logger import logger
from .base import LoadBalancerStrategy, ProviderCandidate, SelectionResult
class StickyPriorityStrategy(LoadBalancerStrategy):
"""
粘性优先级策略
@@ -154,14 +153,18 @@ class StickyPriorityStrategy(LoadBalancerStrategy):
)
# 粘性提供商不健康,选择备用提供商
logger.warning(f"Sticky provider {sticky_candidate.provider.name} is unhealthy, selecting backup")
logger.warning(
f"Sticky provider {sticky_candidate.provider.name} is unhealthy, selecting backup"
)
# 从同一优先级组中选择健康的备用提供商
backup_candidate = self._select_backup_provider(highest_group)
if not backup_candidate:
# 如果没有健康的备用,降级使用不健康的粘性提供商
logger.warning("No healthy backup provider available, falling back to unhealthy sticky provider")
logger.warning(
"No healthy backup provider available, falling back to unhealthy sticky provider"
)
backup_candidate = sticky_candidate
self._record_selection(backup_candidate.provider, is_sticky=False)

View File

@@ -25,7 +25,6 @@ from src.plugins.rate_limit.base import RateLimitStrategy
from src.plugins.token.base import TokenCounterPlugin
class PluginManager:
"""
统一的插件管理器
@@ -140,9 +139,11 @@ class PluginManager:
# 检查 API 版本兼容性
plugin_api_version = getattr(plugin_instance.metadata, "api_version", "1.0")
if not self._is_api_version_compatible(plugin_api_version):
logger.warning(f"Plugin {plugin_instance.name} has incompatible API version "
logger.warning(
f"Plugin {plugin_instance.name} has incompatible API version "
f"{plugin_api_version} (supported: {self.SUPPORTED_API_VERSION}), "
f"plugin will be disabled")
f"plugin will be disabled"
)
plugin_instance.enabled = False
self._incompatible_plugins.append(plugin_instance.name)
@@ -378,12 +379,16 @@ class PluginManager:
else:
# 初始化失败,禁用插件
plugin.enabled = False
logger.error(f"Failed to initialize plugin: {plugin.name}, plugin has been disabled")
logger.error(
f"Failed to initialize plugin: {plugin.name}, plugin has been disabled"
)
except Exception as e:
results[f"{plugin.name}"] = False
# 初始化异常,禁用插件
plugin.enabled = False
logger.error(f"Error initializing plugin {plugin.name}: {e}, plugin has been disabled")
logger.error(
f"Error initializing plugin {plugin.name}: {e}, plugin has been disabled"
)
return results
@@ -458,8 +463,10 @@ class PluginManager:
if len(result) != len(plugins):
remaining = [p for p in plugins if p not in result]
circular_names = [p.name for p in remaining]
logger.error(f"Circular dependency detected among plugins: {circular_names}. "
f"These plugins will be disabled.")
logger.error(
f"Circular dependency detected among plugins: {circular_names}. "
f"These plugins will be disabled."
)
# 禁用存在循环依赖的插件,而不是继续加载
for plugin in remaining:
plugin.enabled = False

View File

@@ -83,7 +83,9 @@ class MonitorPlugin(BasePlugin):
pass
@abstractmethod
async def increment(self, name: str, value: float = 1, labels: dict[str, str] | None = None) -> Any:
async def increment(
self, name: str, value: float = 1, labels: dict[str, str] | None = None
) -> Any:
"""
增加计数器

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