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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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