mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from .context import ApiRequestContext
|
||||
|
||||
|
||||
class ApiMode(str, Enum):
|
||||
STANDARD = "standard"
|
||||
PROXY = "proxy"
|
||||
ADMIN = "admin"
|
||||
USER = "user" # JWT 认证的普通用户(不要求管理员权限)
|
||||
PUBLIC = "public"
|
||||
MANAGEMENT = "management" # Management Token 认证
|
||||
|
||||
|
||||
class ApiAdapter(ABC):
|
||||
"""所有API格式适配器的抽象基类。"""
|
||||
|
||||
name: str = "base"
|
||||
mode: ApiMode = ApiMode.STANDARD
|
||||
api_format: str | None = None # 对应 Provider API 格式提示
|
||||
audit_log_enabled: bool = True
|
||||
audit_success_event = None
|
||||
audit_failure_event = None
|
||||
eager_request_body: bool = True
|
||||
|
||||
@abstractmethod
|
||||
async def handle(self, context: ApiRequestContext) -> Response:
|
||||
"""处理请求并返回 FastAPI Response。"""
|
||||
|
||||
def authorize(self, context: ApiRequestContext) -> None:
|
||||
"""可选的授权钩子,默认允许通过。"""
|
||||
return None
|
||||
|
||||
def extract_api_key(self, request: Request) -> str | None:
|
||||
"""
|
||||
从请求中提取客户端 API 密钥。
|
||||
|
||||
子类应覆盖此方法以支持各自的认证头格式。
|
||||
|
||||
Args:
|
||||
request: FastAPI Request 对象
|
||||
|
||||
Returns:
|
||||
提取的 API 密钥,如果未找到则返回 None
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_audit_metadata(
|
||||
self,
|
||||
context: ApiRequestContext,
|
||||
*,
|
||||
success: bool,
|
||||
status_code: int | None,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""允许适配器在审计日志中追加自定义字段。"""
|
||||
return {}
|
||||
|
||||
def detect_capability_requirements(
|
||||
self,
|
||||
headers: dict[str, str],
|
||||
request_body: dict[str, Any] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
检测请求中隐含的能力需求(子类可覆盖)
|
||||
|
||||
不同 API 格式有不同的能力声明方式,例如:
|
||||
- Claude: anthropic-beta: context-1m-xxx 表示需要 1M 上下文
|
||||
- 其他格式可能有不同的请求头或请求体字段
|
||||
|
||||
Args:
|
||||
headers: 请求头字典
|
||||
request_body: 请求体字典(可选)
|
||||
|
||||
Returns:
|
||||
检测到的能力需求,如 {"context_1m": True}
|
||||
"""
|
||||
return {}
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.models.database import UserRole
|
||||
|
||||
from .adapter import ApiAdapter, ApiMode
|
||||
from .context import ApiRequestContext
|
||||
|
||||
|
||||
class AdminApiAdapter(ApiAdapter):
|
||||
"""管理员端点适配器基类,提供统一的权限校验。"""
|
||||
|
||||
mode = ApiMode.ADMIN
|
||||
required_roles: tuple[UserRole, ...] = (UserRole.ADMIN,)
|
||||
|
||||
def authorize(self, context: ApiRequestContext) -> None:
|
||||
user = context.user
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
|
||||
# 检查是否使用独立余额Key访问管理接口
|
||||
if context.api_key and context.api_key.is_standalone:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="独立余额Key不允许访问管理接口,仅可用于代理请求"
|
||||
)
|
||||
|
||||
if not any(user.role == role for role in self.required_roles):
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
@@ -0,0 +1,15 @@
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.api.base.context import ApiRequestContext
|
||||
|
||||
from .adapter import ApiAdapter, ApiMode
|
||||
|
||||
|
||||
class AuthenticatedApiAdapter(ApiAdapter):
|
||||
"""通用需要登录的适配器基类。"""
|
||||
|
||||
mode = ApiMode.USER
|
||||
|
||||
def authorize(self, context: ApiRequestContext) -> None: # type: ignore[override]
|
||||
if not context.user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from src.config.settings import config
|
||||
from src.core.api_format.headers import get_header_value
|
||||
from src.core.http_compression import is_gzip_content_encoding, normalize_content_encoding
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey, ManagementToken, User
|
||||
from src.utils.perf import PerfRecorder
|
||||
from src.utils.request_utils import (
|
||||
get_request_identity_metadata,
|
||||
update_request_state,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_optional_str(value: Any) -> str | None:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _snapshot_optional_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ContextBuildSnapshot:
|
||||
request_id: str
|
||||
start_time: float
|
||||
request_method: str
|
||||
request_path: str
|
||||
client_ip: str
|
||||
user_agent: str
|
||||
original_headers: dict[str, str]
|
||||
request_content_type: str | None
|
||||
query_params: dict[str, str]
|
||||
path_params: dict[str, Any]
|
||||
prefetched_balance_remaining: float | None
|
||||
gateway_execution_path: str | None
|
||||
rate_limit_scope: str | None
|
||||
tx_committed_by_route: bool
|
||||
client_content_encoding: str | None
|
||||
client_accept_encoding: str | None
|
||||
perf_metrics: dict[str, Any] | None
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, request: Request) -> _ContextBuildSnapshot:
|
||||
request_state = getattr(request, "state", None)
|
||||
original_headers = dict(request.headers)
|
||||
identity = get_request_identity_metadata(request)
|
||||
request_id = identity.request_id or str(uuid.uuid4())[:8]
|
||||
accept_encoding = get_header_value(original_headers, "accept-encoding")
|
||||
if isinstance(accept_encoding, str):
|
||||
accept_encoding = accept_encoding.strip() or None
|
||||
perf_metrics = getattr(request.state, "perf_metrics", None)
|
||||
perf_payload = perf_metrics if isinstance(perf_metrics, dict) and perf_metrics else None
|
||||
return cls(
|
||||
request_id=request_id,
|
||||
start_time=time.time(),
|
||||
request_method=request.method,
|
||||
request_path=request.url.path,
|
||||
client_ip=identity.client_ip,
|
||||
user_agent=identity.user_agent,
|
||||
original_headers=original_headers,
|
||||
request_content_type=get_header_value(original_headers, "content-type"),
|
||||
query_params=dict(request.query_params),
|
||||
path_params=dict(getattr(request, "path_params", {}) or {}),
|
||||
prefetched_balance_remaining=_snapshot_optional_float(
|
||||
getattr(request_state, "prefetched_balance_remaining", None)
|
||||
),
|
||||
gateway_execution_path=_snapshot_optional_str(
|
||||
getattr(request_state, "gateway_execution_path", None)
|
||||
),
|
||||
rate_limit_scope=_snapshot_optional_str(
|
||||
getattr(request_state, "rate_limit_scope", None)
|
||||
),
|
||||
tx_committed_by_route=getattr(request_state, "tx_committed_by_route", False) is True,
|
||||
client_content_encoding=normalize_content_encoding(
|
||||
get_header_value(original_headers, "content-encoding")
|
||||
),
|
||||
client_accept_encoding=accept_encoding,
|
||||
perf_metrics=perf_payload,
|
||||
)
|
||||
|
||||
|
||||
def _apply_context_state_markers(
|
||||
request: Request,
|
||||
*,
|
||||
request_id: str,
|
||||
user: User | None,
|
||||
api_key: ApiKey | None,
|
||||
) -> None:
|
||||
update_request_state(request, request_id=request_id)
|
||||
if user:
|
||||
update_request_state(request, user_id=user.id)
|
||||
if api_key:
|
||||
update_request_state(request, api_key_id=api_key.id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiRequestContext:
|
||||
"""统一的API请求上下文,贯穿Pipeline与格式适配器。"""
|
||||
|
||||
request: Request
|
||||
db: Session
|
||||
user: User | None
|
||||
api_key: ApiKey | None
|
||||
request_id: str
|
||||
start_time: float
|
||||
request_method: str
|
||||
request_path: str
|
||||
client_ip: str
|
||||
user_agent: str
|
||||
original_headers: dict[str, str]
|
||||
query_params: dict[str, str]
|
||||
request_content_type: str | None = None
|
||||
perf_metrics: dict[str, Any] | None = None
|
||||
raw_body: bytes | None = None
|
||||
json_body: dict[str, Any] | None = None
|
||||
balance_remaining: float | None = None
|
||||
prefetched_balance_remaining: float | None = None
|
||||
mode: str = "standard" # standard / proxy
|
||||
api_format_hint: str | None = None
|
||||
|
||||
# URL 路径参数(如 Gemini API 的 /v1beta/models/{model}:generateContent)
|
||||
path_params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Management Token(用于管理 API 认证)
|
||||
management_token: ManagementToken | None = None
|
||||
|
||||
# 供适配器扩展的状态存储
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
audit_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# 高频轮询端点日志抑制标志
|
||||
quiet_logging: bool = False
|
||||
gateway_execution_path: str | None = None
|
||||
rate_limit_scope: str | None = None
|
||||
tx_committed_by_route: bool = False
|
||||
client_content_encoding: str | None = None
|
||||
client_accept_encoding: str | None = None
|
||||
|
||||
def _get_perf_metrics(self) -> dict[str, Any] | None:
|
||||
if isinstance(self.perf_metrics, dict):
|
||||
return self.perf_metrics
|
||||
perf_metrics = getattr(self.request.state, "perf_metrics", None)
|
||||
if isinstance(perf_metrics, dict):
|
||||
self.perf_metrics = perf_metrics
|
||||
return perf_metrics
|
||||
return None
|
||||
|
||||
def sync_runtime_state_from_request(self) -> None:
|
||||
request_state = getattr(self.request, "state", None)
|
||||
self.prefetched_balance_remaining = _snapshot_optional_float(
|
||||
getattr(request_state, "prefetched_balance_remaining", self.prefetched_balance_remaining)
|
||||
)
|
||||
self.gateway_execution_path = _snapshot_optional_str(
|
||||
getattr(request_state, "gateway_execution_path", self.gateway_execution_path)
|
||||
)
|
||||
self.rate_limit_scope = _snapshot_optional_str(
|
||||
getattr(request_state, "rate_limit_scope", self.rate_limit_scope)
|
||||
)
|
||||
self.tx_committed_by_route = (
|
||||
getattr(request_state, "tx_committed_by_route", self.tx_committed_by_route) is True
|
||||
)
|
||||
|
||||
async def ensure_raw_body_async(self) -> bytes:
|
||||
"""按需读取原始请求体,避免所有请求都在 Pipeline 阶段预读。"""
|
||||
if self.raw_body is not None:
|
||||
return self.raw_body
|
||||
|
||||
perf_metrics = self._get_perf_metrics()
|
||||
perf_sampled = isinstance(perf_metrics, dict) and bool(perf_metrics)
|
||||
body_start = PerfRecorder.start(force=perf_sampled)
|
||||
body_size = 0
|
||||
try:
|
||||
self.raw_body = await asyncio.wait_for(
|
||||
self.request.body(), timeout=config.request_body_timeout
|
||||
)
|
||||
body_size = len(self.raw_body or b"")
|
||||
except TimeoutError as exc:
|
||||
timeout_sec = int(config.request_body_timeout)
|
||||
logger.error("读取请求体超时({}s),可能客户端未发送完整请求体", timeout_sec)
|
||||
raise HTTPException(
|
||||
status_code=408,
|
||||
detail=f"Request timeout: body not received within {timeout_sec} seconds",
|
||||
) from exc
|
||||
except ClientDisconnect:
|
||||
logger.warning(
|
||||
"[Context] 客户端在读取请求体期间断开连接: {} {}",
|
||||
self.request_method,
|
||||
self.request_path,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=499,
|
||||
detail="Client closed request",
|
||||
)
|
||||
finally:
|
||||
body_duration = PerfRecorder.stop(
|
||||
body_start,
|
||||
"pipeline_body_read",
|
||||
labels={"mode": self.mode},
|
||||
log_hint=f"size={body_size}",
|
||||
)
|
||||
if isinstance(perf_metrics, dict):
|
||||
pipeline_metrics = perf_metrics.setdefault("pipeline", {})
|
||||
pipeline_metrics["body_read_ms"] = int((body_duration or 0) * 1000)
|
||||
pipeline_metrics["body_bytes"] = int(body_size)
|
||||
|
||||
return self.raw_body or b""
|
||||
|
||||
async def ensure_json_body_async(self) -> dict[str, Any]:
|
||||
"""异步懒加载 JSON 请求体。"""
|
||||
await self.ensure_raw_body_async()
|
||||
return self.ensure_json_body()
|
||||
|
||||
def ensure_json_body(self) -> dict[str, Any]:
|
||||
"""确保请求体已解析为JSON并返回。"""
|
||||
if self.json_body is not None:
|
||||
return self.json_body
|
||||
|
||||
if not self.raw_body:
|
||||
raise HTTPException(status_code=400, detail="请求体不能为空")
|
||||
|
||||
perf_metrics = self._get_perf_metrics()
|
||||
perf_sampled = isinstance(perf_metrics, dict) and bool(perf_metrics)
|
||||
parse_start = PerfRecorder.start(force=perf_sampled)
|
||||
|
||||
def _record_parse_duration(duration: float | None) -> None:
|
||||
if duration is None:
|
||||
return
|
||||
if not isinstance(perf_metrics, dict):
|
||||
return
|
||||
perf_metrics.setdefault("pipeline", {})["json_parse_ms"] = int(duration * 1000)
|
||||
|
||||
body_to_parse = self.raw_body
|
||||
content_encoding = self.client_content_encoding or normalize_content_encoding(
|
||||
get_header_value(self.original_headers, "content-encoding")
|
||||
)
|
||||
if is_gzip_content_encoding(content_encoding):
|
||||
try:
|
||||
body_to_parse = gzip.decompress(body_to_parse)
|
||||
except OSError as exc:
|
||||
parse_duration = PerfRecorder.stop(
|
||||
parse_start,
|
||||
"pipeline_json_parse",
|
||||
labels={"mode": self.mode},
|
||||
)
|
||||
_record_parse_duration(parse_duration)
|
||||
logger.warning("gzip 请求体解压失败: {}", exc)
|
||||
raise HTTPException(status_code=400, detail="gzip 请求体解压失败") from exc
|
||||
|
||||
try:
|
||||
self.json_body = json.loads(body_to_parse.decode("utf-8"))
|
||||
parse_duration = PerfRecorder.stop(
|
||||
parse_start,
|
||||
"pipeline_json_parse",
|
||||
labels={"mode": self.mode},
|
||||
)
|
||||
_record_parse_duration(parse_duration)
|
||||
except json.JSONDecodeError as exc:
|
||||
parse_duration = PerfRecorder.stop(
|
||||
parse_start,
|
||||
"pipeline_json_parse",
|
||||
labels={"mode": self.mode},
|
||||
)
|
||||
_record_parse_duration(parse_duration)
|
||||
logger.warning(f"解析JSON失败: {exc}")
|
||||
raise HTTPException(status_code=400, detail="请求体必须是合法的JSON") from exc
|
||||
|
||||
return self.json_body
|
||||
|
||||
def add_audit_metadata(self, **values: Any) -> None:
|
||||
"""向审计日志附加字段(会自动过滤 None)。"""
|
||||
for key, value in values.items():
|
||||
if value is not None:
|
||||
self.audit_metadata[key] = value
|
||||
|
||||
def extend_audit_metadata(self, data: dict[str, Any]) -> None:
|
||||
"""批量附加审计字段。"""
|
||||
for key, value in data.items():
|
||||
if value is not None:
|
||||
self.audit_metadata[key] = value
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
request: Request,
|
||||
db: Session,
|
||||
user: User | None,
|
||||
api_key: ApiKey | None,
|
||||
raw_body: bytes | None = None,
|
||||
mode: str = "standard",
|
||||
api_format_hint: str | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
) -> ApiRequestContext:
|
||||
"""创建上下文实例并提前读取必要的元数据。"""
|
||||
snapshot = _ContextBuildSnapshot.from_request(request)
|
||||
_apply_context_state_markers(
|
||||
request,
|
||||
request_id=snapshot.request_id,
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
context = cls(
|
||||
request=request,
|
||||
db=db,
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
request_id=snapshot.request_id,
|
||||
start_time=snapshot.start_time,
|
||||
request_method=snapshot.request_method,
|
||||
request_path=snapshot.request_path,
|
||||
client_ip=snapshot.client_ip,
|
||||
user_agent=snapshot.user_agent,
|
||||
original_headers=snapshot.original_headers,
|
||||
request_content_type=snapshot.request_content_type,
|
||||
query_params=snapshot.query_params,
|
||||
perf_metrics=snapshot.perf_metrics,
|
||||
raw_body=raw_body,
|
||||
prefetched_balance_remaining=snapshot.prefetched_balance_remaining,
|
||||
mode=mode,
|
||||
api_format_hint=api_format_hint,
|
||||
path_params=dict(path_params or snapshot.path_params),
|
||||
gateway_execution_path=snapshot.gateway_execution_path,
|
||||
rate_limit_scope=snapshot.rate_limit_scope,
|
||||
tx_committed_by_route=snapshot.tx_committed_by_route,
|
||||
client_content_encoding=snapshot.client_content_encoding,
|
||||
client_accept_encoding=snapshot.client_accept_encoding,
|
||||
)
|
||||
|
||||
if snapshot.perf_metrics is not None:
|
||||
context.extra["perf"] = snapshot.perf_metrics
|
||||
|
||||
return context
|
||||
@@ -0,0 +1,548 @@
|
||||
"""
|
||||
公共模型查询服务
|
||||
|
||||
为 Claude/OpenAI/Gemini 的 /models 端点提供统一的查询逻辑
|
||||
|
||||
查询逻辑:
|
||||
1. 找到指定 api_format 的活跃端点
|
||||
2. 端点下有活跃的 Key
|
||||
3. Provider 关联了该模型(Model 表)
|
||||
4. Key 的 allowed_models 允许该模型(null = 允许所有)
|
||||
"""
|
||||
|
||||
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.access_restrictions import AccessRestrictions
|
||||
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.models.database import Model, Provider, ProviderEndpoint
|
||||
from src.services.cache.model_list_cache import MODELS_LIST_CACHE_PREFIX as _CACHE_KEY_PREFIX
|
||||
from src.services.cache.model_list_cache import (
|
||||
invalidate_models_list_cache,
|
||||
)
|
||||
from src.services.model.availability import ModelAvailabilityQuery
|
||||
from src.services.provider.format import normalize_endpoint_signature
|
||||
|
||||
_CACHE_TTL = CacheTTL.MODEL # 300 秒
|
||||
|
||||
|
||||
def _get_cache_key(api_formats: list[str], client_format: str | None = None) -> str:
|
||||
"""生成缓存 key"""
|
||||
formats_str = ",".join(sorted(api_formats))
|
||||
format_key = (client_format or "any").lower()
|
||||
return f"{_CACHE_KEY_PREFIX}:{format_key}:{formats_str}"
|
||||
|
||||
|
||||
async def _get_cached_models(
|
||||
api_formats: list[str], client_format: str | None = None
|
||||
) -> list[ModelInfo] | None:
|
||||
"""从缓存获取模型列表"""
|
||||
cache_key = _get_cache_key(api_formats, client_format)
|
||||
try:
|
||||
cached = await CacheService.get(cache_key)
|
||||
if cached:
|
||||
logger.debug(f"[ModelsService] 缓存命中: {cache_key}, {len(cached)} 个模型")
|
||||
return [ModelInfo(**item) for item in cached]
|
||||
except Exception as e:
|
||||
logger.warning(f"[ModelsService] 缓存读取失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _set_cached_models(
|
||||
api_formats: list[str],
|
||||
models: list[ModelInfo],
|
||||
client_format: str | None = None,
|
||||
) -> None:
|
||||
"""将模型列表写入缓存"""
|
||||
cache_key = _get_cache_key(api_formats, client_format)
|
||||
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"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
|
||||
|
||||
|
||||
__all__ = ["AccessRestrictions", "invalidate_models_list_cache", "ModelInfo"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelInfo:
|
||||
"""统一的模型信息结构"""
|
||||
|
||||
id: str # 模型 ID (GlobalModel.name 或 provider_model_name)
|
||||
display_name: str
|
||||
description: str | None
|
||||
created_at: str | None # ISO 格式
|
||||
created_timestamp: int # Unix 时间戳
|
||||
provider_name: str
|
||||
provider_id: str = "" # Provider ID,用于权限过滤
|
||||
# 能力配置
|
||||
streaming: bool = True
|
||||
vision: bool = False
|
||||
function_calling: bool = False
|
||||
extended_thinking: bool = False
|
||||
image_generation: bool = False
|
||||
structured_output: bool = False
|
||||
# 规格参数
|
||||
context_limit: int | None = None
|
||||
output_limit: int | None = None
|
||||
# 元信息
|
||||
family: str | None = None
|
||||
knowledge_cutoff: str | None = None
|
||||
input_modalities: list[str] | None = None
|
||||
output_modalities: list[str] | None = None
|
||||
|
||||
|
||||
# AccessRestrictions -- re-export from src.core.access_restrictions (see __all__)
|
||||
|
||||
|
||||
def _normalize_api_formats(
|
||||
api_formats: list[str] | None,
|
||||
provider_to_formats: dict[str, set[str]] | None = None,
|
||||
) -> list[str]:
|
||||
"""规范化 API 格式列表(endpoint signature,小写 canonical),必要时从 provider_to_formats 兜底"""
|
||||
if api_formats:
|
||||
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(normalize_endpoint_signature(str(fmt)) for fmt in formats if fmt)
|
||||
return list(all_formats)
|
||||
|
||||
|
||||
def _get_provider_model_names_for_formats(
|
||||
model: Model, usable_formats: set[str] | None = None
|
||||
) -> set[str]:
|
||||
"""
|
||||
获取模型在指定格式下支持的 Provider 模型名称集合
|
||||
|
||||
用于 check_model_allowed_with_mappings 的 candidate_models 参数,
|
||||
确保权限检查时只考虑当前格式支持的模型名。
|
||||
"""
|
||||
names: set[str] = {model.provider_model_name}
|
||||
raw_mappings = model.provider_model_mappings
|
||||
if not isinstance(raw_mappings, list):
|
||||
return names
|
||||
|
||||
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):
|
||||
continue
|
||||
name = raw.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
|
||||
mapping_api_formats = raw.get("api_formats")
|
||||
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())
|
||||
|
||||
return names
|
||||
|
||||
|
||||
def get_compatible_provider_formats(
|
||||
db: Session,
|
||||
client_format: str,
|
||||
api_formats: list[str],
|
||||
global_conversion_enabled: bool,
|
||||
) -> dict[str, set[str]]:
|
||||
"""
|
||||
获取与客户端格式兼容的 Provider -> formats 映射
|
||||
|
||||
兼容性基于端点格式、format_acceptance_config 与全局格式转换开关。
|
||||
"""
|
||||
normalized_formats = _normalize_api_formats(api_formats)
|
||||
if not normalized_formats:
|
||||
return {}
|
||||
|
||||
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_family,
|
||||
ProviderEndpoint.endpoint_kind,
|
||||
ProviderEndpoint.format_acceptance_config,
|
||||
Provider.enable_format_conversion,
|
||||
)
|
||||
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||
.filter(
|
||||
Provider.is_active.is_(True),
|
||||
ProviderEndpoint.is_active.is_(True),
|
||||
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,
|
||||
api_family,
|
||||
endpoint_kind,
|
||||
format_acceptance_config,
|
||||
provider_conversion_enabled,
|
||||
) 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}")
|
||||
skip_endpoint_check = global_conversion_enabled or bool(provider_conversion_enabled)
|
||||
is_compatible, _needs_conversion, _reason = is_format_compatible(
|
||||
client_format_norm,
|
||||
endpoint_format,
|
||||
format_acceptance_config,
|
||||
is_stream=False,
|
||||
effective_conversion_enabled=global_conversion_enabled,
|
||||
skip_endpoint_check=skip_endpoint_check,
|
||||
)
|
||||
if not is_compatible:
|
||||
continue
|
||||
provider_to_formats.setdefault(provider_id, set()).add(endpoint_format)
|
||||
|
||||
return provider_to_formats
|
||||
|
||||
|
||||
def get_available_provider_ids(
|
||||
db: Session,
|
||||
api_formats: list[str],
|
||||
provider_to_formats: dict[str, set[str]] | None = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
返回有可用端点的 Provider IDs
|
||||
|
||||
条件:
|
||||
- 端点 api_format 匹配
|
||||
- 端点是活跃的
|
||||
- Provider 下有活跃的 Key 且支持该 api_format(Key 直属 Provider,通过 api_formats 过滤)
|
||||
"""
|
||||
normalized_formats = _normalize_api_formats(api_formats, provider_to_formats)
|
||||
if provider_to_formats is None:
|
||||
provider_to_formats = ModelAvailabilityQuery.get_providers_with_active_endpoints(
|
||||
db, normalized_formats
|
||||
)
|
||||
if not provider_to_formats:
|
||||
return set()
|
||||
|
||||
return ModelAvailabilityQuery.get_providers_with_active_keys(
|
||||
db,
|
||||
set(provider_to_formats.keys()),
|
||||
normalized_formats,
|
||||
provider_to_formats,
|
||||
)
|
||||
|
||||
|
||||
def _get_available_model_ids_for_format(
|
||||
db: Session,
|
||||
api_formats: list[str],
|
||||
provider_to_formats: dict[str, set[str]] | None = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
获取指定格式下真正可用的模型 ID 集合
|
||||
|
||||
一个模型可用需满足:
|
||||
1. 端点 api_format 匹配且活跃
|
||||
2. 端点下有活跃的 Key
|
||||
3. **该端点的 Provider 关联了该模型**
|
||||
4. Key 的 allowed_models 允许该模型(null = 允许该 Provider 关联的所有模型)
|
||||
"""
|
||||
normalized_formats = _normalize_api_formats(api_formats, provider_to_formats)
|
||||
if provider_to_formats is None:
|
||||
provider_to_formats = ModelAvailabilityQuery.get_providers_with_active_endpoints(
|
||||
db, normalized_formats
|
||||
)
|
||||
if not provider_to_formats:
|
||||
return set()
|
||||
|
||||
provider_key_rules = ModelAvailabilityQuery.get_provider_key_rules(
|
||||
db,
|
||||
provider_ids=set(provider_to_formats.keys()),
|
||||
api_formats=normalized_formats,
|
||||
provider_to_endpoint_formats=provider_to_formats,
|
||||
)
|
||||
|
||||
provider_ids_with_format = set(provider_key_rules.keys())
|
||||
if not provider_ids_with_format:
|
||||
return set()
|
||||
|
||||
models = (
|
||||
ModelAvailabilityQuery.base_active_models(db, eager_load=True)
|
||||
.filter(Model.provider_id.in_(provider_ids_with_format))
|
||||
.all()
|
||||
)
|
||||
|
||||
available_model_ids: set[str] = set()
|
||||
|
||||
for model in models:
|
||||
model_provider_id = model.provider_id
|
||||
global_model = model.global_model
|
||||
if not model_provider_id or not global_model or not global_model.name:
|
||||
continue
|
||||
|
||||
# 该模型的 Provider 必须有匹配格式的端点
|
||||
if model_provider_id not in provider_ids_with_format:
|
||||
continue
|
||||
|
||||
# 检查该 provider 下是否有 Key 允许这个模型
|
||||
from src.core.model_permissions import check_model_allowed_with_mappings
|
||||
|
||||
model_id = global_model.name
|
||||
model_mappings = (global_model.config or {}).get("model_mappings")
|
||||
|
||||
rules = provider_key_rules.get(model_provider_id, [])
|
||||
for allowed_models, usable_formats in rules:
|
||||
# None = 不限制
|
||||
if allowed_models is None:
|
||||
available_model_ids.add(model_id)
|
||||
break
|
||||
|
||||
# 检查是否允许该模型(支持 model_mappings 正则匹配)
|
||||
candidate_models = _get_provider_model_names_for_formats(model, usable_formats)
|
||||
is_allowed, _ = check_model_allowed_with_mappings(
|
||||
model_name=model_id,
|
||||
allowed_models=allowed_models,
|
||||
model_mappings=model_mappings,
|
||||
candidate_models=candidate_models,
|
||||
)
|
||||
if is_allowed:
|
||||
available_model_ids.add(model_id)
|
||||
break
|
||||
|
||||
return available_model_ids
|
||||
|
||||
|
||||
def _extract_model_info(model: Any) -> ModelInfo | None:
|
||||
"""
|
||||
从 Model 对象提取 ModelInfo
|
||||
|
||||
前置条件:model 必须关联 GlobalModel(由 base_active_models 内连接保证)
|
||||
如果 global_model 为 None(不应发生),返回 None 并记录日志。
|
||||
"""
|
||||
global_model = model.global_model
|
||||
if global_model is None:
|
||||
logger.warning(
|
||||
f"[ModelService] Model {getattr(model, 'id', 'unknown')} 缺少 global_model,跳过"
|
||||
)
|
||||
return None
|
||||
|
||||
model_id: str = global_model.name
|
||||
display_name: str = global_model.display_name
|
||||
created_at: str | None = (
|
||||
model.created_at.strftime("%Y-%m-%dT%H:%M:%SZ") if model.created_at else None
|
||||
)
|
||||
created_timestamp: int = int(model.created_at.timestamp()) if model.created_at else 0
|
||||
provider_name: str = model.provider.name if model.provider else "unknown"
|
||||
provider_id: str = model.provider_id or ""
|
||||
|
||||
# 从 GlobalModel.config 提取配置信息
|
||||
config: dict = global_model.config or {}
|
||||
description: str | None = config.get("description")
|
||||
|
||||
return ModelInfo(
|
||||
id=model_id,
|
||||
display_name=display_name,
|
||||
description=description,
|
||||
created_at=created_at,
|
||||
created_timestamp=created_timestamp,
|
||||
provider_name=provider_name,
|
||||
provider_id=provider_id,
|
||||
# 能力配置
|
||||
streaming=config.get("streaming", True),
|
||||
vision=config.get("vision", False),
|
||||
function_calling=config.get("function_calling", False),
|
||||
extended_thinking=config.get("extended_thinking", False),
|
||||
image_generation=config.get("image_generation", False),
|
||||
structured_output=config.get("structured_output", False),
|
||||
# 规格参数
|
||||
context_limit=config.get("context_limit"),
|
||||
output_limit=config.get("output_limit"),
|
||||
# 元信息
|
||||
family=config.get("family"),
|
||||
knowledge_cutoff=config.get("knowledge_cutoff"),
|
||||
input_modalities=config.get("input_modalities"),
|
||||
output_modalities=config.get("output_modalities"),
|
||||
)
|
||||
|
||||
|
||||
async def list_available_models(
|
||||
db: Session,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: list[str] | None = None,
|
||||
restrictions: AccessRestrictions | None = None,
|
||||
provider_to_formats: dict[str, set[str]] | None = None,
|
||||
client_format: str | None = None,
|
||||
) -> list[ModelInfo]:
|
||||
"""
|
||||
获取可用模型列表(已去重,带缓存)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
provider_to_formats: Provider -> formats 映射(兼容转换过滤用)
|
||||
client_format: 客户端格式(用于缓存隔离)
|
||||
|
||||
Returns:
|
||||
去重后的 ModelInfo 列表,按创建时间倒序
|
||||
"""
|
||||
if not available_provider_ids:
|
||||
return []
|
||||
|
||||
# 缓存策略:只有完全无访问限制时才使用缓存
|
||||
# - restrictions is None: 未传入限制对象
|
||||
# - restrictions 的两个字段都为 None: 传入了限制对象但无实际限制
|
||||
# 以上两种情况返回的结果相同,可以共享全局缓存
|
||||
use_cache = restrictions is None or (
|
||||
restrictions.allowed_providers is None and restrictions.allowed_models is None
|
||||
)
|
||||
|
||||
normalized_formats = _normalize_api_formats(api_formats, provider_to_formats)
|
||||
|
||||
# 尝试从缓存获取
|
||||
if normalized_formats and use_cache:
|
||||
cached = await _get_cached_models(normalized_formats, client_format)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 如果提供了 api_formats,获取真正可用的模型 ID
|
||||
available_model_ids: set[str] | None = None
|
||||
if normalized_formats:
|
||||
available_model_ids = _get_available_model_ids_for_format(
|
||||
db, normalized_formats, provider_to_formats
|
||||
)
|
||||
if not available_model_ids:
|
||||
return []
|
||||
|
||||
all_models = (
|
||||
ModelAvailabilityQuery.base_active_models(db, eager_load=True)
|
||||
.filter(Model.provider_id.in_(available_provider_ids))
|
||||
.order_by(Model.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
result: list[ModelInfo] = []
|
||||
seen_model_ids: set[str] = set()
|
||||
|
||||
for model in all_models:
|
||||
info = _extract_model_info(model)
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
# 如果有 available_model_ids 限制,检查是否在其中
|
||||
if available_model_ids is not None and info.id not in available_model_ids:
|
||||
continue
|
||||
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
if not restrictions.is_model_allowed(info.id, info.provider_id):
|
||||
continue
|
||||
|
||||
if info.id in seen_model_ids:
|
||||
continue
|
||||
seen_model_ids.add(info.id)
|
||||
result.append(info)
|
||||
|
||||
# 只有无限制的情况才写入缓存
|
||||
if normalized_formats and use_cache:
|
||||
await _set_cached_models(normalized_formats, result, client_format)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def find_model_by_id(
|
||||
db: Session,
|
||||
model_id: str,
|
||||
available_provider_ids: set[str],
|
||||
api_formats: list[str] | None = None,
|
||||
restrictions: AccessRestrictions | None = None,
|
||||
provider_to_formats: dict[str, set[str]] | None = None,
|
||||
) -> ModelInfo | None:
|
||||
"""
|
||||
按 ID 查找模型(仅支持 GlobalModel.name)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
model_id: 模型 ID
|
||||
available_provider_ids: 有可用端点的 Provider ID 集合
|
||||
api_formats: API 格式列表,用于检查 Key 的 allowed_models
|
||||
restrictions: API Key/User 的访问限制
|
||||
provider_to_formats: Provider -> formats 映射(兼容转换过滤用)
|
||||
|
||||
Returns:
|
||||
ModelInfo 或 None
|
||||
"""
|
||||
if not available_provider_ids:
|
||||
return None
|
||||
|
||||
normalized_formats = _normalize_api_formats(api_formats, provider_to_formats)
|
||||
|
||||
# 如果提供了 api_formats,获取真正可用的模型 ID
|
||||
available_model_ids: set[str] | None = None
|
||||
if normalized_formats:
|
||||
available_model_ids = _get_available_model_ids_for_format(
|
||||
db, normalized_formats, provider_to_formats
|
||||
)
|
||||
# 快速检查:如果目标模型不在可用列表中,直接返回 None
|
||||
if available_model_ids is not None and model_id not in available_model_ids:
|
||||
return None
|
||||
|
||||
# 快速检查:如果 restrictions 明确限制了模型列表且目标模型不在其中,直接返回 None
|
||||
if restrictions is not None and restrictions.allowed_models is not None:
|
||||
if model_id not in restrictions.allowed_models:
|
||||
return None
|
||||
|
||||
models_by_global = (
|
||||
ModelAvailabilityQuery.find_by_global_model_name(db, model_id, eager_load=True)
|
||||
.order_by(Model.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
def is_model_accessible(m: Model) -> bool:
|
||||
"""检查模型是否可访问"""
|
||||
if m.provider_id not in available_provider_ids:
|
||||
return False
|
||||
# 检查 API Key/User 访问限制
|
||||
if restrictions is not None:
|
||||
provider_id = m.provider_id or ""
|
||||
if not restrictions.is_model_allowed(model_id, provider_id):
|
||||
return False
|
||||
return True
|
||||
|
||||
model = next((m for m in models_by_global if is_model_accessible(m)), None)
|
||||
|
||||
if not model:
|
||||
return None
|
||||
|
||||
return _extract_model_info(model)
|
||||
@@ -0,0 +1,49 @@
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Query
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaginationMeta:
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
count: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def paginate_query(query: Query, limit: int, offset: int) -> tuple[int, list[T]]:
|
||||
"""
|
||||
对 SQLAlchemy 查询应用 limit/offset,并返回总数与结果列表。
|
||||
"""
|
||||
total = int(query.order_by(None).with_entities(func.count()).scalar() or 0)
|
||||
records = query.offset(offset).limit(limit).all()
|
||||
return total, records
|
||||
|
||||
|
||||
def paginate_sequence(
|
||||
items: Sequence[T], limit: int, offset: int
|
||||
) -> tuple[list[T], PaginationMeta]:
|
||||
"""
|
||||
对内存序列应用分页,返回切片和元数据。
|
||||
"""
|
||||
total = len(items)
|
||||
sliced = list(items[offset : offset + limit])
|
||||
meta = PaginationMeta(total=total, limit=limit, offset=offset, count=len(sliced))
|
||||
return sliced, meta
|
||||
|
||||
|
||||
def build_pagination_payload(items: list[dict], meta: PaginationMeta, **extra: Any) -> dict:
|
||||
"""
|
||||
构建标准分页响应 payload。
|
||||
"""
|
||||
payload: dict = {"items": items, "meta": meta.to_dict()}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user