mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 的健康状态")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 不存在")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
删除提供商
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 Providers(2.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)}
|
||||
|
||||
|
||||
# -------- 邮件模板适配器 --------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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与格式适配器。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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密钥")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 key(family: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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 key(family: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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. 回退到旧格式
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,6 @@ Claude SSE 流解析器
|
||||
解析 Claude Messages API 的 Server-Sent Events 流。
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,7 +4,6 @@ OpenAI SSE 流解析器
|
||||
解析 OpenAI Chat Completions API 的 Server-Sent Events 流。
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,4 +12,3 @@ router.include_router(user_router)
|
||||
router.include_router(admin_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""OAuth 公开端点(无需登录)。"""
|
||||
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""OAuth 用户端点(需登录)。"""
|
||||
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""公开模块状态 API(供登录页等使用)"""
|
||||
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 连接状态"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,7 +11,6 @@ from src.clients.redis_client import get_redis_client
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""缓存服务"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
统一的枚举定义
|
||||
避免重复定义造成的不一致
|
||||
|
||||
注意:APIFormat 已移至 src/core/api_format/enums.py
|
||||
注意:APIFormat 架构已移除,统一使用 endpoint signature(family:kind)。
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
错误消息处理工具函数
|
||||
"""
|
||||
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def extract_error_message(error: Exception, status_code: int | None = None) -> str:
|
||||
"""
|
||||
从异常中提取错误消息,优先使用上游原始响应(用于链路追踪/调试)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
# ============ 能力匹配检查 ============
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
优雅降级装饰器
|
||||
当主要功能失败时,自动切换到备用方案
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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("初始化数据库...")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_formats(endpoint 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):
|
||||
|
||||
@@ -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 key(family:kind),如 "openai:chat"
|
||||
api_format = Column(String(50), nullable=False)
|
||||
# 新架构字段(Phase 1/3):用于将 api_format 拆分为结构化维度
|
||||
api_family = Column(String(50), nullable=True) # openai/claude/gemini
|
||||
endpoint_kind = Column(String(50), nullable=True) # chat/cli/video/...
|
||||
base_url = Column(String(500), nullable=False)
|
||||
|
||||
# 请求配置
|
||||
@@ -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)
|
||||
|
||||
# 使用统计
|
||||
|
||||
@@ -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_ai(Vertex AI Service Account)"
|
||||
description="认证类型:api_key(标准 API Key)或 vertex_ai(Vertex AI Service Account)",
|
||||
)
|
||||
auth_config: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="认证配置(JSON):vertex_ai 时存储完整 Service Account JSON"
|
||||
default=None, description="认证配置(JSON):vertex_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_ai(Vertex AI Service Account)"
|
||||
description="认证类型:api_key(标准 API Key)或 vertex_ai(Vertex AI Service Account)",
|
||||
)
|
||||
auth_config: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="认证配置(JSON):vertex_ai 时存储完整 Service Account JSON"
|
||||
default=None, description="认证配置(JSON):vertex_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):
|
||||
|
||||
@@ -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="阶梯上限(tokens),null 表示无上限(最后一个阶梯)"
|
||||
None, ge=1, description="阶梯上限(tokens),null 表示无上限(最后一个阶梯)"
|
||||
)
|
||||
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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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认证插件
|
||||
|
||||
@@ -17,7 +17,6 @@ from src.services.auth.service import AuthService
|
||||
from .base import AuthContext, AuthPlugin
|
||||
|
||||
|
||||
|
||||
class JwtAuthPlugin(AuthPlugin):
|
||||
"""
|
||||
JWT认证插件
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user