mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
0
_deprecated_py_src/core/__init__.py
Normal file
0
_deprecated_py_src/core/__init__.py
Normal file
113
_deprecated_py_src/core/access_restrictions.py
Normal file
113
_deprecated_py_src/core/access_restrictions.py
Normal 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
|
||||
185
_deprecated_py_src/core/api_format/__init__.py
Normal file
185
_deprecated_py_src/core/api_format/__init__.py
Normal 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",
|
||||
]
|
||||
130
_deprecated_py_src/core/api_format/auth.py
Normal file
130
_deprecated_py_src/core/api_format/auth.py
Normal 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",
|
||||
]
|
||||
654
_deprecated_py_src/core/api_format/capabilities.py
Normal file
654
_deprecated_py_src/core/api_format/capabilities.py
Normal 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",
|
||||
]
|
||||
30
_deprecated_py_src/core/api_format/conversion/__init__.py
Normal file
30
_deprecated_py_src/core/api_format/conversion/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
API 格式转换子模块(Canonical)
|
||||
|
||||
对外提供:
|
||||
- `format_conversion_registry`: 全局转换注册表(Hub-and-Spoke)
|
||||
- `register_default_normalizers()`: 注册默认 Normalizers(OPENAI/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",
|
||||
]
|
||||
139
_deprecated_py_src/core/api_format/conversion/compatibility.py
Normal file
139
_deprecated_py_src/core/api_format/conversion/compatibility.py
Normal 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",
|
||||
]
|
||||
251
_deprecated_py_src/core/api_format/conversion/constants.py
Normal file
251
_deprecated_py_src/core/api_format/conversion/constants.py
Normal 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
|
||||
25
_deprecated_py_src/core/api_format/conversion/exceptions.py
Normal file
25
_deprecated_py_src/core/api_format/conversion/exceptions.py
Normal 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",
|
||||
]
|
||||
221
_deprecated_py_src/core/api_format/conversion/field_mappings.py
Normal file
221
_deprecated_py_src/core/api_format/conversion/field_mappings.py
Normal 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",
|
||||
]
|
||||
273
_deprecated_py_src/core/api_format/conversion/image_resolver.py
Normal file
273
_deprecated_py_src/core/api_format/conversion/image_resolver.py
Normal 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"]
|
||||
469
_deprecated_py_src/core/api_format/conversion/internal.py
Normal file
469
_deprecated_py_src/core/api_format/conversion/internal.py
Normal 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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
199
_deprecated_py_src/core/api_format/conversion/normalizer.py
Normal file
199
_deprecated_py_src/core/api_format/conversion/normalizer.py
Normal 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",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Normalizers
|
||||
|
||||
实现各格式 <-> internal 的标准化器。
|
||||
|
||||
本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
1538
_deprecated_py_src/core/api_format/conversion/normalizers/claude.py
Normal file
1538
_deprecated_py_src/core/api_format/conversion/normalizers/claude.py
Normal file
File diff suppressed because it is too large
Load Diff
2257
_deprecated_py_src/core/api_format/conversion/normalizers/gemini.py
Normal file
2257
_deprecated_py_src/core/api_format/conversion/normalizers/gemini.py
Normal file
File diff suppressed because it is too large
Load Diff
1987
_deprecated_py_src/core/api_format/conversion/normalizers/openai.py
Normal file
1987
_deprecated_py_src/core/api_format/conversion/normalizers/openai.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
734
_deprecated_py_src/core/api_format/conversion/registry.py
Normal file
734
_deprecated_py_src/core/api_format/conversion/registry.py
Normal 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 共享 ClaudeNormalizer,gemini: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",
|
||||
]
|
||||
285
_deprecated_py_src/core/api_format/conversion/stream_bridge.py
Normal file
285
_deprecated_py_src/core/api_format/conversion/stream_bridge.py
Normal 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",
|
||||
]
|
||||
145
_deprecated_py_src/core/api_format/conversion/stream_events.py
Normal file
145
_deprecated_py_src/core/api_format/conversion/stream_events.py
Normal 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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
237
_deprecated_py_src/core/api_format/conversion/thinking_cache.py
Normal file
237
_deprecated_py_src/core/api_format/conversion/thinking_cache.py
Normal 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"]
|
||||
296
_deprecated_py_src/core/api_format/detection.py
Normal file
296
_deprecated_py_src/core/api_format/detection.py
Normal 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/messages(chat/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",
|
||||
]
|
||||
71
_deprecated_py_src/core/api_format/enums.py
Normal file
71
_deprecated_py_src/core/api_format/enums.py
Normal 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 路径/认证变体/数据格式变体等。
|
||||
|
||||
注意:不复用现有 EndpointType(EndpointType 用于请求上下文检测/功能分类)。
|
||||
"""
|
||||
|
||||
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",
|
||||
]
|
||||
720
_deprecated_py_src/core/api_format/headers.py
Normal file
720
_deprecated_py_src/core/api_format/headers.py
Normal 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
|
||||
435
_deprecated_py_src/core/api_format/metadata.py
Normal file
435
_deprecated_py_src/core/api_format/metadata.py
Normal file
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
API endpoint metadata (new mode).
|
||||
|
||||
新模式下,系统以 (ApiFamily, EndpointKind) 作为结构化标识;
|
||||
在需要用 string 做 key(DB / 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",
|
||||
]
|
||||
563
_deprecated_py_src/core/api_format/schema_utils.py
Normal file
563
_deprecated_py_src/core/api_format/schema_utils.py
Normal 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 #952:MCP 工具可能在任意嵌套层级定义 $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",
|
||||
]
|
||||
84
_deprecated_py_src/core/api_format/signature.py
Normal file
84
_deprecated_py_src/core/api_format/signature.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Endpoint signature utilities.
|
||||
|
||||
新模式下,系统以 (ApiFamily, EndpointKind) 作为结构化标识;
|
||||
在需要用 string 做 key(JSON 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",
|
||||
]
|
||||
113
_deprecated_py_src/core/api_format/utils.py
Normal file
113
_deprecated_py_src/core/api_format/utils.py
Normal 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 key(canonical: 全小写 `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
|
||||
130
_deprecated_py_src/core/batch_committer.py
Normal file
130
_deprecated_py_src/core/batch_committer.py
Normal 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()
|
||||
250
_deprecated_py_src/core/cache_service.py
Normal file
250
_deprecated_py_src/core/cache_service.py
Normal 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)
|
||||
133
_deprecated_py_src/core/cache_utils.py
Normal file
133
_deprecated_py_src/core/cache_utils.py
Normal 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,
|
||||
}
|
||||
263
_deprecated_py_src/core/crypto.py
Normal file
263
_deprecated_py_src/core/crypto.py
Normal 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 等敏感数据。
|
||||
使用 Fernet(AES-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())
|
||||
47
_deprecated_py_src/core/enums.py
Normal file
47
_deprecated_py_src/core/enums.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
统一的枚举定义
|
||||
避免重复定义造成的不一致
|
||||
|
||||
注意:APIFormat 架构已移除,统一使用 endpoint signature(family: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"
|
||||
53
_deprecated_py_src/core/error_utils.py
Normal file
53
_deprecated_py_src/core/error_utils.py
Normal 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)
|
||||
772
_deprecated_py_src/core/exceptions.py
Normal file
772
_deprecated_py_src/core/exceptions.py
Normal 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)
|
||||
46
_deprecated_py_src/core/http_compression.py
Normal file
46
_deprecated_py_src/core/http_compression.py
Normal 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
|
||||
281
_deprecated_py_src/core/key_capabilities.py
Normal file
281
_deprecated_py_src/core/key_capabilities.py
Normal 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",
|
||||
)
|
||||
146
_deprecated_py_src/core/logger.py
Normal file
146
_deprecated_py_src/core/logger.py
Normal 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"]
|
||||
109
_deprecated_py_src/core/metrics.py
Normal file
109
_deprecated_py_src/core/metrics.py
Normal 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"],
|
||||
)
|
||||
419
_deprecated_py_src/core/model_permissions.py
Normal file
419
_deprecated_py_src/core/model_permissions.py
Normal 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_models(Provider 支持且 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
|
||||
47
_deprecated_py_src/core/modules/__init__.py
Normal file
47
_deprecated_py_src/core/modules/__init__.py
Normal 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",
|
||||
]
|
||||
127
_deprecated_py_src/core/modules/base.py
Normal file
127
_deprecated_py_src/core/modules/base.py
Normal 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
|
||||
245
_deprecated_py_src/core/modules/hooks.py
Normal file
245
_deprecated_py_src/core/modules/hooks.py
Normal 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()
|
||||
416
_deprecated_py_src/core/modules/registry.py
Normal file
416
_deprecated_py_src/core/modules/registry.py
Normal 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()
|
||||
166
_deprecated_py_src/core/oauth_plan.py
Normal file
166
_deprecated_py_src/core/oauth_plan.py
Normal 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"
|
||||
25
_deprecated_py_src/core/provider_auth_types.py
Normal file
25
_deprecated_py_src/core/provider_auth_types.py
Normal 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)
|
||||
600
_deprecated_py_src/core/provider_oauth_utils.py
Normal file
600
_deprecated_py_src/core/provider_oauth_utils.py
Normal 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 构建可用的代理 URL(best-effort)。
|
||||
|
||||
说明:
|
||||
- core 层不解析 ProxyNode(node_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
|
||||
5
_deprecated_py_src/core/provider_templates/__init__.py
Normal file
5
_deprecated_py_src/core/provider_templates/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""固定 Provider 模板与 OAuth 常量。
|
||||
|
||||
注意:此包会包含从 CLIProxyAPI 复制的固定端点与 OAuth 客户端常量。
|
||||
敏感信息(如 client_secret / refresh_token / access_token)不得出现在日志或 API 响应中。
|
||||
"""
|
||||
169
_deprecated_py_src/core/provider_templates/fixed_providers.py
Normal file
169
_deprecated_py_src/core/provider_templates/fixed_providers.py
Normal 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
|
||||
|
||||
# 上游 API(ProviderEndpoint.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",
|
||||
]
|
||||
7
_deprecated_py_src/core/provider_templates/types.py
Normal file
7
_deprecated_py_src/core/provider_templates/types.py
Normal 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"]
|
||||
46
_deprecated_py_src/core/provider_types.py
Normal file
46
_deprecated_py_src/core/provider_types.py
Normal 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",
|
||||
]
|
||||
54
_deprecated_py_src/core/redis_utils.py
Normal file
54
_deprecated_py_src/core/redis_utils.py
Normal 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
|
||||
466
_deprecated_py_src/core/resilience.py
Normal file
466
_deprecated_py_src/core/resilience.py
Normal 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",
|
||||
]
|
||||
234
_deprecated_py_src/core/stream_types.py
Normal file
234
_deprecated_py_src/core/stream_types.py
Normal 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}")
|
||||
145
_deprecated_py_src/core/usage_tokens.py
Normal file
145
_deprecated_py_src/core/usage_tokens.py
Normal 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 均为 0,total 为合计值。
|
||||
"""
|
||||
# 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",
|
||||
]
|
||||
179
_deprecated_py_src/core/validators.py
Normal file
179
_deprecated_py_src/core/validators.py
Normal 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
|
||||
225
_deprecated_py_src/core/vertex_auth.py
Normal file
225
_deprecated_py_src/core/vertex_auth.py
Normal 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
|
||||
|
||||
# 缓存 Token(LRU:新条目放在末尾)
|
||||
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
|
||||
77
_deprecated_py_src/core/video_utils.py
Normal file
77
_deprecated_py_src/core/video_utils.py
Normal 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)
|
||||
Reference in New Issue
Block a user