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:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1734 additions and 2432 deletions
+82
View File
@@ -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="未登录")
+359
View File
@@ -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_formatKey 直属 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)
+49
View File
@@ -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