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 1735 additions and 2433 deletions

View File

View File

@@ -0,0 +1,113 @@
"""
API Key / User 访问限制数据类型。
从 api/base/models_service.py 下沉到 core 层,
消除 services→api 的反向依赖。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from src.core.api_format.signature import normalize_signature_key
from src.core.logger import logger
if TYPE_CHECKING:
from src.models.database import ApiKey, User
def _safe_normalize_signature(value: str) -> str:
"""归一化 endpoint signature解析失败时原样返回小写"""
try:
return normalize_signature_key(value)
except ValueError:
logger.warning("[AccessRestrictions] 无法归一化 API 格式 '{}', 原样使用小写形式", value)
return value.strip().lower()
@dataclass
class AccessRestrictions:
"""API Key 或 User 的访问限制"""
allowed_providers: list[str] | None = None # 允许的 Provider ID 列表
allowed_models: list[str] | None = None # 允许的模型名称列表
allowed_api_formats: list[str] | None = None # 允许的 API 格式列表
@classmethod
def from_api_key_and_user(cls, api_key: ApiKey | None, user: User | None) -> AccessRestrictions:
"""
从 API Key 和 User 合并访问限制
限制逻辑:
- API Key 的限制优先于 User 的限制
- 如果 API Key 有限制,使用 API Key 的限制
- 如果 API Key 无限制但 User 有限制,使用 User 的限制
- 两者都无限制则返回空限制
"""
allowed_providers: list[str] | None = None
allowed_models: list[str] | None = None
allowed_api_formats: list[str] | None = None
# 优先使用 API Key 的限制
if api_key:
if api_key.allowed_providers is not None:
allowed_providers = api_key.allowed_providers
if api_key.allowed_models is not None:
allowed_models = api_key.allowed_models
if api_key.allowed_api_formats is not None:
allowed_api_formats = api_key.allowed_api_formats
# 如果 API Key 没有限制,检查 User 的限制
if user:
if allowed_providers is None and user.allowed_providers is not None:
allowed_providers = user.allowed_providers
if allowed_models is None and user.allowed_models is not None:
allowed_models = user.allowed_models
if allowed_api_formats is None and user.allowed_api_formats is not None:
allowed_api_formats = user.allowed_api_formats
return cls(
allowed_providers=allowed_providers,
allowed_models=allowed_models,
allowed_api_formats=allowed_api_formats,
)
def is_api_format_allowed(self, api_format: str) -> bool:
"""
检查 API 格式是否被允许
Args:
api_format: endpoint signature"openai:chat"
Returns:
True 如果格式被允许False 否则
"""
if self.allowed_api_formats is None:
return True
target = _safe_normalize_signature(api_format)
allowed = {_safe_normalize_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:
"""
检查模型是否被允许访问
Args:
model_id: 模型 ID
provider_id: Provider ID
Returns:
True 如果模型被允许False 否则
"""
# 检查 Provider 限制
if self.allowed_providers is not None:
if provider_id not in self.allowed_providers:
return False
# 检查模型限制
if self.allowed_models is not None:
if model_id not in self.allowed_models:
return False
return True

View File

@@ -0,0 +1,185 @@
"""
API 格式核心模块(新模式)。
系统内部统一使用 endpoint signature key 作为“格式”标识:
`<api_family>:<endpoint_kind>`(全小写,例如 "openai:chat")。
"""
from src.core.api_format.auth import (
ApiKeyAuthHandler,
AuthHandler,
BearerAuthHandler,
GoogApiKeyAuthHandler,
OAuth2AuthHandler,
QueryKeyAuthHandler,
get_auth_handler,
get_default_auth_method_for_endpoint,
)
from src.core.api_format.capabilities import (
ApiFormatCapability,
ProviderFormatBehavior,
ProviderFormatCapability,
compute_total_input_context_for_api_format,
fetch_models_for_api_format,
get_api_format_capability,
get_provider_default_body_rules,
get_provider_default_body_rules_for_endpoint,
get_provider_format_behavior,
get_provider_format_capability,
list_api_format_capabilities,
register_api_format_capability,
register_provider_default_body_rules,
register_provider_format_behavior,
register_provider_format_capability,
resolve_billing_template_for_api_format,
resolve_provider_variants_for_endpoint,
)
from src.core.api_format.detection import (
RequestContext,
detect_cli_format_from_path,
detect_format_and_key_from_starlette,
detect_format_from_request,
detect_format_from_response,
detect_request_context,
)
from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind, EndpointType
from src.core.api_format.headers import (
CORE_REDACT_HEADERS,
HOP_BY_HOP_HEADERS,
RESPONSE_DROP_HEADERS,
UPSTREAM_DROP_HEADERS,
HeaderBuilder,
build_adapter_base_headers_for_endpoint,
build_adapter_headers_for_endpoint,
build_upstream_headers_for_endpoint,
detect_capabilities_for_endpoint,
extract_client_api_key_for_endpoint,
extract_client_api_key_for_endpoint_with_query,
extract_set_headers_from_rules,
filter_response_headers,
get_adapter_protected_keys_for_endpoint,
get_extra_headers_from_endpoint,
get_header_value,
merge_headers_with_protection,
normalize_headers,
redact_headers_for_log,
resolve_header_name_case,
)
from src.core.api_format.metadata import (
ENDPOINT_DEFINITIONS,
EndpointDefinition,
can_passthrough_endpoint,
get_auth_config_for_endpoint,
get_data_format_id_for_endpoint,
get_default_body_rules_for_endpoint,
get_default_path_for_endpoint,
get_endpoint_definition,
get_extra_headers_for_endpoint,
get_local_path_for_endpoint,
get_protected_keys_for_endpoint,
list_endpoint_definitions,
make_endpoint_signature,
resolve_endpoint_definition,
)
from src.core.api_format.signature import (
EndpointSignature,
make_signature_key,
normalize_signature_key,
parse_signature_key,
)
from src.core.api_format.utils import (
get_base_format,
is_cli_format,
is_convertible_format,
is_same_format,
normalize_format,
)
__all__ = [
# Enums
"ApiFamily",
"EndpointKind",
"AuthMethod",
"EndpointType",
# Signature
"EndpointSignature",
"make_signature_key",
"parse_signature_key",
"normalize_signature_key",
# Metadata
"EndpointDefinition",
"ENDPOINT_DEFINITIONS",
"list_endpoint_definitions",
"get_endpoint_definition",
"resolve_endpoint_definition",
"make_endpoint_signature",
"get_default_path_for_endpoint",
"get_local_path_for_endpoint",
"get_auth_config_for_endpoint",
"get_extra_headers_for_endpoint",
"get_protected_keys_for_endpoint",
"get_data_format_id_for_endpoint",
"get_default_body_rules_for_endpoint",
"can_passthrough_endpoint",
# Utils
"is_cli_format",
"get_base_format",
"normalize_format",
"is_same_format",
"is_convertible_format",
# Headers
"UPSTREAM_DROP_HEADERS",
"CORE_REDACT_HEADERS",
"HOP_BY_HOP_HEADERS",
"RESPONSE_DROP_HEADERS",
"normalize_headers",
"get_header_value",
"extract_client_api_key_for_endpoint",
"extract_client_api_key_for_endpoint_with_query",
"detect_capabilities_for_endpoint",
"HeaderBuilder",
"build_upstream_headers_for_endpoint",
"merge_headers_with_protection",
"filter_response_headers",
"redact_headers_for_log",
"resolve_header_name_case",
"build_adapter_base_headers_for_endpoint",
"build_adapter_headers_for_endpoint",
"get_adapter_protected_keys_for_endpoint",
"extract_set_headers_from_rules",
"get_extra_headers_from_endpoint",
# Detection
"detect_format_from_request",
"detect_format_and_key_from_starlette",
"detect_format_from_response",
"detect_cli_format_from_path",
"detect_request_context",
"RequestContext",
# Auth
"AuthHandler",
"BearerAuthHandler",
"ApiKeyAuthHandler",
"GoogApiKeyAuthHandler",
"OAuth2AuthHandler",
"QueryKeyAuthHandler",
"get_auth_handler",
"get_default_auth_method_for_endpoint",
# Capabilities
"ApiFormatCapability",
"ProviderFormatBehavior",
"ProviderFormatCapability",
"get_api_format_capability",
"get_provider_default_body_rules",
"get_provider_default_body_rules_for_endpoint",
"get_provider_format_behavior",
"get_provider_format_capability",
"list_api_format_capabilities",
"register_api_format_capability",
"register_provider_default_body_rules",
"register_provider_format_behavior",
"register_provider_format_capability",
"resolve_billing_template_for_api_format",
"resolve_provider_variants_for_endpoint",
"compute_total_input_context_for_api_format",
"fetch_models_for_api_format",
]

View File

@@ -0,0 +1,130 @@
"""
认证处理器
将认证逻辑从 API 格式中解耦,支持多种认证方式。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from src.core.api_format.enums import AuthMethod
from src.core.api_format.metadata import resolve_endpoint_definition
from src.core.api_format.signature import EndpointSignature
if TYPE_CHECKING:
from starlette.requests import Request
class AuthHandler(ABC):
"""认证处理器基类"""
@abstractmethod
def extract_credentials(self, request: Request) -> str | None:
"""从请求中提取凭证"""
@abstractmethod
def build_headers(self, credentials: str) -> dict[str, str]:
"""构造上游请求的认证 Header"""
class BearerAuthHandler(AuthHandler):
"""Authorization: Bearer <token>"""
def extract_credentials(self, request: Request) -> str | None:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return None
def build_headers(self, credentials: str) -> dict[str, str]:
return {"Authorization": f"Bearer {credentials}"}
class ApiKeyAuthHandler(AuthHandler):
"""x-api-key: <key>"""
def extract_credentials(self, request: Request) -> str | None:
return request.headers.get("x-api-key")
def build_headers(self, credentials: str) -> dict[str, str]:
return {"x-api-key": credentials}
class GoogApiKeyAuthHandler(AuthHandler):
"""x-goog-api-key: <key> (支持 ?key=)"""
def extract_credentials(self, request: Request) -> str | None:
return request.query_params.get("key") or request.headers.get("x-goog-api-key")
def build_headers(self, credentials: str) -> dict[str, str]:
return {"x-goog-api-key": credentials}
class QueryKeyAuthHandler(AuthHandler):
"""?key= 参数认证(仅提取,通常用于 Gemini"""
def extract_credentials(self, request: Request) -> str | None:
return request.query_params.get("key")
def build_headers(self, credentials: str) -> dict[str, str]:
return {"x-goog-api-key": credentials}
class OAuth2AuthHandler(AuthHandler):
"""
Google OAuth2 / Service Account 认证
目前使用 Authorization: Bearer 透传 access token。
"""
def extract_credentials(self, request: Request) -> str | None:
auth = request.headers.get("authorization", "")
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return None
def build_headers(self, credentials: str) -> dict[str, str]:
return {"Authorization": f"Bearer {credentials}"}
_AUTH_HANDLERS: dict[AuthMethod, AuthHandler] = {
AuthMethod.BEARER: BearerAuthHandler(),
AuthMethod.API_KEY: ApiKeyAuthHandler(),
AuthMethod.GOOG_API_KEY: GoogApiKeyAuthHandler(),
AuthMethod.OAUTH2: OAuth2AuthHandler(),
AuthMethod.QUERY_KEY: QueryKeyAuthHandler(),
}
def get_auth_handler(auth_method: AuthMethod) -> AuthHandler:
"""获取认证处理器实例"""
handler = _AUTH_HANDLERS.get(auth_method)
if not handler:
raise ValueError(f"Unsupported auth method: {auth_method}")
return handler
def get_default_auth_method_for_endpoint(
value: str | EndpointSignature | tuple, # tuple[ApiFamily, EndpointKind]
) -> AuthMethod:
"""
新模式:从 endpoint signature 推断默认 AuthMethod。
只接受 `family:kind` / EndpointSignature / (ApiFamily, EndpointKind)。
"""
definition = resolve_endpoint_definition(value)
return definition.auth_method if definition else AuthMethod.BEARER
__all__ = [
"AuthHandler",
"BearerAuthHandler",
"ApiKeyAuthHandler",
"GoogApiKeyAuthHandler",
"OAuth2AuthHandler",
"QueryKeyAuthHandler",
"get_auth_handler",
"get_default_auth_method_for_endpoint",
]

View File

@@ -0,0 +1,654 @@
from __future__ import annotations
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Sequence
import httpx
from src.config.settings import config
from src.core.api_format.enums import ApiFamily, EndpointKind
from src.core.api_format.headers import (
BROWSER_FINGERPRINT_HEADERS,
build_adapter_headers_for_endpoint,
)
from src.core.api_format.signature import EndpointSignature, make_signature_key, parse_signature_key
from src.core.logger import logger
from src.core.provider_types import normalize_provider_type
ModelFetcher = Callable[
[httpx.AsyncClient, str, str, str, dict[str, str] | None],
Awaitable[tuple[list[dict[str, Any]], str | None]],
]
TotalInputContextResolver = Callable[[int, int, int], int]
_SENSITIVE_QUERY_PARAMS_PATTERN = re.compile(
r"([?&])(key|api_key|apikey|token|secret|password|credential)=([^&]*)",
re.IGNORECASE,
)
def _redact_url_for_log(url: str) -> str:
return _SENSITIVE_QUERY_PARAMS_PATTERN.sub(r"\1\2=***", url)
def _default_total_input_context(
input_tokens: int,
cache_read_input_tokens: int,
_cache_creation_input_tokens: int = 0,
) -> int:
return input_tokens + cache_read_input_tokens
def _claude_total_input_context(
input_tokens: int,
cache_read_input_tokens: int,
cache_creation_input_tokens: int = 0,
) -> int:
return input_tokens + cache_creation_input_tokens + cache_read_input_tokens
@dataclass(frozen=True, slots=True)
class ApiFormatCapability:
api_format: str
billing_template: str | None = None
total_input_context_resolver: TotalInputContextResolver = _default_total_input_context
model_fetcher: ModelFetcher | None = None
@dataclass(frozen=True, slots=True)
class ProviderFormatCapability:
provider_type: str
endpoint_sig: str = ""
same_format_variant: str | None = None
cross_format_variant: str | None = None
default_body_rules: tuple[dict[str, Any], ...] | None = None
@dataclass(frozen=True, slots=True)
class ProviderFormatBehavior:
provider_type: str
same_format_variant: str | None = None
cross_format_variant: str | None = None
_registry: dict[str, ApiFormatCapability] = {}
_provider_registry: dict[tuple[str, str], ProviderFormatCapability] = {}
def _normalize_api_format(api_format: str | None) -> str:
return str(api_format or "").strip().lower()
def _normalize_endpoint_sig(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any],
) -> str:
if isinstance(value, str):
try:
return parse_signature_key(value).key
except Exception:
return value.strip().lower()
if isinstance(value, EndpointSignature):
return value.key
if isinstance(value, tuple) and len(value) == 2:
return make_signature_key(value[0], value[1])
return str(value).strip().lower()
def register_api_format_capability(capability: ApiFormatCapability) -> None:
"""注册或覆盖 api_format 能力。"""
fmt = _normalize_api_format(capability.api_format)
if not fmt:
raise ValueError("api_format 不能为空")
_registry[fmt] = ApiFormatCapability(
api_format=fmt,
billing_template=capability.billing_template,
total_input_context_resolver=capability.total_input_context_resolver,
model_fetcher=capability.model_fetcher,
)
def get_api_format_capability(api_format: str | None) -> ApiFormatCapability | None:
"""按 api_format 获取能力定义。"""
return _registry.get(_normalize_api_format(api_format))
def list_api_format_capabilities() -> list[ApiFormatCapability]:
"""列出已注册能力。"""
return list(_registry.values())
def register_provider_format_capability(
provider_type: str,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any] = "",
*,
same_format_variant: str | None = None,
cross_format_variant: str | None = None,
default_body_rules: Sequence[dict[str, Any]] | None = None,
) -> None:
"""注册 provider + endpoint 维度的格式能力。"""
pt = normalize_provider_type(provider_type)
if not pt:
raise ValueError("provider_type 不能为空")
sig = _normalize_endpoint_sig(endpoint_sig)
current = _provider_registry.get((pt, sig))
_provider_registry[(pt, sig)] = ProviderFormatCapability(
provider_type=pt,
endpoint_sig=sig,
same_format_variant=(
same_format_variant
if same_format_variant is not None
else (current.same_format_variant if current else None)
),
cross_format_variant=(
cross_format_variant
if cross_format_variant is not None
else (current.cross_format_variant if current else None)
),
default_body_rules=(
tuple(deepcopy(list(default_body_rules)))
if default_body_rules is not None
else (current.default_body_rules if current else None)
),
)
def get_provider_format_capability(
provider_type: str | None,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any] = "",
) -> ProviderFormatCapability | None:
"""获取 provider + endpoint 维度能力,未命中时回退 provider 级默认能力。"""
pt = normalize_provider_type(provider_type)
if not pt:
return None
sig = _normalize_endpoint_sig(endpoint_sig)
return _provider_registry.get((pt, sig)) or _provider_registry.get((pt, ""))
def register_provider_behavior_variant(
provider_type: str,
*,
same_format: bool = False,
cross_format: bool = False,
) -> None:
"""注册 provider 维度的格式变体标志。"""
pt = normalize_provider_type(provider_type)
current = get_provider_format_capability(pt)
register_provider_format_capability(
pt,
same_format_variant=(
pt if same_format else (current.same_format_variant if current else None)
),
cross_format_variant=(
pt if cross_format else (current.cross_format_variant if current else None)
),
)
def register_provider_format_behavior(
provider_type: str,
*,
same_format_variant: str | None = None,
cross_format_variant: str | None = None,
) -> None:
"""兼容接口:按显式 variant 名称注册 provider 行为。"""
register_provider_format_capability(
provider_type,
same_format_variant=same_format_variant,
cross_format_variant=cross_format_variant,
)
def get_provider_format_behavior(provider_type: str | None) -> ProviderFormatBehavior | None:
"""兼容接口:获取 provider 维度的格式变体能力。"""
capability = get_provider_format_capability(provider_type)
if capability is None:
return None
return ProviderFormatBehavior(
provider_type=capability.provider_type,
same_format_variant=capability.same_format_variant,
cross_format_variant=capability.cross_format_variant,
)
def get_provider_behavior_variants(
provider_type: str | None,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any] = "",
) -> tuple[str | None, str | None]:
capability = get_provider_format_capability(provider_type, endpoint_sig)
if capability is None:
return None, None
return capability.same_format_variant, capability.cross_format_variant
def resolve_provider_variants_for_endpoint(
provider_type: str | None,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any] = "",
) -> tuple[str | None, str | None]:
return get_provider_behavior_variants(provider_type, endpoint_sig)
def register_provider_default_body_rules(
provider_type: str,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any],
rules: Sequence[dict[str, Any]],
) -> None:
"""注册 provider + endpoint 维度的默认 body_rules。"""
register_provider_format_capability(
provider_type,
endpoint_sig,
default_body_rules=rules,
)
def get_provider_default_body_rules(
provider_type: str | None,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any],
) -> list[dict[str, Any]] | None:
"""获取 provider + endpoint 维度默认 body_rules。"""
capability = get_provider_format_capability(provider_type, endpoint_sig)
if capability is None or capability.default_body_rules is None:
return None
return deepcopy(list(capability.default_body_rules))
def get_provider_default_body_rules_for_endpoint(
provider_type: str | None,
endpoint_sig: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | tuple[Any, Any] = "",
) -> list[dict[str, Any]] | None:
return get_provider_default_body_rules(provider_type, endpoint_sig)
def resolve_billing_template_for_api_format(api_format: str | None) -> str | None:
"""解析 api_format 对应的计费模板。"""
capability = get_api_format_capability(api_format)
if capability and capability.billing_template:
return capability.billing_template
family = _normalize_api_format(api_format).split(":", 1)[0]
if family in {"claude", "openai", "gemini"}:
return family
return None
def compute_total_input_context_for_api_format(
api_format: str | None,
input_tokens: int,
cache_read_input_tokens: int,
cache_creation_input_tokens: int = 0,
) -> int:
"""按 api_format 计算阶梯计费口径中的总输入上下文。"""
capability = get_api_format_capability(api_format)
if capability is not None:
return capability.total_input_context_resolver(
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
)
if resolve_billing_template_for_api_format(api_format) == "claude":
return _claude_total_input_context(
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
)
return _default_total_input_context(
input_tokens,
cache_read_input_tokens,
cache_creation_input_tokens,
)
def _build_v1_models_url(base_url: str) -> str:
base_url = str(base_url or "").rstrip("/")
if base_url.endswith("/v1"):
return f"{base_url}/models"
return f"{base_url}/v1/models"
async def _fetch_openai_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
headers = build_adapter_headers_for_endpoint(api_format, api_key, extra_headers)
models_url = _build_v1_models_url(base_url)
try:
response = await client.get(models_url, headers=headers)
logger.debug("OpenAI models request to {}: status={}", models_url, response.status_code)
if response.status_code == 200:
data = response.json()
models: list[dict[str, Any]] = []
if isinstance(data, dict) and isinstance(data.get("data"), list):
models = [m for m in data["data"] if isinstance(m, dict)]
elif isinstance(data, list):
models = [m for m in data if isinstance(m, dict)]
for model in models:
model.setdefault("api_format", api_format)
return models, None
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning("OpenAI models request to {} failed: {}", models_url, error_msg)
return [], error_msg
except Exception as exc:
error_msg = f"Request error: {str(exc)}"
logger.warning("Failed to fetch models from {}: {}", models_url, exc)
return [], error_msg
async def _fetch_openai_cli_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
headers = {"User-Agent": config.internal_user_agent_openai_cli}
if extra_headers:
headers.update(extra_headers)
return await _fetch_openai_models(client, base_url, api_key, api_format, headers)
async def _fetch_claude_models_paginated(
client: httpx.AsyncClient,
base_url: str,
headers: dict[str, str],
api_format: str,
) -> tuple[list[dict[str, Any]], str | None]:
models_url = _build_v1_models_url(base_url)
try:
all_models: list[dict[str, Any]] = []
seen_ids: set[str] = set()
after_id: str | None = None
limit = 100
max_pages = 20
for _ in range(max_pages):
params: dict[str, Any] = {"limit": limit}
if after_id:
params["after_id"] = after_id
response = await client.get(models_url, headers=headers, params=params)
logger.debug(
"Claude models request to {}: status={}, after_id={}",
models_url,
response.status_code,
after_id,
)
if response.status_code != 200:
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning("Claude models request to {} failed: {}", models_url, error_msg)
return [], error_msg
data = response.json()
page_models: list[dict[str, Any]] = []
if isinstance(data, dict) and isinstance(data.get("data"), list):
page_models = [m for m in data["data"] if isinstance(m, dict)]
elif isinstance(data, list):
page_models = [m for m in data if isinstance(m, dict)]
for model in page_models:
model_id = model.get("id")
if isinstance(model_id, str) and model_id and model_id in seen_ids:
continue
if isinstance(model_id, str) and model_id:
seen_ids.add(model_id)
model.setdefault("api_format", api_format)
all_models.append(model)
if not isinstance(data, dict):
break
has_more = bool(data.get("has_more"))
last_id = data.get("last_id")
if not has_more:
break
if not isinstance(last_id, str) or not last_id:
break
if after_id == last_id:
break
after_id = last_id
return all_models, None
except Exception as exc:
error_msg = f"Request error: {str(exc)}"
logger.warning("Failed to fetch Claude models from {}: {}", models_url, exc)
return [], error_msg
async def _fetch_claude_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
*,
force_bearer_fallback: bool,
) -> tuple[list[dict[str, Any]], str | None]:
headers = build_adapter_headers_for_endpoint(api_format, api_key, extra_headers)
if force_bearer_fallback and "authorization" not in {k.lower() for k in headers}:
headers["Authorization"] = f"Bearer {api_key}"
return await _fetch_claude_models_paginated(client, base_url, headers, api_format)
async def _fetch_claude_chat_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
return await _fetch_claude_models(
client,
base_url,
api_key,
api_format,
extra_headers,
force_bearer_fallback=True,
)
async def _fetch_claude_cli_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
headers = {"User-Agent": config.internal_user_agent_claude_cli}
if extra_headers:
headers.update(extra_headers)
return await _fetch_claude_models(
client,
base_url,
api_key,
api_format,
headers,
force_bearer_fallback=False,
)
async def _fetch_gemini_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
base_url_clean = str(base_url or "").rstrip("/")
if base_url_clean.endswith("/v1beta"):
models_url = f"{base_url_clean}/models?key={api_key}"
else:
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
headers: dict[str, str] = {**BROWSER_FINGERPRINT_HEADERS}
if extra_headers:
headers.update(extra_headers)
try:
response = await client.get(models_url, headers=headers)
logger.debug(
"Gemini models request to {}: status={}",
_redact_url_for_log(models_url),
response.status_code,
)
if response.status_code == 200:
data = response.json()
if isinstance(data, dict) and isinstance(data.get("models"), list):
out: list[dict[str, Any]] = []
for model in data["models"]:
if not isinstance(model, dict):
continue
out.append(
{
"id": str(model.get("name", "")).replace("models/", ""),
"owned_by": "google",
"display_name": model.get("displayName", ""),
"api_format": api_format,
}
)
return out, None
return [], None
error_body = response.text[:500] if response.text else "(empty)"
error_msg = f"HTTP {response.status_code}: {error_body}"
logger.warning(
"Gemini models request to {} failed: {}",
_redact_url_for_log(models_url),
error_msg,
)
return [], error_msg
except Exception as exc:
sanitized_error = _redact_url_for_log(str(exc))
error_msg = f"Request error: {sanitized_error}"
logger.warning(
"Failed to fetch Gemini models from {}: {}",
_redact_url_for_log(models_url),
sanitized_error,
)
return [], error_msg
async def _fetch_gemini_cli_models(
client: httpx.AsyncClient,
base_url: str,
api_key: str,
api_format: str,
extra_headers: dict[str, str] | None,
) -> tuple[list[dict[str, Any]], str | None]:
headers = {"User-Agent": config.internal_user_agent_gemini_cli}
if extra_headers:
headers.update(extra_headers)
return await _fetch_gemini_models(client, base_url, api_key, api_format, headers)
async def fetch_models_for_api_format(
client: httpx.AsyncClient,
*,
api_format: str,
base_url: str,
api_key: str,
extra_headers: dict[str, str] | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""按 api_format 获取模型列表。"""
normalized_api_format = _normalize_api_format(api_format)
capability = get_api_format_capability(normalized_api_format)
if capability is None or capability.model_fetcher is None:
return [], f"Unknown API format: {api_format}"
return await capability.model_fetcher(
client,
base_url,
api_key,
normalized_api_format,
extra_headers,
)
def _register_builtin_capabilities() -> None:
register_api_format_capability(
ApiFormatCapability(
api_format="openai:chat",
billing_template="openai",
model_fetcher=_fetch_openai_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="openai:cli",
billing_template="openai",
model_fetcher=_fetch_openai_cli_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="openai:compact",
billing_template="openai",
model_fetcher=_fetch_openai_cli_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="claude:chat",
billing_template="claude",
total_input_context_resolver=_claude_total_input_context,
model_fetcher=_fetch_claude_chat_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="claude:cli",
billing_template="claude",
total_input_context_resolver=_claude_total_input_context,
model_fetcher=_fetch_claude_cli_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="gemini:chat",
billing_template="gemini",
model_fetcher=_fetch_gemini_models,
)
)
register_api_format_capability(
ApiFormatCapability(
api_format="gemini:cli",
billing_template="gemini",
model_fetcher=_fetch_gemini_cli_models,
)
)
_register_builtin_capabilities()
__all__ = [
"ApiFormatCapability",
"ProviderFormatBehavior",
"ProviderFormatCapability",
"compute_total_input_context_for_api_format",
"fetch_models_for_api_format",
"get_api_format_capability",
"get_provider_behavior_variants",
"get_provider_default_body_rules",
"get_provider_default_body_rules_for_endpoint",
"get_provider_format_behavior",
"get_provider_format_capability",
"list_api_format_capabilities",
"register_api_format_capability",
"register_provider_behavior_variant",
"register_provider_default_body_rules",
"register_provider_format_behavior",
"register_provider_format_capability",
"resolve_billing_template_for_api_format",
"resolve_provider_variants_for_endpoint",
]

View File

@@ -0,0 +1,30 @@
"""
API 格式转换子模块Canonical
对外提供:
- `format_conversion_registry`: 全局转换注册表Hub-and-Spoke
- `register_default_normalizers()`: 注册默认 NormalizersOPENAI/CLAUDE/GEMINI
- `StreamState`: 统一流式状态容器
"""
from src.core.api_format.conversion.compatibility import is_format_compatible
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.registry import (
FormatConversionRegistry,
format_conversion_registry,
register_default_normalizers,
)
from src.core.api_format.conversion.stream_state import StreamState
__all__ = [
# Registry
"FormatConversionRegistry",
"format_conversion_registry",
"register_default_normalizers",
# Stream state
"StreamState",
# Exceptions
"FormatConversionError",
# Compatibility
"is_format_compatible",
]

View File

@@ -0,0 +1,139 @@
"""
格式兼容性检查
用于候选筛选时判断端点是否可以处理客户端请求格式。
三层开关优先级(从高到低):
1. 全局开关 ON → 强制允许(跳过后续检查)
2. 全局开关 OFF → 看提供商开关
- 提供商开关 ON → 强制允许(跳过端点检查)
- 提供商开关 OFF → 看端点配置
3. 端点配置format_acceptance_config
- enabled=true + 白名单/黑名单检查 → 允许
- enabled=false 或未配置 → 禁止
转换逻辑:
1. 格式完全匹配 -> 透传(无需转换)
2. data_format_id 相同 -> 透传(无需数据转换,如 claude:chat / claude:cli
3. 格式不同且 data_format_id 不同 -> 需要检查三层开关
- 通过开关检查后,检查转换器能力
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.metadata import can_passthrough_endpoint
logger = logging.getLogger(__name__)
def is_format_compatible(
client_format: str,
endpoint_api_format: str,
endpoint_format_acceptance_config: dict | None,
is_stream: bool,
effective_conversion_enabled: bool,
registry: FormatConversionRegistry | None = None,
*,
skip_endpoint_check: bool = False,
) -> tuple[bool, bool, str | None]:
"""
检查端点是否兼容客户端格式
Args:
client_format: 客户端请求格式
endpoint_api_format: 端点的 API 格式
endpoint_format_acceptance_config: 端点的格式接受配置
is_stream: 是否是流式请求
effective_conversion_enabled: 格式转换总开关(通常来自环境变量/Feature Flag
registry: 转换器注册表(可选,默认使用全局单例)
skip_endpoint_check: 是否跳过端点配置检查(当全局或提供商开关为 ON 时设为 True
Returns:
(is_compatible, needs_conversion, skip_reason)
- is_compatible: 是否兼容
- needs_conversion: 是否需要转换
- skip_reason: 不兼容时的原因
"""
# 延迟导入避免循环依赖
if registry is None:
from src.core.api_format.conversion.registry import (
format_conversion_registry,
register_default_normalizers,
)
register_default_normalizers()
registry = format_conversion_registry
# 统一大写用于比较和 registry 查找registry 以大写 key 索引 normalizer
client_key = client_format.upper()
provider_key = endpoint_api_format.upper()
# 1. 格式完全匹配 -> 透传(无需转换)
if provider_key == client_key:
return True, False, None
# 2. data_format_id 相同 -> 透传(无需数据转换,也无需格式转换开关)
# 例如claude:chat / claude:cli 的 data_format_id 都是 “claude”只是认证方式不同
if can_passthrough_endpoint(client_key, provider_key):
return True, False, None
# 3. 格式不同且 data_format_id 不同 -> 需要检查格式转换开关(分层开关)
#
# 设计语义(与模块顶部注释一致):
# - 全局开关 ON -> 强制允许跨格式(通常 caller 会传 skip_endpoint_check=True
# - 全局开关 OFF -> 不再”一刀切”拒绝,而是回退到 provider/endpoint 开关:
# - provider 开关 ON -> 强制允许skip_endpoint_check=True跳过端点检查
# - provider 开关 OFF -> 由端点 format_acceptance_config 决定skip_endpoint_check=False
#
# 说明:
# - effective_conversion_enabled 表示”全局默认允许”,不是”全局总闸/kill switch”
# - 当它为 False 时我们仍然会继续执行后续检查provider/endpoint
# 兼容”按 Provider/Endpoint 精细化开启转换”的场景。
# 4. 如果全局或提供商开关为 ON跳过端点配置检查
if not skip_endpoint_check:
# 检查端点配置(第三层开关)
if endpoint_format_acceptance_config is None:
return False, False, "端点未配置格式接受策略"
config = endpoint_format_acceptance_config
if not isinstance(config, dict):
return False, False, "端点格式配置无效"
if not config.get("enabled", False):
return False, False, "端点格式接受未启用"
# 检查 reject_formats优先
reject_formats = config.get("reject_formats", [])
if client_key in [f.upper() for f in reject_formats]:
return False, False, f"端点拒绝 {client_format} 格式"
# 检查 accept_formats
accept_formats = config.get("accept_formats", [])
if accept_formats and client_key not in [f.upper() for f in accept_formats]:
return False, False, f"端点不接受 {client_format} 格式"
# 检查流式转换
if is_stream and not config.get("stream_conversion", True):
return False, False, "端点不支持流式格式转换"
# 5. 需要数据转换的情况data_format_id 不同)
# 检查转换器能力
if not registry.can_convert_full(
client_key,
provider_key,
require_stream=is_stream,
):
return False, False, f"不存在 {client_format} <-> {endpoint_api_format} 的完整转换器"
return True, True, None
__all__ = [
"is_format_compatible",
]

View File

@@ -0,0 +1,251 @@
"""格式转换层常量定义 & 跨格式工具转换函数。
将跨层共享的常量集中在 core 层,避免 core -> services 的反向依赖。
OpenAI Chat <-> Responses API 的工具 / tool_choice / web_search 双向转换
由 openai.py 和 openai_cli.py 共享,避免两端维护不一致。
"""
from __future__ import annotations
import json
from typing import Any
def stable_json_dumps(value: Any) -> str:
"""Serialize JSON deterministically for cache-sensitive fallback generation."""
return json.dumps(value, ensure_ascii=False, sort_keys=True)
# Thinking 签名验证的跳过标记
# 当无法获取真实签名时,使用此值作为占位符
DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
# ---------------------------------------------------------------------------
# OpenAI Chat / Responses API 跨格式透传字段白名单
# 由 openai.py 和 openai_cli.py 共享,避免两端维护不一致。
# ---------------------------------------------------------------------------
# 已由 normalizer 显式处理的字段 — 不需要从 extra 还原
OPENAI_HANDLED_KEYS: frozenset[str] = frozenset(
{
"messages",
"model",
"max_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
"reasoning_effort",
"n",
"presence_penalty",
"frequency_penalty",
"seed",
"logprobs",
"top_logprobs",
"response_format",
"verbosity",
"text",
"input",
"instructions",
"max_output_tokens",
"web_search_options",
"stream_options",
# 已废弃的 Chat API 字段,不需要透传
"function_call",
"functions",
}
)
# Chat Completions 允许透传的字段
OPENAI_CHAT_PASSTHROUGH_KEYS: frozenset[str] = frozenset(
{
"metadata",
"user",
"safety_identifier",
"prompt_cache_key",
"service_tier",
"prompt_cache_retention",
"modalities",
"audio",
"store",
"prediction",
"logit_bias",
}
)
# Responses API 允许透传的字段
OPENAI_RESPONSES_PASSTHROUGH_KEYS: frozenset[str] = frozenset(
{
"include",
"conversation",
"context_management",
"previous_response_id",
"background",
"max_tool_calls",
"prompt",
"truncation",
"metadata",
"user",
"safety_identifier",
"prompt_cache_key",
"service_tier",
"prompt_cache_retention",
"store",
}
)
# ---------------------------------------------------------------------------
# OpenAI Chat <-> Responses API 工具 / tool_choice / web_search 双向转换
# ---------------------------------------------------------------------------
def responses_tool_to_chat_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
"""Responses API tool -> Chat Completions tool (嵌套结构)。"""
tool_type = str(tool.get("type") or "")
if tool_type == "function":
name = str(tool.get("name") or "")
if not name:
return None
function: dict[str, Any] = {"name": name}
if isinstance(tool.get("description"), str):
function["description"] = tool["description"]
if isinstance(tool.get("parameters"), dict):
function["parameters"] = tool["parameters"]
if tool.get("strict") is not None:
function["strict"] = tool.get("strict")
return {"type": "function", "function": function}
if tool_type == "custom":
name = str(tool.get("name") or "")
if not name:
return None
custom: dict[str, Any] = {"name": name}
if isinstance(tool.get("description"), str):
custom["description"] = tool["description"]
if isinstance(tool.get("format"), dict):
custom["format"] = tool["format"]
return {"type": "custom", "custom": custom}
return None
def chat_tool_to_responses_tool(tool: dict[str, Any]) -> dict[str, Any] | None:
"""Chat Completions tool (嵌套结构) -> Responses API tool (扁平结构)。"""
tool_type = str(tool.get("type") or "")
if tool_type == "function" and isinstance(tool.get("function"), dict):
function = tool["function"]
name = str(function.get("name") or "")
if not name:
return None
translated: dict[str, Any] = {"type": "function", "name": name}
if isinstance(function.get("description"), str):
translated["description"] = function["description"]
if isinstance(function.get("parameters"), dict):
translated["parameters"] = function["parameters"]
if function.get("strict") is not None:
translated["strict"] = function.get("strict")
return translated
if tool_type == "custom" and isinstance(tool.get("custom"), dict):
custom = tool["custom"]
name = str(custom.get("name") or "")
if not name:
return None
translated = {"type": "custom", "name": name}
if isinstance(custom.get("description"), str):
translated["description"] = custom["description"]
if isinstance(custom.get("format"), dict):
translated["format"] = custom["format"]
return translated
return None
def responses_web_search_tool_to_chat_options(
tool: dict[str, Any],
) -> dict[str, Any] | None:
"""Responses API web_search tool -> Chat Completions web_search_options。"""
tool_type = str(tool.get("type") or "")
if not tool_type.startswith("web_search"):
return None
options: dict[str, Any] = {}
user_location = tool.get("user_location")
if isinstance(user_location, dict):
approximate = dict(user_location)
approximate.pop("type", None)
options["user_location"] = {"type": "approximate", "approximate": approximate}
search_context_size = tool.get("search_context_size")
if isinstance(search_context_size, str) and search_context_size:
options["search_context_size"] = search_context_size
return options or None
def chat_web_search_options_to_responses_tools(
web_search_options: Any,
) -> list[dict[str, Any]] | None:
"""Chat Completions web_search_options -> Responses API web_search tool 列表。"""
if not isinstance(web_search_options, dict):
return None
tool: dict[str, Any] = {"type": "web_search"}
user_location = web_search_options.get("user_location")
if isinstance(user_location, dict):
approximate = user_location.get("approximate")
if isinstance(approximate, dict):
tool["user_location"] = {"type": "approximate", **approximate}
search_context_size = web_search_options.get("search_context_size")
if isinstance(search_context_size, str) and search_context_size:
tool["search_context_size"] = search_context_size
return [tool]
def responses_tool_choice_to_chat(
tool_choice: dict[str, Any],
) -> dict[str, Any] | None:
"""Responses API tool_choice dict -> Chat Completions tool_choice dict。"""
choice_type = str(tool_choice.get("type") or "")
if choice_type == "allowed_tools":
mode = tool_choice.get("mode")
tools = tool_choice.get("tools")
if isinstance(mode, str) and isinstance(tools, list):
return {"type": "allowed_tools", "allowed_tools": {"mode": mode, "tools": tools}}
if choice_type == "function":
fn = tool_choice.get("function")
name = str(
tool_choice.get("name") or (fn.get("name") if isinstance(fn, dict) else "") or ""
)
if name:
return {"type": "function", "function": {"name": name}}
if choice_type == "custom":
custom = tool_choice.get("custom")
name = str(
tool_choice.get("name")
or (custom.get("name") if isinstance(custom, dict) else "")
or ""
)
if name:
return {"type": "custom", "custom": {"name": name}}
return None
def chat_tool_choice_to_responses(
tool_choice: dict[str, Any],
) -> dict[str, Any] | None:
"""Chat Completions tool_choice dict -> Responses API tool_choice dict。"""
choice_type = str(tool_choice.get("type") or "")
if choice_type == "allowed_tools" and isinstance(tool_choice.get("allowed_tools"), dict):
allowed_tools = tool_choice["allowed_tools"]
mode = allowed_tools.get("mode")
tools = allowed_tools.get("tools")
if isinstance(mode, str) and isinstance(tools, list):
return {"type": "allowed_tools", "mode": mode, "tools": tools}
if choice_type == "function" and isinstance(tool_choice.get("function"), dict):
name = str(tool_choice["function"].get("name") or "")
if name:
return {"type": "function", "name": name}
if choice_type == "custom" and isinstance(tool_choice.get("custom"), dict):
name = str(tool_choice["custom"].get("name") or "")
if name:
return {"type": "custom", "name": name}
return None

View File

@@ -0,0 +1,25 @@
"""
格式转换异常
用于严格模式转换失败时抛出,让编排器可以尝试下一个候选。
"""
class FormatConversionError(Exception):
"""
格式转换失败异常
在严格模式下,转换失败会抛出此异常,
让 Orchestrator 可以捕获并尝试下一个候选。
"""
def __init__(self, source_format: str, target_format: str, message: str) -> None:
self.source_format = source_format
self.target_format = target_format
self.message = message
super().__init__(f"格式转换失败 ({source_format} -> {target_format}): {message}")
__all__ = [
"FormatConversionError",
]

View File

@@ -0,0 +1,221 @@
"""
字段映射配置(集中定义)
该文件用于承载:
- role/stop_reason/usage/error 的常见映射表
注意:
- conversion 层只负责 body 结构转换,不维护 model_in_body/stream_in_body/auth_header 等元数据;
这些应复用 `src/core/api_format/metadata.py`API_FORMAT_DEFINITIONS作为单一事实来源。
"""
# 角色映射仅作为辅助system/tool 的具体落点以 Normalizer 规则为准)
ROLE_MAPPINGS: dict[str, dict[str, str]] = {
"OPENAI": {
"user": "user",
"assistant": "assistant",
"system": "system",
"developer": "developer",
"tool": "tool",
},
"CLAUDE": {"user": "user", "assistant": "assistant"},
"GEMINI": {"user": "user", "assistant": "model"},
}
# 停止原因映射internal -> provider未知值使用 UNKNOWN 并写入 extra/raw
STOP_REASON_MAPPINGS: dict[str, dict[str, str]] = {
"CLAUDE": {
"end_turn": "end_turn",
"max_tokens": "max_tokens",
"stop_sequence": "stop_sequence",
"tool_use": "tool_use",
"pause_turn": "end_turn",
"refusal": "end_turn",
# Claude 通常以错误/阻断体现,这里仅兜底
"content_filtered": "end_turn",
"unknown": "end_turn",
},
"OPENAI": {
"end_turn": "stop",
"max_tokens": "length",
"stop_sequence": "stop",
"tool_use": "tool_calls",
"content_filtered": "content_filter",
"refusal": "content_filter",
"pause_turn": "stop",
"unknown": "stop",
},
"GEMINI": {
"end_turn": "STOP",
"max_tokens": "MAX_TOKENS",
"stop_sequence": "STOP",
# Gemini finishReason 对工具调用并没有稳定等价枚举,这里保守兜底为 STOP
"tool_use": "STOP",
"content_filtered": "SAFETY",
"refusal": "SAFETY",
"pause_turn": "OTHER",
"unknown": "OTHER",
},
}
# 使用量字段映射provider usage field -> internal UsageInfo field
USAGE_FIELD_MAPPINGS: dict[str, dict[str, str]] = {
"CLAUDE": {
"input_tokens": "input_tokens",
"output_tokens": "output_tokens",
"cache_read_input_tokens": "cache_read_tokens",
"cache_creation_input_tokens": "cache_write_tokens",
},
"OPENAI": {
"prompt_tokens": "input_tokens",
"completion_tokens": "output_tokens",
"total_tokens": "total_tokens",
},
"GEMINI": {
"promptTokenCount": "input_tokens",
"candidatesTokenCount": "output_tokens",
"totalTokenCount": "total_tokens",
"cachedContentTokenCount": "cache_read_tokens",
},
}
# 错误类型映射provider -> internal ErrorType.value
ERROR_TYPE_MAPPINGS: dict[str, dict[str, str]] = {
"CLAUDE": {
"invalid_request_error": "invalid_request",
"authentication_error": "authentication",
"permission_error": "permission_denied",
"not_found_error": "not_found",
"rate_limit_error": "rate_limit",
"timeout_error": "server_error",
"overloaded_error": "overloaded",
"billing_error": "permission_denied",
"api_error": "server_error",
},
"OPENAI": {
"invalid_request_error": "invalid_request",
"invalid_api_key": "authentication",
"insufficient_quota": "rate_limit",
"rate_limit_exceeded": "rate_limit",
"server_error": "server_error",
"context_length_exceeded": "context_length_exceeded",
"content_policy_violation": "content_filtered",
},
"GEMINI": {
"INVALID_ARGUMENT": "invalid_request",
"UNAUTHENTICATED": "authentication",
"PERMISSION_DENIED": "permission_denied",
"NOT_FOUND": "not_found",
"RESOURCE_EXHAUSTED": "rate_limit",
"INTERNAL": "server_error",
"UNAVAILABLE": "overloaded",
},
}
# 可重试的错误类型internal ErrorType.value
RETRYABLE_ERROR_TYPES: set[str] = {
"rate_limit",
"overloaded",
"server_error",
}
# OpenAI reasoning_effort -> thinking budget_tokens
# 参考 new-api relay-claude.go:178-196
REASONING_EFFORT_TO_THINKING_BUDGET: dict[str, int] = {
"low": 1280,
"medium": 2048,
"high": 4096,
"xhigh": 8192,
}
# thinking budget_tokens -> OpenAI reasoning_effort反向映射取最近区间
THINKING_BUDGET_TO_REASONING_EFFORT: list[tuple[int, str]] = [
(1664, "low"), # <= 1664 -> low (midpoint of 1280..2048)
(3072, "medium"), # <= 3072 -> medium (midpoint of 2048..4096)
(6144, "high"), # <= 6144 -> high (midpoint of 4096..8192)
(2**31, "xhigh"), # > 6144 -> xhigh
]
# Claude output_config.effort -> 标准化 reasoning_effort
CLAUDE_EFFORT_TO_REASONING_EFFORT: dict[str, str] = {
"low": "low",
"medium": "medium",
"high": "high",
"max": "xhigh",
# "auto" 不映射,让目标格式使用默认行为
}
# 标准化 reasoning_effort -> Claude output_config.effort
REASONING_EFFORT_TO_CLAUDE_EFFORT: dict[str, str] = {
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "max",
}
# OpenAI web_search_options.search_context_size -> Claude web_search max_uses
WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES: dict[str, int] = {
"low": 1,
"medium": 5,
"high": 10,
}
# Claude max_tokens 兜底默认值(仅在 GlobalModel.output_limit 和请求 max_tokens 均为空时使用)
# 参考 new-api setting/model_setting/claude.go 的 DefaultMaxTokens["default"]
CLAUDE_DEFAULT_MAX_TOKENS: int = 8192
def get_claude_default_max_tokens(_model: str) -> int:
"""获取 Claude 的 max_tokens 兜底默认值。
正常情况下应优先使用 GlobalModel.config.output_limit通过 InternalRequest.output_limit 传入),
此函数仅在 output_limit 不可用时作为最终兜底。
"""
return CLAUDE_DEFAULT_MAX_TOKENS
# thinking budget_tokens 占 max_tokens 的比例(参考 new-api: 0.8
THINKING_BUDGET_TOKENS_PERCENTAGE: float = 0.8
# thinking budget_tokens 最小值Claude API 要求 >= 1024
THINKING_BUDGET_TOKENS_MIN: int = 1280
def get_claude_default_thinking_budget(model: str) -> int:
"""根据模型名称计算 thinking budget_tokens 默认值。
budget = max(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE, THINKING_BUDGET_TOKENS_MIN)
"""
max_tokens = get_claude_default_max_tokens(model)
return max(int(max_tokens * THINKING_BUDGET_TOKENS_PERCENTAGE), THINKING_BUDGET_TOKENS_MIN)
# 跨格式 thinking 转换时,非 Claude 模型的默认 budget_tokens 安全值
CROSS_FORMAT_THINKING_BUDGET_DEFAULT: int = 8192
__all__ = [
"ROLE_MAPPINGS",
"STOP_REASON_MAPPINGS",
"USAGE_FIELD_MAPPINGS",
"ERROR_TYPE_MAPPINGS",
"RETRYABLE_ERROR_TYPES",
"REASONING_EFFORT_TO_THINKING_BUDGET",
"THINKING_BUDGET_TO_REASONING_EFFORT",
"CLAUDE_EFFORT_TO_REASONING_EFFORT",
"REASONING_EFFORT_TO_CLAUDE_EFFORT",
"WEB_SEARCH_CONTEXT_SIZE_TO_MAX_USES",
"CLAUDE_DEFAULT_MAX_TOKENS",
"THINKING_BUDGET_TOKENS_PERCENTAGE",
"THINKING_BUDGET_TOKENS_MIN",
"CROSS_FORMAT_THINKING_BUDGET_DEFAULT",
"get_claude_default_max_tokens",
"get_claude_default_thinking_budget",
]

View File

@@ -0,0 +1,273 @@
"""
图片 URL 解析器
当跨格式转换时,目标格式需要 base64 图片数据(如 Claude 不原生支持 URL 图片引用),
该模块负责自动下载图片 URL 并转换为 base64 内嵌数据。
使用方式:在跨格式转换前/后调用 resolve_image_urls()。
"""
import asyncio
import base64
import ipaddress
import mimetypes
import socket
from urllib.parse import urljoin, urlparse
import httpx
from src.core.api_format.conversion.internal import (
FileBlock,
ImageBlock,
InternalRequest,
)
from src.core.logger import logger
# 需要 base64 图片数据的目标格式前缀
_FORMATS_REQUIRING_BASE64 = frozenset({"CLAUDE"})
# 图片下载超时(秒)
_DOWNLOAD_TIMEOUT = 15.0
# 单张图片最大大小字节20MB
_MAX_IMAGE_SIZE = 20 * 1024 * 1024
# 并发下载数量限制
_MAX_CONCURRENT_DOWNLOADS = 8
# 最大重定向次数
_MAX_REDIRECTS = 5
def _is_private_ip(addr: str) -> bool:
"""检查 IP 地址是否为私有/内网地址。"""
try:
ip = ipaddress.ip_address(addr)
return bool(ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved)
except ValueError:
return True
async def _resolve_and_validate_host(hostname: str) -> list[str] | None:
"""DNS 解析并校验所有 IP 均为公网地址SSRF 防护)。
返回已校验的公网 IP 列表;如果任一 IP 为私有地址或解析失败,返回 None。
"""
try:
loop = asyncio.get_running_loop()
infos = await loop.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
ips: list[str] = []
for _family, _type, _proto, _canonname, sockaddr in infos:
addr = sockaddr[0]
if _is_private_ip(addr):
return None
ips.append(addr)
return ips or None
except (socket.gaierror, ValueError, OSError):
# DNS 解析失败:安全默认拒绝
return None
async def _validate_url(url: str) -> bool:
"""校验 URL 的 scheme 和主机地址,通过返回 True否则返回 False。"""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
logger.warning("[ImageResolver] 不支持的 URL scheme: {}", url[:100])
return False
hostname = parsed.hostname or ""
resolved = await _resolve_and_validate_host(hostname)
if resolved is None:
logger.warning("[ImageResolver] 拒绝下载私有网络地址: {}", url[:100])
return False
return True
def _validate_peer_ip(resp: httpx.Response) -> bool:
"""校验 HTTP 响应的实际对端 IP 是否为公网地址(防 DNS rebinding TOCTOU 绕过)。
httpx 通过 extensions["network_stream"] 暴露底层连接,
从中可获取对端地址进行二次校验。
"""
try:
network_stream = resp.extensions.get("network_stream")
if network_stream is None:
logger.debug("[ImageResolver] network_stream 不可用, DNS rebinding 检测跳过")
return True
# asyncio transport 标准 extra info key 是 peername
peername = network_stream.get_extra_info("peername")
if peername is not None:
peer_ip = peername[0] if isinstance(peername, tuple) else str(peername)
if _is_private_ip(peer_ip):
logger.warning("[ImageResolver] DNS rebinding 检测: 实际连接到私有 IP {}", peer_ip)
return False
except Exception as e:
# 无法获取对端信息时放行(不阻塞正常功能),依赖前置 DNS 校验
logger.debug("[ImageResolver] 无法获取对端 IP 信息DNS rebinding 检测跳过): {}", e)
return True
async def _download_file(
client: httpx.AsyncClient, url: str, semaphore: asyncio.Semaphore
) -> tuple[str, str] | None:
"""下载 URL 并返回 (base64_data, media_type),失败返回 None。
手动处理重定向,每一跳都检查目标地址(防止重定向到内网的 SSRF 绕过)。
连接建立后二次校验对端 IP防 DNS rebinding
"""
async with semaphore:
try:
if not await _validate_url(url):
return None
current_url = url
for _ in range(_MAX_REDIRECTS + 1):
async with client.stream("GET", current_url) as resp:
# DNS rebinding 防护:校验实际连接的对端 IP
if not _validate_peer_ip(resp):
return None
if resp.is_redirect:
location = resp.headers.get("location", "")
if not location:
logger.warning("[ImageResolver] 重定向缺少 Location: {}", url[:100])
return None
redirect_url = urljoin(str(current_url), location)
if not await _validate_url(redirect_url):
return None
current_url = redirect_url
continue
resp.raise_for_status()
content_type = resp.headers.get("content-type", "")
media_type = content_type.split(";")[0].strip().lower()
if not media_type:
media_type = _guess_media_type(current_url)
# 检查 MIME 类型是否为目标 API 可接受的类型
if not any(media_type.startswith(p) for p in _ACCEPTED_MIME_PREFIXES):
logger.warning(
"[ImageResolver] 不支持的 MIME 类型 {}, 跳过: {}",
media_type,
url[:100],
)
return None
# 预检 Content-Length如果有
content_length = resp.headers.get("content-length")
if content_length:
try:
if int(content_length) > _MAX_IMAGE_SIZE:
logger.warning(
"[ImageResolver] Content-Length 超过大小限制: {}",
url[:100],
)
return None
except (TypeError, ValueError):
pass
# 流式累计读取并检查大小
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > _MAX_IMAGE_SIZE:
logger.warning(
"[ImageResolver] 文件超过大小限制 ({} bytes > {}): {}",
total,
_MAX_IMAGE_SIZE,
url[:100],
)
return None
chunks.append(chunk)
data = b"".join(chunks)
b64 = base64.b64encode(data).decode("ascii")
return b64, media_type
logger.warning("[ImageResolver] 超过最大重定向次数: {}", url[:100])
return None
except Exception as e:
logger.warning("[ImageResolver] 下载文件失败: {} - {}", url[:100], e)
return None
def _guess_media_type(url: str) -> str:
"""从 URL 路径猜测 MIME 类型。"""
path = url.split("?")[0]
mt, _ = mimetypes.guess_type(path)
return mt or "application/octet-stream"
# Claude API 支持的 MIME 类型前缀(图片/文档/音频等可直接作为 base64 内嵌的类型)
_ACCEPTED_MIME_PREFIXES: tuple[str, ...] = (
"image/",
"application/pdf",
"text/",
"audio/",
"video/",
)
def _is_data_url(url: str) -> bool:
"""判断是否是 data: URL已内嵌 base64"""
return url.startswith("data:")
async def resolve_image_urls(
internal: InternalRequest,
target_format: str,
) -> None:
"""遍历 InternalRequest 中所有 ImageBlock/FileBlock对有 url 无 data 的进行下载转 base64。
仅当 target_format 需要 base64 时执行下载(如 CLAUDE
直接修改 internal 对象,无返回值。
"""
target_upper = str(target_format).upper()
# 检查目标格式是否需要 base64
needs_base64 = any(target_upper.startswith(prefix) for prefix in _FORMATS_REQUIRING_BASE64)
if not needs_base64:
return
# 收集所有需要下载的 block 及其 URL
download_items: list[tuple[ImageBlock | FileBlock, str]] = []
for msg in internal.messages:
for block in msg.content:
if isinstance(block, ImageBlock):
if block.url and not block.data and not _is_data_url(block.url):
download_items.append((block, block.url))
elif isinstance(block, FileBlock):
if block.file_url and not block.data and not _is_data_url(block.file_url):
download_items.append((block, block.file_url))
if not download_items:
return
semaphore = asyncio.Semaphore(_MAX_CONCURRENT_DOWNLOADS)
# 复用同一个 client 并发下载所有文件(禁用自动重定向,在 _download_file 中手动处理)
async with httpx.AsyncClient(
follow_redirects=False,
timeout=httpx.Timeout(_DOWNLOAD_TIMEOUT),
limits=httpx.Limits(max_connections=_MAX_CONCURRENT_DOWNLOADS),
) as client:
tasks = [_download_file(client, url, semaphore) for _, url in download_items]
results = await asyncio.gather(*tasks)
# 回写结果
for (block, url), result in zip(download_items, results):
if result is not None:
b64_data, media_type = result
block.data = b64_data
if not block.media_type:
block.media_type = media_type
# 保留原始 URL 以便调试,清除源 URL 字段避免语义模糊
block.extra["original_url"] = url
if isinstance(block, ImageBlock):
block.url = None
elif isinstance(block, FileBlock):
block.file_url = None
__all__ = ["resolve_image_urls"]

View File

@@ -0,0 +1,469 @@
"""
格式转换内部表示Internal / Canonical Format
该模块定义 Hub-and-Spoke 架构的"中间表示法",用于把不同 Provider 的请求/响应/流式事件
统一映射到稳定的内部结构,再转换为目标格式。
设计原则:
- 类型安全:尽量用 dataclass + Enum 表达语义,便于 IDE/静态检查
- 可扩展:未知/不可逆字段写入 extra/raw避免静默丢失
- 兼容优先UnknownBlock 在内部保留,但默认在输出阶段丢弃(可观测、可随时调整策略)
字段修改须知:
- 本文件是所有 normalizer 的共享契约,修改字段语义会同时影响所有格式的输入输出
- 每个字段的注释标注了各格式的映射关系OpenAI/Claude/Gemini
- 修改前请检查 tests/core/api_format/conversion/ 下的 roundtrip + schema 测试
- 新增字段应标注 "可选" 并给默认值,避免破坏现有 normalizer
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class Role(str, Enum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
DEVELOPER = "developer"
TOOL = "tool"
UNKNOWN = "unknown"
class ContentType(str, Enum):
TEXT = "text"
THINKING = "thinking"
IMAGE = "image"
FILE = "file"
AUDIO = "audio"
TOOL_USE = "tool_use"
TOOL_RESULT = "tool_result"
UNKNOWN = "unknown"
class StopReason(str, Enum):
END_TURN = "end_turn"
MAX_TOKENS = "max_tokens"
STOP_SEQUENCE = "stop_sequence"
TOOL_USE = "tool_use"
# Claude streaming 里会出现官方文档枚举pause_turn / refusal
PAUSE_TURN = "pause_turn"
REFUSAL = "refusal"
CONTENT_FILTERED = "content_filtered"
UNKNOWN = "unknown"
class ErrorType(str, Enum):
INVALID_REQUEST = "invalid_request"
AUTHENTICATION = "authentication"
PERMISSION_DENIED = "permission_denied"
NOT_FOUND = "not_found"
RATE_LIMIT = "rate_limit"
OVERLOADED = "overloaded"
SERVER_ERROR = "server_error"
CONTENT_FILTERED = "content_filtered"
CONTEXT_LENGTH_EXCEEDED = "context_length_exceeded"
UNKNOWN = "unknown"
@dataclass
class TextBlock:
"""文本内容块
Format mapping:
OpenAI: message.content (string) / content[].type="text"
Claude: content[].type="text"
Gemini: parts[].text
"""
type: ContentType = field(default=ContentType.TEXT, init=False)
text: str = ""
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ThinkingBlock:
"""思考过程内容块
Format mapping:
OpenAI: message.reasoning_content / delta.reasoning_content
Claude: content[].type="thinking" (thinking + signature)
Gemini: parts[].thought=true (text + thoughtSignature)
"""
type: ContentType = field(default=ContentType.THINKING, init=False)
thinking: str = ""
# Claude signature / Gemini thoughtSignature; OpenAI 无对应字段
signature: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ImageBlock:
"""图片内容块
Format mapping:
OpenAI: content[].type="image_url" -> image_url.url (URL or data:mime;base64,...)
Claude: content[].type="image" -> source.type="base64" | source.type="url"
Gemini: parts[].inlineData (base64) / parts[].fileData (URI)
"""
type: ContentType = field(default=ContentType.IMAGE, init=False)
data: str | None = None # base64 encoded image data (mutually exclusive with url)
media_type: str | None = None # MIME type, e.g. "image/png"
url: str | None = None # URL reference (mutually exclusive with data)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolUseBlock:
"""工具调用内容块
Format mapping:
OpenAI: message.tool_calls[].id / .function.name / .function.arguments(JSON str)
Claude: content[].type="tool_use" -> id / name / input(dict)
Gemini: parts[].functionCall -> name / args(dict); id 由 normalizer 生成
Contract:
tool_id: roundtrip 保留; OpenAI/Claude 原生提供, Gemini 由 normalizer 合成
tool_name: 必须非空
tool_input: 已解析的 dict (非 JSON 字符串)
"""
type: ContentType = field(default=ContentType.TOOL_USE, init=False)
tool_id: str = ""
tool_name: str = ""
tool_input: dict[str, Any] = field(default_factory=dict)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolResultBlock:
"""工具结果内容块
Format mapping:
OpenAI: role="tool" message -> tool_call_id + content(string)
Claude: content[].type="tool_result" -> tool_use_id + content
Gemini: parts[].functionResponse -> name + response(dict)
Contract:
tool_use_id: 关联 ToolUseBlock.tool_id; OpenAI/Claude 必须非空
tool_name: Gemini functionResponse.name 需要; OpenAI/Claude 可为 None
output: 结构化输出 (dict/list); 与 content_text 二选一
content_text: 纯文本输出; 与 output 二选一
"""
type: ContentType = field(default=ContentType.TOOL_RESULT, init=False)
tool_use_id: str = ""
tool_name: str | None = None
output: Any = None
content_text: str | None = None
is_error: bool = False
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class FileBlock:
"""文件内容块PDF、文档等
Format mapping:
OpenAI: content[].type="file" -> file.file_data(data URL) / file.file_id
Claude: content[].type="document" -> source.type="base64" / source.type="url"
Gemini: parts[].fileData -> fileUri + mimeType
"""
type: ContentType = field(default=ContentType.FILE, init=False)
data: str | None = None # base64 encoded file data
media_type: str | None = None # MIME type
file_id: str | None = None # OpenAI file reference
file_url: str | None = None # Gemini fileData URI
filename: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class AudioBlock:
"""音频内容块
Format mapping:
OpenAI: content[].type="input_audio" -> input_audio.data + input_audio.format
Claude: content[].type="audio" (planned)
Gemini: parts[].inlineData (audio MIME)
"""
type: ContentType = field(default=ContentType.AUDIO, init=False)
data: str | None = None # base64 encoded audio data
media_type: str | None = None # full MIME (e.g. audio/mp3)
format: str | None = None # short format name (e.g. mp3, wav)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class UnknownBlock:
"""未知内容块(用于前向兼容)"""
type: ContentType = field(default=ContentType.UNKNOWN, init=False)
raw_type: str = "" # 原始的类型字符串(各格式不一致)
payload: dict[str, Any] = field(default_factory=dict) # 原始结构(尽量保持)
extra: dict[str, Any] = field(default_factory=dict)
ContentBlock = (
TextBlock
| ThinkingBlock
| ImageBlock
| FileBlock
| AudioBlock
| ToolUseBlock
| ToolResultBlock
| UnknownBlock
)
@dataclass
class InternalMessage:
"""统一的消息表示"""
role: Role
content: list[ContentBlock] # 统一使用列表,纯文本用单个 TextBlock
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolDefinition:
"""统一的工具定义"""
name: str
description: str | None = None
parameters: dict[str, Any] | None = None # JSON Schema
extra: dict[str, Any] = field(default_factory=dict)
class ToolChoiceType(str, Enum):
AUTO = "auto"
NONE = "none"
REQUIRED = "required"
TOOL = "tool"
@dataclass
class ToolChoice:
"""统一的工具选择"""
type: ToolChoiceType
tool_name: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class InstructionSegment:
"""系统/开发者指令段
OpenAI 区分 system/developer 两种 role, Claude/Gemini 只有 system string.
instructions 列表保留 OpenAI 的 role 语义和顺序, system 字段是 join 后的纯文本兜底.
"""
role: Role # Role.SYSTEM / Role.DEVELOPER only
text: str = ""
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ThinkingConfig:
"""统一的思考/推理配置
Format mapping:
OpenAI: reasoning_effort ("low"/"medium"/"high") -> budget_tokens via lookup table
Claude: thinking.type="enabled" + thinking.budget_tokens
Gemini: generationConfig.thinkingConfig.thinkingBudget
"""
enabled: bool = False
budget_tokens: int | None = None # None = provider default
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ResponseFormatConfig:
"""统一的响应格式配置JSON mode / structured output"""
type: str = "text" # "text" | "json_object" | "json_schema"
json_schema: dict[str, Any] | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalRequest:
"""统一的请求表示
Format mapping (key fields):
model: OpenAI/Claude body.model; Gemini URL path param
instructions: OpenAI system/developer messages; Claude/Gemini -> join to system string
system: instructions join fallback; Claude system param; Gemini systemInstruction
max_tokens: OpenAI max_tokens/max_completion_tokens; Claude max_tokens; Gemini maxOutputTokens
tools: OpenAI tools[].function; Claude tools[]; Gemini tools[].functionDeclarations
tool_choice: OpenAI tool_choice; Claude tool_choice; Gemini toolConfig.functionCallingConfig
"""
model: str
messages: list[InternalMessage]
# 指令层:保留 system/developer 结构与顺序
instructions: list[InstructionSegment] = field(default_factory=list)
# 兼容字段instructions 的 join 文本(无 role 标签),用于 Claude/Gemini 这类仅接受字符串 system 的格式
system: str | None = None
max_tokens: int | None = None
temperature: float | None = None
top_p: float | None = None
top_k: int | None = None
stop_sequences: list[str] | None = None
stream: bool = False
tools: list[ToolDefinition] | None = None
tool_choice: ToolChoice | None = None # auto/none/required 或指定 tool_name
# 思考/推理配置
thinking: ThinkingConfig | None = None
# 并行工具调用控制
parallel_tool_calls: bool | None = None
# 采样参数
n: int | None = None
presence_penalty: float | None = None
frequency_penalty: float | None = None
seed: int | None = None
logprobs: bool | None = None
top_logprobs: int | None = None
# 响应格式
response_format: ResponseFormatConfig | None = None
# 模型输出上限(来自 GlobalModel.config.output_limit用于跨格式转换时的 max_tokens 默认值)
output_limit: int | None = None
extra: dict[str, Any] = field(default_factory=dict) # 未识别字段透传
def to_debug_dict(self) -> dict[str, Any]:
"""用于日志和调试的简化表示"""
return {
"model": self.model,
"instruction_count": len(self.instructions),
"message_count": len(self.messages),
"has_system": bool(self.instructions) or bool(self.system),
"max_tokens": self.max_tokens,
"stream": self.stream,
"tool_count": len(self.tools) if self.tools else 0,
"extra_keys": list(self.extra.keys()),
}
@dataclass
class UsageInfo:
"""统一的使用量信息"""
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalResponse:
"""统一的响应表示
Format mapping:
id: OpenAI id; Claude id; Gemini (none, synthesized)
model: OpenAI model; Claude model; Gemini model (from metadata)
content: OpenAI choices[0].message; Claude content[]; Gemini candidates[0].content.parts
stop_reason: OpenAI finish_reason; Claude stop_reason; Gemini finishReason
usage: OpenAI usage; Claude usage; Gemini usageMetadata
"""
id: str
model: str
content: list[ContentBlock]
stop_reason: StopReason | None = None
usage: UsageInfo | None = None
extra: dict[str, Any] = field(default_factory=dict)
def to_debug_dict(self) -> dict[str, Any]:
"""用于日志和调试的简化表示"""
usage = None
if self.usage:
usage = {
"input": self.usage.input_tokens,
"output": self.usage.output_tokens,
}
return {
"id": self.id,
"model": self.model,
"content_block_count": len(self.content),
"stop_reason": self.stop_reason.value if self.stop_reason else None,
"usage": usage,
"extra_keys": list(self.extra.keys()),
}
@dataclass
class InternalError:
"""统一的错误表示"""
type: ErrorType
message: str
code: str | None = None # 原始错误码
param: str | None = None # 导致错误的参数
retryable: bool = False # 是否可重试
extra: dict[str, Any] = field(default_factory=dict)
def to_debug_dict(self) -> dict[str, Any]:
"""用于日志和调试"""
return {
"type": self.type.value,
"message": self.message,
"code": self.code,
"param": self.param,
"retryable": self.retryable,
"extra": self.extra,
}
@dataclass(frozen=True)
class FormatCapabilities:
supports_stream: bool = True
supports_error_conversion: bool = True
supports_tools: bool = True
supports_images: bool = False
supported_features: frozenset[str] = field(default_factory=frozenset)
__all__ = [
"Role",
"ContentType",
"StopReason",
"ErrorType",
"ToolChoiceType",
"TextBlock",
"ThinkingBlock",
"ImageBlock",
"FileBlock",
"AudioBlock",
"ToolUseBlock",
"ToolResultBlock",
"UnknownBlock",
"ContentBlock",
"InternalMessage",
"InstructionSegment",
"ToolDefinition",
"ToolChoice",
"ThinkingConfig",
"ResponseFormatConfig",
"InternalRequest",
"UsageInfo",
"InternalResponse",
"InternalError",
"FormatCapabilities",
]

View File

@@ -0,0 +1,85 @@
"""
视频格式转换内部表示Internal Video Format
用于 Video API 的 Hub-and-Spoke 统一中间表示。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
class VideoStatus(str, Enum):
PENDING = "pending"
SUBMITTED = "submitted"
QUEUED = "queued"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
EXPIRED = "expired"
@dataclass
class InternalVideoRequest:
"""统一的视频生成请求格式"""
prompt: str
model: str = "sora-2"
duration_seconds: int = 4
aspect_ratio: str = "16:9"
resolution: str = "720p"
reference_image_url: str | None = None # base64 或 URL
character_ids: list[str] = field(default_factory=list)
extra: dict[str, Any] = field(default_factory=dict)
preferred_provider: str | None = None
preferred_format: str | None = None
@dataclass
class InternalVideoTask:
"""统一的视频任务状态"""
id: str
external_id: str | None = None
status: VideoStatus = VideoStatus.PENDING
progress_percent: int = 0
progress_message: str | None = None
video_url: str | None = None
video_urls: list[str] = field(default_factory=list)
thumbnail_url: str | None = None
video_duration_seconds: int | None = None
video_size_bytes: int | None = None
created_at: datetime | None = None
completed_at: datetime | None = None
expires_at: datetime | None = None
error_code: str | None = None
error_message: str | None = None
original_request: InternalVideoRequest | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class InternalVideoPollResult:
"""轮询结果"""
status: VideoStatus
progress_percent: int = 0
video_url: str | None = None
video_urls: list[str] = field(default_factory=list)
expires_at: datetime | None = None
error_code: str | None = None
error_message: str | None = None
raw_response: dict[str, Any] | None = None
video_duration_seconds: float | None = None # 实际视频时长
__all__ = [
"VideoStatus",
"InternalVideoRequest",
"InternalVideoTask",
"InternalVideoPollResult",
]

View File

@@ -0,0 +1,199 @@
"""
格式标准化器接口FormatNormalizer
每个格式OpenAI/Claude/Gemini实现一个 Normalizer将 provider 结构转换到 internal
再从 internal 输出到目标格式。
"""
from abc import ABC, abstractmethod
from typing import Any
from .internal import (
FormatCapabilities,
ImageBlock,
InternalError,
InternalRequest,
InternalResponse,
)
from .internal_video import InternalVideoPollResult, InternalVideoRequest, InternalVideoTask
from .stream_events import InternalStreamEvent
from .stream_state import StreamState
class FormatNormalizer(ABC):
"""格式标准化器基类"""
FORMAT_ID: str # 如 "CLAUDE", "OPENAI", "GEMINI"
capabilities: FormatCapabilities
# ============ 请求转换 ============
@abstractmethod
def request_to_internal(self, request: dict[str, Any]) -> InternalRequest:
"""将格式特定请求转换为内部表示"""
raise NotImplementedError
@abstractmethod
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
"""将内部表示转换为格式特定请求
Args:
internal: 内部请求表示
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
"""
raise NotImplementedError
# ============ 同格式变体补丁(可选) ============
def patch_for_variant(
self,
request: dict[str, Any],
variant: str,
) -> dict[str, Any] | None:
"""同格式 + variant 场景下的轻量补丁(跳过 internal 转换)。
子类可覆盖此方法,对已知 variant 直接在原始请求体上做最小修改。
返回 None 表示不支持该 variant 的快速路径registry 将回退到完整的
request_to_internal -> request_from_internal 流程。
"""
return None
# ============ 响应转换 ============
@abstractmethod
def response_to_internal(self, response: dict[str, Any]) -> InternalResponse:
"""将格式特定响应转换为内部表示"""
raise NotImplementedError
@abstractmethod
def response_from_internal(
self,
internal: InternalResponse,
*,
requested_model: str | None = None,
) -> dict[str, Any]:
"""将内部表示转换为格式特定响应
Args:
internal: 内部响应表示
requested_model: 用户请求的原始模型名(可选)。
如果提供,响应中的 model 字段将使用此值,
而不是上游返回的映射后模型名。
"""
raise NotImplementedError
# ============ 流式转换(可选) ============
def stream_chunk_to_internal(
self,
chunk: dict[str, Any],
state: StreamState,
) -> list[InternalStreamEvent]:
"""将格式特定流式块转换为内部事件"""
raise NotImplementedError
def stream_event_from_internal(
self,
event: InternalStreamEvent,
state: StreamState,
) -> list[dict[str, Any]]:
"""将内部事件转换为格式特定流式块"""
raise NotImplementedError
# ============ 错误转换(可选) ============
def is_error_response(self, response: dict[str, Any]) -> bool:
"""基于 body 的兜底判断(不可靠),子类可覆盖"""
return False
def error_to_internal(self, error_response: dict[str, Any]) -> InternalError:
"""将格式特定错误转换为内部表示"""
raise NotImplementedError
def error_from_internal(self, internal: InternalError) -> dict[str, Any]:
"""将内部错误表示转换为格式特定错误"""
raise NotImplementedError
# ============ 图片工具方法 ============
@staticmethod
def _image_url_to_block(url: str) -> ImageBlock:
"""将 image_url 字符串转换为 ImageBlock支持 data URL 和外部 URL"""
if url.startswith("data:") and ";base64," in url:
header, _, data = url.partition(",")
media_type = header.split(";")[0].split(":", 1)[-1]
return ImageBlock(data=data, media_type=media_type)
return ImageBlock(url=url)
# ============ 通用解析工具 ============
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_str_list(self, value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
if isinstance(value, list):
return [str(x) for x in value if x is not None]
return None
def _extract_extra(self, payload: dict[str, Any], known_keys: set[str]) -> dict[str, Any]:
return {k: v for k, v in payload.items() if k not in known_keys}
def _merge_dropped(self, target: dict[str, int], source: dict[str, int]) -> None:
for k, v in source.items():
target[k] = target.get(k, 0) + int(v)
# ============ 视频转换(可选) ============
def video_request_to_internal(self, request: dict[str, Any]) -> InternalVideoRequest:
"""将视频请求转换为内部表示"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_request_from_internal(self, internal: InternalVideoRequest) -> dict[str, Any]:
"""将内部视频请求转换为格式特定请求"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_task_to_internal(self, response: dict[str, Any]) -> InternalVideoTask:
"""将视频任务响应转换为内部表示"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_task_from_internal(
self, internal: InternalVideoTask, *, base_url: str | None = None
) -> dict[str, Any]:
"""将内部视频任务转换为格式特定响应
Args:
internal: 内部视频任务表示
base_url: 可选的基础 URL用于构建完整的下载链接
"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_poll_to_internal(self, response: dict[str, Any]) -> InternalVideoPollResult:
"""将视频轮询响应转换为内部表示"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
__all__ = [
"FormatNormalizer",
]

View File

@@ -0,0 +1,9 @@
"""
Normalizers
实现各格式 <-> internal 的标准化器。
本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。
"""
__all__: list[str] = []

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,734 @@
"""
格式转换注册表Canonical / Hub-and-Spoke
实现路径:
source -> internal -> target
说明:
- 旧 N×N converters 已移除;这里是唯一的格式转换实现。
- 转换失败将抛出 `FormatConversionError`(不再静默回退)。
"""
import ast
import copy
import importlib
import inspect
import threading
import time
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.image_resolver import resolve_image_urls
from src.core.api_format.conversion.internal import InternalRequest, ToolResultBlock, ToolUseBlock
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_state import StreamState
from src.core.logger import logger
from src.core.metrics import format_conversion_duration_seconds, format_conversion_total
@contextmanager
def _track_conversion_metrics(
direction: str,
source: str,
target: str,
) -> Generator[None]:
start = time.perf_counter()
try:
yield
format_conversion_total.labels(direction, source, target, "success").inc()
except Exception:
format_conversion_total.labels(direction, source, target, "error").inc()
raise
finally:
format_conversion_duration_seconds.labels(direction, source, target).observe(
time.perf_counter() - start
)
_MATERIALIZING: tuple[str, str] = ("__materializing__", "")
class FormatConversionRegistry:
"""基于 Normalizer 的格式转换注册表"""
def __init__(self) -> None:
self._normalizers: dict[str, FormatNormalizer] = {}
self._lazy_normalizers: dict[str, tuple[str, str]] = {}
self._lock = threading.RLock()
def register(self, normalizer: FormatNormalizer) -> None:
key = str(normalizer.FORMAT_ID).upper()
with self._lock:
self._normalizers[key] = normalizer
self._lazy_normalizers.pop(key, None)
logger.info(f"[FormatConversionRegistry] 注册 normalizer: {normalizer.FORMAT_ID}")
def register_lazy(self, format_id: str, module_path: str, class_name: str) -> None:
key = str(format_id).upper()
with self._lock:
if key in self._normalizers:
logger.debug(
"[FormatConversionRegistry] 跳过 lazy 注册normalizer 已实例化): {}",
key,
)
return
existing = self._lazy_normalizers.get(key)
if existing and existing != (module_path, class_name):
logger.warning(
"[FormatConversionRegistry] FORMAT_ID '{}' 重复 lazy 注册,{}.{}, 将覆盖 {}.{}",
key,
module_path,
class_name,
existing[0],
existing[1],
)
self._lazy_normalizers[key] = (module_path, class_name)
logger.info(
"[FormatConversionRegistry] 注册 lazy normalizer: {} -> {}.{}",
key,
module_path,
class_name,
)
def _materialize_lazy_normalizer(self, key: str) -> FormatNormalizer | None:
with self._lock:
existing = self._normalizers.get(key)
if existing is not None:
return existing
lazy_spec = self._lazy_normalizers.get(key)
if lazy_spec is None or lazy_spec is _MATERIALIZING:
return None
# 标记为正在加载,防止其他线程重复 materialize
self._lazy_normalizers[key] = _MATERIALIZING
module_path, class_name = lazy_spec
try:
mod = importlib.import_module(module_path)
obj = getattr(mod, class_name, None)
if not inspect.isclass(obj) or not issubclass(obj, FormatNormalizer):
raise TypeError(f"{module_path}.{class_name} 不是有效的 FormatNormalizer")
normalizer = obj()
except Exception as e:
logger.error(
"[FormatConversionRegistry] lazy 加载 {}.{} 失败: {}",
module_path,
class_name,
e,
)
# 恢复 lazy_spec 以便后续重试
with self._lock:
if self._lazy_normalizers.get(key) is _MATERIALIZING:
self._lazy_normalizers[key] = lazy_spec
return None
self.register(normalizer)
key_upper = str(normalizer.FORMAT_ID).upper()
with self._lock:
return self._normalizers.get(key) or self._normalizers.get(key_upper)
def _find_registered_by_data_format_id(self, target_dfid: str) -> FormatNormalizer | None:
from src.core.api_format.metadata import get_data_format_id_for_endpoint
with self._lock:
registered_items = list(self._normalizers.items())
for reg_key, reg_normalizer in registered_items:
if get_data_format_id_for_endpoint(reg_key) == target_dfid:
return reg_normalizer
return None
def _find_lazy_key_by_data_format_id(self, target_dfid: str) -> str | None:
from src.core.api_format.metadata import get_data_format_id_for_endpoint
with self._lock:
lazy_keys = list(self._lazy_normalizers.keys())
for lazy_key in lazy_keys:
if get_data_format_id_for_endpoint(lazy_key) == target_dfid:
return lazy_key
return None
def get_normalizer(self, format_id: str) -> FormatNormalizer | None:
key = str(format_id).upper()
# 1. 精确匹配
with self._lock:
normalizer = self._normalizers.get(key)
if normalizer is not None:
return normalizer
# 2. lazy 精确匹配
normalizer = self._materialize_lazy_normalizer(key)
if normalizer is not None:
return normalizer
# 2. data_format_id 回退:如 "claude:cli" (dfid="claude") -> ClaudeNormalizer (dfid="claude")
from src.core.api_format.metadata import get_data_format_id_for_endpoint
target_dfid = get_data_format_id_for_endpoint(format_id)
if not target_dfid:
return None
# 3. data_format_id 在已实例化 normalizer 中回退
normalizer = self._find_registered_by_data_format_id(target_dfid)
if normalizer is not None:
return normalizer
# 4. data_format_id 在 lazy normalizer 中回退
lazy_key = self._find_lazy_key_by_data_format_id(target_dfid)
if lazy_key:
return self._materialize_lazy_normalizer(lazy_key)
return None
def _require_normalizer(self, format_id: str) -> FormatNormalizer:
normalizer = self.get_normalizer(format_id)
if normalizer is None:
raise FormatConversionError(format_id, format_id, f"未注册 Normalizer: {format_id}")
return normalizer
def _same_normalizer(self, source_format: str, target_format: str) -> bool:
"""判断两个 format_id 是否解析到同一个 normalizer 实例(即底层数据格式相同,可直接透传)。
例如 claude:chat / claude:cli 共享 ClaudeNormalizergemini:chat / gemini:cli 共享 GeminiNormalizer。
"""
if str(source_format).upper() == str(target_format).upper():
return True
src = self.get_normalizer(source_format)
tgt = self.get_normalizer(target_format)
return src is not None and src is tgt
def _repair_internal_tool_call_ids(self, internal: InternalRequest) -> dict[str, int]:
"""修复 InternalRequest 中空的 tool id/tool_use_id避免上游校验报错。"""
pending_tool_ids: list[str] = []
auto_counter = 0
generated_tool_use_ids = 0
filled_tool_result_ids = 0
def next_tool_id() -> str:
nonlocal auto_counter
auto_counter += 1
return f"call_auto_{auto_counter}"
for message in internal.messages:
for block in message.content:
if isinstance(block, ToolUseBlock):
tool_id = str(block.tool_id or "").strip()
if not tool_id:
tool_id = next_tool_id()
block.tool_id = tool_id
generated_tool_use_ids += 1
pending_tool_ids.append(tool_id)
continue
if isinstance(block, ToolResultBlock):
tool_use_id = str(block.tool_use_id or "").strip()
if tool_use_id:
block.tool_use_id = tool_use_id
if tool_use_id in pending_tool_ids:
pending_tool_ids.remove(tool_use_id)
continue
filled_tool_result_ids += 1
if pending_tool_ids:
block.tool_use_id = pending_tool_ids.pop(0)
else:
block.tool_use_id = next_tool_id()
return {
"generated_tool_use_ids": generated_tool_use_ids,
"filled_tool_result_ids": filled_tool_result_ids,
}
# ==================== 请求/响应转换(严格) ====================
def convert_request(
self,
request: dict[str, Any],
source_format: str,
target_format: str,
*,
target_variant: str | None = None,
output_limit: int | None = None,
) -> dict[str, Any]:
if self._same_normalizer(source_format, target_format) and not target_variant:
return copy.deepcopy(request)
# 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换)
if self._same_normalizer(source_format, target_format) and target_variant:
normalizer = self._require_normalizer(source_format)
with _track_conversion_metrics(
"request_patch", str(source_format).upper(), str(target_format).upper()
):
patched = normalizer.patch_for_variant(copy.deepcopy(request), target_variant)
if patched is not None:
return patched
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
with _track_conversion_metrics(
"request", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.request_to_internal(copy.deepcopy(request))
internal.output_limit = output_limit
repair_stats = self._repair_internal_tool_call_ids(internal)
if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]:
logger.debug(
"[FormatConversionRegistry] repaired internal tool call ids: source={}, target={}, generated_tool_use_ids={}, filled_tool_result_ids={}",
str(source_format).upper(),
str(target_format).upper(),
repair_stats["generated_tool_use_ids"],
repair_stats["filled_tool_result_ids"],
)
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
async def convert_request_async(
self,
request: dict[str, Any],
source_format: str,
target_format: str,
*,
target_variant: str | None = None,
output_limit: int | None = None,
) -> dict[str, Any]:
"""异步版本的 convert_request在 internal 阶段执行图片 URL 下载等异步操作。"""
if self._same_normalizer(source_format, target_format) and not target_variant:
return copy.deepcopy(request)
# 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换)
if self._same_normalizer(source_format, target_format) and target_variant:
normalizer = self._require_normalizer(source_format)
with _track_conversion_metrics(
"request_patch", str(source_format).upper(), str(target_format).upper()
):
patched = normalizer.patch_for_variant(copy.deepcopy(request), target_variant)
if patched is not None:
return patched
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
with _track_conversion_metrics(
"request", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.request_to_internal(copy.deepcopy(request))
internal.output_limit = output_limit
repair_stats = self._repair_internal_tool_call_ids(internal)
if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]:
logger.debug(
"[FormatConversionRegistry] repaired internal tool call ids: source={}, target={}, generated_tool_use_ids={}, filled_tool_result_ids={}",
str(source_format).upper(),
str(target_format).upper(),
repair_stats["generated_tool_use_ids"],
repair_stats["filled_tool_result_ids"],
)
# 异步阶段:解析图片 URL -> base64仅在目标格式需要时
await resolve_image_urls(internal, str(target_format).upper())
return tgt.request_from_internal(internal, target_variant=target_variant)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_response(
self,
response: dict[str, Any],
source_format: str,
target_format: str,
*,
requested_model: str | None = None,
) -> dict[str, Any]:
"""转换响应格式
Args:
response: 原始响应
source_format: 源格式
target_format: 目标格式
requested_model: 用户请求的原始模型名(可选)。
如果提供,响应中的 model 字段将使用此值,
而不是上游返回的映射后模型名。
"""
if self._same_normalizer(source_format, target_format):
response_copy = copy.deepcopy(response)
# 即使格式相同,也需要替换 model 字段
if requested_model and isinstance(response_copy, dict):
# 支持不同格式的 model 字段名
if "model" in response_copy:
response_copy["model"] = requested_model
elif "modelVersion" in response_copy:
response_copy["modelVersion"] = requested_model
return response_copy
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
with _track_conversion_metrics(
"response", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.response_to_internal(copy.deepcopy(response))
return tgt.response_from_internal(internal, requested_model=requested_model)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_error_response(
self,
error_response: dict[str, Any],
source_format: str,
target_format: str,
) -> dict[str, Any]:
if self._same_normalizer(source_format, target_format):
return copy.deepcopy(error_response)
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
if not (
src.capabilities.supports_error_conversion
and tgt.capabilities.supports_error_conversion
):
raise FormatConversionError(
source_format,
target_format,
"source/target normalizer 不支持错误转换",
)
with _track_conversion_metrics(
"error", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.error_to_internal(copy.deepcopy(error_response))
return tgt.error_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
# ==================== 视频格式转换 ====================
def convert_video_request(
self,
request: dict[str, Any],
source_format: str,
target_format: str,
) -> dict[str, Any]:
"""转换视频请求格式OpenAI <-> Gemini
Args:
request: 原始视频请求
source_format: 源格式(如 openai:video, gemini:video
target_format: 目标格式
Returns:
转换后的视频请求
"""
# 统一使用基础格式 ID去掉 :video 后缀)
src_base = self._video_format_to_base(source_format)
tgt_base = self._video_format_to_base(target_format)
if src_base == tgt_base:
return copy.deepcopy(request)
src = self._require_normalizer(src_base)
tgt = self._require_normalizer(tgt_base)
with _track_conversion_metrics(
"video_request", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.video_request_to_internal(copy.deepcopy(request))
return tgt.video_request_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def convert_video_task(
self,
task_response: dict[str, Any],
source_format: str,
target_format: str,
) -> dict[str, Any]:
"""转换视频任务响应格式OpenAI <-> Gemini
Args:
task_response: 原始任务响应
source_format: 源格式
target_format: 目标格式
Returns:
转换后的任务响应
"""
src_base = self._video_format_to_base(source_format)
tgt_base = self._video_format_to_base(target_format)
if src_base == tgt_base:
return copy.deepcopy(task_response)
src = self._require_normalizer(src_base)
tgt = self._require_normalizer(tgt_base)
with _track_conversion_metrics(
"video_task", str(source_format).upper(), str(target_format).upper()
):
try:
internal = src.video_task_to_internal(copy.deepcopy(task_response))
return tgt.video_task_from_internal(internal)
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
def can_convert_video(self, source_format: str, target_format: str) -> bool:
"""检查是否支持视频格式转换"""
src_base = self._video_format_to_base(source_format)
tgt_base = self._video_format_to_base(target_format)
if src_base == tgt_base:
return True
src = self.get_normalizer(src_base)
tgt = self.get_normalizer(tgt_base)
if src is None or tgt is None:
return False
# 检查是否有视频转换方法
return (
hasattr(src, "video_request_to_internal")
and hasattr(src, "video_task_to_internal")
and hasattr(tgt, "video_request_from_internal")
and hasattr(tgt, "video_task_from_internal")
)
def _video_format_to_base(self, format_id: str) -> str:
"""将视频格式 ID 转换为基础格式 ID
例如: openai:video -> openai:chat, gemini:video -> gemini:chat
"""
upper = str(format_id).upper()
if upper.endswith(":VIDEO"):
base = upper[:-6] # 去掉 :VIDEO
return f"{base}:CHAT"
return upper
# ==================== 流式转换(严格) ====================
def convert_stream_chunk(
self,
chunk: dict[str, Any],
source_format: str,
target_format: str,
state: StreamState | None = None,
) -> list[dict[str, Any]]:
if self._same_normalizer(source_format, target_format):
return [chunk]
src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format)
if not (src.capabilities.supports_stream and tgt.capabilities.supports_stream):
raise FormatConversionError(
source_format,
target_format,
"source/target normalizer 不支持流式转换",
)
if state is None:
# 调用方应提供预初始化的 state包含 model/message_id
# 这里仅作为防御性回退,可能导致响应中 model 字段为空
logger.debug(
f"convert_stream_chunk: state is None, creating empty StreamState "
f"(source={source_format}, target={target_format})"
)
state = StreamState()
with _track_conversion_metrics(
"stream", str(source_format).upper(), str(target_format).upper()
):
try:
events = src.stream_chunk_to_internal(chunk, state)
out: list[dict[str, Any]] = []
for event in events:
out.extend(tgt.stream_event_from_internal(event, state))
return out
except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e
# ==================== 能力查询 ====================
def can_convert_request(self, source_format: str, target_format: str) -> bool:
if self._same_normalizer(source_format, target_format):
return True
return (
self.get_normalizer(source_format) is not None
and self.get_normalizer(target_format) is not None
)
def can_convert_response(self, source_format: str, target_format: str) -> bool:
return self.can_convert_request(source_format, target_format)
def can_convert_stream(self, source_format: str, target_format: str) -> bool:
if self._same_normalizer(source_format, target_format):
return True
src = self.get_normalizer(source_format)
tgt = self.get_normalizer(target_format)
if src is None or tgt is None:
return False
return bool(src.capabilities.supports_stream and tgt.capabilities.supports_stream)
def can_convert_error(self, source_format: str, target_format: str) -> bool:
if self._same_normalizer(source_format, target_format):
return True
src = self.get_normalizer(source_format)
tgt = self.get_normalizer(target_format)
if src is None or tgt is None:
return False
return bool(
src.capabilities.supports_error_conversion
and tgt.capabilities.supports_error_conversion
)
def can_convert_full(
self, format_a: str, format_b: str, *, require_stream: bool = False
) -> bool:
if not self.can_convert_request(format_a, format_b):
return False
if not self.can_convert_request(format_b, format_a):
return False
if require_stream:
return self.can_convert_stream(format_a, format_b) and self.can_convert_stream(
format_b, format_a
)
return True
def list_normalizers(self) -> list[str]:
with self._lock:
all_keys = set(self._normalizers.keys()) | set(self._lazy_normalizers.keys())
return sorted(all_keys)
def get_supported_targets(self, source_format: str) -> list[str]:
src = str(source_format).upper()
with self._lock:
all_keys = set(self._normalizers.keys()) | set(self._lazy_normalizers.keys())
if src not in all_keys:
return []
return [k for k in sorted(all_keys) if k != src]
# 全局注册表(唯一实现)
format_conversion_registry = FormatConversionRegistry()
_DEFAULT_NORMALIZERS_REGISTERED = False
_REGISTRATION_LOCK = threading.Lock()
def _is_format_normalizer_base(node: ast.expr) -> bool:
if isinstance(node, ast.Name):
return node.id == "FormatNormalizer"
if isinstance(node, ast.Attribute):
return node.attr == "FormatNormalizer"
return False
def _extract_format_id_literal(class_node: ast.ClassDef) -> str | None:
for stmt in class_node.body:
if isinstance(stmt, ast.Assign):
for target in stmt.targets:
if isinstance(target, ast.Name) and target.id == "FORMAT_ID":
if isinstance(stmt.value, ast.Constant) and isinstance(stmt.value.value, str):
value = stmt.value.value.strip()
return value or None
elif isinstance(stmt, ast.AnnAssign):
target = stmt.target
if isinstance(target, ast.Name) and target.id == "FORMAT_ID":
value = stmt.value
if isinstance(value, ast.Constant) and isinstance(value.value, str):
text = value.value.strip()
return text or None
return None
def _discover_normalizer_specs(normalizers_dir: Path) -> list[tuple[str, str, str]]:
specs: list[tuple[str, str, str]] = []
for py_file in sorted(normalizers_dir.glob("*.py")):
if py_file.name.startswith("_"):
continue
module_name = py_file.stem
module_path = f"src.core.api_format.conversion.normalizers.{module_name}"
module_specs: list[tuple[str, str, str]] = []
# 优先 AST 发现,避免导入大模块
try:
source = py_file.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_file))
for node in tree.body:
if not isinstance(node, ast.ClassDef):
continue
if not any(_is_format_normalizer_base(base) for base in node.bases):
continue
fmt_id = _extract_format_id_literal(node)
if fmt_id:
module_specs.append((fmt_id, module_path, node.name))
except Exception as e:
logger.warning("[FormatConversionRegistry] AST 扫描 {} 失败: {}", module_path, e)
if module_specs:
specs.extend(module_specs)
continue
# AST 无法识别时,回退到反射发现(保持兼容)
try:
mod = importlib.import_module(module_path)
except Exception as e:
logger.error("[FormatConversionRegistry] 导入 {} 失败: {}", module_path, e)
continue
for _attr_name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, FormatNormalizer)
and obj is not FormatNormalizer
and hasattr(obj, "FORMAT_ID")
and obj.__module__ == mod.__name__
):
fmt_id = str(getattr(obj, "FORMAT_ID", "")).strip()
if fmt_id:
module_specs.append((fmt_id, module_path, obj.__name__))
if not module_specs:
logger.warning("[FormatConversionRegistry] 未在 {} 发现可注册 normalizer", module_path)
continue
specs.extend(module_specs)
return specs
def register_default_normalizers() -> None:
"""自动发现并懒注册 normalizers/ 目录下的所有 FormatNormalizer 实现"""
global _DEFAULT_NORMALIZERS_REGISTERED # noqa: PLW0603 - module-level 缓存
# 快速路径:已注册则直接返回(无锁)
if _DEFAULT_NORMALIZERS_REGISTERED:
return
# 慢路径:加锁后双重检查
with _REGISTRATION_LOCK:
if _DEFAULT_NORMALIZERS_REGISTERED:
return
normalizers_dir = Path(__file__).parent / "normalizers"
for fmt_id, module_path, class_name in _discover_normalizer_specs(normalizers_dir):
format_conversion_registry.register_lazy(fmt_id, module_path, class_name)
_DEFAULT_NORMALIZERS_REGISTERED = True
logger.info(
"[FormatConversionRegistry] 已懒注册 {} 个 normalizer",
len(format_conversion_registry.list_normalizers()),
)
__all__ = [
"FormatConversionRegistry",
"format_conversion_registry",
"register_default_normalizers",
]

View File

@@ -0,0 +1,285 @@
"""Sync<->stream bridge helpers for the conversion layer.
We already have:
- streaming conversion: source stream chunk -> internal events -> target stream chunk
- sync conversion: source response -> internal response -> target response
This module fills the missing link:
- aggregate internal stream events into a single InternalResponse (stream -> sync)
- expand an InternalResponse into internal stream events (sync -> stream)
Used by handler-layer upstream policies that force upstream streaming mode.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Iterable, Iterator
from .internal import (
ContentType,
ImageBlock,
InternalResponse,
StopReason,
TextBlock,
ToolUseBlock,
UsageInfo,
)
from .stream_events import (
ContentBlockStartEvent,
ContentBlockStopEvent,
ContentDeltaEvent,
InternalStreamEvent,
MessageStartEvent,
MessageStopEvent,
ToolCallDeltaEvent,
UsageEvent,
)
@dataclass
class _BlockBuilder:
block_type: ContentType
text: str = ""
tool_id: str | None = None
tool_name: str | None = None
tool_args_json: str = ""
image_data: str | None = None
image_media_type: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
def finalize(self) -> Any:
if self.block_type == ContentType.TEXT:
return TextBlock(text=self.text, extra=self.extra)
if self.block_type == ContentType.TOOL_USE:
tool_input: dict[str, Any] = {}
raw = self.tool_args_json.strip()
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
tool_input = parsed
except Exception:
tool_input = {}
return ToolUseBlock(
tool_id=str(self.tool_id or ""),
tool_name=str(self.tool_name or ""),
tool_input=tool_input,
extra=self.extra,
)
if self.block_type == ContentType.IMAGE:
return ImageBlock(
data=self.image_data,
media_type=self.image_media_type,
url=None,
extra=self.extra,
)
# Unknown block type: best-effort drop.
return TextBlock(text=self.text, extra=self.extra)
class InternalStreamAggregator:
"""Aggregate internal stream events into a single InternalResponse (best-effort)."""
def __init__(
self,
*,
fallback_id: str = "resp",
fallback_model: str = "",
) -> None:
self._fallback_id = fallback_id
self._fallback_model = fallback_model
self._id: str | None = None
self._model: str | None = None
self._stop_reason: StopReason | None = None
self._usage: UsageInfo | None = None
self._open: dict[int, _BlockBuilder] = {}
self._final: dict[int, Any] = {}
def feed(self, events: Iterable[InternalStreamEvent]) -> None:
for ev in events:
if isinstance(ev, MessageStartEvent):
if ev.message_id:
self._id = ev.message_id
if ev.model:
self._model = ev.model
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, UsageEvent):
if ev.usage:
self._usage = ev.usage
continue
if isinstance(ev, ContentBlockStartEvent):
b = _BlockBuilder(block_type=ev.block_type, extra=dict(ev.extra or {}))
if ev.block_type == ContentType.TOOL_USE:
b.tool_id = ev.tool_id
b.tool_name = ev.tool_name
if ev.block_type == ContentType.IMAGE:
b.image_data = b.extra.get("image_data") or b.extra.get("data")
b.image_media_type = b.extra.get("image_media_type") or b.extra.get("mime_type")
self._open[int(ev.block_index)] = b
continue
if isinstance(ev, ContentDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TEXT)
self._open[idx] = b
if ev.text_delta:
b.text += ev.text_delta
continue
if isinstance(ev, ToolCallDeltaEvent):
idx = int(ev.block_index)
b = self._open.get(idx)
if b is None:
b = _BlockBuilder(block_type=ContentType.TOOL_USE)
self._open[idx] = b
if ev.input_delta:
b.tool_args_json += ev.input_delta
continue
if isinstance(ev, ContentBlockStopEvent):
idx = int(ev.block_index)
b = self._open.pop(idx, None)
if b is not None:
self._final.setdefault(idx, b.finalize())
continue
if isinstance(ev, MessageStopEvent):
self._stop_reason = ev.stop_reason
if ev.usage:
self._usage = ev.usage
# Flush remaining open blocks (best-effort).
for idx, b in list(self._open.items()):
self._final.setdefault(idx, b.finalize())
self._open.clear()
continue
@property
def open_count(self) -> int:
"""当前未关闭的 block 数量。"""
return len(self._open)
@property
def final_count(self) -> int:
"""已完成的 block 数量。"""
return len(self._final)
@property
def usage(self) -> UsageInfo | None:
return self._usage
@property
def stop_reason(self) -> StopReason | None:
return self._stop_reason
def build(self) -> InternalResponse:
# Flush remaining open blocks (best-effort) in case MessageStopEvent was never received.
for idx, b in list(self._open.items()):
self._final.setdefault(idx, b.finalize())
self._open.clear()
rid = self._id or self._fallback_id
model = self._model or self._fallback_model
content = [self._final[k] for k in sorted(self._final.keys())]
return InternalResponse(
id=str(rid or "resp"),
model=str(model or ""),
content=content,
stop_reason=self._stop_reason,
usage=self._usage,
)
def iter_internal_response_as_stream_events(
internal: InternalResponse,
*,
chunk_text: bool = False,
text_chunk_size: int = 200,
) -> Iterator[InternalStreamEvent]:
"""Expand an InternalResponse into internal stream events (best-effort).
This is used to simulate SSE when the upstream is forced to sync mode.
"""
msg_id = str(internal.id or "resp")
model = str(internal.model or "")
yield MessageStartEvent(message_id=msg_id, model=model)
block_index = 0
for block in internal.content or []:
# Text
if isinstance(block, TextBlock):
yield ContentBlockStartEvent(block_index=block_index, block_type=ContentType.TEXT)
text = str(block.text or "")
if not chunk_text or text_chunk_size <= 0:
if text:
yield ContentDeltaEvent(block_index=block_index, text_delta=text)
else:
for i in range(0, len(text), text_chunk_size):
part = text[i : i + text_chunk_size]
if part:
yield ContentDeltaEvent(block_index=block_index, text_delta=part)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Tool use
if isinstance(block, ToolUseBlock):
tool_id = block.tool_id or f"tool_{block_index}"
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=tool_id,
tool_name=block.tool_name or None,
)
payload = {}
if isinstance(block.tool_input, dict):
payload = block.tool_input
yield ToolCallDeltaEvent(
block_index=block_index,
tool_id=str(tool_id),
input_delta=json.dumps(payload, ensure_ascii=False),
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Image
if isinstance(block, ImageBlock):
yield ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.IMAGE,
extra={
"image_data": block.data,
"image_media_type": block.media_type,
},
)
yield ContentBlockStopEvent(block_index=block_index)
block_index += 1
continue
# Unknown blocks: ignore.
block_index += 1
yield MessageStopEvent(
stop_reason=internal.stop_reason or StopReason.END_TURN, usage=internal.usage
)
__all__ = [
"InternalStreamAggregator",
"iter_internal_response_as_stream_events",
]

View File

@@ -0,0 +1,145 @@
"""
类型安全的流式事件定义InternalStreamEvent
用于把 OpenAI/Claude/Gemini 的流式协议映射为统一事件序列,再由目标格式 Normalizer 输出。
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from .internal import ContentType, InternalError, StopReason, UsageInfo
class StreamEventType(str, Enum):
"""流式事件类型"""
MESSAGE_START = "message_start"
CONTENT_BLOCK_START = "content_block_start"
CONTENT_DELTA = "content_delta"
TOOL_CALL_DELTA = "tool_call_delta"
CONTENT_BLOCK_STOP = "content_block_stop"
MESSAGE_STOP = "message_stop"
USAGE = "usage"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class MessageStartEvent:
"""消息开始事件"""
type: StreamEventType = field(default=StreamEventType.MESSAGE_START, init=False)
message_id: str = ""
model: str = ""
usage: UsageInfo | None = None # Claude 流式响应的 message_start 可能包含 usage
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentBlockStartEvent:
"""内容块开始事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_BLOCK_START, init=False)
block_index: int = 0
block_type: ContentType = ContentType.TEXT
# 工具调用时使用TOOL_USE block
tool_id: str | None = None
tool_name: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentDeltaEvent:
"""内容增量事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_DELTA, init=False)
block_index: int = 0
text_delta: str = ""
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolCallDeltaEvent:
"""工具调用增量事件(工具输入 JSON 的字符串片段)"""
type: StreamEventType = field(default=StreamEventType.TOOL_CALL_DELTA, init=False)
block_index: int = 0
tool_id: str = ""
input_delta: str = "" # JSON 字符串片段
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ContentBlockStopEvent:
"""内容块结束事件"""
type: StreamEventType = field(default=StreamEventType.CONTENT_BLOCK_STOP, init=False)
block_index: int = 0
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class MessageStopEvent:
"""消息结束事件"""
type: StreamEventType = field(default=StreamEventType.MESSAGE_STOP, init=False)
stop_reason: StopReason | None = None
usage: UsageInfo | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class UsageEvent:
"""使用量事件"""
type: StreamEventType = field(default=StreamEventType.USAGE, init=False)
usage: UsageInfo = field(default_factory=UsageInfo)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ErrorEvent:
"""错误事件"""
type: StreamEventType = field(default=StreamEventType.ERROR, init=False)
error: InternalError
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class UnknownStreamEvent:
"""未知事件(用于前向兼容)"""
type: StreamEventType = field(default=StreamEventType.UNKNOWN, init=False)
raw_type: str = ""
payload: dict[str, Any] = field(default_factory=dict)
extra: dict[str, Any] = field(default_factory=dict)
InternalStreamEvent = (
MessageStartEvent
| ContentBlockStartEvent
| ContentDeltaEvent
| ToolCallDeltaEvent
| ContentBlockStopEvent
| MessageStopEvent
| UsageEvent
| ErrorEvent
| UnknownStreamEvent
)
__all__ = [
"StreamEventType",
"MessageStartEvent",
"ContentBlockStartEvent",
"ContentDeltaEvent",
"ToolCallDeltaEvent",
"ContentBlockStopEvent",
"MessageStopEvent",
"UsageEvent",
"ErrorEvent",
"UnknownStreamEvent",
"InternalStreamEvent",
]

View File

@@ -0,0 +1,47 @@
"""
统一流式状态容器StreamState
目标:在多个 chunk 之间维护转换上下文,但避免把“某个格式特定的状态字段”固化在核心层。
每个 Normalizer 通过 `substate(format_id)` 获取自己的隔离状态字典。
"""
from dataclasses import dataclass, field
from typing import Any
@dataclass
class StreamState:
"""
统一的流式状态容器
关键点:
- 不把具体格式字段固化为属性,避免 source/target 互相污染
- 每个 Normalizer 只读写自己的隔离子状态:`state.substate(self.FORMAT_ID)`
"""
# 可选:便于调试与链路追踪(不强依赖)
model: str = ""
message_id: str = ""
# Registry/调用层的通用扩展信息(与具体格式无关)
extra: dict[str, Any] = field(default_factory=dict)
# 各 Normalizer 的隔离状态key: FORMAT_ID
by_format: dict[str, dict[str, Any]] = field(default_factory=dict)
def substate(self, format_id: str) -> dict[str, Any]:
"""获取指定格式的隔离子状态"""
key = str(format_id).upper()
return self.by_format.setdefault(key, {})
def reset(self) -> None:
"""重置状态(重试时调用)"""
self.model = ""
self.message_id = ""
self.extra.clear()
self.by_format.clear()
__all__ = [
"StreamState",
]

View File

@@ -0,0 +1,237 @@
"""Thinking block signature cache (triple-layer).
三层缓存设计:
Layer 1: tool_use_id -> thoughtSignature (工具调用签名恢复)
Layer 2: signature -> model_family (跨模型兼容校验)
Layer 3: session_id -> latest signature (会话级签名追踪 + rewind 检测)
同时保留原有的 model:text -> signature 兼容层。
"""
from __future__ import annotations
import hashlib
import threading
import time
from typing import Any
from src.core.api_format.conversion.constants import DUMMY_THOUGHT_SIGNATURE
# 签名最小长度阈值
MIN_SIGNATURE_LENGTH = 50
# TTL: 2 小时
_SIGNATURE_TTL_SECONDS = 2 * 60 * 60
# 各层缓存上限
_TOOL_CACHE_LIMIT = 500
_FAMILY_CACHE_LIMIT = 200
_SESSION_CACHE_LIMIT = 1000
_TEXT_CACHE_LIMIT = 1000
class _CacheEntry:
"""带时间戳的缓存条目,支持 TTL 过期。"""
__slots__ = ("data", "created_at")
def __init__(self, data: Any) -> None:
self.data = data
self.created_at: float = time.monotonic()
def is_expired(self, now: float | None = None) -> bool:
return ((now or time.monotonic()) - self.created_at) > _SIGNATURE_TTL_SECONDS
class _SessionEntry:
"""Session 层缓存数据,包含消息计数用于 rewind 检测。"""
__slots__ = ("signature", "message_count")
def __init__(self, signature: str, message_count: int) -> None:
self.signature = signature
self.message_count = message_count
class ThinkingSignatureCache:
"""Triple-layer thinking signature cache.
Layer 1 (tool): tool_use_id -> thoughtSignature
当客户端(如 OpenCode) 在 tool_result 中丢弃了 signature 时用于恢复。
Layer 2 (family): signature -> model_family
防止跨模型签名污染Claude 签名不能用在 Gemini 上)。
Layer 3 (session): session_id -> latest signature + message_count
会话级追踪,支持 rewind 检测(用户删除消息后不会注入来自"未来"的签名)。
Legacy (text): SHA256(model + text) -> signature
向后兼容的 get_or_dummy() 接口。
"""
def __init__(self) -> None:
self._tool_sigs: dict[str, _CacheEntry] = {}
self._families: dict[str, _CacheEntry] = {}
self._sessions: dict[str, _CacheEntry] = {}
self._text_sigs: dict[str, _CacheEntry] = {}
self._lock = threading.Lock()
# ===== Layer 1: Tool Use ID -> Signature =====
def cache_tool_signature(self, tool_use_id: str, signature: str) -> None:
"""缓存工具调用对应的 thinking signature。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
self._tool_sigs[tool_use_id] = _CacheEntry(signature)
if len(self._tool_sigs) > _TOOL_CACHE_LIMIT:
self._prune(self._tool_sigs, limit=_TOOL_CACHE_LIMIT)
def get_tool_signature(self, tool_use_id: str) -> str | None:
"""查找工具调用对应的 signature。"""
with self._lock:
entry = self._tool_sigs.get(tool_use_id)
if entry is None:
return None
if entry.is_expired():
self._tool_sigs.pop(tool_use_id, None)
return None
return entry.data
# ===== Layer 2: Signature -> Model Family =====
def cache_thinking_family(self, signature: str, family: str) -> None:
"""记录 signature 所属的模型家族。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
self._families[signature] = _CacheEntry(family)
if len(self._families) > _FAMILY_CACHE_LIMIT:
self._prune(self._families, limit=_FAMILY_CACHE_LIMIT)
def get_signature_family(self, signature: str) -> str | None:
"""查找 signature 所属的模型家族。"""
with self._lock:
entry = self._families.get(signature)
if entry is None:
return None
if entry.is_expired():
self._families.pop(signature, None)
return None
return entry.data
# ===== Layer 3: Session ID -> Latest Signature =====
def cache_session_signature(
self, session_id: str, signature: str, message_count: int = 0
) -> None:
"""存储会话的最新 thinking signature。
Rewind 检测:当 message_count 小于已缓存值时,说明用户删除了消息,
强制更新签名以避免注入来自"未来"的签名。
"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
with self._lock:
existing = self._sessions.get(session_id)
should_store = True
if existing and not existing.is_expired():
entry: _SessionEntry = existing.data
if message_count < entry.message_count:
# Rewind detected: 用户删除了消息,强制更新
pass
elif message_count == entry.message_count:
# 同一轮消息:仅当新签名更长(更完整)时才替换
should_store = len(signature) > len(entry.signature)
# else: 正常递增,更新
if should_store:
self._sessions[session_id] = _CacheEntry(_SessionEntry(signature, message_count))
if len(self._sessions) > _SESSION_CACHE_LIMIT:
self._prune(self._sessions, limit=_SESSION_CACHE_LIMIT)
def get_session_signature(self, session_id: str) -> str | None:
"""获取会话的最新 thinking signature。"""
with self._lock:
entry = self._sessions.get(session_id)
if entry is None:
return None
if entry.is_expired():
self._sessions.pop(session_id, None)
return None
return entry.data.signature
# ===== Legacy: model:text -> signature向后兼容 =====
def get_or_dummy(self, model: str, thinking_text: str) -> str | None:
"""Legacy: 根据 model + thinking_text 查找 signature。
Gemini 模型在未命中时返回 DUMMY_THOUGHT_SIGNATURE跳过验证
"""
key = self._text_key(model, thinking_text)
with self._lock:
entry = self._text_sigs.get(key)
if entry is not None:
if entry.is_expired():
self._text_sigs.pop(key, None)
else:
return entry.data
if str(model).startswith("gemini-"):
return DUMMY_THOUGHT_SIGNATURE
return None
def cache(self, model: str, thinking_text: str, signature: str) -> None:
"""Legacy: 缓存 model + thinking_text -> signature。"""
if len(signature) < MIN_SIGNATURE_LENGTH:
return
key = self._text_key(model, thinking_text)
with self._lock:
if key in self._text_sigs:
self._text_sigs[key] = _CacheEntry(signature)
return
if len(self._text_sigs) >= _TEXT_CACHE_LIMIT:
# FIFO 淘汰 1/4
evict_n = max(1, _TEXT_CACHE_LIMIT // 4)
for k in list(self._text_sigs.keys())[:evict_n]:
self._text_sigs.pop(k, None)
self._text_sigs[key] = _CacheEntry(signature)
# ===== Utilities =====
@staticmethod
def _text_key(model: str, thinking_text: str) -> str:
content = f"{model}\x00{thinking_text}"
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:32]
@staticmethod
def _prune(d: dict[str, _CacheEntry], *, limit: int | None = None) -> None:
"""Remove expired entries and optionally enforce a size limit."""
now = time.monotonic()
expired = [k for k, v in d.items() if v.is_expired(now)]
for k in expired:
d.pop(k, None)
if limit is None or len(d) <= limit:
return
excess = len(d) - limit
for k, _entry in sorted(d.items(), key=lambda kv: kv[1].created_at)[:excess]:
d.pop(k, None)
def clear(self) -> None:
"""清空所有缓存层(用于测试或手动重置)。"""
with self._lock:
self._tool_sigs.clear()
self._families.clear()
self._sessions.clear()
self._text_sigs.clear()
signature_cache = ThinkingSignatureCache()
__all__ = ["ThinkingSignatureCache", "signature_cache", "MIN_SIGNATURE_LENGTH"]

View File

@@ -0,0 +1,296 @@
"""
API 格式检测
提供从请求头、响应内容等检测 API 格式的函数。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from starlette.requests import Request
from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind, EndpointType
from src.core.api_format.signature import EndpointSignature, make_signature_key
@dataclass(frozen=True)
class RequestContext:
"""请求上下文 - 三维度信息"""
endpoint: EndpointSignature
endpoint_type: EndpointType
auth_method: AuthMethod
credentials: str | None
def _detect_endpoint_type(path: str) -> EndpointType:
normalized = path.lower()
if normalized.startswith("/upload/v1beta/files") or normalized.startswith("/v1beta/files"):
return EndpointType.FILES
if normalized.startswith("/v1/videos") or (
normalized.startswith("/v1beta/") and "predictlongrunning" in normalized
):
return EndpointType.VIDEO
# Gemini operations (视频轮询) 也归类为 VIDEO
if normalized.startswith("/v1beta/operations"):
return EndpointType.VIDEO
if normalized.startswith("/v1/models"):
return EndpointType.MODELS
if "/embeddings" in normalized:
return EndpointType.EMBEDDING
if "/images" in normalized:
return EndpointType.IMAGE
if "/audio" in normalized:
return EndpointType.AUDIO
return EndpointType.CHAT
def _detect_data_format(
path: str, headers: dict[str, str], query_params: dict[str, str] | None
) -> EndpointSignature:
normalized = path.lower()
endpoint_type = _detect_endpoint_type(path)
# Claude: /v1/messageschat/cli 共用路径,按认证头区分)
if normalized.startswith("/v1/messages"):
auth_header = headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CLI)
return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT)
# OpenAI compact: /responses/compact
if "/responses/compact" in normalized:
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.COMPACT)
# OpenAI CLI: /responses
if "/responses" in normalized:
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CLI)
# Gemini family
if normalized.startswith("/v1beta/") or normalized.startswith("/upload/v1beta/"):
kind = EndpointKind.CHAT
if endpoint_type == EndpointType.VIDEO:
kind = EndpointKind.VIDEO
return EndpointSignature(api_family=ApiFamily.GEMINI, endpoint_kind=kind)
# OpenAI family
if normalized.startswith("/v1/videos"):
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.VIDEO)
if normalized.startswith("/v1/chat/completions"):
return EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CHAT)
# Fallback: 基于认证方式猜测协议族(主要用于 /v1/models
sig, _api_key, _auth_source = detect_format_from_request(headers, query_params)
return sig
def _detect_auth_method(
headers: dict[str, str], query_params: dict[str, str] | None
) -> tuple[AuthMethod, str | None]:
# Query key (Gemini) has highest priority
query_key = query_params.get("key") if query_params else None
if query_key:
return AuthMethod.QUERY_KEY, query_key
x_goog_key = headers.get("x-goog-api-key")
if x_goog_key:
return AuthMethod.GOOG_API_KEY, x_goog_key
x_api_key = headers.get("x-api-key")
if x_api_key:
return AuthMethod.API_KEY, x_api_key
auth_header = headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return AuthMethod.BEARER, auth_header[7:].strip()
return AuthMethod.BEARER, None
def detect_format_from_request(
headers: dict[str, str],
query_params: dict[str, str] | None = None,
) -> tuple[EndpointSignature, str | None, str]:
"""
从请求头检测 API 格式和 API Key
检测优先级:
1. x-api-key + anthropic-version -> Claude
2. x-goog-api-key 或 ?key= -> Gemini
3. Authorization: Bearer -> OpenAI (默认)
Args:
headers: 请求头字典key 应为小写)
query_params: 查询参数字典(可选)
Returns:
(endpoint_signature, api_key, auth_source) 元组
- endpoint_signature: EndpointSignature(api_family, endpoint_kind)
- auth_source: 认证来源 ("header""query")
"""
# Claude: x-api-key + anthropic-version (必须同时存在)
if headers.get("x-api-key") and headers.get("anthropic-version"):
return (
EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT),
headers.get("x-api-key"),
"header",
)
# Gemini: query 参数优先(与 Google SDK 行为一致)
query_key = query_params.get("key") if query_params else None
if query_key:
return (
EndpointSignature(api_family=ApiFamily.GEMINI, endpoint_kind=EndpointKind.CHAT),
query_key,
"query",
)
x_goog_key = headers.get("x-goog-api-key")
if x_goog_key:
return (
EndpointSignature(api_family=ApiFamily.GEMINI, endpoint_kind=EndpointKind.CHAT),
x_goog_key,
"header",
)
# OpenAI: Authorization: Bearer (默认)
auth_header = headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return (
EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CHAT),
auth_header[7:].strip(),
"header",
)
# 兜底:兼容部分客户端用 x-api-key 携带 OpenAI token 的情况
if headers.get("x-api-key"):
return (
EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CHAT),
headers.get("x-api-key"),
"header",
)
return (
EndpointSignature(api_family=ApiFamily.OPENAI, endpoint_kind=EndpointKind.CHAT),
None,
"header",
)
def detect_format_and_key_from_starlette(
request: Request,
) -> tuple[str, str | None, str]:
"""
从 Starlette Request 对象检测 API 格式和 API Key
这是一个便捷函数,用于直接处理 Starlette/FastAPI 请求对象。
Args:
request: Starlette Request 对象
Returns:
(format_name, api_key, auth_method) 元组
- format_name: 为小写字符串
- auth_method: 认证方式 ("header""query")
"""
# 规范化 headers 为小写
headers = {k.lower(): v for k, v in request.headers.items()}
query_params = dict(request.query_params)
api_format, api_key, auth_method = detect_format_from_request(headers, query_params)
# 返回小写格式名
return api_format.key, api_key, auth_method
def detect_request_context(request: Request) -> RequestContext:
"""
从 Request 中检测三维度信息
Returns:
RequestContext(data_format, endpoint_type, auth_method, credentials)
"""
headers = {k.lower(): v for k, v in request.headers.items()}
query_params = dict(request.query_params)
endpoint_type = _detect_endpoint_type(request.url.path)
data_format = _detect_data_format(request.url.path, headers, query_params)
auth_method, credentials = _detect_auth_method(headers, query_params)
return RequestContext(
endpoint=data_format,
endpoint_type=endpoint_type,
auth_method=auth_method,
credentials=credentials,
)
def detect_format_from_response(
response_data: dict,
) -> str | None:
"""
从响应内容检测 API 格式
Args:
response_data: 响应 JSON 字典
Returns:
检测到的格式,或 None
"""
# Claude: 有 type="message" 或特定的 content 结构
if response_data.get("type") == "message":
return make_signature_key(ApiFamily.CLAUDE, EndpointKind.CHAT)
if "content" in response_data and isinstance(response_data["content"], list):
first_content = response_data["content"][0] if response_data["content"] else {}
if first_content.get("type") in ("text", "tool_use"):
return make_signature_key(ApiFamily.CLAUDE, EndpointKind.CHAT)
# OpenAI: 有 choices 数组
if "choices" in response_data:
return make_signature_key(ApiFamily.OPENAI, EndpointKind.CHAT)
# Gemini: 有 candidates 数组
if "candidates" in response_data:
return make_signature_key(ApiFamily.GEMINI, EndpointKind.CHAT)
return None
def detect_cli_format_from_path(
path: str,
base_signature: str,
) -> bool:
"""
根据请求路径检测是否为 CLI 模式
CLI 模式的特征:
- OpenAI CLI: 请求 /responses 路径
- Claude CLI: 有特定的路径模式
- Gemini CLI: 有特定的路径模式
Args:
path: 请求路径
base_format: 基础格式
Returns:
True 如果是 CLI 模式
"""
# OpenAI CLI 特征: /v1/responses 路径
if str(base_signature).lower().startswith("openai:") and "/responses" in path.lower():
return True
# 其他 CLI 模式通常由 Adapter 层根据具体业务逻辑判断
return False
__all__ = [
"detect_format_from_request",
"detect_format_and_key_from_starlette",
"detect_format_from_response",
"detect_cli_format_from_path",
"detect_request_context",
"RequestContext",
]

View File

@@ -0,0 +1,71 @@
"""API format enums.
新模式下系统使用结构化的 (ApiFamily, EndpointKind) / `family:kind` signature 作为唯一标识。
"""
from enum import Enum
class ApiFamily(str, Enum):
"""
协议族(兼容族)- 决定数据格式与认证方式的基础。
注意:不叫 Provider 避免与 ORM 的 Provider 模型撞名。
"""
OPENAI = "openai" # openai-compatible含 deepseek, grok, qwen 等)
CLAUDE = "claude" # claude-compatible
GEMINI = "gemini" # gemini-compatible
@property
def priority(self) -> int:
"""基础优先级(数字越小越优先)"""
return {
ApiFamily.OPENAI: 1,
ApiFamily.CLAUDE: 2,
ApiFamily.GEMINI: 3,
}.get(self, 99)
class EndpointKind(str, Enum):
"""
端点变体 - 决定 API 路径/认证变体/数据格式变体等。
注意:不复用现有 EndpointTypeEndpointType 用于请求上下文检测/功能分类)。
"""
CHAT = "chat"
CLI = "cli"
COMPACT = "compact"
VIDEO = "video"
IMAGE = "image"
class AuthMethod(str, Enum):
"""认证方式 - 决定如何构造认证 Header"""
BEARER = "bearer" # Authorization: Bearer {token}
API_KEY = "api_key" # x-api-key: {key}
GOOG_API_KEY = "goog_key" # x-goog-api-key: {key}
OAUTH2 = "oauth2" # Google OAuth2 / Service Account
QUERY_KEY = "query_key" # ?key={key} (Gemini 备用)
class EndpointType(str, Enum):
"""端点类型 - 决定 API 功能类别"""
CHAT = "chat" # Chat/Completion API
VIDEO = "video" # Video Generation API
FILES = "files" # Files API
IMAGE = "image" # Image Generation API
AUDIO = "audio" # Audio API
EMBEDDING = "embedding" # Embedding API
MODELS = "models" # Models API
__all__ = [
"ApiFamily",
"EndpointKind",
"AuthMethod",
"EndpointType",
]

View File

@@ -0,0 +1,720 @@
"""
统一的请求头处理模块
职责:
1. 请求头规范化(大小写统一)
2. 客户端 API Key 提取
3. 能力需求检测
4. 上游请求头构建
5. 响应头过滤
6. 日志脱敏
"""
from __future__ import annotations
import json
from collections.abc import Set as AbstractSet
from typing import Any, Callable
from src.core.api_format.enums import ApiFamily
from src.core.api_format.metadata import (
get_auth_config_for_endpoint,
get_extra_headers_for_endpoint,
get_protected_keys_for_endpoint,
resolve_endpoint_definition,
)
from src.core.api_format.signature import EndpointSignature, parse_signature_key
from src.core.logger import logger
# =============================================================================
# 头部常量定义
# =============================================================================
# 通用浏览器指纹 Headers用于绕过 Cloudflare 等反爬防护
# 基于 Electron 桌面客户端的真实请求头构建,作为所有 adapter 请求的底层默认值
BROWSER_FINGERPRINT_HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/140.0.7339.249 Electron/38.7.0 Safari/537.36"
),
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN",
"sec-ch-ua": '"Not=A?Brand";v="24", "Chromium";v="140"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"Sec-Fetch-Site": "cross-site",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Dest": "empty",
}
# Anthropic/Claude 专属 Headers仅 Claude API family 使用)
# 包含 Stainless SDK 指纹和 direct-browser-access 标记
_ANTHROPIC_EXTRA_HEADERS: dict[str, str] = {
"anthropic-dangerous-direct-browser-access": "true",
"x-stainless-os": "Unknown",
"x-stainless-runtime": "browser:chrome",
"x-stainless-arch": "unknown",
"x-stainless-lang": "js",
"x-stainless-package-version": "0.41.0",
"x-stainless-runtime-version": "140.0.7339",
"x-stainless-retry-count": "0",
}
# 转发给上游时需要剔除的头部(系统管理 + 认证替换 + 客户端/代理元数据)
UPSTREAM_DROP_HEADERS: frozenset[str] = frozenset(
{
# 认证头 - 会被替换为 Provider 的认证
"authorization",
"x-api-key",
"x-goog-api-key",
# 系统管理头 - 由 HTTP 客户端重新生成
"host",
"content-length",
"transfer-encoding",
"connection",
# 编码头 - 丢弃客户端值,由 BROWSER_FINGERPRINT_HEADERS 统一设置
"accept-encoding",
"content-encoding",
# 反向代理 / 网关注入的头部 - 属于本站基础设施,不应泄露给上游
"x-real-ip",
"x-real-proto",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-scheme",
"x-forwarded-host",
"x-forwarded-port",
}
)
# 最小必脱敏集合(编译时常量,用于快速路径)
# 完整脱敏应使用 SystemConfigService.get_sensitive_headers()
CORE_REDACT_HEADERS: frozenset[str] = frozenset(
{
"authorization",
"x-api-key",
"x-goog-api-key",
}
)
# Hop-by-hop 头部 (RFC 7230)
HOP_BY_HOP_HEADERS: frozenset[str] = frozenset(
{
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
)
# 响应时需要过滤的头部body-dependent + hop-by-hop
RESPONSE_DROP_HEADERS: frozenset[str] = (
frozenset(
{
"content-length",
"content-encoding",
"transfer-encoding",
"content-type",
}
)
| HOP_BY_HOP_HEADERS
)
# =============================================================================
# 请求头规范化
# =============================================================================
def normalize_headers(headers: dict[str, str]) -> dict[str, str]:
"""
将请求头 key 统一为小写
用于处理 context.original_headers 的大小写敏感问题。
"""
return {k.lower(): v for k, v in headers.items()}
def get_header_value(headers: dict[str, str], key: str, default: str = "") -> str:
"""
大小写不敏感地获取请求头值
Args:
headers: 原始请求头(可能大小写不一致)
key: 要获取的 key任意大小写
default: 未找到时的默认值
Returns:
头部值,未找到返回 default
"""
key_lower = key.lower()
for k, v in headers.items():
if k.lower() == key_lower:
return v
return default
# =============================================================================
# 客户端 API Key 提取
# =============================================================================
def extract_client_api_key_for_endpoint(
headers: dict[str, str],
endpoint: str | EndpointSignature | tuple,
) -> str | None:
"""
新模式:从客户端请求头提取 API Key。
Args:
headers: 原始请求头(自动处理大小写)
endpoint: endpoint signature`family:kind` / EndpointSignature / (ApiFamily, EndpointKind)
"""
auth_header, auth_type = get_auth_config_for_endpoint(endpoint)
value = get_header_value(headers, auth_header)
if not value:
return None
if auth_type == "bearer":
if value.lower().startswith("bearer "):
return value[7:]
return None
return value
def resolve_header_name_case(
headers: dict[str, str] | None,
preferred_key: str,
) -> str:
"""Preserve original header casing when replacing an existing header."""
if headers:
preferred_lower = preferred_key.lower()
for key in headers.keys():
if str(key).lower() == preferred_lower:
return str(key)
return preferred_key
def extract_client_api_key_for_endpoint_with_query(
headers: dict[str, str],
query_params: dict[str, str] | None,
endpoint: str | EndpointSignature | tuple,
) -> str | None:
"""
新模式:从客户端请求头或 URL 参数提取 API Key。
Gemini family 优先级:
1. URL 参数 ?key=
2. x-goog-api-key 请求头
"""
try:
sig = (
endpoint
if isinstance(endpoint, EndpointSignature)
else (
parse_signature_key(endpoint) # type: ignore[arg-type]
if isinstance(endpoint, str)
else EndpointSignature(api_family=endpoint[0], endpoint_kind=endpoint[1])
) # type: ignore[index]
)
except Exception:
sig = None
if sig and sig.api_family.value == "gemini":
query_key = query_params.get("key") if query_params else None
if query_key:
return query_key
return extract_client_api_key_for_endpoint(headers, endpoint)
# =============================================================================
# 能力需求检测
# =============================================================================
def detect_capabilities_for_endpoint(
headers: dict[str, str],
endpoint: str | EndpointSignature | tuple,
request_body: dict[str, Any] | None = None, # noqa: ARG001 - 预留
) -> dict[str, bool]:
"""
新模式:从请求头检测能力需求。
当前支持:
- Claude family: anthropic-beta 头中的 context-1m
"""
requirements: dict[str, bool] = {}
try:
sig = (
endpoint
if isinstance(endpoint, EndpointSignature)
else (
parse_signature_key(endpoint) # type: ignore[arg-type]
if isinstance(endpoint, str)
else EndpointSignature(api_family=endpoint[0], endpoint_kind=endpoint[1])
) # type: ignore[index]
)
except Exception:
sig = None
if sig and sig.api_family.value == "claude":
beta_header = get_header_value(headers, "anthropic-beta")
if "context-1m" in beta_header.lower():
requirements["context_1m"] = True
return requirements
# =============================================================================
# 上游请求头构建
# =============================================================================
class HeaderBuilder:
"""
请求头构建器
使用 lower-case key 索引确保唯一性和确定的优先级。
优先级(后者覆盖前者):原始头部 < endpoint 头部 < extra 头部 < 认证头
"""
def __init__(self) -> None:
# key: (original_case_key, value)
self._headers: dict[str, tuple[str, str]] = {}
def add(self, key: str, value: str) -> HeaderBuilder:
"""添加单个头部(会覆盖同名头部,但保留已存在 key 的原始大小写)"""
key_lower = key.lower()
existing = self._headers.get(key_lower)
stored_key = existing[0] if existing else key
self._headers[key_lower] = (stored_key, value)
return self
def add_many(self, headers: dict[str, str]) -> HeaderBuilder:
"""批量添加头部"""
for k, v in headers.items():
self.add(k, v)
return self
def add_protected(
self, headers: dict[str, str], protected_keys: AbstractSet[str]
) -> HeaderBuilder:
"""
添加头部但保护指定的 key 不被覆盖
用于 endpoint 额外请求头不能覆盖认证头的场景。
"""
protected_lower = {k.lower() for k in protected_keys}
for k, v in headers.items():
if k.lower() not in protected_lower:
self.add(k, v)
return self
def remove(self, keys: frozenset[str]) -> HeaderBuilder:
"""移除指定的头部"""
for k in keys:
self._headers.pop(k.lower(), None)
return self
def rename(self, from_key: str, to_key: str) -> HeaderBuilder:
"""
重命名头部(保留原值)
如果 from_key 不存在,则不做任何操作。
"""
from_lower = from_key.lower()
if from_lower in self._headers:
_, value = self._headers.pop(from_lower)
self._headers[to_key.lower()] = (to_key, value)
return self
def apply_rules(
self,
rules: list[dict[str, Any]],
protected_keys: AbstractSet[str] | None = None,
*,
body: dict[str, Any] | None = None,
original_body: dict[str, Any] | None = None,
condition_evaluator: (
Callable[[dict[str, Any], dict[str, Any], dict[str, Any] | None], bool] | None
) = None,
) -> HeaderBuilder:
"""
应用请求头规则
支持的规则类型:
- set: 设置/覆盖头部 {"action": "set", "key": "X-Custom", "value": "fixed"}
- drop: 删除头部 {"action": "drop", "key": "X-Unwanted"}
- rename: 重命名头部 {"action": "rename", "from": "X-Old", "to": "X-New"}
Args:
rules: 规则列表
protected_keys: 受保护的 key不能被 set/drop/rename 修改)
body: 条件规则评估用的当前请求体
original_body: 条件规则评估用的原始请求体
condition_evaluator: 条件评估函数;未提供时带 condition 的规则 fail-closed
"""
protected_lower = {k.lower() for k in protected_keys} if protected_keys else set()
for rule in rules:
condition = rule.get("condition")
if condition is not None:
if (
not isinstance(condition, dict)
or body is None
or condition_evaluator is None
or not condition_evaluator(body, condition, original_body)
):
continue
action = rule.get("action")
if action == "set":
key = rule.get("key", "")
value = rule.get("value", "")
if key and key.lower() not in protected_lower:
self.add(key, value)
elif action == "drop":
key = rule.get("key", "")
if key and key.lower() not in protected_lower:
self._headers.pop(key.lower(), None)
elif action == "rename":
from_key = rule.get("from", "")
to_key = rule.get("to", "")
if from_key and to_key:
# 两个 key 都不能是受保护的
if (
from_key.lower() not in protected_lower
and to_key.lower() not in protected_lower
):
self.rename(from_key, to_key)
return self
def build(self) -> dict[str, str]:
"""构建最终的头部字典"""
result: dict[str, str] = {}
for original_key, value in self._headers.values():
result[original_key] = _normalize_header_value_for_httpx(original_key, value)
return result
def _normalize_header_value_for_httpx(key: str, value: str) -> str:
"""将 header 值归一化为 httpx/h11 可发送的 ASCII 字符串。
说明:
- 当前 httpx/h11 栈会对 str 类型 header 值执行 ASCII 编码。
- 若值包含非 ASCII 字符(如中文),会抛出 UnicodeEncodeError。
- 因此这里统一做 ASCII 归一化,确保请求可稳定发出。
"""
if value.isascii():
return value
key_lower = key.lower()
# Codex CLI 元数据是 JSON 字符串,优先重编码为 ASCII JSON语义最稳定。
if key_lower == "x-codex-turn-metadata":
try:
normalized = json.dumps(json.loads(value), ensure_ascii=True, separators=(",", ":"))
logger.debug(
"Header '{}' contains non-ASCII chars, normalized as ASCII JSON",
key,
)
return normalized
except Exception:
# 非法 JSON 时走通用兜底,避免阻断请求。
pass
# 兜底:仅将非 ASCII 字符替换为 \uXXXX保留 ASCII 字符原样
escaped = "".join(c if c.isascii() else f"\\u{ord(c):04x}" for c in value)
logger.warning(
"Header '{}' contains non-ASCII chars, escaped for httpx compatibility",
key,
)
return escaped
def build_upstream_headers_for_endpoint(
original_headers: dict[str, str],
endpoint: str | EndpointSignature | tuple,
provider_api_key: str,
*,
endpoint_headers: dict[str, str] | None = None,
extra_headers: dict[str, str] | None = None,
drop_headers: frozenset[str] | None = None,
header_rules: list[dict[str, Any]] | None = None,
body: dict[str, Any] | None = None,
original_body: dict[str, Any] | None = None,
condition_evaluator: (
Callable[[dict[str, Any], dict[str, Any], dict[str, Any] | None], bool] | None
) = None,
) -> dict[str, str]:
"""
新模式:构建发送给上游 Provider 的请求头(基于 endpoint signature
优先级(后者覆盖前者):
1. 原始头部(排除 drop_headers
2. endpoint 配置头部
3. header_rules用户自定义的请求头规则支持 set/drop/rename
4. extra_headers
5. 认证头(最高优先级,始终设置)
"""
if drop_headers is None:
drop_headers = UPSTREAM_DROP_HEADERS
auth_header, auth_type = get_auth_config_for_endpoint(endpoint)
auth_value = f"Bearer {provider_api_key}" if auth_type == "bearer" else provider_api_key
protected_keys = {auth_header.lower(), "content-type"}
builder = HeaderBuilder()
for k, v in original_headers.items():
if k.lower() not in drop_headers:
builder.add(k, v)
if endpoint_headers:
builder.add_protected(endpoint_headers, protected_keys)
# 应用用户自定义的请求头规则(认证头受保护)
if header_rules:
builder.apply_rules(
header_rules,
protected_keys,
body=body,
original_body=original_body,
condition_evaluator=condition_evaluator,
)
if extra_headers:
builder.add_many(extra_headers)
builder.add(resolve_header_name_case(original_headers, auth_header), auth_value)
result = builder.build()
if not any(k.lower() == "content-type" for k in result):
result["Content-Type"] = "application/json"
return result
def merge_headers_with_protection(
base_headers: dict[str, str],
extra_headers: dict[str, str] | None,
protected_keys: frozenset[str] | set[str],
) -> dict[str, str]:
"""
合并头部但保护指定的 key 不被覆盖
等价于原 build_safe_headers 的功能。
Args:
base_headers: 基础头部
extra_headers: 要合并的额外头部
protected_keys: 受保护的 key 集合
Returns:
合并后的头部
"""
if not extra_headers:
return dict(base_headers)
builder = HeaderBuilder()
builder.add_many(base_headers)
builder.add_protected(extra_headers, protected_keys)
return builder.build()
# =============================================================================
# 响应头过滤
# =============================================================================
def filter_response_headers(
headers: dict[str, str] | None,
drop_headers: frozenset[str] | None = None,
) -> dict[str, str]:
"""
过滤上游响应头中不应透传给客户端的字段
Args:
headers: 上游响应头
drop_headers: 要剔除的头部集合None 使用默认值)
Returns:
过滤后的头部
"""
if not headers:
return {}
if drop_headers is None:
drop_headers = RESPONSE_DROP_HEADERS
return {k: v for k, v in headers.items() if k.lower() not in drop_headers}
# =============================================================================
# 日志脱敏
# =============================================================================
def redact_headers_for_log(
headers: dict[str, str],
redact_keys: frozenset[str] | None = None,
) -> dict[str, str]:
"""
将敏感头部值替换为 *** 用于日志记录
Args:
headers: 原始头部
redact_keys: 要脱敏的 key 集合None 使用 CORE_REDACT_HEADERS
Returns:
脱敏后的头部
Note:
完整的脱敏应该使用 SystemConfigService.get_sensitive_headers()
来获取用户配置的敏感头列表。
"""
if redact_keys is None:
redact_keys = CORE_REDACT_HEADERS
return {k: "***" if k.lower() in redact_keys else v for k, v in headers.items()}
# =============================================================================
# Adapter 统一接口
# =============================================================================
def build_adapter_base_headers_for_endpoint(
endpoint: str | EndpointSignature | tuple,
api_key: str,
*,
include_extra: bool = True,
) -> dict[str, str]:
"""
新模式:根据 endpoint signature 构建基础请求头。
浏览器指纹 headers 作为底层默认值注入Claude API family 额外注入 Anthropic 专属 header。
认证头和 extra_headers 会覆盖它们。
"""
auth_header, auth_type = get_auth_config_for_endpoint(endpoint)
auth_value = f"Bearer {api_key}" if auth_type == "bearer" else api_key
# 以浏览器指纹为底层默认值,绕过 Cloudflare 等反爬防护
headers: dict[str, str] = {**BROWSER_FINGERPRINT_HEADERS}
# Claude API family 额外注入 Anthropic 专属 header
definition = resolve_endpoint_definition(endpoint)
if definition and definition.api_family == ApiFamily.CLAUDE:
headers.update(_ANTHROPIC_EXTRA_HEADERS)
headers[auth_header] = auth_value
headers["Content-Type"] = "application/json"
if include_extra:
extra = get_extra_headers_for_endpoint(endpoint)
if extra:
headers.update(extra)
return headers
def build_adapter_headers_for_endpoint(
endpoint: str | EndpointSignature | tuple,
api_key: str,
extra_headers: dict[str, str] | None = None,
) -> dict[str, str]:
"""
新模式:构建完整的 Adapter 请求头(包含 extra_headers
"""
base = build_adapter_base_headers_for_endpoint(endpoint, api_key)
if not extra_headers:
return base
protected = get_protected_keys_for_endpoint(endpoint)
return merge_headers_with_protection(base, extra_headers, protected)
def get_adapter_protected_keys_for_endpoint(
endpoint: str | EndpointSignature | tuple,
) -> tuple[str, ...]:
"""新模式:获取 Adapter 的受保护头部 key。"""
return tuple(get_protected_keys_for_endpoint(endpoint))
# =============================================================================
# Header Rules 工具函数
# =============================================================================
def extract_set_headers_from_rules(
header_rules: list[dict[str, Any]] | None,
) -> dict[str, str] | None:
"""
从 header_rules 中提取 set 操作生成的头部字典
用于需要构造额外请求头的场景(如模型列表查询、模型测试等)。
注意drop 和 rename 操作在这里不适用,因为它们用于修改已存在的头部。
Args:
header_rules: 请求头规则列表 [{"action": "set", "key": "X-Custom", "value": "val"}, ...]
Returns:
set 操作生成的头部字典,如果没有则返回 None
"""
if not header_rules:
return None
headers: dict[str, str] = {}
for rule in header_rules:
if rule.get("action") == "set":
key = rule.get("key", "")
value = rule.get("value", "")
if key:
headers[key] = value
return headers if headers else None
def get_extra_headers_from_endpoint(endpoint: Any) -> dict[str, str] | None:
"""
从 endpoint 提取额外请求头
用于需要构造额外请求头的场景(如模型列表查询、模型测试等)。
Args:
endpoint: ProviderEndpoint 对象
Returns:
额外请求头字典,如果没有则返回 None
"""
header_rules = getattr(endpoint, "header_rules", None)
return extract_set_headers_from_rules(header_rules)
# =============================================================================
# 请求头辅助工具
# =============================================================================
def set_accept_if_absent(headers: dict[str, str], value: str = "text/event-stream") -> None:
"""Set the ``Accept`` header only if not already present (case-insensitive check).
Used by stream handlers to request SSE format from upstream without overriding
provider-specific Accept headers (e.g. Kiro's ``application/vnd.amazon.eventstream``).
"""
if not any(k.lower() == "accept" for k in headers):
headers["Accept"] = value

View File

@@ -0,0 +1,435 @@
"""
API endpoint metadata (new mode).
新模式下,系统以 (ApiFamily, EndpointKind) 作为结构化标识;
在需要用 string 做 keyDB / JSON dict / metrics label / logs统一使用
`family:kind` 的 endpoint signature key全小写
"""
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass, field
from types import MappingProxyType
from typing import Any
from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind
from src.core.api_format.signature import EndpointSignature, make_signature_key, parse_signature_key
@dataclass(frozen=True, slots=True)
class EndpointDefinition:
"""
端点定义ApiFamily + EndpointKind
- aliases: 用于调试/展示/配置的别名(不用于“接受 legacy APIFormat”
- default_path: 上游默认路径,可被 ProviderEndpoint.custom_path 覆盖
- auth_method/auth_header/auth_type: 认证信息header/bearer 等)
- extra_headers/protected_keys: 格式固定头与保护头
- model_in_body/stream_in_body: 结构差异标记(用于 request/response 构造/规范化)
- data_format_id: 数据格式标识(相同即可透传;不同需格式转换)
"""
api_family: ApiFamily
endpoint_kind: EndpointKind
aliases: Sequence[str] = field(default_factory=tuple)
default_path: str = "/"
path_prefix: str = ""
auth_method: AuthMethod = AuthMethod.BEARER
auth_header: str = "Authorization"
auth_type: str = "bearer" # "bearer" | "header"
extra_headers: Mapping[str, str] = field(default_factory=dict)
protected_keys: frozenset[str] = field(default_factory=frozenset)
model_in_body: bool = True
stream_in_body: bool = True
data_format_id: str = ""
default_body_rules: Sequence[dict[str, Any]] = field(default_factory=tuple)
@property
def signature(self) -> EndpointSignature:
return EndpointSignature(api_family=self.api_family, endpoint_kind=self.endpoint_kind)
@property
def signature_key(self) -> str:
return self.signature.key
def iter_aliases(self) -> Iterable[str]:
# 统一包含 signature key便于配置/展示)
yield self.signature_key
for alias in self.aliases:
value = str(alias or "").strip()
if value:
yield value
CODEX_DEFAULT_BODY_RULES: tuple[dict[str, Any], ...] = (
{"action": "drop", "path": "max_output_tokens"},
{"action": "drop", "path": "temperature"},
{"action": "drop", "path": "top_p"},
{"action": "set", "path": "store", "value": False},
{
"action": "set",
"path": "instructions",
"value": "You are GPT-5.",
"condition": {"path": "instructions", "op": "not_exists"},
},
)
_ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition] = {
# Claude
(ApiFamily.CLAUDE, EndpointKind.CHAT): EndpointDefinition(
api_family=ApiFamily.CLAUDE,
endpoint_kind=EndpointKind.CHAT,
aliases=("claude", "anthropic", "claude_compatible"),
default_path="/v1/messages",
auth_method=AuthMethod.API_KEY,
auth_header="x-api-key",
auth_type="header",
extra_headers={"anthropic-version": "2023-06-01"},
protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}),
data_format_id="claude",
),
(ApiFamily.CLAUDE, EndpointKind.CLI): EndpointDefinition(
api_family=ApiFamily.CLAUDE,
endpoint_kind=EndpointKind.CLI,
aliases=("claude_cli", "claude-cli"),
default_path="/v1/messages",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="claude",
),
# OpenAI
(ApiFamily.OPENAI, EndpointKind.CHAT): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.CHAT,
aliases=(
"openai",
"openai_compatible",
"deepseek",
"grok",
"moonshot",
"zhipu",
"qwen",
"baichuan",
"minimax",
),
default_path="/v1/chat/completions",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="openai_chat",
),
(ApiFamily.OPENAI, EndpointKind.CLI): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.CLI,
aliases=("openai_cli", "responses"),
default_path="/v1/responses",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="openai_responses",
),
(ApiFamily.OPENAI, EndpointKind.COMPACT): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.COMPACT,
aliases=("openai_compact", "responses_compact"),
default_path="/v1/responses/compact",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
# compact endpoint is non-streaming by design.
stream_in_body=False,
data_format_id="openai_responses",
default_body_rules=CODEX_DEFAULT_BODY_RULES,
),
(ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.VIDEO,
aliases=("openai_video", "sora"),
default_path="/v1/videos",
auth_method=AuthMethod.BEARER,
auth_header="Authorization",
auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}),
model_in_body=True,
stream_in_body=False,
data_format_id="openai_video",
),
# Gemini
(ApiFamily.GEMINI, EndpointKind.CHAT): EndpointDefinition(
api_family=ApiFamily.GEMINI,
endpoint_kind=EndpointKind.CHAT,
aliases=("gemini", "google", "vertex"),
default_path="/v1beta/models/{model}:{action}",
auth_method=AuthMethod.GOOG_API_KEY,
auth_header="x-goog-api-key",
auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
model_in_body=False,
stream_in_body=False,
data_format_id="gemini",
),
(ApiFamily.GEMINI, EndpointKind.CLI): EndpointDefinition(
api_family=ApiFamily.GEMINI,
endpoint_kind=EndpointKind.CLI,
aliases=("gemini_cli", "gemini-cli"),
default_path="/v1beta/models/{model}:{action}",
auth_method=AuthMethod.GOOG_API_KEY,
auth_header="x-goog-api-key",
auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
model_in_body=False,
stream_in_body=False,
data_format_id="gemini",
),
(ApiFamily.GEMINI, EndpointKind.VIDEO): EndpointDefinition(
api_family=ApiFamily.GEMINI,
endpoint_kind=EndpointKind.VIDEO,
aliases=("gemini_video", "veo"),
default_path="/v1beta/models/{model}:predictLongRunning",
auth_method=AuthMethod.GOOG_API_KEY,
auth_header="x-goog-api-key",
auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
model_in_body=False,
stream_in_body=False,
data_format_id="gemini_video",
),
}
# 对外只暴露只读视图,避免被随意修改
ENDPOINT_DEFINITIONS: Mapping[tuple[ApiFamily, EndpointKind], EndpointDefinition] = (
MappingProxyType(_ENDPOINT_DEFINITIONS)
)
def list_endpoint_definitions() -> list[EndpointDefinition]:
return list(ENDPOINT_DEFINITIONS.values())
def get_endpoint_definition(
api_family: ApiFamily, endpoint_kind: EndpointKind
) -> EndpointDefinition:
return ENDPOINT_DEFINITIONS[(api_family, endpoint_kind)]
def resolve_endpoint_definition(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> EndpointDefinition | None:
"""
Resolve an endpoint definition from a signature-like input.
Accepted inputs:
- EndpointSignature
- (ApiFamily, EndpointKind)
- "family:kind" signature string
"""
try:
if isinstance(value, EndpointSignature):
return ENDPOINT_DEFINITIONS.get((value.api_family, value.endpoint_kind))
if isinstance(value, tuple) and len(value) == 2:
fam, kind = value
if isinstance(fam, ApiFamily) and isinstance(kind, EndpointKind):
return ENDPOINT_DEFINITIONS.get((fam, kind))
if isinstance(value, str):
sig = parse_signature_key(value)
return ENDPOINT_DEFINITIONS.get((sig.api_family, sig.endpoint_kind))
except Exception:
return None
return None
def get_default_path_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> str:
definition = resolve_endpoint_definition(value)
return definition.default_path if definition else "/"
def get_local_path_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> str:
definition = resolve_endpoint_definition(value)
if not definition:
return "/"
prefix = definition.path_prefix or ""
return prefix + definition.default_path
def get_auth_config_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> tuple[str, str]:
definition = resolve_endpoint_definition(value)
if not definition:
return "Authorization", "bearer"
return definition.auth_header, definition.auth_type
def get_extra_headers_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> Mapping[str, str]:
definition = resolve_endpoint_definition(value)
return definition.extra_headers if definition else {}
def get_protected_keys_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> frozenset[str]:
definition = resolve_endpoint_definition(value)
return (
definition.protected_keys
if definition and definition.protected_keys
else frozenset({"authorization", "content-type"})
)
def get_data_format_id_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> str:
"""
获取端点的数据格式标识。
- 相同 data_format_id 可透传(不需要数据转换)
- 不同 data_format_id 需要走 format conversion
"""
definition = resolve_endpoint_definition(value)
if definition and definition.data_format_id:
return definition.data_format_id
return ""
def get_default_body_rules_for_endpoint(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
*,
provider_type: str | None = None,
) -> list[dict[str, Any]]:
"""获取端点的默认 body_rules。
优先查找 unified api_format capability registry 中的 provider 维度规则(如 Codex 对 openai:cli 的定制规则),
找不到时回退到 EndpointDefinition 上的通用默认规则。
"""
# 确保 provider plugins 已注册(填充 capabilities 中的 provider registry
# ensure_providers_bootstrapped 是幂等的,重复调用无副作用
if provider_type:
try:
import importlib
envelope = importlib.import_module("src.services.provider.envelope")
getattr(envelope, "ensure_providers_bootstrapped")()
except Exception:
pass
# 1) provider_type 维度的注册规则优先
if provider_type:
pt = provider_type.strip().lower()
sig = _normalize_sig_key(value)
from src.core.api_format.capabilities import get_provider_default_body_rules
provider_rules = get_provider_default_body_rules(pt, sig)
if provider_rules is not None:
return provider_rules
# 2) 回退到 EndpointDefinition 上的通用默认规则
definition = resolve_endpoint_definition(value)
if not definition or not definition.default_body_rules:
return []
return deepcopy(list(definition.default_body_rules))
# ---------------------------------------------------------------------------
# Provider-scoped default body rules compatibility wrappers
# ---------------------------------------------------------------------------
def register_provider_default_body_rules(
provider_type: str,
endpoint_sig: str,
rules: Sequence[dict[str, Any]],
) -> None:
"""兼容入口:注册特定 provider_type + endpoint_sig 的默认 body_rules真实存储位于 core registry。"""
pt = provider_type.strip().lower()
sig = _normalize_sig_key(endpoint_sig)
from src.core.api_format.capabilities import (
register_provider_default_body_rules as register_provider_default_body_rules_in_registry,
)
register_provider_default_body_rules_in_registry(pt, sig, rules)
def _normalize_sig_key(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> str:
"""将各种端点标识形式归一化为 signature key 字符串。"""
if isinstance(value, str):
try:
return parse_signature_key(value).key
except Exception:
return value.strip().lower()
if isinstance(value, EndpointSignature):
return value.key
if isinstance(value, tuple) and len(value) == 2:
return make_signature_key(value[0], value[1])
return str(value).strip().lower()
def can_passthrough_endpoint(
client: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
provider: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> bool:
"""
判断两个 endpoint signature 是否可以透传(无需数据转换)。
透传条件:
1) signature 完全相同
2) data_format_id 相同(如 claude:chat / claude:cli
"""
try:
if isinstance(client, str) and isinstance(provider, str):
if parse_signature_key(client).key == parse_signature_key(provider).key:
return True
except Exception:
pass
client_id = get_data_format_id_for_endpoint(client)
provider_id = get_data_format_id_for_endpoint(provider)
return bool(client_id) and client_id == provider_id
def make_endpoint_signature(api_family: str, endpoint_kind: str) -> str:
"""
Helper: build canonical signature key from raw strings (lowercased/trimmed).
This is used in places that store family/kind separately in DB.
"""
return make_signature_key(api_family, endpoint_kind)
__all__ = [
"CODEX_DEFAULT_BODY_RULES",
"EndpointDefinition",
"ENDPOINT_DEFINITIONS",
"list_endpoint_definitions",
"get_endpoint_definition",
"resolve_endpoint_definition",
"get_default_path_for_endpoint",
"get_local_path_for_endpoint",
"get_auth_config_for_endpoint",
"get_extra_headers_for_endpoint",
"get_protected_keys_for_endpoint",
"get_data_format_id_for_endpoint",
"get_default_body_rules_for_endpoint",
"can_passthrough_endpoint",
"make_endpoint_signature",
]

View File

@@ -0,0 +1,563 @@
"""JSON Schema cleaning utilities shared across Gemini-compatible providers.
Google Gemini / Antigravity v1internal 的 function declaration API 对 JSON Schema
有严格限制。特别是当目标模型为 Claude 时Schema 必须严格符合 JSON Schema draft 2020-12。
本模块对齐 Antigravity-Manager common/json_schema.rs 的完整清洗逻辑:
1. $ref / $defs 展开Schema Flattening
2. allOf 合并
3. anyOf / oneOf 联合类型折叠(择优保留最复杂的分支)
4. 白名单字段过滤(只保留 Gemini 支持的字段)
5. 约束字段迁移到 description保留语义信息
6. 类型数组降级(["string", "null"] → "string"
7. 类型大小写归一化
8. 隐式类型注入
9. required 字段对齐
"""
from __future__ import annotations
import copy
from typing import Any
# Gemini 白名单:只有这些字段在 Schema 节点中允许存在
_ALLOWED_SCHEMA_FIELDS: frozenset[str] = frozenset(
{
"type",
"description",
"properties",
"required",
"items",
"enum",
"title",
}
)
# 约束字段:删除前将语义信息迁移到 description
_CONSTRAINT_FIELDS: tuple[tuple[str, str], ...] = (
("minLength", "minLen"),
("maxLength", "maxLen"),
("pattern", "pattern"),
("minimum", "min"),
("maximum", "max"),
("multipleOf", "multipleOf"),
("exclusiveMinimum", "exclMin"),
("exclusiveMaximum", "exclMax"),
("minItems", "minItems"),
("maxItems", "maxItems"),
("format", "format"),
)
# Legacy: 向后兼容的简单禁止列表(不再使用,保留用于其他调用者)
GEMINI_FORBIDDEN_SCHEMA_FIELDS: frozenset[str] = frozenset(
{
"$schema",
"additionalProperties",
"const",
"contentEncoding",
"contentMediaType",
"default",
"exclusiveMaximum",
"exclusiveMinimum",
"multipleOf",
"patternProperties",
"propertyNames",
}
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def clean_gemini_schema(schema: dict[str, Any]) -> None:
"""Recursively clean a JSON Schema for Gemini / Antigravity v1internal.
对齐 AM common/json_schema.rs clean_json_schema
1. 收集并展开 $ref / $defs / definitions
2. 递归白名单清洗
"""
if not isinstance(schema, dict):
return
# Phase 1: 收集所有 $defs递归所有层级
all_defs: dict[str, Any] = {}
_collect_all_defs(schema, all_defs)
# 移除根层级的 $defs / definitions
schema.pop("$defs", None)
schema.pop("definitions", None)
# Phase 2: 展开 $ref递归替换为实际定义
_flatten_refs(schema, all_defs)
# Phase 3: 递归清洗
_clean_recursive(schema, is_schema_node=True)
def clone_schema_with_openai_object_fixes(schema: dict[str, Any]) -> dict[str, Any]:
"""Clone a schema and add missing properties for object nodes.
OpenAI function tools reject object-typed parameter schemas when the object
node does not declare a ``properties`` object. Keep the schema otherwise
unchanged and only backfill empty ``properties`` where needed.
"""
cloned = copy.deepcopy(schema)
_ensure_object_properties_recursive(cloned)
return cloned
def clone_openai_tool_with_fixed_parameters(tool: dict[str, Any]) -> dict[str, Any]:
"""Clone an OpenAI Chat/Responses tool and repair function parameter schemas."""
cloned = copy.deepcopy(tool)
function = cloned.get("function")
if isinstance(function, dict):
params = function.get("parameters")
if isinstance(params, dict):
_ensure_object_properties_recursive(params)
params = cloned.get("parameters")
if isinstance(params, dict):
_ensure_object_properties_recursive(params)
return cloned
# ---------------------------------------------------------------------------
# Phase 1: $defs 收集
# ---------------------------------------------------------------------------
def _collect_all_defs(value: Any, defs: dict[str, Any]) -> None:
"""递归收集所有层级的 $defs 和 definitions。
对齐 AM #952MCP 工具可能在任意嵌套层级定义 $defs。
"""
if isinstance(value, dict):
for defs_key in ("$defs", "definitions"):
d = value.get(defs_key)
if isinstance(d, dict):
for k, v in d.items():
if k not in defs:
defs[k] = v
for key, v in value.items():
if key not in ("$defs", "definitions"):
_collect_all_defs(v, defs)
elif isinstance(value, list):
for item in value:
_collect_all_defs(item, defs)
# ---------------------------------------------------------------------------
# Phase 2: $ref 展开
# ---------------------------------------------------------------------------
def _flatten_refs(obj: dict[str, Any], defs: dict[str, Any], _seen: set[str] | None = None) -> None:
"""递归展开 $ref用定义内容替换引用。
对齐 AM flatten_refs
- 从 $ref 路径中提取名称 (e.g. #/$defs/MyType → MyType)
- 合并定义内容到当前节点
- 无法解析的 $ref 降级为 type: string
- 使用 _seen 防止循环 $ref 导致无限递归
"""
if _seen is None:
_seen = set()
ref_path = obj.pop("$ref", None)
if isinstance(ref_path, str):
ref_name = ref_path.rsplit("/", 1)[-1]
if ref_name in _seen:
# 循环引用:降级为 string 类型,避免无限递归
obj.setdefault("type", "string")
_append_hint(obj, f"(Circular $ref: {ref_path})")
else:
_seen.add(ref_name)
def_schema = defs.get(ref_name)
if isinstance(def_schema, dict):
for k, v in def_schema.items():
if k not in obj:
# 深拷贝避免共享引用导致后续修改污染
obj[k] = copy.deepcopy(v)
# 递归处理合并后的完整节点(包含所有子节点)
_flatten_refs(obj, defs, _seen)
else:
# 无法解析:降级为 string 类型
obj.setdefault("type", "string")
hint = f"(Unresolved $ref: {ref_path})"
desc = obj.get("description", "")
if not isinstance(desc, str):
desc = ""
if hint not in desc:
obj["description"] = f"{desc} {hint}".strip()
# 回溯:允许同一 $def 在兄弟节点中再次被引用(菱形引用不是循环)
_seen.discard(ref_name)
# $ref 展开后递归调用已处理所有子节点,无需再遍历
return
# 仅对非 $ref 节点遍历子节点
for v in obj.values():
if isinstance(v, dict):
_flatten_refs(v, defs, _seen)
elif isinstance(v, list):
for item in v:
if isinstance(item, dict):
_flatten_refs(item, defs, _seen)
# ---------------------------------------------------------------------------
# Phase 3: 递归清洗
# ---------------------------------------------------------------------------
def _clean_recursive(value: Any, *, is_schema_node: bool) -> bool:
"""递归清洗 Schema 节点,返回 is_effectively_nullable。
对齐 AM clean_json_schema_recursive 的完整逻辑。
"""
if not isinstance(value, dict):
if isinstance(value, list):
for item in value:
_clean_recursive(item, is_schema_node=is_schema_node)
return False
is_nullable = False
# 0. allOf 合并
_merge_all_of(value)
# 0.5 结构归一化type=object 但有 items → 移到 properties
if (value.get("type") == "object" or "properties" in value) and "items" in value:
items = value.pop("items")
if isinstance(items, dict):
props = value.setdefault("properties", {})
if isinstance(props, dict):
props.update({k: v for k, v in items.items() if k not in props})
# 1. 递归处理 properties
props = value.get("properties")
if isinstance(props, dict):
nullable_keys: set[str] = set()
for k, v in props.items():
if isinstance(v, dict):
if _clean_recursive(v, is_schema_node=True):
nullable_keys.add(k)
# 从 required 中移除 nullable 的键
if nullable_keys:
req = value.get("required")
if isinstance(req, list):
req[:] = [r for r in req if not (isinstance(r, str) and r in nullable_keys)]
if not req:
value.pop("required", None)
# 隐式类型注入
if "type" not in value:
value["type"] = "object"
# 处理 items
items = value.get("items")
if isinstance(items, dict):
_clean_recursive(items, is_schema_node=True)
if "type" not in value:
value["type"] = "array"
# Fallback: 对既没 properties 也没 items 的对象递归处理
if "properties" not in value and "items" not in value:
skip_keys = {"anyOf", "oneOf", "allOf", "enum", "type"}
for k, v in value.items():
if k not in skip_keys and isinstance(v, (dict, list)):
_clean_recursive(v, is_schema_node=False)
# 1.5 递归清洗 anyOf / oneOf 分支
for combo_key in ("anyOf", "oneOf"):
combo = value.get(combo_key)
if isinstance(combo, list):
for branch in combo:
if isinstance(branch, dict):
_clean_recursive(branch, is_schema_node=True)
# 2. anyOf / oneOf 折叠:选取最佳分支合并到当前节点
union_to_merge = None
if value.get("type") is None or value.get("type") == "object":
for combo_key in ("anyOf", "oneOf"):
combo = value.get(combo_key)
if isinstance(combo, list):
union_to_merge = combo
break
if union_to_merge is not None:
best, all_types = _extract_best_branch(union_to_merge)
if best is not None and isinstance(best, dict):
for k, v in best.items():
if k == "properties":
target = value.setdefault("properties", {})
if isinstance(target, dict) and isinstance(v, dict):
for pk, pv in v.items():
if pk not in target:
target[pk] = pv
elif k == "required":
target_req = value.setdefault("required", [])
if isinstance(target_req, list) and isinstance(v, list):
for rv in v:
if rv not in target_req:
target_req.append(rv)
elif k not in value:
value[k] = v
# 添加类型提示
if len(all_types) > 1:
_append_hint(value, f"Accepts: {' | '.join(all_types)}")
# 移除 anyOf / oneOf已合并
value.pop("anyOf", None)
value.pop("oneOf", None)
# 3. 判断是否为 Schema 节点
is_not_schema_payload = "functionCall" in value or "functionResponse" in value
has_standard = any(k in value for k in _ALLOWED_SCHEMA_FIELDS)
# 3.5 启发式修复Schema 节点但没有标准关键字 → 把所有 key 移到 properties
if is_schema_node and not has_standard and value and not is_not_schema_payload:
all_keys = list(value.keys())
new_props: dict[str, Any] = {}
for k in all_keys:
new_props[k] = value.pop(k)
value["type"] = "object"
value["properties"] = new_props
# 递归清洗刚移入的属性
for v in new_props.values():
if isinstance(v, dict):
_clean_recursive(v, is_schema_node=True)
has_standard = True
looks_like_schema = (is_schema_node or has_standard) and not is_not_schema_payload
if looks_like_schema:
# 4. 约束迁移到 description
_move_constraints_to_description(value)
# 5. 白名单过滤
keys_to_remove = [k for k in value if k not in _ALLOWED_SCHEMA_FIELDS]
for k in keys_to_remove:
del value[k]
# 6. 空 Object 处理
if value.get("type") == "object" and "properties" not in value:
value["properties"] = {}
# 7. required 字段对齐
valid_keys = None
p = value.get("properties")
if isinstance(p, dict):
valid_keys = set(p.keys())
req = value.get("required")
if isinstance(req, list):
if valid_keys is not None:
req[:] = [r for r in req if isinstance(r, str) and r in valid_keys]
else:
req.clear()
# 隐式类型注入(如果白名单过滤后丢失了 type
if "type" not in value:
if "enum" in value:
value["type"] = "string"
elif "properties" in value:
value["type"] = "object"
elif "items" in value:
value["type"] = "array"
# 8. 类型处理:数组 → 单一类型 + 大小写归一化
fallback_type = (
"object" if "properties" in value else "array" if "items" in value else "string"
)
type_val = value.get("type")
if type_val is not None:
selected: str | None = None
if isinstance(type_val, str):
lower = type_val.lower()
if lower == "null":
is_nullable = True
else:
selected = lower
elif isinstance(type_val, list):
for item in type_val:
if isinstance(item, str):
lower = item.lower()
if lower == "null":
is_nullable = True
elif selected is None:
selected = lower
value["type"] = selected if selected else fallback_type
if is_nullable:
_append_hint(value, "(nullable)")
# 9. enum 值强制转字符串
enum_val = value.get("enum")
if isinstance(enum_val, list):
for i, item in enumerate(enum_val):
if not isinstance(item, str):
enum_val[i] = "null" if item is None else str(item)
return is_nullable
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _merge_all_of(obj: dict[str, Any]) -> None:
"""合并 allOf 数组中的所有子 Schema。对齐 AM merge_all_of。"""
all_of = obj.pop("allOf", None)
if not isinstance(all_of, list):
return
merged_props: dict[str, Any] = {}
merged_required: list[str] = []
merged_required_seen: set[str] = set()
other_fields: dict[str, Any] = {}
for sub in all_of:
if not isinstance(sub, dict):
continue
# 合并 properties
p = sub.get("properties")
if isinstance(p, dict):
merged_props.update(p)
# 合并 required
r = sub.get("required")
if isinstance(r, list):
for item in r:
if isinstance(item, str) and item not in merged_required_seen:
merged_required_seen.add(item)
merged_required.append(item)
# 合并其余字段
for k, v in sub.items():
if k not in ("properties", "required", "allOf") and k not in other_fields:
other_fields[k] = v
for k, v in other_fields.items():
if k not in obj:
obj[k] = v
if merged_props:
target = obj.setdefault("properties", {})
if isinstance(target, dict):
for k, v in merged_props.items():
if k not in target:
target[k] = v
if merged_required:
target_req = obj.setdefault("required", [])
if isinstance(target_req, list):
existing = {r for r in target_req if isinstance(r, str)}
for r in merged_required:
if r not in existing:
target_req.append(r)
def _score_branch(val: Any) -> int:
"""对 Schema 分支打分Object(3) > Array(2) > Scalar(1) > Null(0)。"""
if not isinstance(val, dict):
return 0
if "properties" in val or val.get("type") == "object":
return 3
if "items" in val or val.get("type") == "array":
return 2
t = val.get("type")
if isinstance(t, str) and t != "null":
return 1
return 0
def _get_type_name(val: Any) -> str | None:
"""获取 Schema 的类型名称。"""
if not isinstance(val, dict):
return None
t = val.get("type")
if isinstance(t, str):
return t
if "properties" in val:
return "object"
if "items" in val:
return "array"
return None
def _extract_best_branch(
union: list[Any],
) -> tuple[dict[str, Any] | None, list[str]]:
"""从 anyOf/oneOf 中选取最佳非 null 分支。返回 (best, all_types)。"""
best: dict[str, Any] | None = None
best_score = -1
all_types: list[str] = []
for item in union:
score = _score_branch(item)
tn = _get_type_name(item)
if tn and tn not in all_types:
all_types.append(tn)
if score > best_score:
best_score = score
if isinstance(item, dict):
best = item
return best, all_types
def _move_constraints_to_description(obj: dict[str, Any]) -> None:
"""将约束字段迁移到 description。对齐 AM move_constraints_to_description。"""
hints: list[str] = []
for field, label in _CONSTRAINT_FIELDS:
val = obj.get(field)
if val is not None:
hints.append(f"{label}: {val}")
if hints:
_append_hint(obj, f"[Constraint: {', '.join(hints)}]")
def _append_hint(obj: dict[str, Any], hint: str) -> None:
"""追加提示到 description 字段。"""
desc = obj.get("description", "")
if not isinstance(desc, str):
desc = ""
if hint not in desc:
obj["description"] = f"{desc} {hint}".strip() if desc else hint
def _schema_type_includes_object(type_value: Any) -> bool:
if isinstance(type_value, str):
return type_value.lower() == "object"
if isinstance(type_value, list):
return any(isinstance(item, str) and item.lower() == "object" for item in type_value)
return False
def _ensure_object_properties_recursive(value: Any) -> None:
if isinstance(value, dict):
if _schema_type_includes_object(value.get("type")) and not isinstance(
value.get("properties"), dict
):
value["properties"] = {}
for item in value.values():
_ensure_object_properties_recursive(item)
return
if isinstance(value, list):
for item in value:
_ensure_object_properties_recursive(item)
__all__ = [
"GEMINI_FORBIDDEN_SCHEMA_FIELDS",
"clean_gemini_schema",
"clone_openai_tool_with_fixed_parameters",
"clone_schema_with_openai_object_fixes",
]

View File

@@ -0,0 +1,84 @@
"""
Endpoint signature utilities.
新模式下,系统以 (ApiFamily, EndpointKind) 作为结构化标识;
在需要用 string 做 keyJSON dict / metrics label / logs统一使用 `family:kind` 的 signature key。
"""
from __future__ import annotations
from dataclasses import dataclass
from src.core.api_format.enums import ApiFamily, EndpointKind
@dataclass(frozen=True, slots=True)
class EndpointSignature:
api_family: ApiFamily
endpoint_kind: EndpointKind
@property
def key(self) -> str:
return make_signature_key(self.api_family, self.endpoint_kind)
def make_signature_key(api_family: ApiFamily | str, endpoint_kind: EndpointKind | str) -> str:
fam = api_family.value if isinstance(api_family, ApiFamily) else str(api_family).strip().lower()
kind = (
endpoint_kind.value
if isinstance(endpoint_kind, EndpointKind)
else str(endpoint_kind).strip().lower()
)
return f"{fam}:{kind}"
def parse_signature_key(value: str) -> EndpointSignature:
"""
Parse a signature key into structured enums.
Canonical form: `<api_family>:<endpoint_kind>`, both lowercase.
"""
raw = str(value).strip()
if not raw or ":" not in raw:
raise ValueError(f"Invalid endpoint signature: {value!r}")
fam_raw, kind_raw = raw.split(":", 1)
fam = ApiFamily(fam_raw.strip().lower())
kind = EndpointKind(kind_raw.strip().lower())
return EndpointSignature(api_family=fam, endpoint_kind=kind)
def normalize_signature_key(value: str) -> str:
"""Normalize signature key (case/whitespace) to canonical lowercase `family:kind`."""
sig = parse_signature_key(value)
return sig.key
def normalize_endpoint_signature(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind] | None,
*,
default: EndpointSignature | None = None,
) -> EndpointSignature | None:
if value is None:
return default
if isinstance(value, EndpointSignature):
return value
if isinstance(value, tuple) and len(value) == 2:
fam, kind = value
if isinstance(fam, ApiFamily) and isinstance(kind, EndpointKind):
return EndpointSignature(api_family=fam, endpoint_kind=kind)
return default
if isinstance(value, str):
try:
return parse_signature_key(value)
except Exception:
return default
return default
__all__ = [
"EndpointSignature",
"make_signature_key",
"parse_signature_key",
"normalize_signature_key",
"normalize_endpoint_signature",
]

View File

@@ -0,0 +1,113 @@
"""
API 格式工具函数
提供格式判断、规范化等工具函数,供整个项目使用。
"""
from __future__ import annotations
def is_cli_format(format_id: str | None) -> bool:
"""
判断是否为 CLI 透传格式
新模式下使用 endpoint signature`family:kind`CLI 的 kind 为 `cli`。
Args:
format_id: endpoint signature key"openai:cli"
Returns:
True 如果是 CLI 格式
Examples:
>>> is_cli_format("claude:cli")
True
>>> is_cli_format("claude:chat")
False
"""
if format_id is None:
return False
text = str(format_id).strip()
return text.lower().endswith(":cli")
def get_base_format(format_id: str | None) -> str | None:
"""
获取基础格式CLI -> CHAT
Args:
format_id: 格式标识符
Returns:
基础格式字符串,或 None
Examples:
>>> get_base_format("claude:cli")
"claude:chat"
>>> get_base_format("openai:chat")
"openai:chat"
"""
if format_id is None:
return None
text = str(format_id).strip()
if not text:
return None
from src.core.api_format.enums import EndpointKind
from src.core.api_format.signature import make_signature_key, parse_signature_key
try:
sig = parse_signature_key(text)
except Exception:
return None
if sig.endpoint_kind == EndpointKind.CLI:
return make_signature_key(sig.api_family, EndpointKind.CHAT)
return make_signature_key(sig.api_family, sig.endpoint_kind)
def normalize_format(format_id: str | None) -> str | None:
"""
规范化 endpoint signature keycanonical: 全小写 `family:kind`)。
Args:
format_id: endpoint signature key
Returns:
canonical signature key或 None
"""
if format_id is None:
return None
text = str(format_id).strip()
if not text:
return None
from src.core.api_format.signature import normalize_signature_key
try:
return normalize_signature_key(text)
except Exception:
return None
def is_same_format(
format1: str | None,
format2: str | None,
) -> bool:
"""
判断两个格式是否相同
忽略大小写和枚举/字符串差异。
"""
return normalize_format(format1) == normalize_format(format2)
def is_convertible_format(format_id: str | None) -> bool:
"""
判断是否为可转换格式
.. deprecated::
此函数语义已退化(对非 None 输入总返回 True
真正的可转换性应通过 format_conversion_registry.can_convert_*() 查询。
"""
if format_id is None:
return False
return True

View File

@@ -0,0 +1,130 @@
"""
批量提交器 - 减少数据库 commit 次数,提升并发能力
核心思想:
- 非关键数据(监控、统计)不立即 commit
- 在后台定期批量 commit
- 关键数据(计费)仍然立即 commit
"""
import asyncio
from typing import Any
from sqlalchemy.orm import Session
from src.core.logger import logger
class BatchCommitter:
"""批量提交管理器"""
def __init__(self, interval_seconds: float = 1.0):
"""
Args:
interval_seconds: 批量提交间隔(秒)
"""
self.interval_seconds = interval_seconds
self._pending_sessions: set[Session] = set()
self._lock = asyncio.Lock()
self._task = None
async def start(self) -> Any:
"""启动后台批量提交任务"""
if self._task is None:
self._task = asyncio.create_task(self._batch_commit_loop())
logger.info("批量提交器已启动,间隔: {}s", self.interval_seconds)
async def stop(self) -> Any:
"""停止后台任务"""
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("批量提交器已停止")
def mark_dirty(self, session: Session) -> Any:
"""标记 Session 有待提交的更改"""
# 请求级事务由中间件统一 commit/rollback避免后台任务在请求中途误提交。
if session is None:
return
if session.info.get("managed_by_middleware"):
return
self._pending_sessions.add(session)
async def _batch_commit_loop(self) -> None:
"""后台批量提交循环"""
while True:
try:
await asyncio.sleep(self.interval_seconds)
await self._commit_all()
except asyncio.CancelledError:
# 关闭前提交所有待处理的
await self._commit_all()
raise
except Exception as e:
logger.error("批量提交出错: {}", e)
async def _commit_all(self) -> None:
"""提交所有待处理的 Session"""
async with self._lock:
if not self._pending_sessions:
return
sessions_to_commit = list(self._pending_sessions)
self._pending_sessions.clear()
committed = 0
failed = 0
def _sync_commit_all() -> tuple[int, int]:
ok = 0
err = 0
for session in sessions_to_commit:
try:
session.commit()
ok += 1
except Exception as e:
logger.error("提交 Session 失败: {}", e)
try:
session.rollback()
except:
pass
err += 1
return ok, err
try:
committed, failed = await asyncio.to_thread(_sync_commit_all)
except Exception as e:
logger.error("批量提交线程异常: {}", e)
if committed > 0:
logger.debug("批量提交完成: {} 个 Session", committed)
if failed > 0:
logger.warning("批量提交失败: {} 个 Session", failed)
# 全局单例
_batch_committer: BatchCommitter = None
def get_batch_committer() -> BatchCommitter:
"""获取全局批量提交器"""
global _batch_committer
if _batch_committer is None:
_batch_committer = BatchCommitter(interval_seconds=1.0)
return _batch_committer
async def init_batch_committer() -> None:
"""初始化并启动批量提交器"""
committer = get_batch_committer()
await committer.start()
async def shutdown_batch_committer() -> None:
"""关闭批量提交器"""
committer = get_batch_committer()
await committer.stop()

View File

@@ -0,0 +1,250 @@
"""
缓存服务 - 统一的缓存抽象层
"""
from __future__ import annotations
import json
from typing import Any
from src.clients.redis_client import get_redis_client
from src.core.logger import logger
class CacheService:
"""缓存服务"""
@staticmethod
async def get(key: str) -> Any | None:
"""
从缓存获取数据
Args:
key: 缓存键
Returns:
缓存的值,如果不存在则返回 None
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return None
value = await redis.get(key)
if value:
# 尝试 JSON 反序列化
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
# 非 JSON 值:统一返回字符串,避免上层出现 bytes/str 混用
if isinstance(value, (bytes, bytearray)):
try:
return value.decode("utf-8")
except Exception:
return value
return value
return None
except Exception as e:
logger.warning(f"缓存读取失败: {key} - {e}")
return None
@staticmethod
async def set(key: str, value: Any, ttl_seconds: int = 60) -> bool:
"""
设置缓存
Args:
key: 缓存键
value: 缓存值
ttl_seconds: 过期时间(秒),默认 60 秒
Returns:
是否设置成功
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return False
# JSON 序列化
if isinstance(value, (dict, list)):
value = json.dumps(value)
elif not isinstance(value, (str, bytes)):
value = str(value)
await redis.setex(key, ttl_seconds, value)
return True
except Exception as e:
logger.warning(f"缓存写入失败: {key} - {e}")
return False
@staticmethod
async def delete(key: str) -> bool:
"""
删除缓存
Args:
key: 缓存键
Returns:
是否删除成功
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return False
await redis.delete(key)
return True
except Exception as e:
logger.warning(f"缓存删除失败: {key} - {e}")
return False
@staticmethod
async def delete_pattern(pattern: str, batch_size: int = 100) -> int:
"""
删除匹配模式的所有缓存
使用 SCAN 遍历并分批删除,避免阻塞 Redis
Args:
pattern: 缓存键模式(支持 * 通配符)
batch_size: 每批删除的最大键数量
Returns:
删除的键数量
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return 0
# 使用 SCAN 遍历匹配的键
deleted_count = 0
cursor: int = 0
while True:
cursor, keys = await redis.scan(cursor, match=pattern, count=batch_size)
if keys:
# 分批删除,避免单次删除过多键导致 Redis 阻塞
for i in range(0, len(keys), batch_size):
batch = keys[i : i + batch_size]
await redis.delete(*batch)
deleted_count += len(batch)
# cursor 可能是 int 或 str取决于 decode_responses 配置),统一转为 int 比较
if int(cursor) == 0:
break
if deleted_count > 0:
logger.debug(f"缓存模式删除成功: {pattern}, 删除 {deleted_count} 个键")
return deleted_count
except Exception as e:
logger.warning(f"缓存模式删除失败: {pattern} - {e}")
return 0
@staticmethod
async def exists(key: str) -> bool:
"""
检查缓存是否存在
Args:
key: 缓存键
Returns:
是否存在
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return False
return await redis.exists(key) > 0
except Exception as e:
logger.warning(f"缓存检查失败: {key} - {e}")
return False
@staticmethod
async def incr(key: str, ttl_seconds: int | None = None) -> int:
"""
递增缓存值
Args:
key: 缓存键
ttl_seconds: 可选,如果提供则刷新 TTL
Returns:
递增后的值,如果失败返回 0
"""
try:
redis = await get_redis_client(require_redis=False)
if not redis:
return 0
result = await redis.incr(key)
# 如果提供了 TTL刷新过期时间
if ttl_seconds is not None:
await redis.expire(key, ttl_seconds)
return result
except Exception as e:
logger.warning(f"缓存递增失败: {key} - {e}")
return 0
# 缓存键前缀
class CacheKeys:
"""缓存键定义"""
# User 缓存TTL 60秒
USER_BY_ID = "user:id:{user_id}"
USER_BY_EMAIL = "user:email:{email}"
# API Key 缓存TTL 30秒
APIKEY_HASH = "apikey:hash:{key_hash}"
APIKEY_AUTH = "apikey:auth:{key_hash}" # 认证结果缓存
# Provider 配置缓存TTL 300秒
PROVIDER_BY_ID = "provider:id:{provider_id}"
ENDPOINT_BY_ID = "endpoint:id:{endpoint_id}"
API_KEY_BY_ID = "api_key:id:{api_key_id}"
@staticmethod
def user_by_id(user_id: str) -> str:
"""User ID 缓存键"""
return CacheKeys.USER_BY_ID.format(user_id=user_id)
@staticmethod
def user_by_email(email: str) -> str:
"""User Email 缓存键"""
return CacheKeys.USER_BY_EMAIL.format(email=email)
@staticmethod
def apikey_hash(key_hash: str) -> str:
"""API Key Hash 缓存键"""
return CacheKeys.APIKEY_HASH.format(key_hash=key_hash)
@staticmethod
def apikey_auth(key_hash: str) -> str:
"""API Key 认证结果缓存键"""
return CacheKeys.APIKEY_AUTH.format(key_hash=key_hash)
@staticmethod
def provider_by_id(provider_id: str) -> str:
"""Provider ID 缓存键"""
return CacheKeys.PROVIDER_BY_ID.format(provider_id=provider_id)
@staticmethod
def endpoint_by_id(endpoint_id: str) -> str:
"""Endpoint ID 缓存键"""
return CacheKeys.ENDPOINT_BY_ID.format(endpoint_id=endpoint_id)
@staticmethod
def api_key_by_id(api_key_id: str) -> str:
"""API Key ID 缓存键"""
return CacheKeys.API_KEY_BY_ID.format(api_key_id=api_key_id)

View File

@@ -0,0 +1,133 @@
"""
缓存工具类
提供同步缓存接口,用于不适合使用异步缓存的场景
"""
import threading
import time
from collections import OrderedDict
from typing import Any
class SyncLRUCache:
"""
同步 LRU 缓存(带 TTL 和线程安全)
用于需要同步访问的场景,如 ModelMapperMiddleware
"""
def __init__(self, max_size: int = 1000, ttl: int = 300) -> None:
"""
初始化缓存
Args:
max_size: 最大缓存条目数
ttl: 过期时间(秒)
"""
self._cache: OrderedDict = OrderedDict()
self._expiry: dict[Any, float] = {}
self.max_size = max_size
self.ttl = ttl
self._lock = threading.RLock()
def _is_expired(self, key: Any) -> bool:
"""检查 key 是否过期(调用者需确保已持有锁)"""
if key in self._expiry:
return time.time() > self._expiry[key]
return False
def _delete_key(self, key: Any) -> None:
"""删除 key调用者需确保已持有锁"""
if key in self._cache:
del self._cache[key]
if key in self._expiry:
del self._expiry[key]
def get(self, key: Any, default: Any = None) -> Any:
"""获取缓存值"""
with self._lock:
if key not in self._cache:
return default
if self._is_expired(key):
self._delete_key(key)
return default
self._cache.move_to_end(key)
return self._cache[key]
def set(self, key: Any, value: Any, ttl: int | None = None) -> None:
"""设置缓存值"""
with self._lock:
if ttl is None:
ttl = self.ttl
if key in self._cache:
self._cache.move_to_end(key)
self._cache[key] = value
self._expiry[key] = time.time() + ttl
while len(self._cache) > self.max_size:
oldest = next(iter(self._cache))
self._delete_key(oldest)
def delete(self, key: Any) -> None:
"""删除缓存值"""
with self._lock:
self._delete_key(key)
def clear(self) -> None:
"""清空缓存"""
with self._lock:
self._cache.clear()
self._expiry.clear()
def __contains__(self, key: Any) -> bool:
"""检查 key 是否存在"""
with self._lock:
if key not in self._cache:
return False
if self._is_expired(key):
self._delete_key(key)
return False
return True
def __getitem__(self, key: Any) -> Any:
"""获取缓存值(通过索引)"""
with self._lock:
if key not in self._cache:
raise KeyError(key)
if self._is_expired(key):
self._delete_key(key)
raise KeyError(key)
self._cache.move_to_end(key)
return self._cache[key]
def __setitem__(self, key: Any, value: Any) -> None:
"""设置缓存值(通过索引)"""
self.set(key, value)
def __delitem__(self, key: Any) -> None:
"""删除缓存值(通过索引)"""
self.delete(key)
def keys(self) -> list:
"""返回所有未过期的 key"""
with self._lock:
now = time.time()
return [
k for k in self._cache.keys() if k not in self._expiry or now <= self._expiry[k]
]
def get_stats(self) -> dict[str, Any]:
"""获取缓存统计信息"""
with self._lock:
return {
"size": len(self._cache),
"max_size": self.max_size,
"ttl": self.ttl,
}

View File

@@ -0,0 +1,263 @@
"""
加密工具模块
提供API密钥的加密和解密功能
安全说明:
- 生产环境必须设置独立的 ENCRYPTION_KEY
- 加密密钥应独立于 JWT_SECRET_KEY避免密钥轮换问题
- 使用 PBKDF2 派生密钥时会使用应用级 salt
"""
from __future__ import annotations
import base64
import hashlib
import threading
import time
from collections import OrderedDict
from typing import TYPE_CHECKING, Any, cast
from src.core.logger import logger
from src.utils.perf import PerfRecorder
from ..config import config
from ..core.exceptions import DecryptionException
if TYPE_CHECKING:
from cryptography.fernet import Fernet
class CryptoService:
"""
加密服务
提供对称加密功能,用于保护 Provider API Key 等敏感数据。
使用 FernetAES-128-CBC + HMAC-SHA256确保数据机密性和完整性。
"""
_instance: CryptoService | None = None
_instance_lock = threading.Lock()
_cipher: Fernet | None = None
_key_source: str = "unknown" # 记录密钥来源,用于调试
# 应用级 salt基于应用名称生成比硬编码更安全
# 注意:更改此值会导致所有已加密数据无法解密
APP_SALT = hashlib.sha256(b"aether-v1").digest()[:16]
def __new__(cls) -> CryptoService:
if cls._instance is None:
with cls._instance_lock:
if cls._instance is None:
inst = super().__new__(cls)
inst._initialize()
cls._instance = inst
return cls._instance
def _initialize(self) -> None:
"""初始化加密服务"""
from cryptography.fernet import Fernet
logger.info("初始化加密服务")
encryption_key = config.encryption_key
if not encryption_key:
if config.environment == "production":
raise ValueError(
"ENCRYPTION_KEY must be set in production! "
"Use 'python generate_keys.py' to generate a secure key."
)
# 开发环境:使用固定的开发密钥
logger.warning("[DEV] 未设置 ENCRYPTION_KEY使用开发环境默认密钥。")
encryption_key = "dev-encryption-key-do-not-use-in-production"
self._key_source = "development_default"
else:
self._key_source = "environment_variable"
# 派生 Fernet 密钥
key = self._derive_fernet_key(encryption_key)
self._cipher = Fernet(key)
logger.info(f"加密服务初始化成功 (key_source={self._key_source})")
# 解密缓存配置(使用实例变量,避免测试场景下缓存跨实例持久化)
self._decrypt_cache: OrderedDict[str, tuple[str, float]] = OrderedDict()
self._decrypt_cache_lock = threading.Lock()
self._decrypt_cache_enabled = bool(getattr(config, "crypto_decrypt_cache_enabled", False))
self._decrypt_cache_size = int(getattr(config, "crypto_decrypt_cache_size", 0) or 0)
self._decrypt_cache_ttl_seconds = float(
getattr(config, "crypto_decrypt_cache_ttl_seconds", 0.0) or 0.0
)
if self._decrypt_cache_enabled and self._decrypt_cache_size > 0:
logger.info(
"解密缓存已启用 (size={}, ttl={}s)",
self._decrypt_cache_size,
self._decrypt_cache_ttl_seconds,
)
def _derive_fernet_key(self, encryption_key: str) -> bytes:
"""
从密码/密钥派生 Fernet 兼容的密钥
Args:
encryption_key: 原始密钥字符串
Returns:
Fernet 兼容的 base64 编码密钥
"""
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# 首先尝试直接作为 Fernet 密钥使用
try:
key_bytes = (
encryption_key.encode() if isinstance(encryption_key, str) else encryption_key
)
# 验证是否为有效的 Fernet 密钥32 字节 base64 编码)
Fernet(key_bytes)
return key_bytes
except Exception:
pass
# 不是有效的 Fernet 密钥,使用 PBKDF2 派生
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=self.APP_SALT,
iterations=100000,
)
derived_key = kdf.derive(encryption_key.encode())
return base64.urlsafe_b64encode(derived_key)
def encrypt(self, plaintext: str) -> str:
"""
加密字符串
Args:
plaintext: 明文字符串
Returns:
加密后的字符串base64编码
"""
if not plaintext:
return plaintext
try:
encrypted = self._cipher.encrypt(plaintext.encode())
return base64.urlsafe_b64encode(encrypted).decode()
except Exception as e:
logger.error(f"Encryption failed: {e}")
raise ValueError("Failed to encrypt data")
def decrypt(self, ciphertext: str, silent: bool = False) -> str:
"""
解密字符串
Args:
ciphertext: 加密的字符串base64编码
silent: 是否静默模式(失败时不打印错误日志)
Returns:
解密后的明文字符串
Raises:
DecryptionException: 解密失败时抛出异常
"""
if not ciphertext:
return ciphertext
cached = self._get_cached_decrypt(ciphertext)
if cached is not None:
PerfRecorder.record_counter("crypto_decrypt_cache_hits_total", 1)
return cached
PerfRecorder.record_counter("crypto_decrypt_cache_misses_total", 1)
start = PerfRecorder.start()
try:
encrypted = base64.urlsafe_b64decode(ciphertext.encode())
decrypted = self._cipher.decrypt(encrypted)
plaintext = decrypted.decode()
self._set_cached_decrypt(ciphertext, plaintext)
PerfRecorder.stop(start, "crypto_decrypt")
return plaintext
except Exception as e:
PerfRecorder.stop(start, "crypto_decrypt")
if not silent:
logger.error(f"Decryption failed: {e}")
# 抛出自定义异常,方便在上层通过类型判断是否需要打印堆栈
raise DecryptionException(
message=f"解密失败: {str(e)}。可能原因: ENCRYPTION_KEY 已改变或数据已损坏。解决方案: 请在管理面板重新设置 Provider API Key。",
details={"original_error": str(e), "key_source": self._key_source},
)
def hash_api_key(self, api_key: str) -> str:
"""
对API密钥进行哈希用于查找
Args:
api_key: API密钥明文
Returns:
哈希后的值
"""
return hashlib.sha256(api_key.encode()).hexdigest()
def _cache_key(self, ciphertext: str) -> str:
"""生成缓存 key使用密文 hash避免内存中保留完整密文"""
return hashlib.sha256(ciphertext.encode()).hexdigest()[:32]
def _get_cached_decrypt(self, ciphertext: str) -> str | None:
if not self._decrypt_cache_enabled:
return None
if not ciphertext:
return None
if self._decrypt_cache_size <= 0:
return None
cache_key = self._cache_key(ciphertext)
with self._decrypt_cache_lock:
entry = self._decrypt_cache.get(cache_key)
if not entry:
return None
value, expires_at = entry
if expires_at <= time.time():
self._decrypt_cache.pop(cache_key, None)
return None
# 维护 LRU 顺序
self._decrypt_cache.move_to_end(cache_key)
return value
def _set_cached_decrypt(self, ciphertext: str, plaintext: str) -> None:
if not self._decrypt_cache_enabled:
return
if not ciphertext:
return
if self._decrypt_cache_size <= 0:
return
if self._decrypt_cache_ttl_seconds <= 0:
return
cache_key = self._cache_key(ciphertext)
expires_at = time.time() + self._decrypt_cache_ttl_seconds
with self._decrypt_cache_lock:
self._decrypt_cache[cache_key] = (plaintext, expires_at)
self._decrypt_cache.move_to_end(cache_key)
while len(self._decrypt_cache) > self._decrypt_cache_size:
self._decrypt_cache.popitem(last=False)
def get_crypto_service() -> CryptoService:
"""获取加密服务单例(首次使用时才会初始化)。"""
return CryptoService()
class _LazyCryptoServiceProxy:
"""延迟代理,避免 import 阶段触发 cryptography 重载。"""
def __getattr__(self, name: str) -> Any:
return getattr(get_crypto_service(), name)
if TYPE_CHECKING:
crypto_service = CryptoService()
else:
crypto_service = cast(CryptoService, _LazyCryptoServiceProxy())

View File

@@ -0,0 +1,47 @@
"""
统一的枚举定义
避免重复定义造成的不一致
注意APIFormat 架构已移除,统一使用 endpoint signaturefamily:kind
"""
from enum import Enum
class UserRole(Enum):
"""用户角色枚举"""
ADMIN = "admin"
USER = "user"
class ProviderBillingType(Enum):
"""提供商计费类型"""
MONTHLY_QUOTA = "monthly_quota" # 月卡额度
PAY_AS_YOU_GO = "pay_as_you_go" # 按量付费
FREE_TIER = "free_tier" # 免费额度
class AuthSource(str, Enum):
"""认证来源枚举"""
LOCAL = "local" # 本地认证
LDAP = "ldap" # LDAP 认证
OAUTH = "oauth" # OAuth 认证(账号首创来源)
class ErrorCategory(str, Enum):
"""错误分类枚举"""
RATE_LIMIT = "rate_limit"
AUTH = "auth"
INVALID_REQUEST = "invalid_request"
NOT_FOUND = "not_found"
CONTENT_FILTER = "content_filter"
CONTEXT_LENGTH = "context_length"
SERVER_ERROR = "server_error"
TIMEOUT = "timeout"
NETWORK = "network"
CANCELLED = "cancelled"
UNKNOWN = "unknown"

View File

@@ -0,0 +1,53 @@
"""
错误消息处理工具函数
"""
from __future__ import annotations
def extract_error_message(error: Exception, status_code: int | None = None) -> str:
"""
从异常中提取错误消息,优先使用上游原始响应(用于链路追踪/调试)
此函数用于 RequestCandidate 表的 error_message 字段,
用于请求链路追踪中显示原始 Provider 响应。
Args:
error: 异常对象
status_code: 可选的 HTTP 状态码,用于构建更详细的错误消息
Returns:
错误消息字符串(原始 Provider 响应)
"""
# 优先使用 upstream_response 属性(包含上游 Provider 的原始错误,用于调试)
upstream_response = getattr(error, "upstream_response", None)
if upstream_response and isinstance(upstream_response, str) and upstream_response.strip():
return str(upstream_response)
# 回退到异常的字符串表示str 可能为空,如 httpx 超时异常)
error_str = str(error) or repr(error)
if status_code is not None:
return f"HTTP {status_code}: {error_str}"
return error_str
def extract_client_error_message(error: Exception) -> str:
"""
从异常中提取客户端友好的错误消息(用于返回给客户端/Usage 记录)
此函数用于 Usage 表的 error_message 字段,
用于显示给最终用户的友好错误消息。
Args:
error: 异常对象
Returns:
友好的错误消息字符串
"""
# 优先使用 message 属性(已经是友好处理过的消息)
message = getattr(error, "message", None)
if message and isinstance(message, str) and message.strip():
return message
# 回退到异常的字符串表示
return str(error) or repr(error)

View File

@@ -0,0 +1,772 @@
"""
统一的异常处理和错误响应定义
安全说明:
- 生产环境不返回详细错误信息,避免信息泄露
- 使用错误 ID 关联日志,便于排查问题
- 开发环境可返回详细信息用于调试
"""
from __future__ import annotations
import asyncio
import re
import traceback
import uuid
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
from ..config import config
# Pydantic 错误消息中英文翻译映射
PYDANTIC_ERROR_TRANSLATIONS = {
# 字符串验证
r"String should have at least (\d+) characters?": r"字符串长度至少需要 \1 个字符",
r"String should have at most (\d+) characters?": r"字符串长度最多 \1 个字符",
r"string_too_short": "字符串长度不足",
r"string_too_long": "字符串长度超出限制",
# 必填字段
r"Field required": "此字段为必填项",
r"field required": "此字段为必填项",
r"missing": "缺少必填字段",
# 类型错误
r"Input should be a valid string": "输入应为有效的字符串",
r"Input should be a valid integer": "输入应为有效的整数",
r"Input should be a valid number": "输入应为有效的数字",
r"Input should be a valid boolean": "输入应为布尔值",
r"Input should be a valid email address": "输入应为有效的邮箱地址",
r"Input should be a valid list": "输入应为有效的列表",
r"Input should be a valid dictionary": "输入应为有效的字典",
# 数值验证
r"Input should be greater than (\d+)": r"数值应大于 \1",
r"Input should be greater than or equal to (\d+)": r"数值应大于或等于 \1",
r"Input should be less than (\d+)": r"数值应小于 \1",
r"Input should be less than or equal to (\d+)": r"数值应小于或等于 \1",
# 枚举验证
r"Input should be (.+)": r"输入应为 \1",
# 其他
r"value is not a valid email address": "邮箱地址格式无效",
r"invalid.*email": "邮箱地址格式无效",
r"Extra inputs are not permitted": "不允许额外的字段",
r"Value error, (.+)": r"\1", # 自定义验证器的错误直接使用
}
# 字段名中英文翻译映射
FIELD_NAME_TRANSLATIONS = {
"password": "密码",
"username": "用户名",
"email": "邮箱",
"role": "角色",
"initial_gift_usd": "初始赠款",
"name": "名称",
"title": "标题",
"content": "内容",
"ip_address": "IP地址",
"reason": "原因",
"ttl": "过期时间",
"enabled": "启用状态",
"fixed_limit": "固定限制",
"old_password": "旧密码",
"new_password": "新密码",
"allowed_providers": "允许的提供商",
"allowed_models": "允许的模型",
"rate_limit": "速率限制",
"expire_days": "过期天数",
"priority": "优先级",
"type": "类型",
"is_active": "激活状态",
"is_pinned": "置顶状态",
"start_time": "开始时间",
"end_time": "结束时间",
# OAuth 相关字段
"client_id": "Client ID",
"client_secret": "Client Secret",
"redirect_uri": "回调地址",
"frontend_callback_url": "前端回调地址",
"display_name": "显示名称",
"scopes": "授权范围",
}
def translate_pydantic_error(error: dict[str, Any]) -> str:
"""
将 Pydantic 验证错误翻译为中文
Args:
error: Pydantic 错误字典,包含 loc, msg, type 等字段
Returns:
翻译后的中文错误消息
"""
# 获取字段名
loc = error.get("loc", [])
field = str(loc[0]) if loc else ""
field_zh = FIELD_NAME_TRANSLATIONS.get(field, field)
# 获取错误消息
msg = error.get("msg", "验证失败")
# 尝试翻译错误消息
translated_msg = msg
for pattern, replacement in PYDANTIC_ERROR_TRANSLATIONS.items():
if re.search(pattern, msg, re.IGNORECASE):
translated_msg = re.sub(pattern, replacement, msg, flags=re.IGNORECASE)
break
# 组合字段名和错误消息
if field_zh:
return f"{field_zh}: {translated_msg}"
return translated_msg
def translate_pydantic_errors(errors: list[dict[str, Any]]) -> str:
"""
翻译多个 Pydantic 验证错误
Args:
errors: Pydantic 错误列表
Returns:
翻译后的错误消息,多个错误用分号分隔
"""
if not errors:
return "请求数据验证失败"
translated = [translate_pydantic_error(e) for e in errors]
return "; ".join(translated)
# 延迟导入韧性管理器,避免循环导入
def get_resilience_manager() -> Any:
try:
from ..core.resilience import resilience_manager
return resilience_manager
except ImportError:
return None
class ProxyException(HTTPException):
"""代理服务基础异常"""
def __init__(
self,
status_code: int,
error_type: str,
message: str,
details: dict[str, Any] | None = None,
):
self.error_type = error_type
self.message = message
self.details = details or {}
super().__init__(status_code=status_code, detail=message)
class ProviderException(ProxyException):
"""提供商相关异常"""
def __init__(
self,
message: str,
provider_name: str | None = None,
request_metadata: Any | None = None,
**kwargs: Any,
):
self.request_metadata = request_metadata # 保存元数据以便传递
details = {"provider": provider_name} if provider_name else {}
details.update(kwargs)
super().__init__(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
error_type="provider_error",
message=message,
details=details,
)
class ProviderNotAvailableException(ProviderException):
"""提供商不可用"""
def __init__(
self,
message: str,
provider_name: str | None = None,
request_metadata: Any | None = None,
upstream_status: int | None = None,
upstream_response: str | None = None,
):
super().__init__(
message=message,
provider_name=provider_name,
request_metadata=request_metadata,
)
self.upstream_status = upstream_status
self.upstream_response = upstream_response
class ProxyNodeUnavailableError(ProviderException):
"""代理节点不可用ProxyNode 离线/不存在/不健康)"""
def __init__(self, message: str, node_id: str | None = None):
super().__init__(
message=message,
provider_name=None,
proxy_node_id=node_id,
)
class ProviderTimeoutException(ProviderException):
"""提供商请求超时"""
def __init__(self, provider_name: str, timeout: int, request_metadata: Any | None = None):
super().__init__(
message=f"请求超时({timeout}秒)",
provider_name=provider_name,
request_metadata=request_metadata,
timeout=timeout,
)
class ProviderAuthException(ProviderException):
"""提供商认证失败"""
def __init__(self, provider_name: str, request_metadata: Any | None = None):
super().__init__(
message="上游服务认证失败",
provider_name=provider_name,
request_metadata=request_metadata,
)
class ProviderRateLimitException(ProviderException):
"""提供商限流"""
def __init__(
self,
message: str,
provider_name: str | None = None,
request_metadata: Any | None = None,
response_headers: dict[str, str] | None = None, # 添加响应头
retry_after: int | None = None, # 添加重试时间
):
self.response_headers = response_headers or {} # 保存响应头
self.retry_after = retry_after # 保存重试时间
super().__init__(
message=message, provider_name=provider_name, request_metadata=request_metadata
)
class BalanceInsufficientException(ProxyException):
"""余额或额度不足"""
def __init__(self, balance_type: str = "tokens", remaining: float | None = None, **kwargs: Any):
# 兼容旧调用方使用 quota_type= 关键字参数
balance_type = kwargs.get("quota_type", balance_type)
if balance_type.upper() == "USD":
message = "余额不足"
if remaining is not None:
message += f"(剩余: ${remaining:.2f}"
else:
message = f"{balance_type}额度已用尽"
if remaining is not None:
message += f"(剩余: {remaining}"
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
error_type="balance_exceeded",
message=message,
details={"balance_type": balance_type, "remaining": remaining},
)
class RateLimitException(ProxyException):
"""速率限制"""
def __init__(self, limit: int, window: str = "minute"):
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
error_type="rate_limit",
message=f"请求过于频繁,限制为每{window} {limit}",
details={"limit": limit, "window": window},
)
class ConcurrencyLimitError(ProxyException):
"""并发限制异常"""
def __init__(self, message: str, endpoint_id: str | None = None, key_id: str | None = None):
details = {}
if endpoint_id:
details["endpoint_id"] = endpoint_id
if key_id:
details["key_id"] = key_id
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
error_type="concurrency_limit",
message=message,
details=details,
)
class ModelNotSupportedException(ProxyException):
"""模型不支持"""
def __init__(self, model: str, provider_name: str | None = None):
# 客户端消息不暴露提供商信息
message = f"模型 '{model}' 不受支持"
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
error_type="model_not_supported",
message=message,
details={"model": model, "provider": provider_name},
)
class StreamingNotSupportedException(ProxyException):
"""流式请求不支持"""
def __init__(self, model: str, provider_name: str | None = None):
# 客户端消息不暴露提供商信息
message = f"模型 '{model}' 不支持流式请求"
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
error_type="streaming_not_supported",
message=message,
details={"model": model, "provider": provider_name},
)
class InvalidRequestException(ProxyException):
"""无效请求"""
def __init__(self, message: str, field: str | None = None):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
error_type="invalid_request",
message=message,
details={"field": field} if field else {},
)
class NotFoundException(ProxyException):
"""资源未找到"""
def __init__(self, message: str, resource_type: str | None = None):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
error_type="not_found",
message=message,
details={"resource_type": resource_type} if resource_type else {},
)
class ConfirmationRequiredException(ProxyException):
"""需要用户确认的操作"""
def __init__(self, message: str, affected_count: int, action: str = "disable"):
super().__init__(
status_code=status.HTTP_409_CONFLICT,
error_type="confirmation_required",
message=message,
details={"affected_count": affected_count, "action": action},
)
class ForbiddenException(ProxyException):
"""权限不足"""
def __init__(self, message: str, required_role: str | None = None):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
error_type="forbidden",
message=message,
details={"required_role": required_role} if required_role else {},
)
class DecryptionException(ProxyException):
"""解密失败异常 - 已知的配置问题,不需要打印堆栈"""
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
error_type="decryption_error",
message=message,
details=details or {},
)
class JSONParseException(ProviderException):
"""JSON解析错误"""
def __init__(
self,
provider_name: str,
original_error: str,
response_content: str | None = None,
content_type: str | None = None,
request_metadata: Any | None = None,
):
details = {
"original_error": original_error,
"content_type": content_type,
}
if response_content and len(response_content) > 500:
# 截断长内容,但保留头尾
details["response_preview"] = f"{response_content[:200]}...{response_content[-200:]}"
elif response_content:
details["response_content"] = response_content
super().__init__(
message="上游服务返回了无效的响应",
provider_name=provider_name,
request_metadata=request_metadata,
**details,
)
class EmptyStreamException(ProviderException):
"""流式响应为空异常 - 上游返回200但没有发送任何数据"""
def __init__(
self,
provider_name: str,
chunk_count: int = 0,
request_metadata: Any | None = None,
):
super().__init__(
message="上游服务返回了空的流式响应",
provider_name=provider_name,
request_metadata=request_metadata,
chunk_count=chunk_count,
)
class EmbeddedErrorException(ProviderException):
"""响应体内嵌套错误异常 - HTTP 状态码正常但响应体包含错误信息
用于处理某些 Provider如 Gemini返回 HTTP 200 但在响应体中包含错误的情况。
这类错误需要触发重试逻辑。
"""
def __init__(
self,
provider_name: str,
error_code: int | None = None,
error_message: str | None = None,
error_status: str | None = None,
request_metadata: Any | None = None,
):
# 客户端消息不暴露提供商信息
message = "上游服务返回了错误"
if error_code:
message += f" (code={error_code})"
super().__init__(
message=message,
provider_name=provider_name,
request_metadata=request_metadata,
error_code=error_code,
error_status=error_status,
)
self.error_code = error_code
self.error_message = error_message
self.error_status = error_status
class ProviderCompatibilityException(ProviderException):
"""Provider 兼容性错误异常 - 应该触发故障转移
用于处理因 Provider 不支持某些参数或功能导致的错误。
这类错误不是用户请求本身的问题,换一个 Provider 可能就能成功,应该触发故障转移。
常见场景:
- Unsupported parameter不支持的参数
- Unsupported model不支持的模型
- Unsupported feature不支持的功能
"""
def __init__(
self,
message: str,
provider_name: str | None = None,
status_code: int = 400,
upstream_error: str | None = None,
request_metadata: Any | None = None,
):
self.upstream_error = upstream_error
super().__init__(
message=message,
provider_name=provider_name,
request_metadata=request_metadata,
)
# 覆盖状态码为 400保持与上游一致
self.status_code = status_code
class UpstreamClientException(ProxyException):
"""上游返回的客户端错误异常 - HTTP 4xx 错误,不应该重试
用于处理上游 Provider 返回的客户端错误(如图片处理失败、无效请求等)。
这类错误是由用户请求本身的问题导致的,换 Provider 也无济于事,不应该重试。
常见场景:
- 图片处理失败(图片过大、格式不支持等)
- 请求参数无效
- 消息内容违规
"""
def __init__(
self,
message: str,
provider_name: str | None = None,
status_code: int = 400,
error_type: str | None = None,
upstream_error: str | None = None,
request_metadata: Any | None = None,
):
self.upstream_error = upstream_error
self.request_metadata = request_metadata
details = {}
if provider_name:
details["provider"] = provider_name
if error_type:
details["upstream_error_type"] = error_type
if upstream_error:
details["upstream_error"] = upstream_error
super().__init__(
status_code=status_code,
error_type="upstream_client_error",
message=message,
details=details,
)
class ThinkingSignatureException(UpstreamClientException):
"""Thinking 块签名验证失败异常"""
def __init__(
self,
message: str,
provider_name: str | None = None,
upstream_error: str | None = None,
request_metadata: Any | None = None,
):
super().__init__(
message=message,
provider_name=provider_name,
status_code=400,
error_type="thinking_signature_error",
upstream_error=upstream_error,
request_metadata=request_metadata,
)
class ErrorResponse:
"""统一的错误响应格式化器"""
@staticmethod
def create(
error_type: str,
message: str,
status_code: int = 500,
details: dict[str, Any] | None = None,
) -> JSONResponse:
"""创建标准错误响应"""
error_body = {"error": {"type": error_type, "message": message}}
if details:
error_body["error"]["details"] = details
# 记录错误日志
logger.error(f"Error response: {error_type} - {message}")
return JSONResponse(status_code=status_code, content=error_body)
@staticmethod
def from_exception(e: Exception) -> JSONResponse:
"""
从异常创建错误响应
安全说明:
- 生产环境只返回错误 ID不暴露详细信息
- 开发环境返回完整错误信息用于调试
- 所有错误都记录到日志,通过错误 ID 关联
"""
if isinstance(e, ProxyException):
details = e.details.copy() if e.details else {}
status_code = e.status_code
message = e.message # 使用友好的错误消息
# 如果是 ProviderNotAvailableException 且有上游错误信息
if isinstance(e, ProviderNotAvailableException):
if e.upstream_status:
status_code = e.upstream_status
# upstream_response 存入 details 供请求链路追踪使用,不作为客户端消息
if e.upstream_response:
details["upstream_response"] = e.upstream_response
return ErrorResponse.create(
error_type=e.error_type,
message=message,
status_code=status_code,
details=details if details else None,
)
elif isinstance(e, HTTPException):
return ErrorResponse.create(
error_type="http_error", message=str(e.detail), status_code=e.status_code
)
else:
# 未知异常,使用错误 ID 机制
error_id = str(uuid.uuid4())[:8] # 短 ID便于用户报告
error_type_name = type(e).__name__
error_message = str(e)
# 始终记录完整错误到日志
logger.error(f"[{error_id}] Unexpected error: {error_type_name}: {error_message}")
# 根据环境决定返回的详细程度
is_development = config.environment in ("development", "test", "testing")
if is_development:
# 开发环境:返回完整错误信息
return ErrorResponse.create(
error_type="internal_error",
message=f"内部服务器错误: {error_type_name}: {error_message}",
status_code=500,
details={
"error_id": error_id,
"error_type": error_type_name,
"error": error_message,
"traceback": traceback.format_exc().split("\n"),
},
)
else:
# 生产环境:只返回错误 ID
return ErrorResponse.create(
error_type="internal_error",
message="内部服务器错误",
status_code=500,
details={
"error_id": error_id,
"support_info": "请联系管理员并提供此错误 ID",
},
)
@staticmethod
def provider_error(provider_name: str, error: Exception) -> JSONResponse:
"""提供商错误响应 - 基于异常类型判断"""
# 基于异常类型判断,更可靠
if isinstance(error, (asyncio.TimeoutError, httpx.TimeoutException)):
return ErrorResponse.from_exception(ProviderTimeoutException(provider_name, 60))
elif isinstance(error, (httpx.HTTPStatusError,)):
if error.response.status_code == 401:
return ErrorResponse.from_exception(ProviderAuthException(provider_name))
elif error.response.status_code == 429:
return ErrorResponse.from_exception(
ProviderRateLimitException(
message=f"提供商 '{provider_name}' 速率限制",
provider_name=provider_name,
)
)
elif isinstance(error, (httpx.ConnectError, httpx.NetworkError)):
return ErrorResponse.create(
error_type="provider_connection_error",
message=f"无法连接到提供商 {provider_name}",
status_code=503,
details={"provider": provider_name, "error": "Connection failed"},
)
# 如果异常类型无法判断,再通过字符串匹配作为备用
elif "auth" in str(error).lower() or "401" in str(error):
return ErrorResponse.from_exception(ProviderAuthException(provider_name))
elif "rate limit" in str(error).lower() or "429" in str(error):
return ErrorResponse.from_exception(
ProviderRateLimitException(
message=f"提供商 '{provider_name}' 速率限制",
provider_name=provider_name,
)
)
else:
return ErrorResponse.create(
error_type="provider_error",
message=f"提供商请求失败: {str(error)}",
status_code=503,
details={
"provider": provider_name,
"error": str(error),
"error_type": type(error).__name__,
},
)
class ExceptionHandlers:
"""FastAPI异常处理器"""
@staticmethod
async def handle_proxy_exception(request: Request, exc: ProxyException) -> None:
"""处理代理异常"""
return ErrorResponse.from_exception(exc)
@staticmethod
async def handle_http_exception(request: Request, exc: HTTPException) -> None:
"""处理HTTP异常"""
return ErrorResponse.from_exception(exc)
@staticmethod
async def handle_generic_exception(request: Request, exc: Exception) -> None:
"""处理通用异常 - 集成韧性管理"""
# 首先检查是否为HTTPException如果是则委托给HTTP异常处理器
if isinstance(exc, HTTPException):
return await ExceptionHandlers.handle_http_exception(request, exc)
# 获取请求信息用于上下文
request_info = {
"method": request.method,
"url": str(request.url),
"client_ip": (
getattr(request.client, "host", "unknown")
if hasattr(request, "client")
else "unknown"
),
"user_agent": request.headers.get("user-agent", "unknown"),
}
# 使用韧性管理器处理错误
rm = get_resilience_manager()
if rm:
try:
error_result = rm.handle_error(
error=exc,
context=request_info,
operation=f"{request.method} {request.url.path}",
)
# 根据错误处理结果返回适当的响应
if error_result.get("severity") and error_result["severity"].value == "critical":
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
elif error_result.get("severity") and error_result["severity"].value == "high":
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
else:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
return ErrorResponse.create(
status_code=status_code,
error_type="system_error",
message=error_result.get("user_message", "系统遇到未知错误"),
details={
"error_id": error_result.get("error_id"),
"recovery_info": "请稍后重试,如问题持续请联系管理员",
},
)
except Exception as resilience_error:
# 如果韧性管理器本身出错,降级到基本处理
logger.exception("韧性管理器处理异常时出错")
# 降级处理:基本的异常响应
return ErrorResponse.from_exception(exc)

View File

@@ -0,0 +1,46 @@
"""HTTP 压缩相关辅助函数。"""
from __future__ import annotations
def normalize_content_encoding(value: str | None) -> str | None:
"""标准化 Content-Encoding 值(仅做清洗,不做兼容扩展)。"""
if not isinstance(value, str):
return None
normalized = value.strip().lower()
return normalized or None
def is_gzip_content_encoding(value: str | None) -> bool:
"""判断 Content-Encoding 是否为 gzip。"""
return normalize_content_encoding(value) == "gzip"
def accepts_gzip(accept_encoding: str | None) -> bool:
"""判断 Accept-Encoding 是否可接受 gzip。"""
if not isinstance(accept_encoding, str):
return False
for item in accept_encoding.split(","):
token_and_params = [part.strip() for part in item.split(";") if part.strip()]
if not token_and_params:
continue
encoding = token_and_params[0].lower()
if encoding not in {"gzip", "*"}:
continue
quality = 1.0
for param in token_and_params[1:]:
if not param.lower().startswith("q="):
continue
try:
quality = float(param[2:])
except ValueError:
quality = 0.0
break
if quality > 0:
return True
return False

View File

@@ -0,0 +1,281 @@
"""
Key 能力系统
能力类型:
1. 互斥能力 (EXCLUSIVE): 需要时选有的,不需要时选没有的(如 cache_1h
2. 兼容能力 (COMPATIBLE): 需要时选有的,不需要时都可选(如 context_1m
配置模式:
1. user_configurable: 用户可配置(模型级 + Key级强制
2. auto_detect: 自动检测(请求失败后升级)
3. request_param: 从请求参数检测
"""
from dataclasses import dataclass, field
from enum import Enum
class CapabilityMatchMode(Enum):
"""能力匹配模式"""
EXCLUSIVE = "exclusive" # 互斥:需要时选有的,不需要时选没有的
COMPATIBLE = "compatible" # 兼容:需要时选有的,不需要时都可选
class CapabilityConfigMode(Enum):
"""能力配置模式"""
USER_CONFIGURABLE = "user_configurable" # 用户可配置(模型级 + Key级强制
AUTO_DETECT = "auto_detect" # 自动检测(请求失败后升级)
REQUEST_PARAM = "request_param" # 从请求参数检测
@dataclass
class CapabilityDefinition:
"""能力定义"""
name: str
display_name: str
description: str
match_mode: CapabilityMatchMode
config_mode: CapabilityConfigMode
short_name: str = "" # 简短展示名称(用于列表等紧凑场景)
error_patterns: list[str] = field(default_factory=list) # 错误检测关键词组
# ============ 能力注册表 ============
_capabilities: dict[str, CapabilityDefinition] = {}
def register_capability(
name: str,
display_name: str,
description: str,
match_mode: CapabilityMatchMode,
config_mode: CapabilityConfigMode,
short_name: str = "",
error_patterns: list[str] | None = None,
) -> CapabilityDefinition:
"""注册能力"""
cap = CapabilityDefinition(
name=name,
display_name=display_name,
description=description,
match_mode=match_mode,
config_mode=config_mode,
short_name=short_name or display_name, # 默认使用 display_name
error_patterns=error_patterns or [],
)
_capabilities[name] = cap
return cap
def get_capability(name: str) -> CapabilityDefinition | None:
"""获取能力定义"""
return _capabilities.get(name)
def get_all_capabilities() -> list[CapabilityDefinition]:
"""获取所有能力定义"""
return list(_capabilities.values())
def get_user_configurable_capabilities() -> list[CapabilityDefinition]:
"""获取用户可配置的能力列表"""
return [
c for c in _capabilities.values() if c.config_mode == CapabilityConfigMode.USER_CONFIGURABLE
]
# ============ 能力匹配检查 ============
def check_capability_match(
key_capabilities: dict[str, bool] | None,
requirements: dict[str, bool] | None,
) -> tuple[bool, str | None]:
"""
检查 Key 能力是否满足需求
匹配逻辑:
1. EXCLUSIVE互斥能力
- 请求需要且 Key 有 -> 通过
- 请求需要但 Key 没有 -> 拒绝
- 请求不需要但 Key 有 -> 拒绝(避免浪费高价资源)
- 请求不需要且 Key 没有 -> 通过
- 请求未声明但 Key 有 -> 拒绝(关键:未声明等同于不需要)
2. COMPATIBLE兼容能力
- 不做硬过滤,交由排序阶段通过 compute_capability_score() 处理
- 有能力的 Key 优先排序,没有的也不被排除
Args:
key_capabilities: Key 拥有的能力 {"cache_1h": True, ...}
requirements: 请求需要的能力 {"cache_1h": True, "context_1m": False}
Returns:
(is_match, skip_reason) - 是否匹配及跳过原因
"""
key_caps = key_capabilities or {}
reqs = requirements or {}
# 第一步:检查请求声明的需求
for cap_name, is_required in reqs.items():
cap_def = _capabilities.get(cap_name)
if not cap_def:
continue
key_has_cap = key_caps.get(cap_name, False)
if cap_def.match_mode == CapabilityMatchMode.EXCLUSIVE:
if is_required and not key_has_cap:
return False, f"需要{cap_def.display_name}但 Key 不支持"
if not is_required and key_has_cap:
return False, f"不需要{cap_def.display_name}(避免浪费高价资源)"
# COMPATIBLE: 不做硬过滤
# 第二步:检查 Key 拥有的 EXCLUSIVE 能力是否被请求需要
# 如果 Key 有某个 EXCLUSIVE 能力,但请求没有声明需要,应该跳过这个 Key
for cap_name, key_has_cap in key_caps.items():
if not key_has_cap:
continue
cap_def = _capabilities.get(cap_name)
if not cap_def:
continue
if cap_def.match_mode == CapabilityMatchMode.EXCLUSIVE:
# 如果请求没有声明需要这个 EXCLUSIVE 能力,视为不需要
if cap_name not in reqs:
return False, f"不需要{cap_def.display_name}(避免浪费高价资源)"
return True, None
def compute_capability_score(
key_capabilities: dict[str, bool] | None,
requirements: dict[str, bool] | None,
) -> int:
"""
计算 COMPATIBLE 能力不匹配数量
返回 0 表示完全匹配(或无 COMPATIBLE 需求),正数表示有 N 个 COMPATIBLE 能力不满足。
用于候选排序:得分越低越优先。
Args:
key_capabilities: Key 拥有的能力
requirements: 请求需要的能力
Returns:
不满足的 COMPATIBLE 能力数量
"""
key_caps = key_capabilities or {}
reqs = requirements or {}
miss_count = 0
for cap_name, is_required in reqs.items():
if not is_required:
continue
cap_def = _capabilities.get(cap_name)
if not cap_def:
continue
if cap_def.match_mode == CapabilityMatchMode.COMPATIBLE:
if not key_caps.get(cap_name, False):
miss_count += 1
return miss_count
def _match_error_patterns(error_msg: str, patterns: list[str]) -> bool:
"""检查错误信息是否匹配模式(所有关键词都要出现)"""
if not patterns:
return False
msg_lower = error_msg.lower()
return all(p.lower() in msg_lower for p in patterns)
def detect_capability_upgrade_from_error(
error_msg: str,
current_requirements: dict[str, bool] | None = None,
) -> str | None:
"""
从错误信息检测是否需要升级某能力
Args:
error_msg: 错误信息
current_requirements: 当前已有的能力需求
Returns:
需要升级的能力名称,如果不需要升级则返回 None
"""
current_reqs = current_requirements or {}
for cap in _capabilities.values():
if not current_reqs.get(cap.name) and cap.error_patterns:
if _match_error_patterns(error_msg, cap.error_patterns):
return cap.name
return None
# ============ 兼容性别名 ============
# 保留旧 API 兼容
get_capability_definition = get_capability
class _CapabilityDefinitionsProxy:
"""CAPABILITY_DEFINITIONS 代理,提供字典式访问(兼容旧代码)"""
def get(self, name: str) -> CapabilityDefinition | None:
return _capabilities.get(name)
def __getitem__(self, name: str) -> CapabilityDefinition:
result = _capabilities.get(name)
if result is None:
raise KeyError(name)
return result
def __contains__(self, name: str) -> bool:
return name in _capabilities
def values(self) -> list[CapabilityDefinition]:
return list(_capabilities.values())
def items(self) -> list[tuple[str, CapabilityDefinition]]:
return list(_capabilities.items())
CAPABILITY_DEFINITIONS = _CapabilityDefinitionsProxy()
# ============ 注册内置能力 ============
register_capability(
name="cache_1h",
display_name="1 小时缓存",
description="使用 1 小时缓存 TTL价格更高适合长对话",
match_mode=CapabilityMatchMode.COMPATIBLE,
config_mode=CapabilityConfigMode.REQUEST_PARAM,
short_name="1h缓存",
)
register_capability(
name="context_1m",
display_name="CLI 1M 上下文",
description="支持 1M tokens 上下文窗口",
match_mode=CapabilityMatchMode.COMPATIBLE,
config_mode=CapabilityConfigMode.REQUEST_PARAM,
short_name="CLI 1M",
error_patterns=["context", "token", "length", "exceed"], # 上下文超限错误
)
register_capability(
name="gemini_files",
display_name="Gemini 文件 API",
description="支持 Gemini Files API文件上传/管理),仅 Google 官方 API 支持",
match_mode=CapabilityMatchMode.EXCLUSIVE,
config_mode=CapabilityConfigMode.REQUEST_PARAM,
short_name="文件API",
)

View File

@@ -0,0 +1,146 @@
"""
统一日志系统 - 基于 loguru
日志级别策略:
- DEBUG: 开发调试,详细执行流程、变量值、缓存操作
- INFO: 生产环境,关键业务操作、状态变更、请求处理
- WARNING: 潜在问题、降级处理、资源警告
- ERROR: 异常错误、需要关注的故障
输出策略:
- 控制台: 开发环境=DEBUG, 生产环境=INFO (通过 LOG_LEVEL 控制)
- 文件: 始终保存 DEBUG 级别保留30天按大小轮转 (100MB)
使用方式:
from src.core.logger import logger
logger.info("消息")
logger.debug("调试信息")
logger.warning("警告")
logger.error("错误")
logger.exception("异常,带堆栈")
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from loguru import logger
# ============================================================================
# 环境检测
# ============================================================================
IS_DOCKER = (
os.path.exists("/.dockerenv") or os.environ.get("DOCKER_CONTAINER", "false").lower() == "true"
)
# 日志级别: 默认开发环境 DEBUG, 生产环境 INFO
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if not IS_DOCKER else "INFO").upper()
# 是否禁用文件日志 (用于测试或特殊场景)
DISABLE_FILE_LOG = os.getenv("LOG_DISABLE_FILE", "false").lower() == "true"
# 项目根目录
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
# ============================================================================
# 日志格式定义
# ============================================================================
CONSOLE_FORMAT_DEV = (
"<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}"
FILE_FORMAT = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} | {message}"
# ============================================================================
# 日志配置
# ============================================================================
logger.remove()
def _log_filter(record: dict) -> bool: # type: ignore[type-arg]
return "watchfiles" not in record["name"]
if IS_DOCKER:
# 生产环境:禁用 backtrace 和 diagnose减少日志噪音
logger.add(
sys.stdout,
format=CONSOLE_FORMAT_PROD,
level=LOG_LEVEL,
filter=_log_filter, # type: ignore[arg-type]
colorize=False,
backtrace=False,
diagnose=False,
)
else:
logger.add(
sys.stdout,
format=CONSOLE_FORMAT_DEV,
level=LOG_LEVEL,
filter=_log_filter, # type: ignore[arg-type]
colorize=True,
)
if not DISABLE_FILE_LOG:
log_dir = PROJECT_ROOT / "logs"
log_dir.mkdir(exist_ok=True)
# 文件日志通用配置
# 注意: enqueue=False 使用同步模式,避免 multiprocessing 信号量泄漏
# 在 macOS 上,进程异常退出时 POSIX 信号量不会自动释放,导致资源耗尽
file_log_config = {
"format": FILE_FORMAT,
"filter": _log_filter,
"rotation": "100 MB",
"retention": "30 days",
"compression": "gz",
"enqueue": False,
"encoding": "utf-8",
"catch": True,
}
# 生产环境禁用详细堆栈
if IS_DOCKER:
file_log_config["backtrace"] = False
file_log_config["diagnose"] = False
# 主日志文件 - 所有级别
logger.add( # type: ignore[call-overload]
log_dir / "app.log",
level="DEBUG",
**file_log_config,
)
# 错误日志文件 - 仅 ERROR 及以上
error_log_config = file_log_config.copy()
error_log_config["rotation"] = "50 MB"
logger.add( # type: ignore[call-overload]
log_dir / "error.log",
level="ERROR",
**error_log_config,
)
# ============================================================================
# 禁用第三方库噪音日志
# ============================================================================
logging.getLogger("watchfiles").setLevel(logging.ERROR)
logging.getLogger("watchfiles.main").setLevel(logging.ERROR)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
# ============================================================================
# 导出
# ============================================================================
__all__ = ["logger"]

View File

@@ -0,0 +1,109 @@
"""
Prometheus metrics for monitoring
"""
from prometheus_client import Counter, Gauge, Histogram
# 并发槽位占用时长分布(按异常类型聚合,不按 key_id 拆分)
concurrency_slot_duration_seconds = Histogram(
"concurrency_slot_duration_seconds",
"Duration of concurrency slot occupation in seconds",
["exception"],
buckets=[0.1, 0.5, 1, 5, 10, 30, 60, 120, 300, 600], # 0.1s 到 10 分钟
)
# 并发槽位释放计数
concurrency_slot_release_total = Counter(
"concurrency_slot_release_total",
"Total number of concurrency slot releases",
["exception"],
)
# 请求总数(按类型)
request_total = Counter(
"request_total",
"Total number of requests",
["type", "status"], # type values: streaming/non-streaming, status: success/error
)
# 调度并发拒绝计数RPM guard
scheduler_concurrency_denied_total = Counter(
"scheduler_concurrency_denied_total",
"Total number of candidates skipped due to concurrency/RPM limits",
["is_cached_user", "reason", "reservation_phase"],
)
# 健康监控相关
health_open_circuits = Gauge(
"health_open_circuits",
"Number of provider keys currently in circuit breaker open state",
)
# 模型映射解析相关
model_mapping_resolution_total = Counter(
"model_mapping_resolution_total",
"Total number of model mapping resolutions",
["method", "cache_hit"],
# method: direct_match, provider_model_name, mapping, not_found
# cache_hit: true, false
)
model_mapping_resolution_duration_seconds = Histogram(
"model_mapping_resolution_duration_seconds",
"Duration of model mapping resolution in seconds",
["method"],
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0], # 1ms 到 1s
)
model_mapping_conflict_total = Counter(
"model_mapping_conflict_total",
"Total number of mapping conflicts detected (same name maps to multiple GlobalModels)",
)
# ==================== API 格式转换 ====================
format_conversion_total = Counter(
"format_conversion_total",
"Total number of format conversions",
["direction", "source_format", "target_format", "status"], # status: success/error
)
format_conversion_duration_seconds = Histogram(
"format_conversion_duration_seconds",
"Duration of format conversions in seconds",
["direction", "source_format", "target_format"],
buckets=[0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
)
# ==================== Billing migration / shadow billing ====================
billing_requests_total = Counter(
"billing_requests_total",
"Total number of billing calculations",
["engine_mode", "truth_engine"], # low-cardinality labels
)
billing_fallback_total = Counter(
"billing_fallback_total",
"Total number of billing fallbacks to legacy engine",
)
billing_diff_exceeds_threshold_total = Counter(
"billing_diff_exceeds_threshold_total",
"Total number of shadow billing diffs exceeding threshold",
["engine_mode"],
)
billing_invariant_violation_total = Counter(
"billing_invariant_violation_total",
"Total number of billing invariant violations (sum(breakdown)!=total)",
["engine_mode", "truth_engine"],
)
# ==================== Antigravity ====================
antigravity_degradation_total = Counter(
"aether_antigravity_degradation_total",
"Count of Antigravity signature degradation (rectification) events",
["stage"],
)

View File

@@ -0,0 +1,419 @@
"""
模型权限工具
allowed_models 格式: ["claude-sonnet-4", "gpt-4o"]
使用 None/null 表示不限制(允许所有模型)
支持模型映射匹配:
- GlobalModel.config.model_mappings 定义映射模式
- 映射模式支持正则表达式语法
- 例如claude-haiku-.* 可匹配 claude-haiku-4.5, claude-haiku-last
- 使用 regex 库的原生超时保护100ms防止 ReDoS
"""
import re
from functools import lru_cache
import regex
from src.core.logger import logger
# 映射规则限制
MAX_MAPPINGS_PER_MODEL = 50
MAX_MAPPING_LENGTH = 200
MAX_MODEL_NAME_LENGTH = 200 # 与 MAX_MAPPING_LENGTH 保持一致
REGEX_MATCH_TIMEOUT_MS = 100 # 正则匹配超时(毫秒)
# 类型别名
type AllowedModels = list[str] | None
def normalize_allowed_models(allowed_models: AllowedModels) -> set[str] | None:
"""
将 allowed_models 规范化为模型名称集合
Args:
allowed_models: 允许的模型配置(列表)
Returns:
- None: 不限制(允许所有模型)
- set[str]: 允许的模型名称集合(可能为空集,表示拒绝所有)
"""
if allowed_models is None:
return None
return set(allowed_models)
def check_model_allowed(
model_name: str,
allowed_models: AllowedModels,
) -> bool:
"""
检查模型是否被允许
Args:
model_name: 请求的模型名称
allowed_models: 允许的模型配置
Returns:
True: 允许使用该模型
False: 不允许使用该模型
"""
allowed_set = normalize_allowed_models(allowed_models)
if allowed_set is None:
# 不限制
return True
if len(allowed_set) == 0:
# 空集合 = 拒绝所有
return False
# 检查请求的模型名是否在白名单中
return model_name in allowed_set
def merge_allowed_models(
allowed_models_1: AllowedModels,
allowed_models_2: AllowedModels,
) -> AllowedModels:
"""
合并两个 allowed_models 配置,取交集
规则:
- 如果任一为 None返回另一个
- 如果都有值,取交集
Args:
allowed_models_1: 第一个配置
allowed_models_2: 第二个配置
Returns:
合并后的配置
"""
if allowed_models_1 is None:
return allowed_models_2
if allowed_models_2 is None:
return allowed_models_1
intersection = set(allowed_models_1) & set(allowed_models_2)
return sorted(intersection) if intersection else []
def get_allowed_models_preview(
allowed_models: AllowedModels,
max_items: int = 3,
) -> str:
"""
获取 allowed_models 的预览字符串(用于日志和错误消息)
Args:
allowed_models: 允许的模型配置
max_items: 最多显示的模型数
Returns:
预览字符串,如 "gpt-4o, claude-sonnet-4, ..."
"""
if allowed_models is None:
return "(不限制)"
if not allowed_models:
return "(无)"
sorted_models = sorted(allowed_models)
preview = ", ".join(sorted_models[:max_items])
if len(sorted_models) > max_items:
preview += f", ...共{len(sorted_models)}"
return preview
def parse_allowed_models_to_list(allowed_models: AllowedModels) -> list[str]:
"""
解析 allowed_models 为列表
Args:
allowed_models: 允许的模型配置
Returns:
模型名称列表(可能为空)
"""
if allowed_models is None:
return []
return list(allowed_models)
def validate_mapping_pattern(pattern: str) -> tuple[bool, str | None]:
"""
验证映射模式是否安全
Args:
pattern: 待验证的正则模式
Returns:
(is_valid, error_message)
"""
if not pattern or not pattern.strip():
return False, "映射规则不能为空"
if len(pattern) > MAX_MAPPING_LENGTH:
return False, f"映射规则过长 (最大 {MAX_MAPPING_LENGTH} 字符)"
# 尝试编译验证语法
try:
re.compile(f"^{pattern}$", re.IGNORECASE)
except re.error as e:
return False, f"正则表达式语法错误: {e}"
return True, None
def validate_model_mappings(mappings: list[str] | None) -> tuple[bool, str | None]:
"""
验证映射列表是否合法
Args:
mappings: 映射列表
Returns:
(is_valid, error_message)
"""
if not mappings:
return True, None
if len(mappings) > MAX_MAPPINGS_PER_MODEL:
return False, f"映射规则数量超限 (最大 {MAX_MAPPINGS_PER_MODEL} 条)"
for i, mapping in enumerate(mappings):
is_valid, error = validate_mapping_pattern(mapping)
if not is_valid:
return False, f"{i + 1} 条规则无效: {error}"
return True, None
def validate_and_extract_model_mappings(
config: dict | None,
) -> tuple[bool, str | None, list[str] | None]:
"""
从 config 中验证并提取 model_mappings
用于 GlobalModel 创建/更新时的统一验证
Args:
config: GlobalModel 的 config 字典
Returns:
(is_valid, error_message, mappings):
- is_valid: 验证是否通过
- error_message: 错误信息(验证失败时)
- mappings: 提取的映射列表(验证成功时)
"""
if not config or "model_mappings" not in config:
return True, None, None
mappings = config.get("model_mappings")
# 允许显式设置为 None表示清除映射
if mappings is None:
return True, None, None
# 类型验证:必须是列表
if not isinstance(mappings, list):
return False, "model_mappings 必须是数组类型", None
# 元素类型验证:必须是字符串
if not all(isinstance(m, str) for m in mappings):
return False, "model_mappings 数组元素必须是字符串", None
# 业务规则验证
is_valid, error = validate_model_mappings(mappings)
if not is_valid:
return False, error, None
return True, None, mappings
@lru_cache(maxsize=512)
def _compile_pattern_cached(pattern: str) -> regex.Pattern | None:
"""
编译正则模式(带 LRU 缓存)
Args:
pattern: 正则模式字符串
Returns:
编译后的正则对象,如果无效则返回 None
"""
try:
return regex.compile(f"^{pattern}$", regex.IGNORECASE)
except regex.error as e:
logger.debug(f"正则编译失败: pattern={pattern}, error={e}")
return None
def clear_regex_cache() -> None:
"""
清空正则缓存
在 GlobalModel 映射更新时调用此函数以确保缓存一致性
"""
_compile_pattern_cached.cache_clear()
logger.debug("[RegexCache] 缓存已清空")
def _match_with_timeout(
compiled_regex: regex.Pattern, text: str, timeout_ms: int = REGEX_MATCH_TIMEOUT_MS
) -> bool | None:
"""
带超时的正则匹配(使用 regex 库的原生超时支持)
相比 ThreadPoolExecutor 方案的优势:
- C 层面中断匹配,不会留下僵尸线程
- 更低的性能开销
- 更精确的超时控制
Args:
compiled_regex: 编译后的 regex.Pattern 对象
text: 待匹配的文本
timeout_ms: 超时时间(毫秒)
Returns:
True: 匹配成功
False: 匹配失败
None: 超时或异常
"""
try:
# regex 库的 timeout 参数单位是秒
result = compiled_regex.match(text, timeout=timeout_ms / 1000.0)
return result is not None
except TimeoutError:
logger.warning(
f"正则匹配超时 ({timeout_ms}ms): pattern={compiled_regex.pattern[:50]}..., text={text[:50]}..."
)
return None
except Exception as e:
logger.warning(f"正则匹配异常: {e}")
return None
def match_model_with_pattern(pattern: str, model_name: str) -> bool:
"""
检查模型名是否匹配映射模式(支持正则表达式)
安全特性:
- 长度限制检查
- 正则编译缓存
- 正则匹配超时保护100ms使用 regex 库原生超时)
Args:
pattern: 映射模式,支持正则表达式语法
model_name: 被检查的模型名(来自 Key 的 allowed_models
Returns:
True 如果匹配
示例:
match_model_with_pattern("claude-haiku-.*", "claude-haiku-4.5") -> True
match_model_with_pattern("gpt-4o", "gpt-4o") -> True
match_model_with_pattern("gpt-4o", "gpt-4") -> False
"""
# 快速路径:精确匹配
if pattern.lower() == model_name.lower():
return True
# 长度检查
if len(pattern) > MAX_MAPPING_LENGTH or len(model_name) > MAX_MODEL_NAME_LENGTH:
return False
# 使用缓存的编译结果
compiled = _compile_pattern_cached(pattern)
if compiled is None:
return False
# 使用带超时的匹配regex 库原生支持)
result = _match_with_timeout(compiled, model_name)
return result is True
def check_model_allowed_with_mappings(
model_name: str,
allowed_models: AllowedModels,
model_mappings: list[str] | None = None,
candidate_models: set[str] | None = None,
) -> tuple[bool, str | None]:
"""
检查模型是否被允许(支持映射通配符匹配)
匹配优先级:
1. 精确匹配 model_name用户请求的模型名即 GlobalModel.name
2. 精确匹配 candidate_models ∩ allowed_modelsProvider 支持且 Key 允许的模型名)
3. 遍历 model_mappings 正则,检查 allowed_models 中是否有匹配项
映射匹配顺序说明:
- 按 allowed_models 集合的迭代顺序遍历(通常为字母顺序,因为内部使用 set
- 对于每个 allowed_model按 model_mappings 数组顺序依次尝试匹配
- 返回第一个成功匹配的 allowed_model
- 如需确定性行为,请确保 model_mappings 中的规则从最具体到最通用排序
Args:
model_name: 请求的模型名称GlobalModel.name
allowed_models: 允许的模型配置(来自 Provider Key
model_mappings: GlobalModel 的映射列表(来自 config.model_mappings支持正则表达式
candidate_models: 可选的候选模型集合Provider 的 provider_model_names
仅用于步骤 2 的精确匹配,不影响步骤 3 的正则匹配
Returns:
(is_allowed, matched_model_name):
- is_allowed: 是否允许使用该模型
- matched_model_name: 匹配到的模型名(用于实际请求时的模型名替换)
- model_name 精确匹配时为 None无需替换
- candidate_models 或 model_mappings 匹配时返回匹配到的模型名
"""
# 先尝试精确匹配 model_name
if check_model_allowed(model_name, allowed_models):
return True, None
# 获取 allowed_models 的集合
allowed_set = normalize_allowed_models(allowed_models)
if allowed_set is None:
# 不限制,已在 check_model_allowed 中返回 True
return True, None
if len(allowed_set) == 0:
# 空集合 = 拒绝所有
return False, None
# 检查 candidate_models 与 allowed_models 的交集
# candidate_models = Provider 实际支持的模型名provider_model_name + provider_model_mappings
# 如果有交集,说明 Key 的 allowed_models 中有 Provider 支持的模型名,可以直接使用
if candidate_models:
intersection = allowed_set & candidate_models
if intersection:
# 返回第一个匹配的模型名(排序确保确定性),用于实际请求时替换 model_name
return True, sorted(intersection)[0]
# 如果精确匹配失败且有映射配置,尝试映射匹配
if not model_mappings:
return False, None
# 正则映射匹配:直接在 allowed_models 上进行匹配
# GlobalModel.config.model_mappings 定义了"可以用哪些 Provider 模型名来提供服务"
# 如果 Key 的 allowed_models 中有能被正则匹配的模型名,说明这个 Key 可以用于请求
#
# 注意:不再用 candidate_models 限制搜索空间
# 原因:用户可能只配置了 GlobalModel 的正则映射规则,而没有在 Provider Model 的
# provider_model_mappings 中添加对应的模型名。正则映射的语义是"将请求重定向到匹配的模型名"
# 所以应该直接检查 Key 的 allowed_models 是否包含能被正则匹配的模型名。
#
# 遍历 allowed_set检查是否有模型名能匹配 model_mappings 中的任一正则
# 排序确保确定性行为
for allowed_model in sorted(allowed_set):
for mapping_pattern in model_mappings:
if match_model_with_pattern(mapping_pattern, allowed_model):
return True, allowed_model
return False, None

View File

@@ -0,0 +1,47 @@
"""
模块化系统核心
提供可扩展的功能模块管理,支持:
- 声明式模块注册
- available/enabled 双层状态控制
- 延迟导入避免重依赖加载
- 前后端状态同步
"""
from src.core.modules.base import (
ModuleCategory,
ModuleDefinition,
ModuleMetadata,
ModuleStatus,
)
from src.core.modules.hooks import (
AUTH_AUTHENTICATE,
AUTH_CHECK_EXCLUSIVE_MODE,
AUTH_CHECK_REGISTRATION,
AUTH_GET_METHODS,
AUTH_TOKEN_PREFIX_AUTHENTICATORS,
HookDispatcher,
HookSpec,
HookStrategy,
get_hook_dispatcher,
)
from src.core.modules.registry import ModuleRegistry, get_module_registry
__all__ = [
"ModuleCategory",
"ModuleMetadata",
"ModuleDefinition",
"ModuleStatus",
"ModuleRegistry",
"get_module_registry",
# Hook system
"HookDispatcher",
"HookSpec",
"HookStrategy",
"get_hook_dispatcher",
"AUTH_GET_METHODS",
"AUTH_AUTHENTICATE",
"AUTH_CHECK_REGISTRATION",
"AUTH_CHECK_EXCLUSIVE_MODE",
"AUTH_TOKEN_PREFIX_AUTHENTICATORS",
]

View File

@@ -0,0 +1,127 @@
"""
模块基础定义
包含模块元数据、定义和状态的数据结构
"""
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
if TYPE_CHECKING:
from fastapi import APIRouter
from sqlalchemy.orm import Session
class ModuleCategory(str, Enum):
"""模块分类"""
AUTH = "auth" # 认证相关
MONITORING = "monitoring" # 监控相关
SECURITY = "security" # 安全相关
INTEGRATION = "integration" # 第三方集成
class ModuleHealth(str, Enum):
"""模块健康状态"""
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
@dataclass
class ModuleMetadata:
"""
模块元数据 - 纯数据描述,无重依赖
用于声明式定义模块的基本信息和配置
"""
# 基本信息
name: str # 唯一标识: ldap, audit_log
display_name: str # 显示名称: "LDAP 认证"
description: str # 模块描述
# 分类
category: ModuleCategory
# 可用性控制(部署级)
env_key: str # 环境变量名: LDAP_AVAILABLE
default_available: bool = False # 默认是否可用
required_packages: list[str] = field(default_factory=list) # 依赖的 Python 包
dependencies: list[str] = field(default_factory=list) # 依赖的其他模块
# 路由配置 - 模块自定义前缀
api_prefix: str | None = None # 如 "/api/admin/ldap"
# 前端配置
admin_route: str | None = None # 管理页面路由: "/admin/ldap"
admin_menu_icon: str | None = None # 菜单图标
admin_menu_group: str | None = None # 菜单分组: "system", "security"
admin_menu_order: int = 100 # 菜单排序(越小越靠前)
@dataclass
class ModuleDefinition:
"""
完整模块定义
包含元数据和生命周期钩子,钩子函数内部延迟导入重依赖
"""
metadata: ModuleMetadata
# 工厂函数 - 内部再 import 重依赖
router_factory: Callable[[], APIRouter] | None = None
service_factory: Callable[[], Any] | None = None
# 生命周期钩子
on_startup: Callable[[], Awaitable[None]] | None = None
on_shutdown: Callable[[], Awaitable[None]] | None = None
health_check: Callable[[], Awaitable[ModuleHealth]] | None = None
# 自定义依赖检测(可选,用于检测 ldap3 等库是否安装)
check_dependencies: Callable[[], bool] | None = None
# 配置验证(可选,启用模块时调用,返回 (success, error_message)
validate_config: Callable[[Session], tuple[bool, str]] | None = None
# 钩子实现(可选)
# {hook_name: handler_callable}
# 模块通过此字段声明自己对核心扩展点的实现
hooks: dict[str, Callable[..., Any]] = field(default_factory=dict)
@dataclass
class ModuleStatus:
"""
模块运行状态
用于 API 返回,供前端使用
"""
name: str
available: bool # 部署级可用(环境变量 + 依赖库)
enabled: bool # 运行级启用(数据库配置)
active: bool # 最终激活状态 (available && enabled && dependencies_ok)
config_validated: bool # 配置验证通过(只有验证通过才允许启用)
config_error: str | None # 配置验证失败的错误信息
# 显示信息
display_name: str
description: str
category: ModuleCategory
# 前端配置
admin_route: str | None
admin_menu_icon: str | None
admin_menu_group: str | None
admin_menu_order: int
# 健康状态
health: ModuleHealth = ModuleHealth.UNKNOWN

View File

@@ -0,0 +1,245 @@
"""
模块钩子系统
提供模块与核心代码之间的动态扩展点。
模块通过 ModuleDefinition.hooks 声明钩子实现,
核心代码通过 HookDispatcher 调用所有活跃模块的钩子。
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from enum import Enum
from inspect import isawaitable
from typing import TYPE_CHECKING, Any
from src.core.logger import logger
if TYPE_CHECKING:
from sqlalchemy.orm import Session
# 钩子处理器类型: 可以是同步或异步函数
HookHandler = Any # Callable[..., Any]
class HookStrategy(str, Enum):
"""钩子执行策略"""
FIRST_RESULT = "first_result" # 返回第一个非 None 结果
COLLECT_ALL = "collect_all" # 收集所有结果到列表
@dataclass(frozen=True)
class HookSpec:
"""钩子规格定义"""
name: str # 如 "auth.authenticate"
strategy: HookStrategy = HookStrategy.FIRST_RESULT
requires_active_check: bool = True # 是否过滤非活跃模块
# ==================== 预定义钩子规格 ====================
AUTH_GET_METHODS = HookSpec(
name="auth.get_methods",
strategy=HookStrategy.COLLECT_ALL,
)
"""查询所有可用认证方法。返回 list[dict],每个 dict 包含认证方式信息。"""
AUTH_AUTHENTICATE = HookSpec(
name="auth.authenticate",
strategy=HookStrategy.FIRST_RESULT,
)
"""模块参与认证流程。kwargs: db, email, password, auth_type。返回 User 或 None。"""
AUTH_CHECK_REGISTRATION = HookSpec(
name="auth.check_registration",
strategy=HookStrategy.FIRST_RESULT,
)
"""模块检查是否允许本地注册。返回 {"blocked": True, "reason": "..."} 或 None。"""
AUTH_CHECK_EXCLUSIVE_MODE = HookSpec(
name="auth.check_exclusive_mode",
strategy=HookStrategy.FIRST_RESULT,
)
"""检查是否有模块开启了排他登录模式。返回 True 或 None。"""
AUTH_TOKEN_PREFIX_AUTHENTICATORS = HookSpec(
name="auth.token_prefix_authenticators",
strategy=HookStrategy.COLLECT_ALL,
requires_active_check=False, # token 前缀认证是核心鉴权路径,只要模块已注册即可
)
"""获取 token 前缀认证器列表。返回 list[{"prefix": "ae_", "module": "..."}]。"""
class HookDispatcher:
"""
钩子分发器 -- 单例
职责:
- 注册模块的钩子实现
- 在核心代码调用时,只执行活跃模块的钩子
- 支持 FIRST_RESULT 和 COLLECT_ALL 两种执行策略
"""
_instance: HookDispatcher | None = None
def __init__(self) -> None:
# {hook_name: [(module_name, handler), ...]}
self._handlers: defaultdict[str, list[tuple[str, HookHandler]]] = defaultdict(list)
@classmethod
def get_instance(cls) -> HookDispatcher:
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""重置单例(仅用于测试)"""
cls._instance = None
def register(self, hook_name: str, module_name: str, handler: HookHandler) -> None:
"""注册钩子处理器"""
self._handlers[hook_name].append((module_name, handler))
logger.debug("Hook [{}] registered handler from module [{}]", hook_name, module_name)
def has_handlers(self, hook_name: str) -> bool:
"""检查是否有注册的处理器"""
return bool(self._handlers.get(hook_name))
def _get_active_handlers(
self, spec: HookSpec, db: Session | None
) -> list[tuple[str, HookHandler]]:
"""获取活跃模块的处理器列表"""
handlers = self._handlers.get(spec.name, [])
if not handlers:
return []
if not spec.requires_active_check or db is None:
return handlers
from src.core.modules.registry import get_module_registry
registry = get_module_registry()
return [(name, handler) for name, handler in handlers if registry.is_active(name, db)]
# ==================== 异步分发 ====================
async def dispatch(
self,
spec: HookSpec,
**kwargs: Any,
) -> Any:
"""
异步分发钩子调用
从 kwargs 中提取 db 参数用于活跃性检查,所有 kwargs 原样传递给处理器。
Args:
spec: 钩子规格
**kwargs: 传递给处理器的参数(其中 db 同时用于活跃性检查)
Returns:
FIRST_RESULT: 第一个非 None 结果,或 None
COLLECT_ALL: 结果列表
"""
db = kwargs.get("db")
active_handlers = self._get_active_handlers(spec, db)
if not active_handlers:
return [] if spec.strategy == HookStrategy.COLLECT_ALL else None
if spec.strategy == HookStrategy.FIRST_RESULT:
return await self._dispatch_first_result(spec.name, active_handlers, **kwargs)
elif spec.strategy == HookStrategy.COLLECT_ALL:
return await self._dispatch_collect_all(spec.name, active_handlers, **kwargs)
return None
async def _call_handler(self, handler: HookHandler, **kwargs: Any) -> Any:
"""调用处理器(支持同步和异步)"""
result = handler(**kwargs)
if isawaitable(result):
return await result
return result
async def _dispatch_first_result(
self, hook_name: str, handlers: list[tuple[str, HookHandler]], **kwargs: Any
) -> Any:
for module_name, handler in handlers:
try:
result = await self._call_handler(handler, **kwargs)
if result is not None:
return result
except Exception as e:
logger.error("Hook [{}] handler from [{}] failed: {}", hook_name, module_name, e)
return None
async def _dispatch_collect_all(
self, hook_name: str, handlers: list[tuple[str, HookHandler]], **kwargs: Any
) -> list[Any]:
results: list[Any] = []
for module_name, handler in handlers:
try:
result = await self._call_handler(handler, **kwargs)
if result is not None:
if isinstance(result, list):
results.extend(result)
else:
results.append(result)
except Exception as e:
logger.error("Hook [{}] handler from [{}] failed: {}", hook_name, module_name, e)
return results
# ==================== 同步分发 ====================
def dispatch_sync(
self,
spec: HookSpec,
**kwargs: Any,
) -> Any:
"""
同步版本的 dispatch仅适用于同步钩子处理器
从 kwargs 中提取 db 参数用于活跃性检查,所有 kwargs 原样传递给处理器。
用于无法使用 await 的同步上下文(如 OAuthService 的某些方法)。
"""
db = kwargs.get("db")
active_handlers = self._get_active_handlers(spec, db)
if not active_handlers:
return [] if spec.strategy == HookStrategy.COLLECT_ALL else None
if spec.strategy == HookStrategy.FIRST_RESULT:
for module_name, handler in active_handlers:
try:
result = handler(**kwargs)
if result is not None:
return result
except Exception as e:
logger.error(
"Hook [{}] sync handler from [{}] failed: {}", spec.name, module_name, e
)
return None
elif spec.strategy == HookStrategy.COLLECT_ALL:
results: list[Any] = []
for module_name, handler in active_handlers:
try:
result = handler(**kwargs)
if result is not None:
if isinstance(result, list):
results.extend(result)
else:
results.append(result)
except Exception as e:
logger.error(
"Hook [{}] sync handler from [{}] failed: {}", spec.name, module_name, e
)
return results
return None
def get_hook_dispatcher() -> HookDispatcher:
"""获取钩子分发器实例"""
return HookDispatcher.get_instance()

View File

@@ -0,0 +1,416 @@
"""
模块注册中心
负责模块的注册、状态管理和生命周期控制
"""
from __future__ import annotations
import importlib
import importlib.util
import os
from typing import TYPE_CHECKING, Any, Protocol
from src.core.logger import logger
from src.core.modules.base import (
ModuleCategory,
ModuleDefinition,
ModuleHealth,
ModuleStatus,
)
if TYPE_CHECKING:
from sqlalchemy.orm import Session
class ConfigBackend(Protocol):
"""模块配置读写后端协议。
通过 ``ModuleRegistry.set_config_backend()`` 在应用启动时注入实现,
使 core 层无需在运行时 import services 层。
"""
def get_config(self, db: Any, key: str, default: Any = None) -> Any: ...
def set_config(self, db: Any, key: str, value: Any, description: Any = None) -> Any: ...
class _DefaultConfigBackend:
"""默认配置后端:始终返回 default用于独立脚本/极简测试场景)。"""
def get_config(self, _db: Any, _key: str, default: Any = None) -> Any:
return default
def set_config(self, _db: Any, _key: str, _value: Any, _description: Any = None) -> Any:
return None
_DEFAULT_CONFIG_BACKEND: ConfigBackend = _DefaultConfigBackend()
class ModuleRegistry:
"""
模块注册中心 - 单例模式
职责:
- 注册模块定义(仅元数据,不加载重依赖)
- 检查模块可用性(环境变量 + 依赖库)
- 管理模块启用状态(数据库配置)
- 提供模块状态查询
"""
_instance: ModuleRegistry | None = None
_config_backend: ConfigBackend | None = None
def __init__(self) -> None:
self._modules: dict[str, ModuleDefinition] = {}
self._initialized: set[str] = set()
@classmethod
def set_config_backend(cls, backend: ConfigBackend) -> None:
"""注入配置读写后端,消除 core→services 的运行时依赖"""
cls._config_backend = backend
@classmethod
def get_instance(cls) -> ModuleRegistry:
"""获取单例实例"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""重置单例(仅用于测试)"""
cls._instance = None
cls._config_backend = None
def register(self, module: ModuleDefinition) -> None:
"""
注册模块
仅注册元数据,不加载重依赖
"""
name = module.metadata.name
if name in self._modules:
logger.warning(f"Module [{name}] already registered, skipping")
return
self._modules[name] = module
logger.debug(f"Module [{name}] registered")
def get_module(self, name: str) -> ModuleDefinition | None:
"""获取模块定义"""
return self._modules.get(name)
def get_all_modules(self) -> list[ModuleDefinition]:
"""获取所有已注册模块"""
return list(self._modules.values())
# ========== 可用性检查(部署级)==========
def is_available(self, name: str) -> bool:
"""
检查模块是否部署可用
检查顺序:
1. 模块是否已注册
2. 环境变量是否启用
3. 依赖的 Python 包是否安装
4. 自定义依赖检测(如果有)
"""
if name not in self._modules:
return False
module = self._modules[name]
meta = module.metadata
# 1. 检查环境变量
env_value = os.getenv(meta.env_key)
if env_value is not None:
if env_value.lower() not in ("true", "1", "yes"):
return False
elif not meta.default_available:
return False
# 2. 检查依赖的 Python 包
for pkg in meta.required_packages:
if importlib.util.find_spec(pkg) is None:
logger.debug(f"Module [{name}] unavailable: package '{pkg}' not installed")
return False
# 3. 自定义依赖检测
if module.check_dependencies:
try:
if not module.check_dependencies():
logger.debug(f"Module [{name}] unavailable: custom dependency check failed")
return False
except Exception as e:
logger.warning(f"Module [{name}] dependency check error: {e}")
return False
return True
def get_available_modules(self) -> list[ModuleDefinition]:
"""获取所有部署可用的模块"""
return [m for m in self._modules.values() if self.is_available(m.metadata.name)]
# ========== 启用状态检查(运行级)==========
def _get_config_backend(self) -> ConfigBackend:
"""获取配置后端(优先使用已注入的)。"""
if self._config_backend is not None:
return self._config_backend
# 兜底best-effort 动态加载(避免 core→services 的静态依赖)。
try:
module = importlib.import_module("src.services.system.config")
backend = getattr(module, "SystemConfigService", None)
if backend is not None:
return backend # type: ignore[return-value]
except Exception:
pass
# 最终兜底:未注入且无法动态加载时,使用默认后端(始终返回 default
return _DEFAULT_CONFIG_BACKEND
def is_enabled(self, name: str, db: Session) -> bool:
"""
检查模块是否运行启用(数据库配置)
Args:
name: 模块名称
db: 数据库会话
"""
config_key = f"module.{name}.enabled"
value = self._get_config_backend().get_config(db, config_key, default=False)
return bool(value)
def set_enabled(self, name: str, enabled: bool, db: Session) -> None:
"""
设置模块启用状态
Args:
name: 模块名称
enabled: 是否启用
db: 数据库会话
"""
if name not in self._modules:
raise ValueError(f"Module [{name}] not registered")
config_key = f"module.{name}.enabled"
module = self._modules[name]
description = f"模块 [{module.metadata.display_name}] 启用状态"
self._get_config_backend().set_config(db, config_key, enabled, description)
# ========== 激活状态检查 ==========
def is_active(self, name: str, db: Session, _visited: set[str] | None = None) -> bool:
"""
检查模块是否最终激活
激活条件available && enabled && 依赖模块都激活
Args:
name: 模块名称
db: 数据库会话
_visited: 内部递归防御,防止循环依赖导致无限递归
"""
if _visited is None:
_visited = set()
if name in _visited:
logger.warning(f"Circular dependency detected in module activation chain: {name}")
return False
_visited.add(name)
if not self.is_available(name):
return False
if not self.is_enabled(name, db):
return False
# 检查依赖模块
module = self._modules[name]
for dep in module.metadata.dependencies:
if not self.is_active(dep, db, _visited):
return False
return True
# ========== 配置验证 ==========
def validate_config(self, name: str, db: Session) -> tuple[bool, str]:
"""
验证模块配置是否有效
Args:
name: 模块名称
db: 数据库会话
Returns:
(validated, error_message) - validated 为 True 表示配置有效
"""
if name not in self._modules:
return False, "模块不存在"
module = self._modules[name]
# 没有配置验证函数的模块,默认配置有效
if not module.validate_config:
return True, ""
try:
return module.validate_config(db)
except Exception as e:
logger.warning(f"Module [{name}] config validation error: {e}")
return False, f"配置验证出错: {str(e)}"
def reconcile_module_state(self, name: str, db: Session) -> None:
"""
修复模块启用状态与配置的一致性
如果模块已启用但配置验证失败(例如依赖的 Provider Key 被删除),
则自动禁用该模块以保证状态一致性。
此方法是显式的写操作,应在需要状态修复的场景中调用,
而非在纯查询方法中隐式执行。
"""
if name not in self._modules:
return
if not self.is_available(name):
return
config_validated, config_error = self.validate_config(name, db)
if self.is_enabled(name, db) and not config_validated:
self.set_enabled(name, False, db)
logger.info(
f"Module [{name}] auto-disabled: config validation failed" f" ({config_error})"
)
# ========== 状态查询 ==========
def get_module_status(
self, name: str, db: Session, health: ModuleHealth | None = None
) -> ModuleStatus | None:
"""
获取单个模块状态
Args:
name: 模块名称
db: 数据库会话
health: 预先获取的健康状态(可选,用于异步场景)
"""
if name not in self._modules:
return None
module = self._modules[name]
meta = module.metadata
available = self.is_available(name)
# 获取配置验证状态
config_validated = False
config_error: str | None = None
if available:
config_validated, config_error = self.validate_config(name, db)
if config_validated:
config_error = None # 验证通过时清空错误信息
# 获取启用状态
enabled = self.is_enabled(name, db) if available else False
# 计算激活状态available && enabled && config_validated && 依赖模块都激活
is_active = self.is_active(name, db) if available else False
active = is_active and config_validated
return ModuleStatus(
name=name,
available=available,
enabled=enabled,
active=active,
config_validated=config_validated,
config_error=config_error,
display_name=meta.display_name,
description=meta.description,
category=meta.category,
admin_route=meta.admin_route if available else None,
admin_menu_icon=meta.admin_menu_icon,
admin_menu_group=meta.admin_menu_group,
admin_menu_order=meta.admin_menu_order,
health=health if health else ModuleHealth.UNKNOWN,
)
async def check_health(self, name: str) -> ModuleHealth:
"""
执行模块健康检查
Args:
name: 模块名称
Returns:
健康状态
"""
if name not in self._modules:
return ModuleHealth.UNKNOWN
module = self._modules[name]
if not module.health_check:
return ModuleHealth.UNKNOWN
try:
return await module.health_check()
except Exception as e:
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:
"""异步获取模块状态(包含健康检查)"""
if name not in self._modules:
return None
self.reconcile_module_state(name, db)
health = await self.check_health(name) if self.is_available(name) else ModuleHealth.UNKNOWN
return self.get_module_status(name, db, health=health)
async def get_all_status_async(self, db: Session) -> dict[str, ModuleStatus]:
"""异步获取所有模块状态(包含健康检查)"""
result = {}
for name in self._modules:
status = await self.get_module_status_async(name, db)
if status:
result[name] = status
return result
def get_all_status(self, db: Session) -> dict[str, ModuleStatus]:
"""获取所有模块状态(同步版本,不含健康检查)"""
result = {}
for name in self._modules:
self.reconcile_module_state(name, db)
status = self.get_module_status(name, db)
if status:
result[name] = status
return result
def get_available_status(self, db: Session) -> dict[str, ModuleStatus]:
"""获取所有可用模块的状态"""
result = {}
for name, module in self._modules.items():
if self.is_available(name):
status = self.get_module_status(name, db)
if status:
result[name] = status
return result
def get_auth_modules_status(self, db: Session) -> list[ModuleStatus]:
"""获取认证模块状态(供登录页使用)"""
result = []
for name, module in self._modules.items():
if module.metadata.category == ModuleCategory.AUTH:
if self.is_available(name):
status = self.get_module_status(name, db)
if status:
result.append(status)
return result
def get_module_registry() -> ModuleRegistry:
"""获取模块注册中心实例"""
return ModuleRegistry.get_instance()

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import json
from typing import Any
from src.core.crypto import crypto_service
def normalize_oauth_plan_type(plan_type: Any) -> str | None:
if not isinstance(plan_type, str):
return None
normalized = plan_type.strip().lower()
if not normalized:
return None
return normalized
def extract_oauth_plan_type_from_auth_config_data(auth_config: Any) -> str | None:
if not isinstance(auth_config, dict):
return None
# Codex: plan_type (free/plus/team/enterprise)
plan_type = normalize_oauth_plan_type(auth_config.get("plan_type"))
if plan_type:
return plan_type
# Antigravity: tier (PAID/FREE/...)
tier = normalize_oauth_plan_type(auth_config.get("tier"))
if tier:
return tier
return None
def decrypt_auth_config_to_dict(
encrypted_auth_config: str | None,
*,
silent: bool = True,
) -> dict[str, Any] | None:
if not encrypted_auth_config:
return None
try:
decrypted = crypto_service.decrypt(encrypted_auth_config, silent=silent)
parsed = json.loads(decrypted)
if isinstance(parsed, dict):
return parsed
except Exception:
return None
return None
def _strip_provider_prefix(value: str, provider_type: str | None = None) -> str:
normalized = value.strip()
if not normalized:
return ""
prefixes: list[str] = []
if isinstance(provider_type, str) and provider_type.strip():
prefixes.append(provider_type.strip())
# Kiro 目前将套餐信息记录为 "KIRO FREE" / "KIRO PRO+"。
if "kiro" not in {p.lower() for p in prefixes}:
prefixes.append("kiro")
upper = normalized.upper()
for prefix in prefixes:
prefix_upper = prefix.upper()
if upper == prefix_upper:
return ""
if upper.startswith(f"{prefix_upper} "):
return normalized[len(prefix) :].strip()
return normalized
def extract_oauth_plan_type_from_upstream_metadata(
upstream_metadata: Any,
*,
provider_type: str | None = None,
) -> str | None:
if not isinstance(upstream_metadata, dict):
return None
kiro_meta = upstream_metadata.get("kiro")
if isinstance(kiro_meta, dict):
subscription_title = kiro_meta.get("subscription_title")
if isinstance(subscription_title, str):
normalized = _strip_provider_prefix(subscription_title, provider_type=provider_type)
return normalize_oauth_plan_type(normalized)
return None
def extract_oauth_plan_type(
encrypted_auth_config: str | None,
*,
upstream_metadata: Any = None,
provider_type: str | None = None,
silent: bool = True,
) -> str | None:
auth_config = decrypt_auth_config_to_dict(encrypted_auth_config, silent=silent)
plan_type = extract_oauth_plan_type_from_auth_config_data(auth_config)
if plan_type:
return plan_type
return extract_oauth_plan_type_from_upstream_metadata(
upstream_metadata, provider_type=provider_type
)
def normalize_antigravity_tier(raw_tier: Any) -> str | None:
normalized = normalize_oauth_plan_type(raw_tier)
if not normalized:
return None
if "ultra" in normalized:
return "ultra"
if "pro" in normalized or "paid" in normalized:
return "pro"
if "free" in normalized or "legacy" in normalized:
return "free"
return normalized
def _extract_antigravity_tier_raw(tier_obj: Any) -> str | None:
if isinstance(tier_obj, str):
stripped = tier_obj.strip()
return stripped or None
if isinstance(tier_obj, dict):
for key in ("id", "tierType"):
value = tier_obj.get(key)
if isinstance(value, str):
stripped = value.strip()
if stripped:
return stripped
return None
def _format_antigravity_tier_label(
normalized_tier: str,
*,
fallback_raw: str | None = None,
) -> str:
if normalized_tier == "ultra":
return "Ultra"
if normalized_tier == "pro":
return "Pro"
if normalized_tier == "free":
return "Free"
if fallback_raw:
return fallback_raw
return normalized_tier
def extract_antigravity_tier_from_code_assist(code_assist: Any) -> str:
if not isinstance(code_assist, dict):
return "Free"
paid_tier_raw = _extract_antigravity_tier_raw(code_assist.get("paidTier"))
paid_tier = normalize_antigravity_tier(paid_tier_raw)
if paid_tier:
return _format_antigravity_tier_label(paid_tier, fallback_raw=paid_tier_raw)
current_tier_raw = _extract_antigravity_tier_raw(code_assist.get("currentTier"))
current_tier = normalize_antigravity_tier(current_tier_raw)
if current_tier:
return _format_antigravity_tier_label(current_tier, fallback_raw=current_tier_raw)
return "Free"

View File

@@ -0,0 +1,25 @@
"""
Provider 认证相关的数据类型。
从 api/handlers/base/request_builder.py 下沉到 core 层,
消除 services→api 的反向依赖。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass
class ProviderAuthInfo:
"""Provider 认证信息(用于 Service Account 等异步认证场景)"""
auth_header: str
auth_value: str
# 解密后的认证配置(用于 URL 构建等场景,避免重复解密)
decrypted_auth_config: dict[str, Any] | None = None
def as_tuple(self) -> tuple[str, str]:
"""返回 (auth_header, auth_value) 元组"""
return (self.auth_header, self.auth_value)

View File

@@ -0,0 +1,600 @@
from __future__ import annotations
import asyncio
import importlib
import json
import random
from typing import Any, Awaitable, Callable
from urllib.parse import quote, urlsplit, urlunsplit
import httpx
import jwt
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger # pyright: ignore
from src.core.provider_types import ProviderType
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
_OPENAI_ACCOUNTS_CHECK_URL = "https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
_RUST_ONLY_DETAILS = "OAuth provider 外呼仅支持 Rust executor"
def _coerce_proxy_url(proxy_config: dict[str, Any] | None) -> str | None:
"""为 tls-client 构建可用的代理 URLbest-effort
说明:
- core 层不解析 ProxyNodenode_id模式避免 core→services 反向依赖。
- 仅支持手工 URL 模式:{url, username, password, enabled}。
- node_id / tunnel 等复杂模式由 httpx 路径HTTPClientPool处理。
"""
if not proxy_config or not proxy_config.get("enabled", True):
return None
raw_url = proxy_config.get("url")
if not isinstance(raw_url, str) or not raw_url.strip():
return None
proxy_url = raw_url.strip()
username = proxy_config.get("username")
password = proxy_config.get("password")
if isinstance(username, str) and username.strip():
return _inject_auth_into_url(
proxy_url, username.strip(), str(password) if password else None
)
return proxy_url
def _inject_auth_into_url(url: str, username: str, password: str | None = None) -> str:
"""将用户名密码注入 URL仅用于 tls-client 同步请求)。"""
try:
parsed = urlsplit(url)
if not parsed.scheme or not parsed.hostname:
return url
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
host_part = parsed.hostname
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
auth_part = (
f"{encoded_username}:{encoded_password}" if encoded_password else encoded_username
)
netloc = f"{auth_part}@{host_part}"
return urlunsplit((parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment))
except Exception:
return url
def _proxy_display(proxy_config: dict[str, Any] | None) -> str | None:
"""生成用于日志输出的 proxy 摘要(不泄露认证信息)。"""
if not proxy_config or not proxy_config.get("enabled", True):
return None
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
return f"node_id:{node_id.strip()}"
proxy_url = _coerce_proxy_url(proxy_config)
if not proxy_url:
return None
try:
parts = urlsplit(proxy_url)
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
# 仅保留 scheme + host + path移除 userinfo/query/fragment
return urlunsplit((parts.scheme, host, parts.path, "", ""))
except Exception:
return "<invalid_proxy>"
def _redact_url(url: str) -> str:
"""Remove query and fragment to avoid leaking secrets in logs."""
try:
parts = urlsplit(url)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
except Exception:
return "<invalid_url>"
def _format_exc_chain(e: BaseException) -> str:
parts: list[str] = []
cur: BaseException | None = e
while cur is not None:
parts.append(f"{type(cur).__name__}: {cur}")
nxt = getattr(cur, "__cause__", None) or getattr(cur, "__context__", None)
cur = nxt if isinstance(nxt, BaseException) else None
return " <- ".join(parts)
def _load_optional_attr(module_name: str, attr_name: str) -> Any | None:
"""按需加载跨层 helper避免 core 层产生静态 services import。"""
try:
module = importlib.import_module(module_name)
except (ImportError, ModuleNotFoundError):
return None
return getattr(module, attr_name, None)
async def _httpx_post(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_config: dict[str, Any] | None,
timeout_seconds: float,
) -> httpx.Response:
client = await HTTPClientPool.get_proxy_client(proxy_config)
proxy_url = _proxy_display(proxy_config)
safe_url = _redact_url(url)
last_exc: Exception | None = None
# Only retry connection-level transient failures.
for attempt in range(2):
if attempt:
await asyncio.sleep(0.25 * attempt)
try:
return await client.post(
url,
headers=headers,
data=data,
json=json_body,
timeout=timeout_seconds,
)
except httpx.ConnectError as e:
last_exc = e
logger.warning(
"OAuth token POST connect error (attempt={}/2) url={} host={} proxy={} err_chain={} err={!r}",
attempt + 1,
safe_url,
urlsplit(url).netloc,
proxy_url,
_format_exc_chain(e),
e,
)
if attempt == 0:
continue
raise
except httpx.TimeoutException as e:
last_exc = e
logger.warning(
"OAuth token POST timeout (attempt={}/2) url={} host={} proxy={} err_chain={} err={!r}",
attempt + 1,
safe_url,
urlsplit(url).netloc,
proxy_url,
_format_exc_chain(e),
e,
)
# Retry only the first time for timeouts.
if attempt == 0:
continue
raise
except httpx.RequestError as e:
# Other request-level errors (proxy, TLS, etc). Log and re-raise.
last_exc = e
logger.error(
"OAuth token POST request error url={} host={} proxy={} err_chain={} err={!r}",
safe_url,
urlsplit(url).netloc,
proxy_url,
_format_exc_chain(e),
e,
)
raise
# Should not be reachable.
assert last_exc is not None
raise last_exc
def _tls_client_post_sync(
url: str,
*,
headers: dict[str, str] | None,
data: Any,
json_body: Any,
proxy_url: str | None,
timeout_seconds: float,
) -> tuple[int, dict[str, str], str]:
# tls-client is optional at runtime; import only when needed.
import tls_client # pyright: ignore[reportMissingImports]
session = tls_client.Session(
client_identifier="firefox_120",
random_tls_extension_order=True,
)
if proxy_url:
session.proxies = {"http": proxy_url, "https": proxy_url}
# tls-client uses a requests-like API.
resp = session.post(
url,
headers=headers or {},
data=data,
json=json_body,
timeout_seconds=timeout_seconds,
)
# Normalize output
status_code = int(getattr(resp, "status_code", 0))
text = str(getattr(resp, "text", ""))
resp_headers = dict(getattr(resp, "headers", {}) or {})
return status_code, resp_headers, text
async def post_oauth_token(
*,
provider_type: str,
token_url: str,
headers: dict[str, str] | None,
data: Any = None,
json_body: Any = None,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 30.0,
) -> httpx.Response:
"""POST to token endpoint.
Claude Code + Anthropic token URL will try tls-client (Firefox TLS fingerprint) first.
If tls-client is unavailable or fails, fall back to httpx.
IMPORTANT: Never log secrets (tokens, secrets). This function only logs generic errors.
"""
logger.warning(
"Skip Python oauth token request; Rust-only path required. provider_type={} token_url={}",
provider_type,
_redact_url(token_url),
)
return httpx.Response(
status_code=503,
json={"error": {"message": _RUST_ONLY_DETAILS, "type": "provider_unavailable"}},
request=httpx.Request("POST", token_url),
)
def _as_non_empty_str(value: Any) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
return text or None
def _first_non_empty_str(values: list[Any]) -> str | None:
for value in values:
text = _as_non_empty_str(value)
if text:
return text
return None
def _decode_unverified_jwt_payload(token: str) -> dict[str, Any] | None:
try:
claims = jwt.decode(
token,
options={
"verify_signature": False,
"verify_aud": False,
},
)
except Exception:
return None
return claims if isinstance(claims, dict) else None
def _extract_codex_fields_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
auth_info = claims.get("https://api.openai.com/auth")
auth = auth_info if isinstance(auth_info, dict) else {}
result: dict[str, Any] = {}
email = _first_non_empty_str(
[
claims.get("email"),
auth.get("email"),
]
)
if email:
result["email"] = email
account_id = _first_non_empty_str(
[
auth.get("chatgpt_account_id"),
auth.get("chatgptAccountId"),
auth.get("account_id"),
auth.get("accountId"),
claims.get("chatgpt_account_id"),
claims.get("chatgptAccountId"),
claims.get("account_id"),
claims.get("accountId"),
]
)
if account_id:
result["account_id"] = account_id
account_user_id = _first_non_empty_str(
[
auth.get("chatgpt_account_user_id"),
auth.get("chatgptAccountUserId"),
auth.get("account_user_id"),
auth.get("accountUserId"),
claims.get("chatgpt_account_user_id"),
claims.get("chatgptAccountUserId"),
claims.get("account_user_id"),
claims.get("accountUserId"),
]
)
if account_user_id:
result["account_user_id"] = account_user_id
plan_type = _first_non_empty_str(
[
auth.get("chatgpt_plan_type"),
auth.get("chatgptPlanType"),
auth.get("plan_type"),
auth.get("planType"),
claims.get("chatgpt_plan_type"),
claims.get("chatgptPlanType"),
claims.get("plan_type"),
claims.get("planType"),
]
)
if plan_type:
result["plan_type"] = plan_type
user_id = _first_non_empty_str(
[
auth.get("chatgpt_user_id"),
auth.get("chatgptUserId"),
auth.get("user_id"),
auth.get("userId"),
claims.get("chatgpt_user_id"),
claims.get("chatgptUserId"),
claims.get("user_id"),
claims.get("userId"),
claims.get("sub"),
]
)
if user_id:
result["user_id"] = user_id
organizations = auth.get("organizations")
if isinstance(organizations, list) and organizations:
result["organizations"] = organizations
return result
def parse_codex_id_token(id_token: Any) -> dict[str, Any]:
"""Parse Codex token payload without signature verification.
Supports:
- JWT string (typical id_token / sometimes access_token)
- JSON string containing claims
- Already-decoded dict payload
"""
claims: dict[str, Any] | None = None
if isinstance(id_token, dict):
claims = id_token
else:
token_text = _as_non_empty_str(id_token)
if not token_text:
return {}
if token_text.startswith("{"):
try:
payload = json.loads(token_text)
except Exception:
payload = None
if isinstance(payload, dict):
claims = payload
if claims is None:
claims = _decode_unverified_jwt_payload(token_text)
if not isinstance(claims, dict):
return {}
return _extract_codex_fields_from_claims(claims)
async def fetch_google_email(
access_token: str,
*,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> str | None:
logger.warning(
"Skip Python google userinfo request; Rust-only path required. has_token={} proxy={}",
bool(access_token),
_proxy_display(proxy_config),
)
return None
def _extract_openai_account_name(payload: Any, account_id: str) -> str | None:
if not isinstance(payload, dict):
return None
accounts = payload.get("accounts")
if isinstance(accounts, dict):
direct = accounts.get(account_id)
if isinstance(direct, dict):
account = direct.get("account")
account_info = account if isinstance(account, dict) else direct
direct_name = _as_non_empty_str(account_info.get("name"))
if direct_name:
return direct_name
for item in accounts.values():
if not isinstance(item, dict):
continue
account = item.get("account")
account_info = account if isinstance(account, dict) else item
matched_account_id = _first_non_empty_str(
[
account_info.get("id"),
account_info.get("account_id"),
account_info.get("accountId"),
item.get("id"),
item.get("account_id"),
item.get("accountId"),
]
)
if matched_account_id != account_id:
continue
matched_name = _as_non_empty_str(account_info.get("name"))
if matched_name:
return matched_name
return None
async def fetch_openai_account_name(
access_token: str,
account_id: str,
*,
proxy_config: dict[str, Any] | None = None,
timeout_seconds: float = 10.0,
) -> str | None:
logger.warning(
"Skip Python openai account lookup; Rust-only path required. has_token={} account_id={} proxy={}",
bool(access_token),
bool(account_id),
_proxy_display(proxy_config),
)
return None
def extract_claude_email_from_token_response(token: dict[str, Any]) -> str | None:
# CLIProxyAPI expects: { account: { email_address: ... } }
try:
account = token.get("account")
if isinstance(account, dict):
email = account.get("email_address")
if isinstance(email, str) and email:
return email
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Auth Enricher Registry
# ---------------------------------------------------------------------------
AuthEnricherFn = Callable[
[dict[str, Any], dict[str, Any], str, dict[str, Any] | None],
Awaitable[dict[str, Any]],
]
_auth_enrichers: dict[str, AuthEnricherFn] = {}
def register_auth_enricher(provider_type: str, enricher: AuthEnricherFn) -> None:
"""注册 provider 特有的 auth_config enrichment hook。"""
from src.core.provider_types import normalize_provider_type
_auth_enrichers[normalize_provider_type(provider_type)] = enricher
async def _enrich_claude_code(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
email = extract_claude_email_from_token_response(token_response)
if email:
auth_config["email"] = email
return auth_config
async def _enrich_gemini_cli(
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None,
) -> dict[str, Any]:
if not auth_config.get("email"):
email = await fetch_google_email(
access_token,
proxy_config=proxy_config,
timeout_seconds=10.0,
)
if email:
auth_config["email"] = email
return auth_config
def _bootstrap_auth_enrichers() -> None:
# 简单的内置 enrichers 直接注册(兜底,会被 plugin.register_all() 覆盖)
register_auth_enricher("claude_code", _enrich_claude_code)
register_auth_enricher("gemini_cli", _enrich_gemini_cli)
_bootstrap_auth_enrichers()
async def enrich_auth_config(
*,
provider_type: str,
auth_config: dict[str, Any],
token_response: dict[str, Any],
access_token: str,
proxy_config: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Enrich auth_config with non-secret metadata (email/account_id).
各 provider 的 enrichment 逻辑通过 register_auth_enricher 注册。
为支持按需 bootstrap这里会尝试按 provider_type 触发插件注册。
"""
from src.core.provider_types import normalize_provider_type
pt = normalize_provider_type(provider_type)
ensure_providers_bootstrapped = _load_optional_attr(
"src.services.provider.envelope",
"ensure_providers_bootstrapped",
)
if callable(ensure_providers_bootstrapped):
ensure_providers_bootstrapped(provider_types=[pt] if pt else None)
enricher = _auth_enrichers.get(pt)
if enricher:
return await enricher(auth_config, token_response, access_token, proxy_config)
return auth_config
def normalize_oauth_organizations(raw: Any) -> list[dict[str, Any]]:
"""Normalize raw organizations list from OAuth auth_config into a clean list of dicts."""
if not isinstance(raw, list):
return []
result: list[dict[str, Any]] = []
for item in raw:
if not isinstance(item, dict):
continue
normalized: dict[str, Any] = {}
org_id = item.get("id")
if isinstance(org_id, str) and org_id.strip():
normalized["id"] = org_id.strip()
title = item.get("title")
if isinstance(title, str) and title.strip():
normalized["title"] = title.strip()
role = item.get("role")
if isinstance(role, str) and role.strip():
normalized["role"] = role.strip()
if "is_default" in item:
normalized["is_default"] = bool(item.get("is_default"))
if normalized:
result.append(normalized)
return result

View File

@@ -0,0 +1,5 @@
"""固定 Provider 模板与 OAuth 常量。
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
敏感信息(如 client_secret / refresh_token / access_token不得出现在日志或 API 响应中。
"""

View File

@@ -0,0 +1,169 @@
"""固定 Provider 的模板定义。
该文件用于集中管理:
- 固定 Provider 的上游 API base_url / 固定路径策略(通常通过 EndpointDefinition.default_path + 锁定 custom_path=None
- 固定 Provider 的 OAuth2 客户端常量authorize/token/client_id/client_secret/scopes/redirect_uri
注意:该文件会直接包含从参考项目 CLIProxyAPI 复制的 OAuth client_id/client_secret敏感
务必确保:
- 不把 client_secret/refresh_token/access_token 输出到日志
- 不通过 API 响应把敏感信息返回给前端
"""
from __future__ import annotations
from dataclasses import dataclass
from src.core.provider_templates.types import ProviderType
# Antigravity 生产环境 URL唯一定义点services 层通过 re-export 引用)
ANTIGRAVITY_PROD_URL = "https://cloudcode-pa.googleapis.com"
@dataclass(frozen=True, slots=True)
class FixedProviderOAuth:
authorize_url: str
token_url: str
client_id: str
client_secret: str
scopes: list[str]
redirect_uri: str
use_pkce: bool
@dataclass(frozen=True, slots=True)
class FixedProviderTemplate:
provider_type: ProviderType
display_name: str
# 上游 APIProviderEndpoint.base_url 应锁定为该值custom_path 通常保持 None 使用 default_path
api_base_url: str
# 该 Provider 默认创建哪些 endpoint signature
endpoint_signatures: list[str]
# OAuth2 配置(用于生成授权 URL / 换 token / refresh
oauth: FixedProviderOAuth
# ------------------------------
# Fixed templates
# ------------------------------
# 说明client_id/client_secret 从 CLIProxyAPI/internal/auth 复制。
# 该文件包含敏感信息,务必避免输出到日志或 API 响应。
FIXED_PROVIDERS: dict[ProviderType, FixedProviderTemplate] = {
ProviderType.CLAUDE_CODE: FixedProviderTemplate(
provider_type=ProviderType.CLAUDE_CODE,
display_name="ClaudeCode",
api_base_url="https://api.anthropic.com",
endpoint_signatures=["claude:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://claude.ai/oauth/authorize",
token_url="https://console.anthropic.com/v1/oauth/token",
client_id="9d1c250a-e61b-44d9-88ed-5944d1962f5e",
client_secret="",
scopes=["org:create_api_key", "user:profile", "user:inference"],
redirect_uri="http://localhost:54545/callback",
use_pkce=True,
),
),
ProviderType.CODEX: FixedProviderTemplate(
provider_type=ProviderType.CODEX,
display_name="Codex",
api_base_url="https://chatgpt.com/backend-api/codex",
endpoint_signatures=["openai:cli", "openai:compact"],
oauth=FixedProviderOAuth(
authorize_url="https://auth.openai.com/oauth/authorize",
token_url="https://auth.openai.com/oauth/token",
client_id="app_EMoamEEZ73f0CkXaXp7hrann",
client_secret="",
scopes=["openid", "email", "profile", "offline_access"],
redirect_uri="http://localhost:1455/auth/callback",
use_pkce=True,
),
),
ProviderType.KIRO: FixedProviderTemplate(
provider_type=ProviderType.KIRO,
display_name="Kiro",
# Region is resolved from per-key auth_config (imported credentials).
# Keep a templated base_url so endpoints remain fixed/locked.
api_base_url="https://q.{region}.amazonaws.com",
endpoint_signatures=["claude:cli"],
# Kiro does not support Aether's OAuth flow; credentials are imported.
# Keep a placeholder OAuth config so the fixed-provider template shape stays stable.
oauth=FixedProviderOAuth(
authorize_url="",
token_url="",
client_id="",
client_secret="",
scopes=[],
redirect_uri="",
use_pkce=False,
),
),
ProviderType.GEMINI_CLI: FixedProviderTemplate(
provider_type=ProviderType.GEMINI_CLI,
display_name="GeminiCli",
api_base_url="https://cloudcode-pa.googleapis.com",
endpoint_signatures=["gemini:cli"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
redirect_uri="http://localhost:8085/oauth2callback",
use_pkce=False,
),
),
ProviderType.VERTEX_AI: FixedProviderTemplate(
provider_type=ProviderType.VERTEX_AI,
display_name="Vertex AI",
# Vertex uses fixed global base URL; concrete upstream path is selected by transport hook.
api_base_url="https://aiplatform.googleapis.com",
endpoint_signatures=["gemini:chat", "claude:chat"],
# Vertex does not use this OAuth flow (it uses API Key / Service Account).
oauth=FixedProviderOAuth(
authorize_url="",
token_url="",
client_id="",
client_secret="",
scopes=[],
redirect_uri="",
use_pkce=False,
),
),
ProviderType.ANTIGRAVITY: FixedProviderTemplate(
provider_type=ProviderType.ANTIGRAVITY,
display_name="Antigravity",
api_base_url=ANTIGRAVITY_PROD_URL,
endpoint_signatures=["gemini:chat"],
oauth=FixedProviderOAuth(
authorize_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
client_id="1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
client_secret="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",
scopes=[
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
redirect_uri="http://localhost:51121/oauth2callback",
use_pkce=True,
),
),
}
__all__ = [
"FixedProviderOAuth",
"FixedProviderTemplate",
"FIXED_PROVIDERS",
]

View File

@@ -0,0 +1,7 @@
"""Backward-compat re-export — canonical definition is in src.core.provider_types."""
from __future__ import annotations
from src.core.provider_types import ProviderType
__all__ = ["ProviderType"]

View File

@@ -0,0 +1,46 @@
"""Provider type 枚举与工具函数。
所有 provider_type 相关的比较、判断应使用此模块中的 ProviderType 枚举,
避免到处散落字符串字面量。
由于 ProviderType 继承自 str``ProviderType.ANTIGRAVITY == "antigravity"``
始终为 True因此与已有数据库值、序列化格式完全兼容。
"""
from __future__ import annotations
from enum import Enum
class ProviderType(str, Enum):
"""已支持的 Provider 类型。"""
CUSTOM = "custom"
CLAUDE_CODE = "claude_code"
KIRO = "kiro"
CODEX = "codex"
GEMINI_CLI = "gemini_cli"
ANTIGRAVITY = "antigravity"
VERTEX_AI = "vertex_ai"
# 所有有效 provider_type 值的集合(用于校验)
VALID_PROVIDER_TYPES: frozenset[str] = frozenset(pt.value for pt in ProviderType)
def normalize_provider_type(value: object) -> str:
"""将任意输入规范化为小写 provider_type 字符串。
统一处理 ``str(getattr(provider, "provider_type", "") or "").strip().lower()``
这类散落在各处的 normalize 逻辑。
"""
if isinstance(value, ProviderType):
return value.value
return str(value or "").strip().lower()
__all__ = [
"VALID_PROVIDER_TYPES",
"ProviderType",
"normalize_provider_type",
]

View File

@@ -0,0 +1,54 @@
"""Redis batch-delete utilities.
Provides SCAN-based pattern deletion with UNLINK preference to minimise
blocking on the Redis server. Used by cache monitoring and affinity
manager modules.
"""
from __future__ import annotations
from typing import Any
from src.core.logger import logger
# Defaults matching existing usage across the codebase.
DEFAULT_SCAN_BATCH_SIZE = 200
DEFAULT_DELETE_BATCH_SIZE = 500
async def delete_redis_keys(redis: Any, keys: list[str]) -> int:
"""Batch-delete Redis keys, preferring UNLINK over DELETE."""
if not keys:
return 0
try:
unlink = getattr(redis, "unlink", None)
if callable(unlink):
return int(await unlink(*keys))
except Exception as exc:
logger.debug("Redis UNLINK failed, falling back to DELETE: {}", exc)
return int(await redis.delete(*keys))
async def scan_delete_pattern(
redis: Any,
pattern: str,
*,
scan_batch_size: int = DEFAULT_SCAN_BATCH_SIZE,
delete_batch_size: int = DEFAULT_DELETE_BATCH_SIZE,
) -> int:
"""SCAN + batch-delete keys matching *pattern* without blocking Redis."""
deleted_count = 0
cursor: int | str = 0
while True:
cursor, keys = await redis.scan(cursor=cursor, match=pattern, count=scan_batch_size)
if keys:
for i in range(0, len(keys), delete_batch_size):
batch = keys[i : i + delete_batch_size]
deleted_count += await delete_redis_keys(redis, batch)
if int(cursor) == 0:
break
return deleted_count

View File

@@ -0,0 +1,466 @@
"""
系统韧性和风险管控模块
提供全局的错误处理、自动恢复、降级策略和用户友好的错误体验
"""
from __future__ import annotations
import asyncio
import functools
import threading
import time
import traceback
import uuid
from collections import deque
from collections.abc import Callable
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any
from src.core.logger import logger
from ..core.exceptions import ProxyException
class ErrorSeverity(Enum):
"""错误严重程度"""
LOW = "low" # 低级错误,不影响核心功能
MEDIUM = "medium" # 中级错误,影响部分功能
HIGH = "high" # 高级错误,影响主要功能
CRITICAL = "critical" # 严重错误,影响系统可用性
class RecoveryStrategy(Enum):
"""恢复策略"""
RETRY = "retry" # 重试
FALLBACK = "fallback" # 降级
CIRCUIT_BREAKER = "circuit_breaker" # 熔断
GRACEFUL_DEGRADE = "graceful_degrade" # 优雅降级
USER_NOTIFY = "user_notify" # 通知用户
class ErrorPattern:
"""错误模式定义"""
def __init__(
self,
error_types: list[type[Exception]],
severity: ErrorSeverity,
recovery_strategy: RecoveryStrategy,
user_message: str,
auto_recover: bool = True,
max_retries: int = 3,
retry_delay: float = 1.0,
circuit_threshold: int = 5,
):
self.error_types = error_types
self.severity = severity
self.recovery_strategy = recovery_strategy
self.user_message = user_message
self.auto_recover = auto_recover
self.max_retries = max_retries
self.retry_delay = retry_delay
self.circuit_threshold = circuit_threshold
class CircuitBreaker:
"""熔断器"""
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
self.failure_threshold = failure_threshold
self.timeout = timeout
self.failure_count = 0
self.last_failure_time = None
self.state = "closed" # closed, open, half-open
self._lock = threading.Lock()
def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""执行函数调用,应用熔断逻辑"""
with self._lock:
if self.state == "open":
if self._should_attempt_reset():
self.state = "half-open"
else:
raise Exception("服务暂时不可用,请稍后重试")
try:
result = func(*args, **kwargs)
self._on_success()
return result
except Exception as e:
self._on_failure()
raise
def _should_attempt_reset(self) -> bool:
"""检查是否应该尝试重置熔断器"""
if self.last_failure_time is None:
return True
return time.time() - self.last_failure_time >= self.timeout
def _on_success(self) -> None:
"""成功时重置计数器"""
self.failure_count = 0
self.state = "closed"
def _on_failure(self) -> None:
"""失败时增加计数器"""
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "open"
class ResilienceManager:
"""系统韧性管理器"""
_MAX_ERROR_STATS = 500
_MAX_CIRCUIT_BREAKERS = 200
_MAX_LAST_ERRORS = 100
def __init__(self) -> None:
self.error_patterns: list[ErrorPattern] = []
self.circuit_breakers: dict[str, CircuitBreaker] = {}
self.error_stats: dict[str, int] = {}
self.last_errors: deque[dict[str, Any]] = deque(maxlen=self._MAX_LAST_ERRORS)
self._setup_default_patterns()
def _setup_default_patterns(self) -> None:
"""设置默认错误处理模式"""
# 数据库连接错误 - 只捕获特定的数据库相关异常
try:
from sqlalchemy.exc import (
DisconnectionError,
OperationalError,
ProgrammingError,
)
from sqlalchemy.exc import TimeoutError as SQLTimeoutError
# SQL/Schema 编程错误(如缺列/缺表)不应误判为“连接异常重试”。
self.add_error_pattern(
ErrorPattern(
error_types=[ProgrammingError],
severity=ErrorSeverity.HIGH,
recovery_strategy=RecoveryStrategy.USER_NOTIFY,
user_message="数据库结构与当前版本不兼容,请执行 alembic upgrade head 后重试",
auto_recover=False,
)
)
db_exceptions = [
OperationalError,
DisconnectionError,
SQLTimeoutError,
]
except ImportError:
# 如果SQLAlchemy不可用使用通用异常类型
db_exceptions = [ConnectionError, OSError]
self.add_error_pattern(
ErrorPattern(
error_types=db_exceptions,
severity=ErrorSeverity.HIGH,
recovery_strategy=RecoveryStrategy.RETRY,
user_message="数据库连接异常,正在重试...",
max_retries=3,
retry_delay=1.0,
)
)
# 认证相关错误 - 只捕获特定的认证异常
try:
from ..core.exceptions import ForbiddenException, ProviderAuthException
auth_exceptions = [ProviderAuthException, ForbiddenException]
except ImportError:
# 如果无法导入特定异常,使用更保守的方式(不使用通用异常)
auth_exceptions = []
if auth_exceptions:
self.add_error_pattern(
ErrorPattern(
error_types=auth_exceptions,
severity=ErrorSeverity.MEDIUM,
recovery_strategy=RecoveryStrategy.USER_NOTIFY,
user_message="认证失败请检查API密钥或重新登录",
auto_recover=False,
)
)
# 网络请求错误
self.add_error_pattern(
ErrorPattern(
error_types=[ConnectionError, TimeoutError],
severity=ErrorSeverity.MEDIUM,
recovery_strategy=RecoveryStrategy.FALLBACK,
user_message="网络连接异常,正在尝试备用方案...",
max_retries=2,
)
)
def add_error_pattern(self, pattern: ErrorPattern) -> None:
"""添加错误处理模式"""
self.error_patterns.append(pattern)
def get_circuit_breaker(self, key: str) -> CircuitBreaker:
"""获取或创建熔断器"""
if key not in self.circuit_breakers:
# 淘汰旧熔断器,防止无界增长
if len(self.circuit_breakers) >= self._MAX_CIRCUIT_BREAKERS:
# 优先淘汰已恢复(closed)的
closed_keys = [k for k, cb in self.circuit_breakers.items() if cb.state == "closed"]
if closed_keys:
for k in closed_keys:
del self.circuit_breakers[k]
else:
# 全部处于 open/half-open按最后失败时间淘汰最旧的一半
sorted_keys = sorted(
self.circuit_breakers,
key=lambda cb_key: self.circuit_breakers[cb_key].last_failure_time or 0,
)
for k in sorted_keys[: len(sorted_keys) // 2 or 1]:
del self.circuit_breakers[k]
self.circuit_breakers[key] = CircuitBreaker()
return self.circuit_breakers[key]
def handle_error(
self, error: Exception, context: dict[str, Any] = None, operation: str = "unknown"
) -> dict[str, Any]:
"""处理错误并返回处理结果"""
error_id = str(uuid.uuid4())[:8]
context = context or {}
# 记录错误
error_info = {
"error_id": error_id,
"error_type": type(error).__name__,
"error_message": str(error),
"operation": operation,
"context": context,
"timestamp": datetime.now(timezone.utc),
"traceback": traceback.format_exc(),
}
self.last_errors.append(error_info)
# 更新错误统计(超上限时淘汰计数最低的条目)
error_key = f"{type(error).__name__}:{operation}"
self.error_stats[error_key] = self.error_stats.get(error_key, 0) + 1
if len(self.error_stats) > self._MAX_ERROR_STATS:
min_key = min(
(k for k in self.error_stats if k != error_key),
key=lambda k: self.error_stats[k],
default=None,
)
if min_key is not None:
del self.error_stats[min_key]
# 查找匹配的错误处理模式
pattern = self._find_matching_pattern(error)
if pattern:
logger.error(f"错误处理 [{error_id}]: {pattern.user_message}")
return {
"error_id": error_id,
"severity": pattern.severity,
"recovery_strategy": pattern.recovery_strategy,
"user_message": pattern.user_message,
"auto_recover": pattern.auto_recover,
"pattern": pattern,
}
else:
# 未匹配的错误,使用默认处理
logger.error(f"未知错误 [{error_id}]: {str(error)}")
return {
"error_id": error_id,
"severity": ErrorSeverity.MEDIUM,
"recovery_strategy": RecoveryStrategy.USER_NOTIFY,
"user_message": "系统遇到未知错误,请稍后重试或联系管理员",
"auto_recover": False,
"pattern": None,
}
def _find_matching_pattern(self, error: Exception) -> ErrorPattern | None:
"""查找匹配的错误处理模式"""
for pattern in self.error_patterns:
if any(isinstance(error, error_type) for error_type in pattern.error_types):
return pattern
return None
def get_error_stats(self) -> dict[str, Any]:
"""获取错误统计"""
return {
"total_errors": sum(self.error_stats.values()),
"error_breakdown": self.error_stats.copy(),
"recent_errors": len(self.last_errors),
"circuit_breakers": {
key: {"state": cb.state, "failure_count": cb.failure_count}
for key, cb in self.circuit_breakers.items()
},
}
# 全局韧性管理器实例
resilience_manager = ResilienceManager()
def resilient_operation(
operation_name: str | None = None,
max_retries: int | None = None,
retry_delay: float | None = None,
circuit_breaker_key: str | None = None,
context: dict[str, Any] = None,
) -> Any:
"""
韧性操作装饰器
自动处理重试、熔断、错误记录等
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
op_name = operation_name or f"{func.__module__}.{func.__name__}"
retries = max_retries or 3
delay = retry_delay or 1.0
last_error = None
for attempt in range(retries + 1):
try:
# 如果指定了熔断器,使用熔断逻辑
if circuit_breaker_key:
cb = resilience_manager.get_circuit_breaker(circuit_breaker_key)
if asyncio.iscoroutinefunction(func):
return await cb.call(func, *args, **kwargs)
else:
return cb.call(func, *args, **kwargs)
else:
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
except Exception as e:
last_error = e
# 处理错误
error_result = resilience_manager.handle_error(
error=e,
context={**(context or {}), "attempt": attempt + 1, "max_retries": retries},
operation=op_name,
)
# 如果是最后一次尝试,或者不应该自动恢复,直接抛出
if attempt == retries or not error_result.get("auto_recover", True):
raise ProxyException(
status_code=500,
error_type="system_error",
message=error_result["user_message"],
details={
"error_id": error_result["error_id"],
"original_error": str(e),
},
)
# 等待后重试
if attempt < retries:
await asyncio.sleep(delay * (attempt + 1)) # 指数退避
# 这里不应该到达,但作为安全网
raise last_error
@functools.wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> None:
# 对于同步函数,创建异步包装器并运行
return asyncio.run(async_wrapper(*args, **kwargs))
# 根据函数类型返回对应的包装器
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return sync_wrapper
return decorator
@asynccontextmanager
async def safe_operation(operation_name: str, context: dict[str, Any] = None) -> Any:
"""
安全操作上下文管理器
自动处理异常并提供用户友好的错误信息
"""
try:
yield
except Exception as e:
error_result = resilience_manager.handle_error(
error=e, context=context or {}, operation=operation_name
)
# 根据错误严重程度决定是否抛出异常
if error_result["severity"] in [ErrorSeverity.HIGH, ErrorSeverity.CRITICAL]:
raise ProxyException(
status_code=500,
error_type="system_error",
message=error_result["user_message"],
details={"error_id": error_result["error_id"]},
)
else:
# 记录警告但不中断操作
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 decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
try:
if asyncio.iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return func(*args, **kwargs)
except Exception as e:
logger.warning(f"主要功能失败,启用降级模式: {func.__name__}")
if fallback_func:
try:
if asyncio.iscoroutinefunction(fallback_func):
return await fallback_func(*args, **kwargs)
else:
return fallback_func(*args, **kwargs)
except Exception as fallback_error:
logger.exception(f"降级方案也失败了: {fallback_func.__name__}")
raise e # 抛出原始错误
else:
return fallback_value
if asyncio.iscoroutinefunction(func):
return async_wrapper
else:
return lambda *args, **kwargs: asyncio.run(async_wrapper(*args, **kwargs))
return decorator
# 导出主要接口
__all__ = [
"resilience_manager",
"resilient_operation",
"safe_operation",
"graceful_degradation",
"ErrorSeverity",
"RecoveryStrategy",
"ErrorPattern",
]

View File

@@ -0,0 +1,234 @@
"""
响应解析器基类与流式统计类型。
从 api/handlers/base/response_parser.py 下沉到 core 层,
消除 services→api 的反向依赖。同时提供 parser 注册表,
允许 API 层注册具体实现services 层通过 format_id 获取实例。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ParsedChunk:
"""解析后的流式数据块"""
# 原始数据
raw_line: str
event_type: str | None = None
data: dict[str, Any] | None = None
# 提取的内容
text_delta: str = ""
is_done: bool = False
is_error: bool = False
error_message: str | None = None
# 使用量信息(通常在最后一个 chunk 中)
input_tokens: int = 0
output_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
# 响应 ID
response_id: str | None = None
@dataclass
class StreamStats:
"""流式响应统计信息"""
# 计数
chunk_count: int = 0
data_count: int = 0
# Token 使用量
input_tokens: int = 0
output_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
# 内容
collected_text: str = ""
response_id: str | None = None
# 状态
has_completion: bool = False
status_code: int = 200
error_message: str | None = None
# Provider 信息
provider_name: str | None = None
endpoint_id: str | None = None
key_id: str | None = None
# 响应头和完整响应
response_headers: dict[str, str] = field(default_factory=dict)
final_response: dict[str, Any] | None = None
@dataclass
class ParsedResponse:
"""解析后的非流式响应"""
# 原始响应
raw_response: dict[str, Any]
status_code: int
# 提取的内容
text_content: str = ""
response_id: str | None = None
# 使用量
input_tokens: int = 0
output_tokens: int = 0
cache_creation_tokens: int = 0
cache_read_tokens: int = 0
# 错误信息
is_error: bool = False
error_type: str | None = None
error_message: str | None = None
# 从响应体解析出的嵌套状态码(当 HTTP 200 但响应体含错误时使用)
embedded_status_code: int | None = None
class ResponseParser(ABC):
"""
响应解析器基类
定义统一的接口来解析不同 API 格式的响应。
子类需要实现具体的解析逻辑。
"""
# 解析器名称(用于日志)
name: str = "base"
# 支持的 API 格式
api_format: str = "UNKNOWN"
@abstractmethod
def parse_sse_line(self, line: str, stats: StreamStats) -> ParsedChunk | None:
"""
解析单行 SSE 数据
Args:
line: SSE 行数据
stats: 流统计对象(会被更新)
Returns:
解析后的数据块,如果行不包含有效数据则返回 None
"""
pass
@abstractmethod
def parse_response(self, response: dict[str, Any], status_code: int) -> ParsedResponse:
"""
解析非流式响应
Args:
response: 响应 JSON
status_code: HTTP 状态码
Returns:
解析后的响应对象
"""
pass
@abstractmethod
def extract_usage_from_response(self, response: dict[str, Any]) -> dict[str, int]:
"""
从响应中提取 token 使用量
Args:
response: 响应 JSON
Returns:
包含 input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens 的字典
"""
pass
@abstractmethod
def extract_text_content(self, response: dict[str, Any]) -> str:
"""
从响应中提取文本内容
Args:
response: 响应 JSON
Returns:
提取的文本内容
"""
pass
def is_error_response(self, response: dict[str, Any]) -> bool:
"""
判断响应是否为错误响应
Args:
response: 响应 JSON
Returns:
是否为错误响应
"""
return "error" in response
def create_stats(self) -> StreamStats:
"""创建新的流统计对象"""
return StreamStats()
# ---------------------------------------------------------------------------
# Parser 注册表 -- API 层注册具体实现services 层通过 format_id 获取
# ---------------------------------------------------------------------------
_PARSER_REGISTRY: dict[str, type[ResponseParser]] = {}
def register_parser(format_id: str, parser_class: type[ResponseParser]) -> None:
"""注册一个格式对应的 ResponseParser 实现"""
from src.core.api_format.signature import normalize_signature_key
normalized = normalize_signature_key(format_id)
_PARSER_REGISTRY[normalized] = parser_class
def get_parser_for_format(format_id: str) -> ResponseParser:
"""
根据格式 ID 获取 ResponseParser 实例
Args:
format_id: endpoint signature"claude:chat", "openai:cli"
Returns:
ResponseParser 实例
Raises:
KeyError: 格式不存在
"""
from src.core.api_format.signature import normalize_signature_key
if not _PARSER_REGISTRY:
raise KeyError(
f"Parser registry is empty when looking up '{format_id}'. "
"Ensure parsers are registered at startup (import src.api.handlers.base.parsers)."
)
normalized = normalize_signature_key(format_id)
# 1. 精确匹配
if normalized in _PARSER_REGISTRY:
return _PARSER_REGISTRY[normalized]()
# 2. data_format_id 回退:如 "claude:cli" (dfid="claude") -> ClaudeResponseParser (dfid="claude")
from src.core.api_format.metadata import get_data_format_id_for_endpoint
target_dfid = get_data_format_id_for_endpoint(normalized)
if target_dfid:
for reg_key, parser_cls in _PARSER_REGISTRY.items():
reg_dfid = get_data_format_id_for_endpoint(reg_key)
if reg_dfid == target_dfid:
return parser_cls()
raise KeyError(f"Unknown format: {normalized}")

View File

@@ -0,0 +1,145 @@
"""
Usage 相关的 token 解析工具。
该模块用于从不同上游的 usage 结构中提取缓存 token 信息(兼容多种字段命名)。
放在 core 层,便于 services/api 共用,避免跨层反向依赖。
"""
from __future__ import annotations
from typing import Any
from src.core.logger import logger
def extract_cache_creation_tokens(usage: dict[str, Any]) -> int:
"""
提取缓存创建 tokens兼容三种格式
根据 Anthropic API 文档,支持三种格式(按优先级):
1. **嵌套格式(优先级最高)**
usage.cache_creation.ephemeral_5m_input_tokens
usage.cache_creation.ephemeral_1h_input_tokens
2. **扁平新格式(优先级第二)**
usage.claude_cache_creation_5_m_tokens
usage.claude_cache_creation_1_h_tokens
3. **旧格式(优先级第三)**
usage.cache_creation_input_tokens
说明:
- 只要检测到新格式字段(嵌套/扁平),即视为权威来源:哪怕值为 0 也不回退到旧字段。
- 仅当新格式字段完全不存在时,才回退到旧字段。
Args:
usage: API 响应中的 usage 字典
Returns:
缓存创建 tokens 总数
"""
# 1. 检查嵌套格式(最新格式)
cache_creation = usage.get("cache_creation")
has_nested_format = isinstance(cache_creation, dict) and (
"ephemeral_5m_input_tokens" in cache_creation
or "ephemeral_1h_input_tokens" in cache_creation
)
if has_nested_format:
cache_5m = int(cache_creation.get("ephemeral_5m_input_tokens", 0))
cache_1h = int(cache_creation.get("ephemeral_1h_input_tokens", 0))
total = cache_5m + cache_1h
logger.debug(
"Using nested cache_creation: 5m={}, 1h={}, total={}",
cache_5m,
cache_1h,
total,
)
return total
# 2. 检查扁平新格式
has_flat_format = (
"claude_cache_creation_5_m_tokens" in usage or "claude_cache_creation_1_h_tokens" in usage
)
if has_flat_format:
cache_5m = int(usage.get("claude_cache_creation_5_m_tokens", 0))
cache_1h = int(usage.get("claude_cache_creation_1_h_tokens", 0))
total = cache_5m + cache_1h
logger.debug("Using flat new format: 5m={}, 1h={}, total={}", cache_5m, cache_1h, total)
return total
# 3. 回退到旧格式
old_format = int(usage.get("cache_creation_input_tokens", 0))
if old_format > 0:
logger.debug("Using old format: cache_creation_input_tokens={}", old_format)
return old_format
def extract_cache_creation_tokens_detail(usage: dict[str, Any]) -> tuple[int, int, int]:
"""
提取缓存创建 tokens 细分(区分 5m 和 1h
返回 (total, tokens_5m, tokens_1h) 三元组。
当无法区分时tokens_5m 和 tokens_1h 均为 0total 为合计值。
"""
# 1. 嵌套格式
cache_creation = usage.get("cache_creation")
if isinstance(cache_creation, dict) and (
"ephemeral_5m_input_tokens" in cache_creation
or "ephemeral_1h_input_tokens" in cache_creation
):
t5m = int(cache_creation.get("ephemeral_5m_input_tokens", 0))
t1h = int(cache_creation.get("ephemeral_1h_input_tokens", 0))
return t5m + t1h, t5m, t1h
# 2. 扁平新格式
if "claude_cache_creation_5_m_tokens" in usage or "claude_cache_creation_1_h_tokens" in usage:
t5m = int(usage.get("claude_cache_creation_5_m_tokens", 0))
t1h = int(usage.get("claude_cache_creation_1_h_tokens", 0))
return t5m + t1h, t5m, t1h
# 3. 旧格式:无法区分
old = int(usage.get("cache_creation_input_tokens", 0))
return old, 0, 0
def extract_cache_read_tokens(usage: dict[str, Any]) -> int:
"""
提取缓存读取 tokens兼容多种 OpenAI / Claude / Gemini 字段命名)。
优先级:
1. 直接字段cache_read_input_tokens / cache_read_tokens
2. OpenAI Responses: input_tokens_details.cached_tokens
3. OpenAI Chat: prompt_tokens_details.cached_tokens
4. 通用回退cached_tokens
说明:
- 只要检测到更高优先级字段存在,即便值为 0 也不继续回退,
避免被较低优先级字段覆盖。
"""
if "cache_read_input_tokens" in usage:
return int(usage.get("cache_read_input_tokens", 0) or 0)
if "cache_read_tokens" in usage:
return int(usage.get("cache_read_tokens", 0) or 0)
input_details = usage.get("input_tokens_details")
if isinstance(input_details, dict) and "cached_tokens" in input_details:
return int(input_details.get("cached_tokens", 0) or 0)
prompt_details = usage.get("prompt_tokens_details")
if isinstance(prompt_details, dict) and "cached_tokens" in prompt_details:
return int(prompt_details.get("cached_tokens", 0) or 0)
return int(usage.get("cached_tokens", 0) or 0)
__all__ = [
"extract_cache_creation_tokens",
"extract_cache_creation_tokens_detail",
"extract_cache_read_tokens",
]

View File

@@ -0,0 +1,179 @@
"""
输入验证器
包含密码复杂度验证和其他输入验证
"""
from __future__ import annotations
import re
from enum import Enum
class PasswordPolicyLevel(str, Enum):
"""密码策略级别。"""
WEAK = "weak"
MEDIUM = "medium"
STRONG = "strong"
class PasswordValidator:
"""密码复杂度验证器"""
MIN_LENGTH = 6
MEDIUM_MIN_LENGTH = 8
STRONG_MIN_LENGTH = 8
MAX_BYTES = 72
@classmethod
def get_byte_length(cls, password: str) -> int:
"""返回密码的 UTF-8 字节长度。"""
return len(password.encode("utf-8"))
@classmethod
def normalize_policy(cls, policy: PasswordPolicyLevel | str | None) -> PasswordPolicyLevel:
"""规范化密码策略级别,异常值回退为弱策略。"""
if isinstance(policy, PasswordPolicyLevel):
return policy
if isinstance(policy, str):
normalized = policy.strip().lower()
try:
return PasswordPolicyLevel(normalized)
except ValueError:
pass
return PasswordPolicyLevel.WEAK
@classmethod
def validate_basic_input(cls, password: str) -> tuple[bool, str | None]:
"""验证密码输入的基础约束,不修改原始内容。"""
if password == "":
return False, "密码不能为空"
if cls.get_byte_length(password) > cls.MAX_BYTES:
return False, f"密码长度不能超过{cls.MAX_BYTES}字节"
return True, None
@classmethod
def validate_login_input(cls, password: str) -> tuple[bool, str | None]:
"""验证登录时的密码输入,不修改原始内容。"""
return cls.validate_basic_input(password)
@classmethod
def validate(
cls,
password: str,
policy: PasswordPolicyLevel | str | None = None,
) -> tuple[bool, str | None]:
"""验证密码复杂度。"""
if not password:
return False, "密码不能为空"
if cls.get_byte_length(password) > cls.MAX_BYTES:
return False, f"密码长度不能超过{cls.MAX_BYTES}字节"
policy_level = cls.normalize_policy(policy)
# 根据策略级别确定最小长度,避免分两次报长度错误
if policy_level in (PasswordPolicyLevel.MEDIUM, PasswordPolicyLevel.STRONG):
min_len = cls.MEDIUM_MIN_LENGTH
else:
min_len = cls.MIN_LENGTH
if len(password) < min_len:
return False, f"密码长度至少为{min_len}个字符"
if policy_level == PasswordPolicyLevel.MEDIUM:
if not re.search(r"[A-Za-z]", password):
return False, "密码必须包含至少一个字母"
if not re.search(r"\d", password):
return False, "密码必须包含至少一个数字"
elif policy_level == PasswordPolicyLevel.STRONG:
if not re.search(r"[A-Z]", password):
return False, "密码必须包含至少一个大写字母"
if not re.search(r"[a-z]", password):
return False, "密码必须包含至少一个小写字母"
if not re.search(r"\d", password):
return False, "密码必须包含至少一个数字"
if not re.search(r"[!@#$%^&*()_+\-=\[\]{};:'\",.<>?/\\|`~]", password):
return False, "密码必须包含至少一个特殊字符"
return True, None
class EmailValidator:
"""邮箱验证器"""
EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
@classmethod
def validate(cls, email: str) -> tuple[bool, str | None]:
"""
验证邮箱格式
Args:
email: 待验证的邮箱
Returns:
(是否通过, 错误消息)
"""
if not email:
return False, "邮箱不能为空"
if len(email) > 255:
return False, "邮箱长度不能超过255个字符"
if not cls.EMAIL_REGEX.match(email):
return False, "邮箱格式不正确"
return True, None
class UsernameValidator:
"""用户名验证器"""
MIN_LENGTH = 3
MAX_LENGTH = 30
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_.\-]+$")
@classmethod
def validate(cls, username: str) -> tuple[bool, str | None]:
"""
验证用户名
Args:
username: 待验证的用户名
Returns:
(是否通过, 错误消息)
"""
if not username:
return False, "用户名不能为空"
if len(username) < cls.MIN_LENGTH:
return False, f"用户名长度至少为{cls.MIN_LENGTH}个字符"
if len(username) > cls.MAX_LENGTH:
return False, f"用户名长度不能超过{cls.MAX_LENGTH}个字符"
if not cls.USERNAME_REGEX.match(username):
return False, "用户名只能包含字母、数字、下划线、连字符和点号"
# 检查保留用户名
reserved_names = [
"admin",
"root",
"system",
"api",
"test",
"demo",
"user",
"guest",
"bot",
"webhook",
"support",
]
if username.lower() in reserved_names:
return False, "该用户名为系统保留用户名"
return True, None

View File

@@ -0,0 +1,225 @@
"""
Vertex AI Service Account 认证服务
用于处理 Google Service Account 凭证的 JWT 签名和 Access Token 获取。
Access Token 会被缓存,直到过期前 60 秒才刷新。
"""
from __future__ import annotations
import json
import time
from collections import OrderedDict
from typing import Any
import httpx
import jwt
from src.core.logger import logger
class VertexAuthError(Exception):
"""Vertex AI 认证错误"""
pass
def _mask_email(email: str) -> str:
"""脱敏邮箱地址,如 foo@bar.iam.gserviceaccount.com -> foo@***.com"""
if "@" not in email:
return email[:8] + "***" if len(email) > 8 else "***"
local, domain = email.rsplit("@", 1)
# 保留 local 部分前几个字符和域名后缀
masked_local = local[:6] + "***" if len(local) > 6 else local
parts = domain.rsplit(".", 1)
suffix = f".{parts[-1]}" if len(parts) > 1 else ""
return f"{masked_local}@***{suffix}"
class VertexAuthService:
"""
Vertex AI Service Account 认证服务
用于将 Service Account JSON 凭证转换为 Access Token。
使用方式:
service = VertexAuthService(service_account_json)
token = await service.get_access_token()
project_id = service.project_id
# 使用 token 和 project_id 构建请求
"""
# Token 缓存:使用 OrderedDict 实现 LRU
# key = client_email, value = (token, expires_at)
_token_cache: OrderedDict[str, tuple[str, float]] = OrderedDict()
_cache_max_size: int = 100 # 最多缓存 100 个 Service Account 的 Token
# Token 请求端点
TOKEN_URL = "https://oauth2.googleapis.com/token"
# OAuth2 scope
SCOPE = "https://www.googleapis.com/auth/cloud-platform"
def __init__(self, service_account_json: str):
"""
初始化认证服务
Args:
service_account_json: Service Account JSON 字符串或已解析的字典
"""
if isinstance(service_account_json, str):
try:
self.sa_info = json.loads(service_account_json)
except json.JSONDecodeError as e:
raise VertexAuthError(f"Invalid Service Account JSON: {e}")
else:
self.sa_info = service_account_json
# 验证必需字段
required_fields = ["client_email", "private_key", "project_id"]
missing = [f for f in required_fields if f not in self.sa_info]
if missing:
raise VertexAuthError(f"Service Account JSON missing required fields: {missing}")
self.client_email = self.sa_info["client_email"]
self.private_key = self.sa_info["private_key"]
self.project_id = self.sa_info["project_id"]
def _create_jwt(self) -> str:
"""
创建签名的 JWT
Returns:
签名的 JWT 字符串
"""
now = int(time.time())
payload = {
"iss": self.client_email,
"sub": self.client_email,
"aud": self.TOKEN_URL,
"iat": now,
"exp": now + 3600, # 1 小时有效期
"scope": self.SCOPE,
}
return jwt.encode(payload, self.private_key, algorithm="RS256")
async def get_access_token(self, *, httpx_client_kwargs: dict[str, Any] | None = None) -> str:
"""
获取 Access Token带 LRU 缓存)
如果缓存中有有效的 Token距离过期超过 60 秒),直接返回。
否则重新获取 Token。缓存采用 LRU 策略,超过 100 个条目时淘汰最旧的。
Args:
httpx_client_kwargs: 传给 httpx.AsyncClient 的额外参数(如代理配置)。
调用者services 层负责构建core 层不关心代理细节。
Returns:
Access Token 字符串
Raises:
VertexAuthError: 获取 Token 失败
"""
logger.warning(
"[VertexAuth] Skip Python token exchange; Rust-only path required for {}",
_mask_email(self.client_email),
)
raise VertexAuthError("Vertex Service Account 认证仅支持 Rust executor")
# 检查缓存
cache_key = self.client_email
if cache_key in self._token_cache:
token, expires_at = self._token_cache[cache_key]
# 距离过期还有超过 60 秒,使用缓存
if time.time() < expires_at - 60:
# LRU: 移动到末尾(最近使用)
self._token_cache.move_to_end(cache_key)
return token
# 获取新 Token
try:
signed_jwt = self._create_jwt()
client_kwargs = (
httpx_client_kwargs if httpx_client_kwargs is not None else {"timeout": 30}
)
async with httpx.AsyncClient(**client_kwargs) as client:
resp = await client.post(
self.TOKEN_URL,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": signed_jwt,
},
)
resp.raise_for_status()
data = resp.json()
access_token = data["access_token"]
expires_in = data.get("expires_in", 3600)
expires_at = time.time() + expires_in
# 缓存 TokenLRU新条目放在末尾
self._token_cache[cache_key] = (access_token, expires_at)
self._token_cache.move_to_end(cache_key)
# LRU 淘汰:超过最大缓存数时移除最旧的条目
while len(self._token_cache) > self._cache_max_size:
oldest_key = next(iter(self._token_cache))
del self._token_cache[oldest_key]
logger.debug(f"[VertexAuth] Evicted oldest cache entry: {_mask_email(oldest_key)}")
logger.debug(
f"[VertexAuth] Obtained access token for {_mask_email(self.client_email)}, "
f"expires in {expires_in}s (cache size: {len(self._token_cache)})"
)
return access_token
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}"
)
except httpx.TimeoutException:
raw = client_kwargs.get("timeout")
suffix = f" after {raw}s" if isinstance(raw, (int, float)) else ""
raise VertexAuthError(f"Failed to get access token: request timed out{suffix}")
except httpx.RequestError as e:
detail = str(e).strip() or type(e).__name__
raise VertexAuthError(f"Failed to get access token: {detail}")
except Exception as e:
detail = str(e).strip() or type(e).__name__
raise VertexAuthError(f"Failed to get access token: {detail}")
@classmethod
def clear_cache(cls, client_email: str | None = None) -> None:
"""
清除 Token 缓存
Args:
client_email: 指定要清除的账号None 表示清除全部
"""
if client_email:
cls._token_cache.pop(client_email, None)
else:
cls._token_cache.clear()
async def get_vertex_access_token(
service_account_json: str,
*,
httpx_client_kwargs: dict[str, Any] | None = None,
) -> tuple[str, str]:
"""
便捷函数:获取 Vertex AI Access Token 和 Project ID
Args:
service_account_json: Service Account JSON 字符串
httpx_client_kwargs: 传给 httpx.AsyncClient 的额外参数(如代理配置)
Returns:
(access_token, project_id) 元组
"""
service = VertexAuthService(service_account_json)
token = await service.get_access_token(httpx_client_kwargs=httpx_client_kwargs)
return token, service.project_id

View File

@@ -0,0 +1,77 @@
"""
视频/图像相关的纯工具函数。
从 api/handlers 层下沉到 core 层,消除 services→api 的反向依赖。
"""
from __future__ import annotations
import re
# 敏感信息匹配正则(预编译提升性能)
_SENSITIVE_PATTERN = re.compile(
r"(api[_-]?key|token|bearer|authorization)[=:\s]+\S+",
re.IGNORECASE,
)
def sanitize_error_message(message: str, max_length: int = 200) -> str:
"""
移除错误消息中可能包含的敏感信息
Args:
message: 原始错误消息
max_length: 最大长度,默认 200
Returns:
脱敏后的消息
"""
if not message:
return "Request failed"
# 先脱敏再截断,确保敏感信息不会因截断位置而泄露
sanitized = _SENSITIVE_PATTERN.sub("[REDACTED]", message)
return sanitized[:max_length]
def extract_short_id_from_operation(operation_id: str) -> str:
"""
从 operation ID 中提取短 ID
我们对外暴露的 operation name 格式是:
- models/{model}/operations/{short_id}
此函数提取最后一部分作为 short_id用于在数据库中查找任务。
Args:
operation_id: 原始 operation ID"models/veo-3.1/operations/abc123"
Returns:
short_id"abc123"
"""
# 格式: models/{model}/operations/{short_id}
# 或者直接是 short_id
if "/" in operation_id:
# 提取最后一部分
return operation_id.rsplit("/", 1)[-1]
return operation_id
def normalize_gemini_operation_id(operation_id: str) -> str:
"""
规范化 Gemini operation ID保留用于向后兼容
Args:
operation_id: 原始 operation ID
Returns:
规范化后的 operation ID原样返回
"""
return operation_id
def is_image_gen_model(model: str | None) -> bool:
"""判断是否为图像生成模型(模式匹配,覆盖 gemini-*-image / imagen-* 系列)"""
if not model:
return False
m = model.lower()
return "image" in m and ("gemini" in m or "imagen" in m)