refactor(api_format): 重构为结构化的 (ApiFamily, EndpointKind) 标识体系

将扁平的 APIFormat 枚举 (CLAUDE, OPENAI, GEMINI, *_CLI) 重构为二维结构:
- ApiFamily: 协议族 (openai, claude, gemini)
- EndpointKind: 端点变体 (chat, cli, video, image)

新增 EndpointSignature 数据类和 `family:kind` 签名键工具函数,
统一 JSON dict/metrics/logs 中的字符串标识格式。
This commit is contained in:
fawney19
2026-02-01 17:25:28 +08:00
parent 2101a957ce
commit c246ccfc91
51 changed files with 1078 additions and 869 deletions

View File

@@ -76,6 +76,7 @@ import TableRow from '@/components/ui/table-row.vue'
import TableHead from '@/components/ui/table-head.vue' import TableHead from '@/components/ui/table-head.vue'
import TableCell from '@/components/ui/table-cell.vue' import TableCell from '@/components/ui/table-cell.vue'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { API_FORMAT_LABELS } from '@/api/endpoints/types'
import type { ApiFormatStatsItem } from '../types' import type { ApiFormatStatsItem } from '../types'
defineProps<{ defineProps<{
@@ -85,15 +86,13 @@ defineProps<{
// 格式化 API 格式显示名称 // 格式化 API 格式显示名称
function formatApiFormat(format: string): string { function formatApiFormat(format: string): string {
const formatMap: Record<string, string> = { const raw = (format || '').trim()
'CLAUDE': 'Claude', return (
'CLAUDE_CLI': 'Claude CLI', API_FORMAT_LABELS[raw] ||
'OPENAI': 'OpenAI', API_FORMAT_LABELS[raw.toLowerCase()] ||
'OPENAI_CLI': 'OpenAI CLI', API_FORMAT_LABELS[raw.toUpperCase()] ||
'GEMINI': 'Gemini', raw
'GEMINI_CLI': 'Gemini CLI', )
}
return formatMap[format.toUpperCase()] || format
} }
</script> </script>

View File

@@ -1,14 +1,8 @@
""" """
API 格式核心模块 API 格式核心模块(新模式)。
统一管理 API 格式相关的枚举、元数据、工具函数等。 系统内部统一使用 endpoint signature key 作为“格式”标识:
`<api_family>:<endpoint_kind>`(全小写,例如 "openai:chat")。
模块组成:
- enums.py: APIFormat 枚举定义
- metadata.py: 格式元数据定义(别名、路径、认证等)
- headers.py: 请求头处理(构建、过滤、脱敏)
- utils.py: 工具函数is_cli_format, get_base_format 等)
- detection.py: 格式检测(从请求头、响应内容检测格式)
""" """
from src.core.api_format.auth import ( from src.core.api_format.auth import (
@@ -19,7 +13,7 @@ from src.core.api_format.auth import (
OAuth2AuthHandler, OAuth2AuthHandler,
QueryKeyAuthHandler, QueryKeyAuthHandler,
get_auth_handler, get_auth_handler,
get_default_auth_method, get_default_auth_method_for_endpoint,
) )
from src.core.api_format.detection import ( from src.core.api_format.detection import (
RequestContext, RequestContext,
@@ -29,23 +23,22 @@ from src.core.api_format.detection import (
detect_format_from_response, detect_format_from_response,
detect_request_context, detect_request_context,
) )
from src.core.api_format.enums import APIFormat, AuthMethod, EndpointType from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind, EndpointType
from src.core.api_format.headers import ( from src.core.api_format.headers import (
CORE_REDACT_HEADERS, CORE_REDACT_HEADERS,
HOP_BY_HOP_HEADERS, HOP_BY_HOP_HEADERS,
RESPONSE_DROP_HEADERS, RESPONSE_DROP_HEADERS,
SENSITIVE_HEADERS,
UPSTREAM_DROP_HEADERS, UPSTREAM_DROP_HEADERS,
HeaderBuilder, HeaderBuilder,
build_adapter_base_headers, build_adapter_base_headers_for_endpoint,
build_adapter_headers, build_adapter_headers_for_endpoint,
build_upstream_headers, build_upstream_headers_for_endpoint,
detect_capabilities, detect_capabilities_for_endpoint,
extract_client_api_key, extract_client_api_key_for_endpoint,
extract_client_api_key_with_query, extract_client_api_key_for_endpoint_with_query,
extract_set_headers_from_rules, extract_set_headers_from_rules,
filter_response_headers, filter_response_headers,
get_adapter_protected_keys, get_adapter_protected_keys_for_endpoint,
get_extra_headers_from_endpoint, get_extra_headers_from_endpoint,
get_header_value, get_header_value,
merge_headers_with_protection, merge_headers_with_protection,
@@ -53,19 +46,25 @@ from src.core.api_format.headers import (
redact_headers_for_log, redact_headers_for_log,
) )
from src.core.api_format.metadata import ( from src.core.api_format.metadata import (
API_FORMAT_DEFINITIONS, ENDPOINT_DEFINITIONS,
ApiFormatDefinition, EndpointDefinition,
get_api_format_definition, can_passthrough_endpoint,
get_auth_config, get_auth_config_for_endpoint,
get_default_path, get_data_format_id_for_endpoint,
get_extra_headers, get_default_path_for_endpoint,
get_local_path, get_endpoint_definition,
get_protected_keys, get_extra_headers_for_endpoint,
is_cli_api_format, get_local_path_for_endpoint,
list_api_format_definitions, get_protected_keys_for_endpoint,
register_api_format_definition, list_endpoint_definitions,
resolve_api_format, make_endpoint_signature,
resolve_api_format_alias, 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 ( from src.core.api_format.utils import (
get_base_format, get_base_format,
@@ -77,23 +76,29 @@ from src.core.api_format.utils import (
__all__ = [ __all__ = [
# Enums # Enums
"APIFormat", "ApiFamily",
"EndpointKind",
"AuthMethod", "AuthMethod",
"EndpointType", "EndpointType",
# Signature
"EndpointSignature",
"make_signature_key",
"parse_signature_key",
"normalize_signature_key",
# Metadata # Metadata
"ApiFormatDefinition", "EndpointDefinition",
"API_FORMAT_DEFINITIONS", "ENDPOINT_DEFINITIONS",
"get_api_format_definition", "list_endpoint_definitions",
"list_api_format_definitions", "get_endpoint_definition",
"resolve_api_format", "resolve_endpoint_definition",
"resolve_api_format_alias", "make_endpoint_signature",
"register_api_format_definition", "get_default_path_for_endpoint",
"get_default_path", "get_local_path_for_endpoint",
"get_local_path", "get_auth_config_for_endpoint",
"get_auth_config", "get_extra_headers_for_endpoint",
"get_extra_headers", "get_protected_keys_for_endpoint",
"get_protected_keys", "get_data_format_id_for_endpoint",
"is_cli_api_format", "can_passthrough_endpoint",
# Utils # Utils
"is_cli_format", "is_cli_format",
"get_base_format", "get_base_format",
@@ -105,20 +110,19 @@ __all__ = [
"CORE_REDACT_HEADERS", "CORE_REDACT_HEADERS",
"HOP_BY_HOP_HEADERS", "HOP_BY_HOP_HEADERS",
"RESPONSE_DROP_HEADERS", "RESPONSE_DROP_HEADERS",
"SENSITIVE_HEADERS",
"normalize_headers", "normalize_headers",
"get_header_value", "get_header_value",
"extract_client_api_key", "extract_client_api_key_for_endpoint",
"extract_client_api_key_with_query", "extract_client_api_key_for_endpoint_with_query",
"detect_capabilities", "detect_capabilities_for_endpoint",
"HeaderBuilder", "HeaderBuilder",
"build_upstream_headers", "build_upstream_headers_for_endpoint",
"merge_headers_with_protection", "merge_headers_with_protection",
"filter_response_headers", "filter_response_headers",
"redact_headers_for_log", "redact_headers_for_log",
"build_adapter_base_headers", "build_adapter_base_headers_for_endpoint",
"build_adapter_headers", "build_adapter_headers_for_endpoint",
"get_adapter_protected_keys", "get_adapter_protected_keys_for_endpoint",
"extract_set_headers_from_rules", "extract_set_headers_from_rules",
"get_extra_headers_from_endpoint", "get_extra_headers_from_endpoint",
# Detection # Detection
@@ -136,5 +140,5 @@ __all__ = [
"OAuth2AuthHandler", "OAuth2AuthHandler",
"QueryKeyAuthHandler", "QueryKeyAuthHandler",
"get_auth_handler", "get_auth_handler",
"get_default_auth_method", "get_default_auth_method_for_endpoint",
] ]

View File

@@ -9,7 +9,9 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src.core.api_format.enums import APIFormat, AuthMethod 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: if TYPE_CHECKING:
from starlette.requests import Request from starlette.requests import Request
@@ -104,17 +106,16 @@ def get_auth_handler(auth_method: AuthMethod) -> AuthHandler:
return handler return handler
def get_default_auth_method(api_format: APIFormat) -> AuthMethod: def get_default_auth_method_for_endpoint(
"""从 APIFormat 推断默认 AuthMethod兼容旧逻辑""" value: str | EndpointSignature | tuple, # tuple[ApiFamily, EndpointKind]
mapping = { ) -> AuthMethod:
APIFormat.OPENAI: AuthMethod.BEARER, """
APIFormat.OPENAI_CLI: AuthMethod.BEARER, 新模式:从 endpoint signature 推断默认 AuthMethod。
APIFormat.CLAUDE: AuthMethod.API_KEY,
APIFormat.CLAUDE_CLI: AuthMethod.BEARER, 只接受 `family:kind` / EndpointSignature / (ApiFamily, EndpointKind)。
APIFormat.GEMINI: AuthMethod.GOOG_API_KEY, """
APIFormat.GEMINI_CLI: AuthMethod.GOOG_API_KEY, definition = resolve_endpoint_definition(value)
} return definition.auth_method if definition else AuthMethod.BEARER
return mapping.get(api_format, AuthMethod.BEARER)
__all__ = [ __all__ = [
@@ -125,5 +126,5 @@ __all__ = [
"OAuth2AuthHandler", "OAuth2AuthHandler",
"QueryKeyAuthHandler", "QueryKeyAuthHandler",
"get_auth_handler", "get_auth_handler",
"get_default_auth_method", "get_default_auth_method_for_endpoint",
] ]

View File

@@ -12,14 +12,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from src.core.api_format.conversion.registry import FormatConversionRegistry from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.metadata import can_passthrough from src.core.api_format.metadata import can_passthrough_endpoint
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -59,15 +58,16 @@ def is_format_compatible(
register_default_normalizers() register_default_normalizers()
registry = format_conversion_registry registry = format_conversion_registry
provider_format = endpoint_api_format.upper() # 统一大写用于比较和 registry 查找registry 以大写 key 索引 normalizer
client_format_upper = client_format.upper() client_key = client_format.upper()
provider_key = endpoint_api_format.upper()
# 1. 格式完全匹配 -> 透传(无需转换) # 1. 格式完全匹配 -> 透传(无需转换)
if provider_format == client_format_upper: if provider_key == client_key:
return True, False, None return True, False, None
# 2. 格式不同 -> 需要检查全局格式转换开关 # 2. 格式不同 -> 需要检查全局格式转换开关
# 即使 data_format_id 相同(如 CLAUDE/CLAUDE_CLI),也需要全局开关启用 # 即使 data_format_id 相同(如 claude:chat / claude:cli),也需要全局开关启用
if not global_conversion_enabled: if not global_conversion_enabled:
return False, False, "全局格式转换未启用(环境变量 FORMAT_CONVERSION_ENABLED=false" return False, False, "全局格式转换未启用(环境变量 FORMAT_CONVERSION_ENABLED=false"
@@ -83,18 +83,18 @@ def is_format_compatible(
# 检查 reject_formats优先 # 检查 reject_formats优先
reject_formats = config.get("reject_formats", []) reject_formats = config.get("reject_formats", [])
if client_format_upper in [f.upper() for f in reject_formats]: if client_key in [f.upper() for f in reject_formats]:
return False, False, f"端点拒绝 {client_format} 格式" return False, False, f"端点拒绝 {client_format} 格式"
# 检查 accept_formats # 检查 accept_formats
accept_formats = config.get("accept_formats", []) accept_formats = config.get("accept_formats", [])
if accept_formats and client_format_upper not in [f.upper() for f in accept_formats]: if accept_formats and client_key not in [f.upper() for f in accept_formats]:
return False, False, f"端点不接受 {client_format} 格式" return False, False, f"端点不接受 {client_format} 格式"
# 4. 检查是否可以透传data_format_id 相同) # 4. 检查是否可以透传data_format_id 相同)
# 例如:CLAUDE/CLAUDE_CLI 的 data_format_id 都是 "claude",数据格式相同可透传 # 例如:claude:chat / claude:cli 的 data_format_id 都是 "claude",数据格式相同可透传
# OPENAI 是 "openai_chat"OPENAI_CLI 是 "openai_responses",需要转换 # openai:chat 是 "openai_chat"openai:cli 是 "openai_responses",需要转换
if can_passthrough(client_format_upper, provider_format): if can_passthrough_endpoint(client_key, provider_key):
# data_format_id 相同,可透传(无需数据转换) # data_format_id 相同,可透传(无需数据转换)
return True, False, None return True, False, None
@@ -105,11 +105,11 @@ def is_format_compatible(
# 6. 检查转换器能力 # 6. 检查转换器能力
if not registry.can_convert_full( if not registry.can_convert_full(
client_format_upper, client_key,
provider_format, provider_key,
require_stream=is_stream, require_stream=is_stream,
): ):
return False, False, f"不存在 {client_format} <-> {provider_format} 的完整转换器" return False, False, f"不存在 {client_format} <-> {endpoint_api_format} 的完整转换器"
return True, True, None return True, True, None

View File

@@ -5,7 +5,6 @@
""" """
class FormatConversionError(Exception): class FormatConversionError(Exception):
""" """
格式转换失败异常 格式转换失败异常

View File

@@ -9,9 +9,6 @@
这些应复用 `src/core/api_format/metadata.py`API_FORMAT_DEFINITIONS作为单一事实来源。 这些应复用 `src/core/api_format/metadata.py`API_FORMAT_DEFINITIONS作为单一事实来源。
""" """
# 角色映射仅作为辅助system/tool 的具体落点以 Normalizer 规则为准) # 角色映射仅作为辅助system/tool 的具体落点以 Normalizer 规则为准)
ROLE_MAPPINGS: dict[str, dict[str, str]] = { ROLE_MAPPINGS: dict[str, dict[str, str]] = {
"OPENAI": { "OPENAI": {
@@ -128,4 +125,3 @@ __all__ = [
"ERROR_TYPE_MAPPINGS", "ERROR_TYPE_MAPPINGS",
"RETRYABLE_ERROR_TYPES", "RETRYABLE_ERROR_TYPES",
] ]

View File

@@ -10,7 +10,6 @@
- 兼容优先UnknownBlock 在内部保留,但默认在输出阶段丢弃(可观测、可随时调整策略) - 兼容优先UnknownBlock 在内部保留,但默认在输出阶段丢弃(可观测、可随时调整策略)
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any from typing import Any
@@ -293,4 +292,3 @@ __all__ = [
"InternalError", "InternalError",
"FormatCapabilities", "FormatCapabilities",
] ]

View File

@@ -6,6 +6,4 @@ Normalizers
本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。 本目录在 Phase 1 仅创建结构;具体实现将在 Phase 2+ 补齐。
""" """
__all__: list[str] = [] __all__: list[str] = []

View File

@@ -7,7 +7,6 @@ Claude Messages API Normalizer
- 可选Claude error <-> InternalError - 可选Claude error <-> InternalError
""" """
import json import json
from typing import Any from typing import Any
@@ -54,7 +53,7 @@ from src.core.api_format.conversion.stream_state import StreamState
class ClaudeNormalizer(FormatNormalizer): class ClaudeNormalizer(FormatNormalizer):
FORMAT_ID = "CLAUDE" FORMAT_ID = "claude:chat"
capabilities = FormatCapabilities( capabilities = FormatCapabilities(
supports_stream=True, supports_stream=True,
supports_error_conversion=True, supports_error_conversion=True,
@@ -161,7 +160,9 @@ class ClaudeNormalizer(FormatNormalizer):
# Claude Messages API: messages[] 仅允许 user/assistant且需要交替这里做最小修复 # Claude Messages API: messages[] 仅允许 user/assistant且需要交替这里做最小修复
fixed_messages = self._coerce_claude_message_sequence(internal.messages) fixed_messages = self._coerce_claude_message_sequence(internal.messages)
out_messages: list[dict[str, Any]] = [self._internal_message_to_claude(m) for m in fixed_messages] out_messages: list[dict[str, Any]] = [
self._internal_message_to_claude(m) for m in fixed_messages
]
result: dict[str, Any] = { result: dict[str, Any] = {
"model": internal.model, "model": internal.model,
@@ -285,7 +286,9 @@ class ClaudeNormalizer(FormatNormalizer):
stop_reason = None stop_reason = None
if internal.stop_reason is not None: if internal.stop_reason is not None:
stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(internal.stop_reason.value, "end_turn") stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(
internal.stop_reason.value, "end_turn"
)
usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0} usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0}
if internal.usage: if internal.usage:
@@ -353,7 +356,9 @@ class ClaudeNormalizer(FormatNormalizer):
btype = str(block.get("type") or "unknown") btype = str(block.get("type") or "unknown")
if btype == "text": if btype == "text":
events.append(ContentBlockStartEvent(block_index=index, block_type=ContentType.TEXT)) events.append(
ContentBlockStartEvent(block_index=index, block_type=ContentType.TEXT)
)
return events return events
if btype == "tool_use": if btype == "tool_use":
@@ -402,7 +407,9 @@ class ClaudeNormalizer(FormatNormalizer):
tool_id = "" tool_id = ""
if isinstance(mapping, dict): if isinstance(mapping, dict):
tool_id = str(mapping.get(index) or "") tool_id = str(mapping.get(index) or "")
events.append(ToolCallDeltaEvent(block_index=index, tool_id=tool_id, input_delta=str(partial))) events.append(
ToolCallDeltaEvent(block_index=index, tool_id=tool_id, input_delta=str(partial))
)
return events return events
return events return events
@@ -528,7 +535,9 @@ class ClaudeNormalizer(FormatNormalizer):
if isinstance(event, MessageStopEvent): if isinstance(event, MessageStopEvent):
stop_reason = None stop_reason = None
if event.stop_reason is not None: if event.stop_reason is not None:
stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(event.stop_reason.value, "end_turn") stop_reason = STOP_REASON_MAPPINGS.get("CLAUDE", {}).get(
event.stop_reason.value, "end_turn"
)
msg_delta: dict[str, Any] = { msg_delta: dict[str, Any] = {
"type": "message_delta", "type": "message_delta",
@@ -592,7 +601,9 @@ class ClaudeNormalizer(FormatNormalizer):
# Helpers # Helpers
# ========================= # =========================
def _claude_message_to_internal(self, msg: dict[str, Any]) -> tuple[InternalMessage | None, dict[str, int]]: def _claude_message_to_internal(
self, msg: dict[str, Any]
) -> tuple[InternalMessage | None, dict[str, int]]:
dropped: dict[str, int] = {} dropped: dict[str, int] = {}
role_raw = str(msg.get("role") or "unknown") role_raw = str(msg.get("role") or "unknown")
@@ -635,7 +646,9 @@ class ClaudeNormalizer(FormatNormalizer):
if btype == "text": if btype == "text":
text = str(block.get("text") or "") text = str(block.get("text") or "")
if text: if text:
blocks.append(TextBlock(text=text, extra=self._extract_extra(block, {"type", "text"}))) blocks.append(
TextBlock(text=text, extra=self._extract_extra(block, {"type", "text"}))
)
continue continue
if btype == "image": if btype == "image":
@@ -645,7 +658,12 @@ class ClaudeNormalizer(FormatNormalizer):
if stype == "base64": if stype == "base64":
data = src.get("data") data = src.get("data")
media_type = src.get("media_type") media_type = src.get("media_type")
if isinstance(data, str) and data and isinstance(media_type, str) and media_type: if (
isinstance(data, str)
and data
and isinstance(media_type, str)
and media_type
):
blocks.append(ImageBlock(data=data, media_type=media_type)) blocks.append(ImageBlock(data=data, media_type=media_type))
continue continue
dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1 dropped["claude_image_unsupported"] = dropped.get("claude_image_unsupported", 0) + 1
@@ -663,7 +681,9 @@ class ClaudeNormalizer(FormatNormalizer):
tool_id=tool_id, tool_id=tool_id,
tool_name=tool_name, tool_name=tool_name,
tool_input=tool_input, tool_input=tool_input,
extra={"claude": self._extract_extra(block, {"type", "id", "name", "input"})}, extra={
"claude": self._extract_extra(block, {"type", "id", "name", "input"})
},
) )
) )
continue continue
@@ -672,7 +692,9 @@ class ClaudeNormalizer(FormatNormalizer):
tool_use_id = str(block.get("tool_use_id") or "") tool_use_id = str(block.get("tool_use_id") or "")
is_error = bool(block.get("is_error") or False) is_error = bool(block.get("is_error") or False)
raw_content = block.get("content") raw_content = block.get("content")
blocks.append(self._tool_result_from_claude(tool_use_id, raw_content, is_error, block)) blocks.append(
self._tool_result_from_claude(tool_use_id, raw_content, is_error, block)
)
continue continue
dropped_key = f"claude_block:{btype}" dropped_key = f"claude_block:{btype}"
@@ -757,7 +779,9 @@ class ClaudeNormalizer(FormatNormalizer):
texts: list[str] = [] texts: list[str] = []
for item in system_value: for item in system_value:
if not isinstance(item, dict): if not isinstance(item, dict):
dropped["claude_system_item_non_dict"] = dropped.get("claude_system_item_non_dict", 0) + 1 dropped["claude_system_item_non_dict"] = (
dropped.get("claude_system_item_non_dict", 0) + 1
)
continue continue
if item.get("type") == "text": if item.get("type") == "text":
text = item.get("text") text = item.get("text")
@@ -792,8 +816,14 @@ class ClaudeNormalizer(FormatNormalizer):
ToolDefinition( ToolDefinition(
name=name, name=name,
description=tool.get("description"), description=tool.get("description"),
parameters=tool.get("input_schema") if isinstance(tool.get("input_schema"), dict) else None, parameters=(
extra={"claude": self._extract_extra(tool, {"name", "description", "input_schema"})}, tool.get("input_schema")
if isinstance(tool.get("input_schema"), dict)
else None
),
extra={
"claude": self._extract_extra(tool, {"name", "description", "input_schema"})
},
) )
) )
return out or None return out or None
@@ -813,7 +843,9 @@ class ClaudeNormalizer(FormatNormalizer):
return ToolChoice(type=ToolChoiceType.REQUIRED, extra={"claude": tool_choice}) return ToolChoice(type=ToolChoiceType.REQUIRED, extra={"claude": tool_choice})
if ctype in ("tool_use", "tool"): if ctype in ("tool_use", "tool"):
name = str(tool_choice.get("name") or "") name = str(tool_choice.get("name") or "")
return ToolChoice(type=ToolChoiceType.TOOL, tool_name=name, extra={"claude": tool_choice}) return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"claude": tool_choice}
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"claude": tool_choice}) return ToolChoice(type=ToolChoiceType.AUTO, extra={"claude": tool_choice})
@@ -855,7 +887,11 @@ class ClaudeNormalizer(FormatNormalizer):
blocks.append( blocks.append(
{ {
"type": "image", "type": "image",
"source": {"type": "base64", "media_type": b.media_type, "data": b.data}, "source": {
"type": "base64",
"media_type": b.media_type,
"data": b.data,
},
} }
) )
elif b.url: elif b.url:
@@ -901,7 +937,9 @@ class ClaudeNormalizer(FormatNormalizer):
return {"role": role, "content": blocks} return {"role": role, "content": blocks}
def _coerce_claude_message_sequence(self, messages: list[InternalMessage]) -> list[InternalMessage]: def _coerce_claude_message_sequence(
self, messages: list[InternalMessage]
) -> list[InternalMessage]:
normalized: list[InternalMessage] = [] normalized: list[InternalMessage] = []
for m in messages: for m in messages:
role = m.role role = m.role
@@ -940,7 +978,9 @@ class ClaudeNormalizer(FormatNormalizer):
continue continue
if "total_tokens" not in fields: if "total_tokens" not in fields:
fields["total_tokens"] = int(fields.get("input_tokens", 0) + fields.get("output_tokens", 0)) fields["total_tokens"] = int(
fields.get("input_tokens", 0) + fields.get("output_tokens", 0)
)
return UsageInfo( return UsageInfo(
input_tokens=int(fields.get("input_tokens", 0)), input_tokens=int(fields.get("input_tokens", 0)),

View File

@@ -7,13 +7,11 @@ CLAUDE_CLI 的请求/响应 body 与 CLAUDE 一致Anthropic Messages API
如需 CLI 特殊处理,可覆盖 request_from_internal / request_to_internal 等方法。 如需 CLI 特殊处理,可覆盖 request_from_internal / request_to_internal 等方法。
""" """
from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer from src.core.api_format.conversion.normalizers.claude import ClaudeNormalizer
class ClaudeCliNormalizer(ClaudeNormalizer): class ClaudeCliNormalizer(ClaudeNormalizer):
FORMAT_ID = "CLAUDE_CLI" FORMAT_ID = "claude:cli"
__all__ = ["ClaudeCliNormalizer"] __all__ = ["ClaudeCliNormalizer"]

View File

@@ -63,7 +63,7 @@ from src.core.api_format.conversion.stream_state import StreamState
class GeminiNormalizer(FormatNormalizer): class GeminiNormalizer(FormatNormalizer):
FORMAT_ID = "GEMINI" FORMAT_ID = "gemini:chat"
capabilities = FormatCapabilities( capabilities = FormatCapabilities(
supports_stream=True, supports_stream=True,
supports_error_conversion=True, supports_error_conversion=True,

View File

@@ -7,13 +7,11 @@ GEMINI_CLI 的请求/响应 body 与 GEMINI 一致Google Gemini API
如需 CLI 特殊处理,可覆盖 request_from_internal / request_to_internal 等方法。 如需 CLI 特殊处理,可覆盖 request_from_internal / request_to_internal 等方法。
""" """
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
class GeminiCliNormalizer(GeminiNormalizer): class GeminiCliNormalizer(GeminiNormalizer):
FORMAT_ID = "GEMINI_CLI" FORMAT_ID = "gemini:cli"
__all__ = ["GeminiCliNormalizer"] __all__ = ["GeminiCliNormalizer"]

View File

@@ -60,7 +60,8 @@ from src.core.logger import logger
class OpenAINormalizer(FormatNormalizer): class OpenAINormalizer(FormatNormalizer):
FORMAT_ID = "OPENAI" # 新模式ApiFamily + EndpointKind 的 signature key
FORMAT_ID = "openai:chat"
capabilities = FormatCapabilities( capabilities = FormatCapabilities(
supports_stream=True, supports_stream=True,
supports_error_conversion=True, supports_error_conversion=True,

View File

@@ -10,7 +10,6 @@ OpenAI CLI / Responses Normalizer (OPENAI_CLI)
- 未识别的字段会进入 extra/raw未知内容块保留在 internal但默认输出阶段会丢弃。 - 未识别的字段会进入 extra/raw未知内容块保留在 internal但默认输出阶段会丢弃。
""" """
import json import json
import time import time
from typing import Any from typing import Any
@@ -56,7 +55,7 @@ from src.core.api_format.conversion.stream_state import StreamState
class OpenAICliNormalizer(FormatNormalizer): class OpenAICliNormalizer(FormatNormalizer):
FORMAT_ID = "OPENAI_CLI" FORMAT_ID = "openai:cli"
capabilities = FormatCapabilities( capabilities = FormatCapabilities(
supports_stream=True, supports_stream=True,
supports_error_conversion=True, supports_error_conversion=True,
@@ -96,9 +95,7 @@ class OpenAICliNormalizer(FormatNormalizer):
tools = self._tools_to_internal(request.get("tools")) tools = self._tools_to_internal(request.get("tools"))
tool_choice = self._tool_choice_to_internal(request.get("tool_choice")) tool_choice = self._tool_choice_to_internal(request.get("tool_choice"))
max_tokens = self._optional_int( max_tokens = self._optional_int(request.get("max_output_tokens", request.get("max_tokens")))
request.get("max_output_tokens", request.get("max_tokens"))
)
internal = InternalRequest( internal = InternalRequest(
model=model, model=model,
@@ -275,7 +272,9 @@ class OpenAICliNormalizer(FormatNormalizer):
if delta_text: if delta_text:
if not ss.get("text_block_started"): if not ss.get("text_block_started"):
ss["text_block_started"] = True ss["text_block_started"] = True
events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT)) events.append(
ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT)
)
events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text)) events.append(ContentDeltaEvent(block_index=0, text_delta=delta_text))
return events return events
@@ -331,11 +330,16 @@ class OpenAICliNormalizer(FormatNormalizer):
ss["tool_block_started"] = True ss["tool_block_started"] = True
ss["current_tool_id"] = item.get("call_id") or item.get("id") or "" ss["current_tool_id"] = item.get("call_id") or item.get("id") or ""
ss["current_tool_name"] = item.get("name") or "" ss["current_tool_name"] = item.get("name") or ""
events.append(ContentBlockStartEvent( events.append(
block_index=ss.get("block_index", 0), ContentBlockStartEvent(
block_type=ContentType.TOOL_USE, block_index=ss.get("block_index", 0),
extra={"tool_id": ss["current_tool_id"], "tool_name": ss["current_tool_name"]}, block_type=ContentType.TOOL_USE,
)) extra={
"tool_id": ss["current_tool_id"],
"tool_name": ss["current_tool_name"],
},
)
)
ss["block_index"] = ss.get("block_index", 0) + 1 ss["block_index"] = ss.get("block_index", 0) + 1
# message 输出项 # message 输出项
elif item_type == "message": elif item_type == "message":
@@ -357,11 +361,13 @@ class OpenAICliNormalizer(FormatNormalizer):
if etype == "response.function_call_arguments.delta": if etype == "response.function_call_arguments.delta":
delta = chunk.get("delta") or "" delta = chunk.get("delta") or ""
if delta: if delta:
events.append(ToolCallDeltaEvent( events.append(
block_index=ss.get("block_index", 1) - 1, ToolCallDeltaEvent(
tool_id=ss.get("current_tool_id", ""), block_index=ss.get("block_index", 1) - 1,
input_delta=delta, tool_id=ss.get("current_tool_id", ""),
)) input_delta=delta,
)
)
return events return events
# response.function_call_arguments.done工具调用参数完成 # response.function_call_arguments.done工具调用参数完成
@@ -505,7 +511,9 @@ class OpenAICliNormalizer(FormatNormalizer):
return resp_inner return resp_inner
return response return response
def _extract_output_text_blocks(self, payload: dict[str, Any]) -> tuple[list[ContentBlock], dict[str, Any]]: def _extract_output_text_blocks(
self, payload: dict[str, Any]
) -> tuple[list[ContentBlock], dict[str, Any]]:
text_parts: list[str] = [] text_parts: list[str] = []
output = payload.get("output") output = payload.get("output")
@@ -520,11 +528,15 @@ class OpenAICliNormalizer(FormatNormalizer):
if not isinstance(part, dict): if not isinstance(part, dict):
continue continue
ptype = str(part.get("type") or "") ptype = str(part.get("type") or "")
if ptype in ("output_text", "text") and isinstance(part.get("text"), str): if ptype in ("output_text", "text") and isinstance(
part.get("text"), str
):
text_parts.append(part.get("text") or "") text_parts.append(part.get("text") or "")
continue continue
if item.get("type") in ("output_text", "text") and isinstance(item.get("text"), str): if item.get("type") in ("output_text", "text") and isinstance(
item.get("text"), str
):
text_parts.append(item.get("text") or "") text_parts.append(item.get("text") or "")
# 兼容:部分实现可能直接给 output_text # 兼容:部分实现可能直接给 output_text
@@ -572,7 +584,12 @@ class OpenAICliNormalizer(FormatNormalizer):
input_data = input_data.get("messages") input_data = input_data.get("messages")
if not isinstance(input_data, list): if not isinstance(input_data, list):
return [InternalMessage(role=Role.USER, content=[UnknownBlock(raw_type="input", payload={"input": input_data})])] return [
InternalMessage(
role=Role.USER,
content=[UnknownBlock(raw_type="input", payload={"input": input_data})],
)
]
messages: list[InternalMessage] = [] messages: list[InternalMessage] = []
for item in input_data: for item in input_data:
@@ -585,7 +602,13 @@ class OpenAICliNormalizer(FormatNormalizer):
if item_type == "message" or item.get("role"): if item_type == "message" or item.get("role"):
role = self._role_from_value(item.get("role")) role = self._role_from_value(item.get("role"))
blocks = self._responses_content_to_blocks(item.get("content")) blocks = self._responses_content_to_blocks(item.get("content"))
messages.append(InternalMessage(role=role, content=blocks, extra=self._extract_extra(item, {"type", "role", "content"}))) messages.append(
InternalMessage(
role=role,
content=blocks,
extra=self._extract_extra(item, {"type", "role", "content"}),
)
)
continue continue
# function_call -> assistant 消息 + ToolUseBlock # function_call -> assistant 消息 + ToolUseBlock
@@ -594,14 +617,22 @@ class OpenAICliNormalizer(FormatNormalizer):
tool_name = str(item.get("name") or "") tool_name = str(item.get("name") or "")
args_raw = item.get("arguments") or "{}" args_raw = item.get("arguments") or "{}"
try: try:
tool_input = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw if isinstance(args_raw, dict) else {}) tool_input = (
json.loads(args_raw)
if isinstance(args_raw, str)
else (args_raw if isinstance(args_raw, dict) else {})
)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
tool_input = {"_raw": args_raw} tool_input = {"_raw": args_raw}
tool_block = ToolUseBlock( tool_block = ToolUseBlock(
tool_id=tool_id, tool_id=tool_id,
tool_name=tool_name, tool_name=tool_name,
tool_input=tool_input, tool_input=tool_input,
extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "name", "arguments"})}, extra={
"openai_cli": self._extract_extra(
item, {"type", "call_id", "id", "name", "arguments"}
)
},
) )
messages.append(InternalMessage(role=Role.ASSISTANT, content=[tool_block])) messages.append(InternalMessage(role=Role.ASSISTANT, content=[tool_block]))
continue continue
@@ -616,7 +647,9 @@ class OpenAICliNormalizer(FormatNormalizer):
tool_use_id=tool_use_id, tool_use_id=tool_use_id,
output=output, output=output,
content_text=content_text, content_text=content_text,
extra={"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})}, extra={
"openai_cli": self._extract_extra(item, {"type", "call_id", "id", "output"})
},
) )
messages.append(InternalMessage(role=Role.TOOL, content=[result_block])) messages.append(InternalMessage(role=Role.TOOL, content=[result_block]))
continue continue
@@ -640,24 +673,30 @@ class OpenAICliNormalizer(FormatNormalizer):
reasoning_blocks: list[ContentBlock] = [] reasoning_blocks: list[ContentBlock] = []
if summary_parts: if summary_parts:
# 保留 reasoning 的 summary 作为 UnknownBlock便于输出时决策 # 保留 reasoning 的 summary 作为 UnknownBlock便于输出时决策
reasoning_blocks.append(UnknownBlock( reasoning_blocks.append(
raw_type="reasoning", UnknownBlock(
payload={"summary_text": "\n".join(summary_parts), "original": item}, raw_type="reasoning",
)) payload={"summary_text": "\n".join(summary_parts), "original": item},
)
)
else: else:
reasoning_blocks.append(UnknownBlock(raw_type="reasoning", payload=item)) reasoning_blocks.append(UnknownBlock(raw_type="reasoning", payload=item))
messages.append(InternalMessage( messages.append(
role=Role.ASSISTANT, InternalMessage(
content=reasoning_blocks, role=Role.ASSISTANT,
extra={"openai_cli": {"type": "reasoning"}}, content=reasoning_blocks,
)) extra={"openai_cli": {"type": "reasoning"}},
)
)
continue continue
# 其他未知类型 -> 保留为 UnknownBlock # 其他未知类型 -> 保留为 UnknownBlock
messages.append(InternalMessage( messages.append(
role=Role.UNKNOWN, InternalMessage(
content=[UnknownBlock(raw_type=item_type or "unknown", payload=item)], role=Role.UNKNOWN,
)) content=[UnknownBlock(raw_type=item_type or "unknown", payload=item)],
)
)
return messages return messages
@@ -695,20 +734,32 @@ class OpenAICliNormalizer(FormatNormalizer):
# ToolUseBlock -> function_call # ToolUseBlock -> function_call
for block in msg.content: for block in msg.content:
if isinstance(block, ToolUseBlock): if isinstance(block, ToolUseBlock):
out.append({ out.append(
"type": "function_call", {
"call_id": block.tool_id, "type": "function_call",
"name": block.tool_name, "call_id": block.tool_id,
"arguments": json.dumps(block.tool_input, ensure_ascii=False) if block.tool_input else "{}", "name": block.tool_name,
}) "arguments": (
json.dumps(block.tool_input, ensure_ascii=False)
if block.tool_input
else "{}"
),
}
)
continue continue
if isinstance(block, ToolResultBlock): if isinstance(block, ToolResultBlock):
out.append({ out.append(
"type": "function_call_output", {
"call_id": block.tool_use_id, "type": "function_call_output",
"output": block.content_text if block.content_text is not None else block.output, "call_id": block.tool_use_id,
}) "output": (
block.content_text
if block.content_text is not None
else block.output
),
}
)
continue continue
# reasoningUnknownBlock with raw_type="reasoning" # reasoningUnknownBlock with raw_type="reasoning"
@@ -720,10 +771,16 @@ class OpenAICliNormalizer(FormatNormalizer):
out.append(original) out.append(original)
else: else:
summary_text = payload.get("summary_text", "") summary_text = payload.get("summary_text", "")
out.append({ out.append(
"type": "reasoning", {
"summary": [{"type": "summary_text", "text": summary_text}] if summary_text else [], "type": "reasoning",
}) "summary": (
[{"type": "summary_text", "text": summary_text}]
if summary_text
else []
),
}
)
continue continue
# 普通 messageTextBlock # 普通 messageTextBlock
@@ -763,8 +820,15 @@ class OpenAICliNormalizer(FormatNormalizer):
ToolDefinition( ToolDefinition(
name=name, name=name,
description=fn.get("description"), description=fn.get("description"),
parameters=fn.get("parameters") if isinstance(fn.get("parameters"), dict) else None, parameters=(
extra={"openai_tool": self._extract_extra(tool, {"type", "function"}), "openai_function": self._extract_extra(fn, {"name", "description", "parameters"})}, fn.get("parameters") if isinstance(fn.get("parameters"), dict) else None
),
extra={
"openai_tool": self._extract_extra(tool, {"type", "function"}),
"openai_function": self._extract_extra(
fn, {"name", "description", "parameters"}
),
},
) )
) )
continue continue
@@ -776,8 +840,16 @@ class OpenAICliNormalizer(FormatNormalizer):
ToolDefinition( ToolDefinition(
name=name, name=name,
description=tool.get("description"), description=tool.get("description"),
parameters=tool.get("parameters") if isinstance(tool.get("parameters"), dict) else None, parameters=(
extra={"openai_cli": self._extract_extra(tool, {"name", "description", "parameters"})}, tool.get("parameters")
if isinstance(tool.get("parameters"), dict)
else None
),
extra={
"openai_cli": self._extract_extra(
tool, {"name", "description", "parameters"}
)
},
) )
) )
return out or None return out or None
@@ -787,16 +859,24 @@ class OpenAICliNormalizer(FormatNormalizer):
return None return None
if isinstance(tool_choice, str): if isinstance(tool_choice, str):
if tool_choice == "none": if tool_choice == "none":
return ToolChoice(type=ToolChoiceType.NONE, extra={"openai_cli": {"tool_choice": tool_choice}}) return ToolChoice(
type=ToolChoiceType.NONE, extra={"openai_cli": {"tool_choice": tool_choice}}
)
if tool_choice == "auto": if tool_choice == "auto":
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": {"tool_choice": tool_choice}}) return ToolChoice(
type=ToolChoiceType.AUTO, extra={"openai_cli": {"tool_choice": tool_choice}}
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice}) return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
if isinstance(tool_choice, dict): if isinstance(tool_choice, dict):
# OpenAI 兼容结构:{"type":"function","function":{"name":"..."}} # OpenAI 兼容结构:{"type":"function","function":{"name":"..."}}
if tool_choice.get("type") == "function" and isinstance(tool_choice.get("function"), dict): if tool_choice.get("type") == "function" and isinstance(
tool_choice.get("function"), dict
):
name = str(tool_choice["function"].get("name") or "") name = str(tool_choice["function"].get("name") or "")
return ToolChoice(type=ToolChoiceType.TOOL, tool_name=name, extra={"openai_cli": tool_choice}) return ToolChoice(
type=ToolChoiceType.TOOL, tool_name=name, extra={"openai_cli": tool_choice}
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": tool_choice}) return ToolChoice(type=ToolChoiceType.AUTO, extra={"openai_cli": tool_choice})
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice}) return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})

View File

@@ -9,19 +9,17 @@ source -> internal -> target
- 转换失败将抛出 `FormatConversionError`(不再静默回退)。 - 转换失败将抛出 `FormatConversionError`(不再静默回退)。
""" """
import threading import threading
import time import time
from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any from typing import Any
from collections.abc import Generator
from src.core.logger import logger
from src.core.metrics import format_conversion_duration_seconds, format_conversion_total
from src.core.api_format.conversion.exceptions import FormatConversionError from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.api_format.conversion.normalizer import FormatNormalizer from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.stream_state import StreamState 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 @contextmanager
@@ -76,7 +74,9 @@ class FormatConversionRegistry:
src = self._require_normalizer(source_format) src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format) tgt = self._require_normalizer(target_format)
with _track_conversion_metrics("request", str(source_format).upper(), str(target_format).upper()): with _track_conversion_metrics(
"request", str(source_format).upper(), str(target_format).upper()
):
try: try:
internal = src.request_to_internal(request) internal = src.request_to_internal(request)
return tgt.request_from_internal(internal) return tgt.request_from_internal(internal)
@@ -115,7 +115,9 @@ class FormatConversionRegistry:
src = self._require_normalizer(source_format) src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format) tgt = self._require_normalizer(target_format)
with _track_conversion_metrics("response", str(source_format).upper(), str(target_format).upper()): with _track_conversion_metrics(
"response", str(source_format).upper(), str(target_format).upper()
):
try: try:
internal = src.response_to_internal(response) internal = src.response_to_internal(response)
return tgt.response_from_internal(internal, requested_model=requested_model) return tgt.response_from_internal(internal, requested_model=requested_model)
@@ -134,14 +136,19 @@ class FormatConversionRegistry:
src = self._require_normalizer(source_format) src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format) tgt = self._require_normalizer(target_format)
if not (src.capabilities.supports_error_conversion and tgt.capabilities.supports_error_conversion): if not (
src.capabilities.supports_error_conversion
and tgt.capabilities.supports_error_conversion
):
raise FormatConversionError( raise FormatConversionError(
source_format, source_format,
target_format, target_format,
"source/target normalizer 不支持错误转换", "source/target normalizer 不支持错误转换",
) )
with _track_conversion_metrics("error", str(source_format).upper(), str(target_format).upper()): with _track_conversion_metrics(
"error", str(source_format).upper(), str(target_format).upper()
):
try: try:
internal = src.error_to_internal(error_response) internal = src.error_to_internal(error_response)
return tgt.error_from_internal(internal) return tgt.error_from_internal(internal)
@@ -179,7 +186,9 @@ class FormatConversionRegistry:
) )
state = StreamState() state = StreamState()
with _track_conversion_metrics("stream", str(source_format).upper(), str(target_format).upper()): with _track_conversion_metrics(
"stream", str(source_format).upper(), str(target_format).upper()
):
try: try:
events = src.stream_chunk_to_internal(chunk, state) events = src.stream_chunk_to_internal(chunk, state)
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
@@ -194,7 +203,10 @@ class FormatConversionRegistry:
def can_convert_request(self, source_format: str, target_format: str) -> bool: def can_convert_request(self, source_format: str, target_format: str) -> bool:
if str(source_format).upper() == str(target_format).upper(): if str(source_format).upper() == str(target_format).upper():
return True return True
return self.get_normalizer(source_format) is not None and self.get_normalizer(target_format) is not None 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: def can_convert_response(self, source_format: str, target_format: str) -> bool:
return self.can_convert_request(source_format, target_format) return self.can_convert_request(source_format, target_format)
@@ -215,15 +227,22 @@ class FormatConversionRegistry:
tgt = self.get_normalizer(target_format) tgt = self.get_normalizer(target_format)
if src is None or tgt is None: if src is None or tgt is None:
return False return False
return bool(src.capabilities.supports_error_conversion and tgt.capabilities.supports_error_conversion) 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: 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): if not self.can_convert_request(format_a, format_b):
return False return False
if not self.can_convert_request(format_b, format_a): if not self.can_convert_request(format_b, format_a):
return False return False
if require_stream: if require_stream:
return self.can_convert_stream(format_a, format_b) and self.can_convert_stream(format_b, format_a) return self.can_convert_stream(format_a, format_b) and self.can_convert_stream(
format_b, format_a
)
return True return True
def list_normalizers(self) -> list[str]: def list_normalizers(self) -> list[str]:

View File

@@ -4,7 +4,6 @@
用于把 OpenAI/Claude/Gemini 的流式协议映射为统一事件序列,再由目标格式 Normalizer 输出。 用于把 OpenAI/Claude/Gemini 的流式协议映射为统一事件序列,再由目标格式 Normalizer 输出。
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any from typing import Any

View File

@@ -5,7 +5,6 @@
每个 Normalizer 通过 `substate(format_id)` 获取自己的隔离状态字典。 每个 Normalizer 通过 `substate(format_id)` 获取自己的隔离状态字典。
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -46,4 +45,3 @@ class StreamState:
__all__ = [ __all__ = [
"StreamState", "StreamState",
] ]

View File

@@ -12,63 +12,15 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from starlette.requests import Request from starlette.requests import Request
from src.core.api_format.enums import APIFormat, AuthMethod, EndpointType from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind, EndpointType
from src.core.api_format.metadata import API_FORMAT_DEFINITIONS, ApiFormatDefinition from src.core.api_format.signature import EndpointSignature, make_signature_key
def _extract_api_key_by_definition(
headers: dict[str, str],
query_params: dict[str, str] | None,
definition: ApiFormatDefinition,
) -> tuple[str | None, str]:
"""
根据格式定义从请求中提取 API Key
Args:
headers: 请求头字典key 小写)
query_params: 查询参数字典(可选)
definition: API 格式定义
Returns:
(api_key, auth_method) 元组:
- api_key: 提取到的 API Key或 None
- auth_method: 认证方式 ("header""query")
"""
auth_header = definition.auth_header.lower()
auth_type = definition.auth_type
# Gemini 格式query 参数优先(与 Google SDK 行为一致)
if definition.api_format in (APIFormat.GEMINI, APIFormat.GEMINI_CLI):
# 1. 优先检查 ?key= 参数
query_key = query_params.get("key") if query_params else None
if query_key:
return query_key, "query"
# 2. 再检查 x-goog-api-key 请求头
header_value = headers.get(auth_header)
if header_value:
return header_value, "header"
return None, "header"
# 其他格式:从 header 提取
header_value = headers.get(auth_header)
if not header_value:
return None, "header"
if auth_type == "bearer":
# Bearer token: "Bearer xxx"
if header_value.lower().startswith("bearer "):
return header_value[7:].strip(), "header"
return None, "header"
else:
# header 类型: 直接使用值
return header_value, "header"
@dataclass(frozen=True) @dataclass(frozen=True)
class RequestContext: class RequestContext:
"""请求上下文 - 三维度信息""" """请求上下文 - 三维度信息"""
data_format: APIFormat endpoint: EndpointSignature
endpoint_type: EndpointType endpoint_type: EndpointType
auth_method: AuthMethod auth_method: AuthMethod
credentials: str | None credentials: str | None
@@ -99,18 +51,37 @@ def _detect_endpoint_type(path: str) -> EndpointType:
def _detect_data_format( def _detect_data_format(
path: str, headers: dict[str, str], query_params: dict[str, str] | None path: str, headers: dict[str, str], query_params: dict[str, str] | None
) -> APIFormat: ) -> EndpointSignature:
normalized = path.lower() normalized = path.lower()
endpoint_type = _detect_endpoint_type(path)
# Claude: /v1/messageschat/cli 共用路径,按认证头区分)
if normalized.startswith("/v1/messages"): if normalized.startswith("/v1/messages"):
return APIFormat.CLAUDE auth_header = headers.get("authorization", "")
if normalized.startswith("/v1beta/") or normalized.startswith("/upload/v1beta/"): if auth_header.lower().startswith("bearer "):
return APIFormat.GEMINI return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CLI)
if normalized.startswith("/v1/chat/completions") or normalized.startswith("/v1/videos"): return EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT)
return APIFormat.OPENAI
api_format, _api_key, _auth_method = detect_format_from_request(headers, query_params) # OpenAI CLI: /responses
return api_format 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( def _detect_auth_method(
@@ -139,7 +110,7 @@ def _detect_auth_method(
def detect_format_from_request( def detect_format_from_request(
headers: dict[str, str], headers: dict[str, str],
query_params: dict[str, str] | None = None, query_params: dict[str, str] | None = None,
) -> tuple[APIFormat, str | None, str]: ) -> tuple[EndpointSignature, str | None, str]:
""" """
从请求头检测 API 格式和 API Key 从请求头检测 API 格式和 API Key
@@ -153,36 +124,56 @@ def detect_format_from_request(
query_params: 查询参数字典(可选) query_params: 查询参数字典(可选)
Returns: Returns:
(APIFormat, api_key, auth_method) 元组 (endpoint_signature, api_key, auth_source) 元组
- auth_method: 认证方式 ("header""query") - endpoint_signature: EndpointSignature(api_family, endpoint_kind)
- auth_source: 认证来源 ("header""query")
""" """
# Claude: x-api-key + anthropic-version (必须同时存在) # Claude: x-api-key + anthropic-version (必须同时存在)
claude_def = API_FORMAT_DEFINITIONS[APIFormat.CLAUDE] if headers.get("x-api-key") and headers.get("anthropic-version"):
claude_key, claude_auth_method = _extract_api_key_by_definition( return (
headers, query_params, claude_def EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT),
) headers.get("x-api-key"),
if claude_key and headers.get("anthropic-version"): "header",
return APIFormat.CLAUDE, claude_key, claude_auth_method )
# Gemini: x-goog-api-key (header 类型) 或 ?key= # Gemini: query 参数优先(与 Google SDK 行为一致)
gemini_def = API_FORMAT_DEFINITIONS[APIFormat.GEMINI] query_key = query_params.get("key") if query_params else None
gemini_key, gemini_auth_method = _extract_api_key_by_definition( if query_key:
headers, query_params, gemini_def return (
) EndpointSignature(api_family=ApiFamily.GEMINI, endpoint_kind=EndpointKind.CHAT),
if gemini_key: query_key,
return APIFormat.GEMINI, gemini_key, gemini_auth_method "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 (默认) # OpenAI: Authorization: Bearer (默认)
# 注意: 如果只有 x-api-key 但没有 anthropic-version也走 OpenAI 格式 auth_header = headers.get("authorization", "")
openai_def = API_FORMAT_DEFINITIONS[APIFormat.OPENAI] if auth_header.lower().startswith("bearer "):
openai_key, openai_auth_method = _extract_api_key_by_definition( return (
headers, query_params, openai_def 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",
) )
# 如果 OpenAI 格式没有 key但有 x-api-key也用它兼容
if not openai_key and claude_key:
openai_key = claude_key
openai_auth_method = claude_auth_method
return APIFormat.OPENAI, openai_key, openai_auth_method
def detect_format_and_key_from_starlette( def detect_format_and_key_from_starlette(
@@ -208,8 +199,7 @@ def detect_format_and_key_from_starlette(
api_format, api_key, auth_method = detect_format_from_request(headers, query_params) api_format, api_key, auth_method = detect_format_from_request(headers, query_params)
# 返回小写格式名 # 返回小写格式名
format_name = api_format.value.lower() return api_format.key, api_key, auth_method
return format_name, api_key, auth_method
def detect_request_context(request: Request) -> RequestContext: def detect_request_context(request: Request) -> RequestContext:
@@ -227,7 +217,7 @@ def detect_request_context(request: Request) -> RequestContext:
auth_method, credentials = _detect_auth_method(headers, query_params) auth_method, credentials = _detect_auth_method(headers, query_params)
return RequestContext( return RequestContext(
data_format=data_format, endpoint=data_format,
endpoint_type=endpoint_type, endpoint_type=endpoint_type,
auth_method=auth_method, auth_method=auth_method,
credentials=credentials, credentials=credentials,
@@ -236,7 +226,7 @@ def detect_request_context(request: Request) -> RequestContext:
def detect_format_from_response( def detect_format_from_response(
response_data: dict, response_data: dict,
) -> APIFormat | None: ) -> str | None:
""" """
从响应内容检测 API 格式 从响应内容检测 API 格式
@@ -248,26 +238,26 @@ def detect_format_from_response(
""" """
# Claude: 有 type="message" 或特定的 content 结构 # Claude: 有 type="message" 或特定的 content 结构
if response_data.get("type") == "message": if response_data.get("type") == "message":
return APIFormat.CLAUDE return make_signature_key(ApiFamily.CLAUDE, EndpointKind.CHAT)
if "content" in response_data and isinstance(response_data["content"], list): if "content" in response_data and isinstance(response_data["content"], list):
first_content = response_data["content"][0] if response_data["content"] else {} first_content = response_data["content"][0] if response_data["content"] else {}
if first_content.get("type") in ("text", "tool_use"): if first_content.get("type") in ("text", "tool_use"):
return APIFormat.CLAUDE return make_signature_key(ApiFamily.CLAUDE, EndpointKind.CHAT)
# OpenAI: 有 choices 数组 # OpenAI: 有 choices 数组
if "choices" in response_data: if "choices" in response_data:
return APIFormat.OPENAI return make_signature_key(ApiFamily.OPENAI, EndpointKind.CHAT)
# Gemini: 有 candidates 数组 # Gemini: 有 candidates 数组
if "candidates" in response_data: if "candidates" in response_data:
return APIFormat.GEMINI return make_signature_key(ApiFamily.GEMINI, EndpointKind.CHAT)
return None return None
def detect_cli_format_from_path( def detect_cli_format_from_path(
path: str, path: str,
base_format: APIFormat, base_signature: str,
) -> bool: ) -> bool:
""" """
根据请求路径检测是否为 CLI 模式 根据请求路径检测是否为 CLI 模式
@@ -285,7 +275,7 @@ def detect_cli_format_from_path(
True 如果是 CLI 模式 True 如果是 CLI 模式
""" """
# OpenAI CLI 特征: /v1/responses 路径 # OpenAI CLI 特征: /v1/responses 路径
if base_format == APIFormat.OPENAI and "/responses" in path: if str(base_signature).lower().startswith("openai:") and "/responses" in path.lower():
return True return True
# 其他 CLI 模式通常由 Adapter 层根据具体业务逻辑判断 # 其他 CLI 模式通常由 Adapter 层根据具体业务逻辑判断

View File

@@ -1,21 +1,34 @@
""" """API format enums.
API 格式枚举定义
定义所有支持的 API 格式,决定请求/响应的处理方式 新模式下系统使用结构化的 (ApiFamily, EndpointKind) / `family:kind` signature 作为唯一标识
""" """
from enum import Enum from enum import Enum
class APIFormat(Enum): class ApiFamily(str, Enum):
"""API 格式枚举 - 决定请求/响应的处理方式""" """
协议族(兼容族)- 决定数据格式与认证方式的基础。
CLAUDE = "CLAUDE" # Claude API 格式 注意:不叫 Provider 避免与 ORM 的 Provider 模型撞名。
CLAUDE_CLI = "CLAUDE_CLI" # Claude CLI API 格式(使用 authorization: Bearer """
OPENAI = "OPENAI" # OpenAI API 格式
OPENAI_CLI = "OPENAI_CLI" # OpenAI CLI/Responses API 格式(用于 Claude Code 等客户端 OPENAI = "openai" # openai-compatible含 deepseek, grok, qwen 等
GEMINI = "GEMINI" # Google Gemini API 格式 CLAUDE = "claude" # claude-compatible
GEMINI_CLI = "GEMINI_CLI" # Gemini CLI API 格式 GEMINI = "gemini" # gemini-compatible
class EndpointKind(str, Enum):
"""
端点变体 - 决定 API 路径/认证变体/数据格式变体等。
注意:不复用现有 EndpointTypeEndpointType 用于请求上下文检测/功能分类)。
"""
CHAT = "chat"
CLI = "cli"
VIDEO = "video"
IMAGE = "image"
class AuthMethod(str, Enum): class AuthMethod(str, Enum):
@@ -40,4 +53,9 @@ class EndpointType(str, Enum):
MODELS = "models" # Models API MODELS = "models" # Models API
__all__ = ["APIFormat", "AuthMethod", "EndpointType"] __all__ = [
"ApiFamily",
"EndpointKind",
"AuthMethod",
"EndpointType",
]

View File

@@ -15,9 +15,12 @@ from __future__ import annotations
from collections.abc import Set as AbstractSet from collections.abc import Set as AbstractSet
from typing import Any from typing import Any
from src.core.api_format.enums import APIFormat from src.core.api_format.metadata import (
from src.core.api_format.metadata import get_auth_config, get_extra_headers, get_protected_keys get_auth_config_for_endpoint,
get_extra_headers_for_endpoint,
get_protected_keys_for_endpoint,
)
from src.core.api_format.signature import EndpointSignature, parse_signature_key
# ============================================================================= # =============================================================================
# 头部常量定义 # 头部常量定义
@@ -118,65 +121,61 @@ def get_header_value(headers: dict[str, str], key: str, default: str = "") -> st
# ============================================================================= # =============================================================================
def extract_client_api_key(headers: dict[str, str], api_format: APIFormat) -> str | None: def extract_client_api_key_for_endpoint(
headers: dict[str, str],
endpoint: str | EndpointSignature | tuple,
) -> str | None:
""" """
从客户端请求头提取 API Key 新模式:从客户端请求头提取 API Key
自动处理大小写,根据 API 格式使用正确的认证头和类型。
Args: Args:
headers: 原始请求头(自动处理大小写) headers: 原始请求头(自动处理大小写)
api_format: API 格式 endpoint: endpoint signature`family:kind` / EndpointSignature / (ApiFamily, EndpointKind)
Returns:
提取的 API Key未找到返回 None
""" """
auth_header, auth_type = get_auth_config_for_endpoint(endpoint)
auth_header, auth_type = get_auth_config(api_format)
value = get_header_value(headers, auth_header) value = get_header_value(headers, auth_header)
if not value: if not value:
return None return None
if auth_type == "bearer": if auth_type == "bearer":
# Bearer token 格式: "Bearer <token>"
if value.lower().startswith("bearer "): if value.lower().startswith("bearer "):
return value[7:] # 移除 "Bearer " 前缀 return value[7:]
return None return None
# 直接 header 格式
return value return value
def extract_client_api_key_with_query( def extract_client_api_key_for_endpoint_with_query(
headers: dict[str, str], headers: dict[str, str],
query_params: dict[str, str] | None, query_params: dict[str, str] | None,
api_format: APIFormat, endpoint: str | EndpointSignature | tuple,
) -> str | None: ) -> str | None:
""" """
从客户端请求头或 URL 参数提取 API Key 新模式:从客户端请求头或 URL 参数提取 API Key
Gemini 格式优先级(与 Google SDK 行为一致) Gemini family 优先级
1. URL 参数 ?key= 1. URL 参数 ?key=
2. x-goog-api-key 请求头 2. x-goog-api-key 请求头
其他格式仅从请求头提取。
Args:
headers: 原始请求头(自动处理大小写)
query_params: URL 查询参数
api_format: API 格式
Returns:
提取的 API Key未找到返回 None
""" """
# Gemini 格式query 参数优先 try:
if api_format in (APIFormat.GEMINI, APIFormat.GEMINI_CLI): 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 query_key = query_params.get("key") if query_params else None
if query_key: if query_key:
return query_key return query_key
# 其他格式或 Gemini header 方式:使用现有逻辑 return extract_client_api_key_for_endpoint(headers, endpoint)
return extract_client_api_key(headers, api_format)
# ============================================================================= # =============================================================================
@@ -184,29 +183,33 @@ def extract_client_api_key_with_query(
# ============================================================================= # =============================================================================
def detect_capabilities( def detect_capabilities_for_endpoint(
headers: dict[str, str], headers: dict[str, str],
api_format: APIFormat, endpoint: str | EndpointSignature | tuple,
request_body: dict[str, Any] | None = None, # noqa: ARG001 - 预留给部分格式使用 request_body: dict[str, Any] | None = None, # noqa: ARG001 - 预留
) -> dict[str, bool]: ) -> dict[str, bool]:
""" """
从请求头检测能力需求 新模式:从请求头检测能力需求
当前支持: 当前支持
- Claude/Claude CLI: anthropic-beta 头中的 context-1m - Claude family: anthropic-beta 头中的 context-1m
Args:
headers: 原始请求头(自动处理大小写)
api_format: API 格式
request_body: 请求体(部分格式可能需要)
Returns:
能力需求字典,如 {"context_1m": True}
""" """
requirements: dict[str, bool] = {} requirements: dict[str, bool] = {}
if api_format in (APIFormat.CLAUDE, APIFormat.CLAUDE_CLI): 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") beta_header = get_header_value(headers, "anthropic-beta")
if "context-1m" in beta_header.lower(): if "context-1m" in beta_header.lower():
requirements["context_1m"] = True requirements["context_1m"] = True
@@ -242,7 +245,9 @@ class HeaderBuilder:
self.add(k, v) self.add(k, v)
return self return self
def add_protected(self, headers: dict[str, str], protected_keys: AbstractSet[str]) -> HeaderBuilder: def add_protected(
self, headers: dict[str, str], protected_keys: AbstractSet[str]
) -> HeaderBuilder:
""" """
添加头部但保护指定的 key 不被覆盖 添加头部但保护指定的 key 不被覆盖
@@ -310,7 +315,10 @@ class HeaderBuilder:
to_key = rule.get("to", "") to_key = rule.get("to", "")
if from_key and to_key: if from_key and to_key:
# 两个 key 都不能是受保护的 # 两个 key 都不能是受保护的
if from_key.lower() not in protected_lower and to_key.lower() not in protected_lower: if (
from_key.lower() not in protected_lower
and to_key.lower() not in protected_lower
):
self.rename(from_key, to_key) self.rename(from_key, to_key)
return self return self
@@ -320,9 +328,9 @@ class HeaderBuilder:
return {original_key: value for original_key, value in self._headers.values()} return {original_key: value for original_key, value in self._headers.values()}
def build_upstream_headers( def build_upstream_headers_for_endpoint(
original_headers: dict[str, str], original_headers: dict[str, str],
api_format: APIFormat, endpoint: str | EndpointSignature | tuple,
provider_api_key: str, provider_api_key: str,
*, *,
endpoint_headers: dict[str, str] | None = None, endpoint_headers: dict[str, str] | None = None,
@@ -330,55 +338,36 @@ def build_upstream_headers(
drop_headers: frozenset[str] | None = None, drop_headers: frozenset[str] | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
""" """
构建发送给上游 Provider 的请求头 新模式:构建发送给上游 Provider 的请求头(基于 endpoint signature
优先级(后者覆盖前者): 优先级(后者覆盖前者):
1. 原始头部(排除 drop_headers 1. 原始头部(排除 drop_headers
2. endpoint 配置头部 2. endpoint 配置头部
3. extra_headers 3. extra_headers
4. 认证头(最高优先级,始终设置) 4. 认证头(最高优先级,始终设置)
Args:
original_headers: 客户端原始请求头
api_format: API 格式
provider_api_key: Provider 的 API Key已解密
endpoint_headers: Endpoint 配置的额外头部
extra_headers: 调用方传入的额外头部
drop_headers: 需要剔除的头部集合None 使用默认值,空集合表示不剔除)
Returns:
构建好的请求头字典
""" """
# 使用 is None 判断,允许显式传空集合
if drop_headers is None: if drop_headers is None:
drop_headers = UPSTREAM_DROP_HEADERS drop_headers = UPSTREAM_DROP_HEADERS
auth_header, auth_type = get_auth_config(api_format) 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 auth_value = f"Bearer {provider_api_key}" if auth_type == "bearer" else provider_api_key
# 认证头是受保护的,不能被 endpoint_headers 覆盖
protected_keys = {auth_header.lower(), "content-type"} protected_keys = {auth_header.lower(), "content-type"}
builder = HeaderBuilder() builder = HeaderBuilder()
# 1. 添加原始头部(排除 drop_headers
for k, v in original_headers.items(): for k, v in original_headers.items():
if k.lower() not in drop_headers: if k.lower() not in drop_headers:
builder.add(k, v) builder.add(k, v)
# 2. 添加 endpoint 头部(保护认证头)
if endpoint_headers: if endpoint_headers:
builder.add_protected(endpoint_headers, protected_keys) builder.add_protected(endpoint_headers, protected_keys)
# 3. 添加 extra_headers
if extra_headers: if extra_headers:
builder.add_many(extra_headers) builder.add_many(extra_headers)
# 4. 设置认证头(最高优先级,上游始终使用 header 认证)
builder.add(auth_header, auth_value) builder.add(auth_header, auth_value)
# 5. 确保 Content-Type
result = builder.build() result = builder.build()
if not any(k.lower() == "content-type" for k in result): if not any(k.lower() == "content-type" for k in result):
result["Content-Type"] = "application/json" result["Content-Type"] = "application/json"
@@ -470,39 +459,21 @@ def redact_headers_for_log(
return {k: "***" if k.lower() in redact_keys else v for k, v in headers.items()} return {k: "***" if k.lower() in redact_keys else v for k, v in headers.items()}
# =============================================================================
# 兼容层(向后兼容,逐步废弃)
# =============================================================================
# 兼容 request_builder.py 的 SENSITIVE_HEADERS
SENSITIVE_HEADERS = UPSTREAM_DROP_HEADERS
# ============================================================================= # =============================================================================
# Adapter 统一接口 # Adapter 统一接口
# ============================================================================= # =============================================================================
def build_adapter_base_headers( def build_adapter_base_headers_for_endpoint(
api_format: APIFormat, endpoint: str | EndpointSignature | tuple,
api_key: str, api_key: str,
*, *,
include_extra: bool = True, include_extra: bool = True,
) -> dict[str, str]: ) -> dict[str, str]:
""" """
根据 API 格式构建基础请求头 新模式:根据 endpoint signature 构建基础请求头
包含:认证头 + Content-Type + 格式特定的额外头部(如 anthropic-version
Args:
api_format: API 格式
api_key: API Key已解密
include_extra: 是否包含格式特定的额外头部(默认 True
Returns:
基础请求头字典
""" """
auth_header, auth_type = get_auth_config(api_format) auth_header, auth_type = get_auth_config_for_endpoint(endpoint)
auth_value = f"Bearer {api_key}" if auth_type == "bearer" else api_key auth_value = f"Bearer {api_key}" if auth_type == "bearer" else api_key
headers: dict[str, str] = { headers: dict[str, str] = {
@@ -511,53 +482,33 @@ def build_adapter_base_headers(
} }
if include_extra: if include_extra:
extra = get_extra_headers(api_format) extra = get_extra_headers_for_endpoint(endpoint)
if extra: if extra:
headers.update(extra) headers.update(extra)
return headers return headers
def build_adapter_headers( def build_adapter_headers_for_endpoint(
api_format: APIFormat, endpoint: str | EndpointSignature | tuple,
api_key: str, api_key: str,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
""" """
构建完整的 Adapter 请求头 新模式:构建完整的 Adapter 请求头(包含 extra_headers
在基础头部上合并 extra_headers同时保护关键头部不被覆盖。
Args:
api_format: API 格式
api_key: API Key已解密
extra_headers: 调用方传入的额外头部
Returns:
完整的请求头字典
""" """
base = build_adapter_base_headers(api_format, api_key) base = build_adapter_base_headers_for_endpoint(endpoint, api_key)
if not extra_headers: if not extra_headers:
return base return base
protected = get_protected_keys_for_endpoint(endpoint)
protected = get_protected_keys(api_format)
return merge_headers_with_protection(base, extra_headers, protected) return merge_headers_with_protection(base, extra_headers, protected)
def get_adapter_protected_keys(api_format: APIFormat) -> tuple[str, ...]: def get_adapter_protected_keys_for_endpoint(
""" endpoint: str | EndpointSignature | tuple,
获取 Adapter 的受保护头部 key ) -> tuple[str, ...]:
"""新模式:获取 Adapter 的受保护头部 key。"""
用于 get_protected_header_keys() 方法返回值。 return tuple(get_protected_keys_for_endpoint(endpoint))
Args:
api_format: API 格式
Returns:
受保护的头部 key 元组
"""
return tuple(get_protected_keys(api_format))
# ============================================================================= # =============================================================================
@@ -608,4 +559,3 @@ def get_extra_headers_from_endpoint(endpoint: Any) -> dict[str, str] | None:
""" """
header_rules = getattr(endpoint, "header_rules", None) header_rules = getattr(endpoint, "header_rules", None)
return extract_set_headers_from_rules(header_rules) return extract_set_headers_from_rules(header_rules)

View File

@@ -1,94 +1,102 @@
""" """
API 格式元数据定义 API endpoint metadata (new mode).
集中维护 API 格式的元数据,避免新增格式时到处修改常量。 新模式下,系统以 (ApiFamily, EndpointKind) 作为结构化标识;
在需要用 string 做 keyDB / JSON dict / metrics label / logs统一使用
使用方式: `family:kind` 的 endpoint signature key全小写
# 解析格式别名
from src.core.api_format import resolve_api_format
api_format = resolve_api_format("claude") # -> APIFormat.CLAUDE
# 获取格式定义
from src.core.api_format import get_api_format_definition
definition = get_api_format_definition(APIFormat.CLAUDE)
""" """
from __future__ import annotations
import re from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import lru_cache
from types import MappingProxyType from types import MappingProxyType
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
from .enums import APIFormat 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) @dataclass(frozen=True, slots=True)
class ApiFormatDefinition: class EndpointDefinition:
""" """
描述一个 API 格式的所有通用信息 端点定义ApiFamily + EndpointKind
- aliases: 用于 detect_api_format 的 provider 别名或快捷名称 - aliases: 用于调试/展示/配置的别名(不用于“接受 legacy APIFormat”
- default_path: 上游默认请求路径(如 /v1/messages可通过 Endpoint.custom_path 覆盖 - default_path: 上游默认路径,可被 ProviderEndpoint.custom_path 覆盖
- path_prefix: 本站路径前缀(如 /claude, /openai为空表示无前缀 - auth_method/auth_header/auth_type: 认证信息header/bearer 等)
- auth_header: 认证头名称 (如 "x-api-key", "x-goog-api-key") - extra_headers/protected_keys: 格式固定头与保护头
- auth_type: 认证类型 ("header" 直接放值, "bearer" 加 Bearer 前缀) - model_in_body/stream_in_body: 结构差异标记(用于 request/response 构造/规范化)
- extra_headers: 该格式必须携带的额外头部(如 anthropic-version - data_format_id: 数据格式标识(相同即可透传;不同需格式转换
- protected_keys: 不应被 extra_headers 覆盖的头部(小写)
- model_in_body: 是否需要在请求体中包含 model 字段Gemini 等格式通过 URL 传递模型名)
- stream_in_body: 是否需要在请求体中包含 stream 字段Gemini 等格式通过 URL 端点区分流式)
- data_format_id: 数据格式标识,相同 ID 的格式数据结构相同可以透传,不同则需要转换
例如CLAUDE/CLAUDE_CLI 都是 "claude",可以透传
OPENAI 是 "openai_chat"OPENAI_CLI 是 "openai_responses",需要转换
""" """
api_format: APIFormat api_family: ApiFamily
endpoint_kind: EndpointKind
aliases: Sequence[str] = field(default_factory=tuple) aliases: Sequence[str] = field(default_factory=tuple)
default_path: str = "/" # 上游默认请求路径 default_path: str = "/"
path_prefix: str = "" # 本站路径前缀,为空表示无前缀 path_prefix: str = ""
auth_method: AuthMethod = AuthMethod.BEARER
auth_header: str = "Authorization" auth_header: str = "Authorization"
auth_type: str = "bearer" # "bearer" or "header" auth_type: str = "bearer" # "bearer" | "header"
extra_headers: Mapping[str, str] = field(default_factory=dict) # 格式必须的额外头部
protected_keys: frozenset[str] = field(default_factory=frozenset) # 受保护的头部 key小写 extra_headers: Mapping[str, str] = field(default_factory=dict)
model_in_body: bool = True # 是否需要在请求体中包含 model 字段 protected_keys: frozenset[str] = field(default_factory=frozenset)
stream_in_body: bool = True # 是否需要在请求体中包含 stream 字段
data_format_id: str = "" # 数据格式标识,相同 ID 可透传,不同需转换 model_in_body: bool = True
stream_in_body: bool = True
data_format_id: str = ""
@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]: def iter_aliases(self) -> Iterable[str]:
"""返回大小写统一后的别名集合,包含枚举名本身。""" # 统一包含 signature key便于配置/展示)
yield normalize_alias_value(self.api_format.value) yield self.signature_key
for alias in self.aliases: for alias in self.aliases:
normalized = normalize_alias_value(alias) value = str(alias or "").strip()
if normalized: if value:
yield normalized yield value
_DEFINITIONS: dict[APIFormat, ApiFormatDefinition] = { _ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition] = {
APIFormat.CLAUDE: ApiFormatDefinition( # Claude
api_format=APIFormat.CLAUDE, (ApiFamily.CLAUDE, EndpointKind.CHAT): EndpointDefinition(
api_family=ApiFamily.CLAUDE,
endpoint_kind=EndpointKind.CHAT,
aliases=("claude", "anthropic", "claude_compatible"), aliases=("claude", "anthropic", "claude_compatible"),
default_path="/v1/messages", default_path="/v1/messages",
path_prefix="", # 通过请求头区分格式,不使用路径前缀 auth_method=AuthMethod.API_KEY,
auth_header="x-api-key", auth_header="x-api-key",
auth_type="header", auth_type="header",
extra_headers={"anthropic-version": "2023-06-01"}, extra_headers={"anthropic-version": "2023-06-01"},
protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}), protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}),
data_format_id="claude", # CLAUDE/CLAUDE_CLI 数据格式相同 data_format_id="claude",
), ),
APIFormat.CLAUDE_CLI: ApiFormatDefinition( (ApiFamily.CLAUDE, EndpointKind.CLI): EndpointDefinition(
api_format=APIFormat.CLAUDE_CLI, api_family=ApiFamily.CLAUDE,
endpoint_kind=EndpointKind.CLI,
aliases=("claude_cli", "claude-cli"), aliases=("claude_cli", "claude-cli"),
default_path="/v1/messages", default_path="/v1/messages",
path_prefix="", # 与 CLAUDE 共享入口,通过 header 区分 auth_method=AuthMethod.BEARER,
auth_header="Authorization", auth_header="Authorization",
auth_type="bearer", auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}), protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="claude", # CLAUDE/CLAUDE_CLI 数据格式相同 data_format_id="claude",
), ),
APIFormat.OPENAI: ApiFormatDefinition( # OpenAI
api_format=APIFormat.OPENAI, (ApiFamily.OPENAI, EndpointKind.CHAT): EndpointDefinition(
api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.CHAT,
aliases=( aliases=(
"openai", "openai",
"openai_compatible",
"deepseek", "deepseek",
"grok", "grok",
"moonshot", "moonshot",
@@ -96,277 +104,225 @@ _DEFINITIONS: dict[APIFormat, ApiFormatDefinition] = {
"qwen", "qwen",
"baichuan", "baichuan",
"minimax", "minimax",
"openai_compatible",
), ),
default_path="/v1/chat/completions", default_path="/v1/chat/completions",
path_prefix="", # 默认格式 auth_method=AuthMethod.BEARER,
auth_header="Authorization", auth_header="Authorization",
auth_type="bearer", auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}), protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="openai_chat", # Chat Completions API 格式 data_format_id="openai_chat",
), ),
APIFormat.OPENAI_CLI: ApiFormatDefinition( (ApiFamily.OPENAI, EndpointKind.CLI): EndpointDefinition(
api_format=APIFormat.OPENAI_CLI, api_family=ApiFamily.OPENAI,
endpoint_kind=EndpointKind.CLI,
aliases=("openai_cli", "responses"), aliases=("openai_cli", "responses"),
default_path="/responses", default_path="/responses",
path_prefix="", # 与 OPENAI 共享入口 auth_method=AuthMethod.BEARER,
auth_header="Authorization", auth_header="Authorization",
auth_type="bearer", auth_type="bearer",
protected_keys=frozenset({"authorization", "content-type"}), protected_keys=frozenset({"authorization", "content-type"}),
data_format_id="openai_responses", # Responses API 格式,与 OPENAI 不同需转换 data_format_id="openai_responses",
), ),
APIFormat.GEMINI: ApiFormatDefinition( (ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
api_format=APIFormat.GEMINI, 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"), aliases=("gemini", "google", "vertex"),
default_path="/v1beta/models/{model}:{action}", default_path="/v1beta/models/{model}:{action}",
path_prefix="", # 通过请求头区分格式 auth_method=AuthMethod.GOOG_API_KEY,
auth_header="x-goog-api-key", auth_header="x-goog-api-key",
auth_type="header", auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}), protected_keys=frozenset({"x-goog-api-key", "content-type"}),
model_in_body=False, # Gemini 通过 URL 路径传递模型名 model_in_body=False,
stream_in_body=False, # Gemini 通过 URL 端点区分流式streamGenerateContent vs generateContent stream_in_body=False,
data_format_id="gemini", # GEMINI/GEMINI_CLI 数据格式相同 data_format_id="gemini",
), ),
APIFormat.GEMINI_CLI: ApiFormatDefinition( (ApiFamily.GEMINI, EndpointKind.CLI): EndpointDefinition(
api_format=APIFormat.GEMINI_CLI, api_family=ApiFamily.GEMINI,
endpoint_kind=EndpointKind.CLI,
aliases=("gemini_cli", "gemini-cli"), aliases=("gemini_cli", "gemini-cli"),
default_path="/v1beta/models/{model}:{action}", default_path="/v1beta/models/{model}:{action}",
path_prefix="", # 与 GEMINI 共享入口 auth_method=AuthMethod.GOOG_API_KEY,
auth_header="x-goog-api-key", auth_header="x-goog-api-key",
auth_type="header", auth_type="header",
protected_keys=frozenset({"x-goog-api-key", "content-type"}), protected_keys=frozenset({"x-goog-api-key", "content-type"}),
model_in_body=False, # Gemini 通过 URL 路径传递模型名 model_in_body=False,
stream_in_body=False, # Gemini 通过 URL 端点区分流式 stream_in_body=False,
data_format_id="gemini", # GEMINI/GEMINI_CLI 数据格式相同 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",
), ),
} }
# 对外只暴露只读视图,避免被随意修改 # 对外只暴露只读视图,避免被随意修改
API_FORMAT_DEFINITIONS: Mapping[APIFormat, ApiFormatDefinition] = MappingProxyType(_DEFINITIONS) ENDPOINT_DEFINITIONS: Mapping[tuple[ApiFamily, EndpointKind], EndpointDefinition] = (
MappingProxyType(_ENDPOINT_DEFINITIONS)
)
def get_api_format_definition(api_format: APIFormat) -> ApiFormatDefinition: def list_endpoint_definitions() -> list[EndpointDefinition]:
"""获取指定格式的定义,不存在时抛出 KeyError。""" return list(ENDPOINT_DEFINITIONS.values())
return API_FORMAT_DEFINITIONS[api_format]
def list_api_format_definitions() -> list[ApiFormatDefinition]: def get_endpoint_definition(
"""返回所有定义的浅拷贝列表,供遍历使用。""" api_family: ApiFamily, endpoint_kind: EndpointKind
return list(API_FORMAT_DEFINITIONS.values()) ) -> EndpointDefinition:
return ENDPOINT_DEFINITIONS[(api_family, endpoint_kind)]
def build_alias_lookup() -> dict[str, APIFormat]: def resolve_endpoint_definition(
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> EndpointDefinition | None:
""" """
构建 alias -> APIFormat 的查找表。 Resolve an endpoint definition from a signature-like input.
每次调用都会返回新的 dict避免可变全局引发并发问题。
Accepted inputs:
- EndpointSignature
- (ApiFamily, EndpointKind)
- "family:kind" signature string
""" """
lookup: MutableMapping[str, APIFormat] = {} try:
for definition in API_FORMAT_DEFINITIONS.values(): if isinstance(value, EndpointSignature):
for alias in definition.iter_aliases(): return ENDPOINT_DEFINITIONS.get((value.api_family, value.endpoint_kind))
lookup.setdefault(alias, definition.api_format) if isinstance(value, tuple) and len(value) == 2:
return dict(lookup) 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(api_format: APIFormat) -> str: def get_default_path_for_endpoint(
""" value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
获取该格式的上游默认请求路径。 ) -> str:
definition = resolve_endpoint_definition(value)
可通过 Endpoint.custom_path 覆盖。
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
return definition.default_path if definition else "/" return definition.default_path if definition else "/"
def get_local_path(api_format: APIFormat) -> str: 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:
""" """
获取该格式的本站入口路径 获取端点的数据格式标识
本站入口路径 = path_prefix + default_path - 相同 data_format_id 可透传(不需要数据转换)
例如path_prefix="/openai" + default_path="/v1/chat/completions" -> "/openai/v1/chat/completions" - 不同 data_format_id 需要走 format conversion
""" """
definition = API_FORMAT_DEFINITIONS.get(api_format) definition = resolve_endpoint_definition(value)
if definition:
prefix = definition.path_prefix or ""
return prefix + definition.default_path
return "/"
def get_auth_config(api_format: APIFormat) -> tuple[str, str]:
"""
获取该格式的认证配置。
Returns:
(auth_header, auth_type) 元组
- auth_header: 认证头名称
- auth_type: "bearer""header"
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition:
return definition.auth_header, definition.auth_type
return "Authorization", "bearer"
def get_extra_headers(api_format: APIFormat) -> Mapping[str, str]:
"""
获取该格式必须携带的额外头部。
例如 Claude 需要 anthropic-version 头部。
Returns:
额外头部字典(只读)
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition:
return definition.extra_headers
return {}
def get_protected_keys(api_format: APIFormat) -> frozenset[str]:
"""
获取该格式的受保护头部 key小写
这些头部不应被 extra_headers 覆盖。
Returns:
受保护的头部 key 集合
"""
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition:
return definition.protected_keys
return frozenset({"authorization", "content-type"})
def get_data_format_id(api_format: str | APIFormat) -> str:
"""
获取格式的数据格式标识。
相同 data_format_id 的格式数据结构相同,可以透传;不同则需要转换。
Args:
api_format: API 格式(字符串或枚举)
Returns:
数据格式标识,未找到时返回格式名称本身(小写)
"""
# 统一转换为 APIFormat 枚举
if isinstance(api_format, str):
resolved = resolve_api_format(api_format)
if resolved is None:
# 未知格式:返回小写,与已定义格式的 data_format_id 风格一致
return api_format.lower()
api_format = resolved
definition = API_FORMAT_DEFINITIONS.get(api_format)
if definition and definition.data_format_id: if definition and definition.data_format_id:
return definition.data_format_id return definition.data_format_id
# 兜底:返回格式名称本身(小写) return ""
return api_format.value.lower()
def can_passthrough(client_format: str | APIFormat, endpoint_format: str | APIFormat) -> bool: def can_passthrough_endpoint(
client: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
provider: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
) -> bool:
""" """
判断两个格式之间是否可以透传(不需要数据转换)。 判断两个 endpoint signature 是否可以透传(无需数据转换)。
透传条件: 透传条件:
1. 格式完全相同 1) signature 完全相同
2. data_format_id 相同(如 CLAUDE 和 CLAUDE_CLI 都是 "claude" 2) data_format_id 相同(如 claude:chat / claude:cli
Args:
client_format: 客户端请求格式
endpoint_format: 端点 API 格式
Returns:
True 表示可以透传False 表示需要转换
""" """
# 统一转换为字符串比较 try:
client_str = client_format.value if isinstance(client_format, APIFormat) else str(client_format).upper() if isinstance(client, str) and isinstance(provider, str):
endpoint_str = endpoint_format.value if isinstance(endpoint_format, APIFormat) else str(endpoint_format).upper() 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)
if client_str == endpoint_str: provider_id = get_data_format_id_for_endpoint(provider)
return True return bool(client_id) and client_id == provider_id
# 检查 data_format_id
client_data_id = get_data_format_id(client_format)
endpoint_data_id = get_data_format_id(endpoint_format)
return client_data_id == endpoint_data_id
@lru_cache(maxsize=1) def make_endpoint_signature(api_family: str, endpoint_kind: str) -> str:
def _alias_lookup_cache() -> dict[str, APIFormat]:
"""缓存 alias -> APIFormat 查找表,减少重复构建。"""
return build_alias_lookup()
def resolve_api_format_alias(value: str) -> APIFormat | None:
"""根据别名查找 APIFormat找不到时返回 None。"""
if not value:
return None
normalized = normalize_alias_value(value)
if not normalized:
return None
return _alias_lookup_cache().get(normalized)
def resolve_api_format(
value: str | APIFormat | None,
default: APIFormat | None = None,
) -> APIFormat | None:
""" """
将任意字符串/枚举值解析为 APIFormat。 Helper: build canonical signature key from raw strings (lowercased/trimmed).
Args: This is used in places that store family/kind separately in DB.
value: 可以是 APIFormat 或任意字符串/别名
default: 未解析成功时返回的默认值
""" """
if isinstance(value, APIFormat): return make_signature_key(api_family, endpoint_kind)
return value
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return default
upper = stripped.upper()
if upper in APIFormat.__members__:
return APIFormat[upper]
alias = resolve_api_format_alias(stripped)
if alias:
return alias
return default
def register_api_format_definition(definition: ApiFormatDefinition, *, override: bool = False) -> None: __all__ = [
""" "EndpointDefinition",
注册或覆盖 API 格式定义,允许运行时扩展。 "ENDPOINT_DEFINITIONS",
"list_endpoint_definitions",
Args: "get_endpoint_definition",
definition: 要注册的定义 "resolve_endpoint_definition",
override: 若目标枚举已存在,是否允许覆盖 "get_default_path_for_endpoint",
""" "get_local_path_for_endpoint",
existing = _DEFINITIONS.get(definition.api_format) "get_auth_config_for_endpoint",
if existing and not override: "get_extra_headers_for_endpoint",
raise ValueError(f"{definition.api_format.value} 已存在,如需覆盖请设置 override=True") "get_protected_keys_for_endpoint",
_DEFINITIONS[definition.api_format] = definition "get_data_format_id_for_endpoint",
_refresh_metadata_cache() "can_passthrough_endpoint",
"make_endpoint_signature",
]
def _refresh_metadata_cache() -> None:
"""更新别名缓存,供注册函数调用。"""
_alias_lookup_cache.cache_clear()
def normalize_alias_value(value: str) -> str:
"""统一别名格式:去空白、转小写,并将非字母数字转为单个下划线。"""
if value is None:
return ""
text = value.strip().lower()
# 将所有非字母数字字符替换为下划线,并折叠连续的下划线
text = re.sub(r"[^a-z0-9]+", "_", text)
return text.strip("_")
# is_cli_format 和 is_cli_api_format 已移至 utils.py
# 为保持兼容性,从 utils 重新导出
from src.core.api_format.utils import is_cli_format # noqa: E402
# is_cli_api_format 是 is_cli_format 的别名(接受 APIFormat 枚举)
is_cli_api_format = is_cli_format

View File

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

View File

@@ -6,43 +6,34 @@ API 格式工具函数
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: def is_cli_format(format_id: str | None) -> bool:
from src.core.api_format.enums import APIFormat
def is_cli_format(format_id: str | APIFormat | None) -> bool:
""" """
判断是否为 CLI 透传格式 判断是否为 CLI 透传格式
CLI 格式以 _CLI 结尾表示该入口更偏向“CLI 兼容层”(鉴权/UA/路径差异等) 新模式下使用 endpoint signature`family:kind`CLI 的 kind 为 `cli`
是否参与格式转换由转换层决定;当前项目已支持 CLI 格式参与转换。
Args: Args:
format_id: 格式标识符(字符串或 APIFormat 枚举 format_id: endpoint signature key"openai:cli"
Returns: Returns:
True 如果是 CLI 格式 True 如果是 CLI 格式
Examples: Examples:
>>> is_cli_format("CLAUDE_CLI") >>> is_cli_format("claude:cli")
True True
>>> is_cli_format("CLAUDE") >>> is_cli_format("claude:chat")
False False
>>> is_cli_format(APIFormat.OPENAI_CLI)
True
""" """
if format_id is None: if format_id is None:
return False return False
if hasattr(format_id, "value"): text = str(format_id).strip()
format_id = format_id.value return text.lower().endswith(":cli")
return str(format_id).upper().endswith("_CLI")
def get_base_format(format_id: str | APIFormat | None) -> str | None: def get_base_format(format_id: str | None) -> str | None:
""" """
获取基础格式(去除 _CLI 后缀 获取基础格式(CLI -> CHAT
Args: Args:
format_id: 格式标识符 format_id: 格式标识符
@@ -51,41 +42,55 @@ def get_base_format(format_id: str | APIFormat | None) -> str | None:
基础格式字符串,或 None 基础格式字符串,或 None
Examples: Examples:
>>> get_base_format("CLAUDE_CLI") >>> get_base_format("claude:cli")
"CLAUDE" "claude:chat"
>>> get_base_format("OPENAI") >>> get_base_format("openai:chat")
"OPENAI" "openai:chat"
""" """
if format_id is None: if format_id is None:
return None return None
if hasattr(format_id, "value"): text = str(format_id).strip()
format_id = format_id.value if not text:
format_str = str(format_id).upper() return None
if format_str.endswith("_CLI"):
return format_str[:-4] from src.core.api_format.enums import EndpointKind
return format_str 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 | APIFormat | None) -> str | None: def normalize_format(format_id: str | None) -> str | None:
""" """
规范化格式标识符 规范化 endpoint signature keycanonical: 全小写 `family:kind`)。
Args: Args:
format_id: 格式标识符(可能是字符串、枚举或 None format_id: endpoint signature key
Returns: Returns:
大写的格式字符串,或 None canonical signature key,或 None
""" """
if format_id is None: if format_id is None:
return None return None
if hasattr(format_id, "value"): text = str(format_id).strip()
return str(format_id.value).upper() if not text:
return str(format_id).upper() 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( def is_same_format(
format1: str | APIFormat | None, format1: str | None,
format2: str | APIFormat | None, format2: str | None,
) -> bool: ) -> bool:
""" """
判断两个格式是否相同 判断两个格式是否相同
@@ -95,14 +100,13 @@ def is_same_format(
return normalize_format(format1) == normalize_format(format2) return normalize_format(format1) == normalize_format(format2)
def is_convertible_format(format_id: str | APIFormat | None) -> bool: def is_convertible_format(format_id: str | None) -> bool:
""" """
判断是否为可转换格式 判断是否为可转换格式
.. deprecated:: .. deprecated::
此函数语义已退化(对非 None 输入总返回 True 此函数语义已退化(对非 None 输入总返回 True
真正的可转换性应通过 format_conversion_registry.can_convert_*() 查询。 真正的可转换性应通过 format_conversion_registry.can_convert_*() 查询。
保留此函数仅为向后兼容,不建议新代码使用。
""" """
if format_id is None: if format_id is None:
return False return False

View File

@@ -262,7 +262,9 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, MessageStartEvent) for e in events) assert any(isinstance(e, MessageStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"] assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) and e.tool_id == "toolu_1" for e in events) assert any(isinstance(e, ToolCallDeltaEvent) and e.tool_id == "toolu_1" for e in events)
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events) assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events
)
# internal events -> Claude events # internal events -> Claude events
state2 = StreamState() state2 = StreamState()
@@ -273,7 +275,11 @@ def test_claude_stream_chunk_and_event_roundtrip_basic() -> None:
assert out_events[0]["type"] == "message_start" assert out_events[0]["type"] == "message_start"
assert out_events[0]["message"]["id"] == "msg_1" assert out_events[0]["message"]["id"] == "msg_1"
assert any(ev.get("type") == "content_block_delta" and ev.get("delta", {}).get("type") == "input_json_delta" for ev in out_events) assert any(
ev.get("type") == "content_block_delta"
and ev.get("delta", {}).get("type") == "input_json_delta"
for ev in out_events
)
assert out_events[-1]["type"] == "message_stop" assert out_events[-1]["type"] == "message_stop"
@@ -305,9 +311,7 @@ def test_claude_request_metadata_preserved() -> None:
{"type": "text", "text": "System prompt 1"}, {"type": "text", "text": "System prompt 1"},
{"type": "text", "text": "System prompt 2"}, {"type": "text", "text": "System prompt 2"},
], ],
"metadata": { "metadata": {"user_id": "user_abc123_session_xyz456"},
"user_id": "user_abc123_session_xyz456"
},
"max_tokens": 32000, "max_tokens": 32000,
"stream": True, "stream": True,
} }

View File

@@ -33,9 +33,9 @@ def _make_registry_with_cli() -> FormatConversionRegistry:
def test_registry_can_convert_full_with_cli_stream() -> None: def test_registry_can_convert_full_with_cli_stream() -> None:
reg = _make_registry_with_cli() reg = _make_registry_with_cli()
assert reg.can_convert_full("OPENAI_CLI", "OPENAI", require_stream=True) is True assert reg.can_convert_full("openai:cli", "openai:chat", require_stream=True) is True
assert reg.can_convert_full("OPENAI_CLI", "CLAUDE_CLI", require_stream=True) is True assert reg.can_convert_full("openai:cli", "claude:cli", require_stream=True) is True
assert reg.can_convert_full("GEMINI_CLI", "CLAUDE", require_stream=True) is True assert reg.can_convert_full("gemini:cli", "claude:chat", require_stream=True) is True
def test_openai_cli_request_to_claude() -> None: def test_openai_cli_request_to_claude() -> None:
@@ -48,7 +48,7 @@ def test_openai_cli_request_to_claude() -> None:
"max_output_tokens": 12, "max_output_tokens": 12,
} }
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE") claude_req = reg.convert_request(openai_cli_req, "openai:cli", "claude:chat")
assert claude_req["model"] == "gpt-4o-mini" assert claude_req["model"] == "gpt-4o-mini"
assert claude_req["stream"] is True assert claude_req["stream"] is True
assert isinstance(claude_req.get("messages"), list) assert isinstance(claude_req.get("messages"), list)
@@ -69,7 +69,7 @@ def test_claude_response_to_openai_cli() -> None:
"usage": {"input_tokens": 5, "output_tokens": 7}, "usage": {"input_tokens": 5, "output_tokens": 7},
} }
openai_cli_resp = reg.convert_response(claude_resp, "CLAUDE", "OPENAI_CLI") openai_cli_resp = reg.convert_response(claude_resp, "claude:chat", "openai:cli")
assert openai_cli_resp["object"] == "response" assert openai_cli_resp["object"] == "response"
assert isinstance(openai_cli_resp.get("output"), list) assert isinstance(openai_cli_resp.get("output"), list)
msg = cast(dict[str, Any], openai_cli_resp["output"][0]) msg = cast(dict[str, Any], openai_cli_resp["output"][0])
@@ -89,10 +89,12 @@ def test_stream_openai_to_openai_cli_delta() -> None:
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"created": 1, "created": 1,
"model": "gpt-4o-mini", "model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}], "choices": [
{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}
],
} }
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "OPENAI_CLI", state=state) out_events = reg.convert_stream_chunk(chunk, "openai:chat", "openai:cli", state=state)
assert isinstance(out_events, list) and out_events assert isinstance(out_events, list) and out_events
assert out_events[0].get("type") == "response.created" assert out_events[0].get("type") == "response.created"
assert out_events[1].get("type") == "response.output_text.delta" assert out_events[1].get("type") == "response.output_text.delta"
@@ -109,7 +111,7 @@ def test_stream_openai_cli_to_openai_delta() -> None:
"response": {"id": "resp_1", "model": "gpt-4o-mini"}, "response": {"id": "resp_1", "model": "gpt-4o-mini"},
} }
out_events = reg.convert_stream_chunk(chunk, "OPENAI_CLI", "OPENAI", state=state) out_events = reg.convert_stream_chunk(chunk, "openai:cli", "openai:chat", state=state)
assert isinstance(out_events, list) and out_events assert isinstance(out_events, list) and out_events
# 第一个 chunk 先补齐 assistant role # 第一个 chunk 先补齐 assistant role
@@ -149,7 +151,7 @@ def test_openai_cli_function_call_to_claude() -> None:
"stream": True, "stream": True,
} }
claude_req = reg.convert_request(openai_cli_req, "OPENAI_CLI", "CLAUDE") claude_req = reg.convert_request(openai_cli_req, "openai:cli", "claude:chat")
messages = claude_req.get("messages", []) messages = claude_req.get("messages", [])
assert len(messages) == 3 assert len(messages) == 3
@@ -203,14 +205,16 @@ def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
} }
# 转换到 internal 再转回 OPENAI_CLI # 转换到 internal 再转回 OPENAI_CLI
converted = reg.convert_request(openai_cli_req, "OPENAI_CLI", "OPENAI_CLI") converted = reg.convert_request(openai_cli_req, "openai:cli", "openai:cli")
input_items = converted.get("input", []) input_items = converted.get("input", [])
# 应该有 user message, reasoning, assistant message # 应该有 user message, reasoning, assistant message
assert len(input_items) >= 2 assert len(input_items) >= 2
# 找到 reasoning block # 找到 reasoning block
reasoning_items = [i for i in input_items if isinstance(i, dict) and i.get("type") == "reasoning"] reasoning_items = [
i for i in input_items if isinstance(i, dict) and i.get("type") == "reasoning"
]
assert len(reasoning_items) == 1 assert len(reasoning_items) == 1
assert "summary" in reasoning_items[0] assert "summary" in reasoning_items[0]
@@ -247,7 +251,7 @@ def test_claude_tool_use_to_openai_cli() -> None:
], ],
} }
openai_cli_req = reg.convert_request(claude_req, "CLAUDE", "OPENAI_CLI") openai_cli_req = reg.convert_request(claude_req, "claude:chat", "openai:cli")
input_items = openai_cli_req.get("input", []) input_items = openai_cli_req.get("input", [])
assert len(input_items) >= 3 assert len(input_items) >= 3
@@ -259,7 +263,9 @@ def test_claude_tool_use_to_openai_cli() -> None:
assert fc_items[0]["call_id"] == "tool_123" assert fc_items[0]["call_id"] == "tool_123"
# 找到 function_call_output # 找到 function_call_output
fco_items = [i for i in input_items if isinstance(i, dict) and i.get("type") == "function_call_output"] fco_items = [
i for i in input_items if isinstance(i, dict) and i.get("type") == "function_call_output"
]
assert len(fco_items) == 1 assert len(fco_items) == 1
assert fco_items[0]["call_id"] == "tool_123" assert fco_items[0]["call_id"] == "tool_123"
assert fco_items[0]["output"] == "Hello World" assert fco_items[0]["output"] == "Hello World"
@@ -281,7 +287,7 @@ def test_stream_openai_cli_in_progress_event() -> None:
}, },
} }
events1 = reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state) events1 = reg.convert_stream_chunk(created_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events1, list) and events1 assert isinstance(events1, list) and events1
assert events1[0].get("type") == "message_start" assert events1[0].get("type") == "message_start"
@@ -296,7 +302,7 @@ def test_stream_openai_cli_in_progress_event() -> None:
}, },
} }
events2 = reg.convert_stream_chunk(in_progress_chunk, "OPENAI_CLI", "CLAUDE", state=state) events2 = reg.convert_stream_chunk(in_progress_chunk, "openai:cli", "claude:chat", state=state)
# response.in_progress 不应产生任何事件 # response.in_progress 不应产生任何事件
assert events2 == [] assert events2 == []
@@ -311,7 +317,7 @@ def test_stream_openai_cli_function_call_events() -> None:
"type": "response.created", "type": "response.created",
"response": {"id": "resp_456", "model": "gpt-5"}, "response": {"id": "resp_456", "model": "gpt-5"},
} }
reg.convert_stream_chunk(created_chunk, "OPENAI_CLI", "CLAUDE", state=state) reg.convert_stream_chunk(created_chunk, "openai:cli", "claude:chat", state=state)
# response.output_item.added (function_call) # response.output_item.added (function_call)
output_item_chunk = { output_item_chunk = {
@@ -323,7 +329,7 @@ def test_stream_openai_cli_function_call_events() -> None:
}, },
} }
events1 = reg.convert_stream_chunk(output_item_chunk, "OPENAI_CLI", "CLAUDE", state=state) events1 = reg.convert_stream_chunk(output_item_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events1, list) and events1 assert isinstance(events1, list) and events1
assert events1[0].get("type") == "content_block_start" assert events1[0].get("type") == "content_block_start"
@@ -333,7 +339,7 @@ def test_stream_openai_cli_function_call_events() -> None:
"delta": '{"city":', "delta": '{"city":',
} }
events2 = reg.convert_stream_chunk(args_delta_chunk, "OPENAI_CLI", "CLAUDE", state=state) events2 = reg.convert_stream_chunk(args_delta_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events2, list) and events2 assert isinstance(events2, list) and events2
# ToolCallDeltaEvent 转换为 Claude 的 content_block_delta # ToolCallDeltaEvent 转换为 Claude 的 content_block_delta
assert events2[0].get("type") == "content_block_delta" assert events2[0].get("type") == "content_block_delta"
@@ -352,7 +358,7 @@ def test_stream_openai_cli_function_call_events() -> None:
}, },
} }
events3 = reg.convert_stream_chunk(output_done_chunk, "OPENAI_CLI", "CLAUDE", state=state) events3 = reg.convert_stream_chunk(output_done_chunk, "openai:cli", "claude:chat", state=state)
assert isinstance(events3, list) and events3 assert isinstance(events3, list) and events3
assert events3[0].get("type") == "content_block_stop" assert events3[0].get("type") == "content_block_stop"
@@ -486,7 +492,7 @@ def test_real_claude_cli_stream_response_conversion() -> None:
# 收集所有转换后的 OpenAI 格式事件 # 收集所有转换后的 OpenAI 格式事件
all_openai_events: list[dict[str, Any]] = [] all_openai_events: list[dict[str, Any]] = []
for chunk in chunks: for chunk in chunks:
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI", state=state) events = reg.convert_stream_chunk(chunk, "claude:cli", "openai:chat", state=state)
all_openai_events.extend(events) all_openai_events.extend(events)
# 验证转换结果 # 验证转换结果
@@ -542,8 +548,16 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
}, },
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "ping"}, {"type": "ping"},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}, {
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " World"}}, "type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": "Hello"},
},
{
"type": "content_block_delta",
"index": 0,
"delta": {"type": "text_delta", "text": " World"},
},
{"type": "content_block_stop", "index": 0}, {"type": "content_block_stop", "index": 0},
{ {
"type": "message_delta", "type": "message_delta",
@@ -555,7 +569,7 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
all_events: list[dict[str, Any]] = [] all_events: list[dict[str, Any]] = []
for chunk in chunks: for chunk in chunks:
events = reg.convert_stream_chunk(chunk, "CLAUDE_CLI", "OPENAI_CLI", state=state) events = reg.convert_stream_chunk(chunk, "claude:cli", "openai:cli", state=state)
all_events.extend(events) all_events.extend(events)
# 验证 OpenAI CLI 格式事件 # 验证 OpenAI CLI 格式事件
@@ -573,5 +587,7 @@ def test_real_claude_cli_stream_to_openai_cli() -> None:
assert " World" in deltas assert " World" in deltas
# 应该有 response.completed 或 response.done 事件 # 应该有 response.completed 或 response.done 事件
done_events = [e for e in all_events if e.get("type") in ("response.completed", "response.done")] done_events = [
e for e in all_events if e.get("type") in ("response.completed", "response.done")
]
assert len(done_events) >= 1 assert len(done_events) >= 1

View File

@@ -16,8 +16,8 @@ from src.core.api_format.conversion.compatibility import is_format_compatible
def test_same_format_is_compatible() -> None: def test_same_format_is_compatible() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"CLAUDE", "claude:chat",
endpoint_format_acceptance_config=None, endpoint_format_acceptance_config=None,
is_stream=False, is_stream=False,
global_conversion_enabled=False, global_conversion_enabled=False,
@@ -33,8 +33,8 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI", "claude:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -48,8 +48,8 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
def test_global_switch_disabled_blocks_conversion() -> None: def test_global_switch_disabled_blocks_conversion() -> None:
"""全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false阻止转换""" """全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false阻止转换"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=False, global_conversion_enabled=False,
@@ -62,8 +62,8 @@ def test_global_switch_disabled_blocks_conversion() -> None:
def test_endpoint_config_none_blocks_conversion() -> None: def test_endpoint_config_none_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config=None, endpoint_format_acceptance_config=None,
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -76,8 +76,8 @@ def test_endpoint_config_none_blocks_conversion() -> None:
def test_endpoint_disabled_blocks_conversion() -> None: def test_endpoint_disabled_blocks_conversion() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": False}, endpoint_format_acceptance_config={"enabled": False},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -90,9 +90,9 @@ def test_endpoint_disabled_blocks_conversion() -> None:
def test_accept_formats_allows_only_whitelist() -> None: def test_accept_formats_allows_only_whitelist() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["OPENAI"]}, endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["openai:chat"]},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
registry=MagicMock(), registry=MagicMock(),
@@ -104,9 +104,9 @@ def test_accept_formats_allows_only_whitelist() -> None:
def test_reject_formats_blocks_blacklist() -> None: def test_reject_formats_blocks_blacklist() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["CLAUDE"]}, endpoint_format_acceptance_config={"enabled": True, "reject_formats": ["claude:chat"]},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
registry=MagicMock(), registry=MagicMock(),
@@ -118,8 +118,8 @@ def test_reject_formats_blocks_blacklist() -> None:
def test_stream_conversion_disabled_blocks_stream() -> None: def test_stream_conversion_disabled_blocks_stream() -> None:
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True, "stream_conversion": False}, endpoint_format_acceptance_config={"enabled": True, "stream_conversion": False},
is_stream=True, is_stream=True,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -135,8 +135,8 @@ def test_converter_support_required() -> None:
registry.can_convert_full.return_value = False registry.can_convert_full.return_value = False
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -152,9 +152,9 @@ def test_conversion_allowed_when_converter_supports_full() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["CLAUDE"]}, endpoint_format_acceptance_config={"enabled": True, "accept_formats": ["claude:chat"]},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
registry=registry, registry=registry,
@@ -170,8 +170,8 @@ def test_conversion_allowed_when_converter_supports_full() -> None:
def test_claude_cli_to_claude_no_conversion_needed() -> None: def test_claude_cli_to_claude_no_conversion_needed() -> None:
"""CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)""" """CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI", "claude:cli",
"CLAUDE", "claude:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -185,8 +185,8 @@ def test_claude_cli_to_claude_no_conversion_needed() -> None:
def test_claude_to_claude_cli_no_conversion_needed() -> None: def test_claude_to_claude_cli_no_conversion_needed() -> None:
"""CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)""" """CLAUDE 和 CLAUDE_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE", "claude:chat",
"CLAUDE_CLI", "claude:cli",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -200,8 +200,8 @@ def test_claude_to_claude_cli_no_conversion_needed() -> None:
def test_gemini_cli_to_gemini_no_conversion_needed() -> None: def test_gemini_cli_to_gemini_no_conversion_needed() -> None:
"""GEMINI 和 GEMINI_CLI 格式相同,只是认证不同,可透传(需开关启用)""" """GEMINI 和 GEMINI_CLI 格式相同,只是认证不同,可透传(需开关启用)"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"GEMINI_CLI", "gemini:cli",
"GEMINI", "gemini:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -215,8 +215,8 @@ def test_gemini_cli_to_gemini_no_conversion_needed() -> None:
def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None: def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE也受全局开关限制""" """透传格式CLAUDE_CLI -> CLAUDE也受全局开关限制"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI", "claude:cli",
"CLAUDE", "claude:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=False, global_conversion_enabled=False,
@@ -229,8 +229,8 @@ def test_claude_cli_to_claude_blocked_when_global_switch_disabled() -> None:
def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None: def test_claude_cli_to_claude_blocked_when_endpoint_not_configured() -> None:
"""透传格式CLAUDE_CLI -> CLAUDE也需要端点配置""" """透传格式CLAUDE_CLI -> CLAUDE也需要端点配置"""
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"CLAUDE_CLI", "claude:cli",
"CLAUDE", "claude:chat",
endpoint_format_acceptance_config=None, endpoint_format_acceptance_config=None,
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -246,8 +246,8 @@ def test_openai_cli_to_openai_needs_conversion() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置 endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=False, is_stream=False,
global_conversion_enabled=True, # 同族转换也需要全局开关 global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -264,8 +264,8 @@ def test_openai_to_openai_cli_needs_conversion() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI", "openai:chat",
"OPENAI_CLI", "openai:cli",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置 endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=False, is_stream=False,
global_conversion_enabled=True, # 同族转换也需要全局开关 global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -282,8 +282,8 @@ def test_openai_cli_to_openai_stream_needs_conversion() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置 endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=True, is_stream=True,
global_conversion_enabled=True, # 同族转换也需要全局开关 global_conversion_enabled=True, # 同族转换也需要全局开关
@@ -300,8 +300,8 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
registry.can_convert_full.return_value = False registry.can_convert_full.return_value = False
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -318,8 +318,8 @@ def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": True}, endpoint_format_acceptance_config={"enabled": True},
is_stream=False, is_stream=False,
global_conversion_enabled=False, # 全局开关关闭 global_conversion_enabled=False, # 全局开关关闭
@@ -336,8 +336,8 @@ def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config={"enabled": False}, # 端点开关关闭 endpoint_format_acceptance_config={"enabled": False}, # 端点开关关闭
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,
@@ -354,8 +354,8 @@ def test_openai_cli_to_openai_blocked_when_endpoint_not_configured() -> None:
registry.can_convert_full.return_value = True registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible( ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI", "openai:cli",
"OPENAI", "openai:chat",
endpoint_format_acceptance_config=None, # 无端点配置 endpoint_format_acceptance_config=None, # 无端点配置
is_stream=False, is_stream=False,
global_conversion_enabled=True, global_conversion_enabled=True,

View File

@@ -34,7 +34,7 @@ def test_error_conversion_openai_to_claude() -> None:
"error": {"message": "bad request", "type": "invalid_request_error", "code": "bad_request"} "error": {"message": "bad request", "type": "invalid_request_error", "code": "bad_request"}
} }
out = reg.convert_error_response(openai_error, "OPENAI", "CLAUDE") out = reg.convert_error_response(openai_error, "openai:chat", "claude:chat")
assert out.get("type") == "error" assert out.get("type") == "error"
assert isinstance(out.get("error"), dict) assert isinstance(out.get("error"), dict)
assert out["error"]["message"] == "bad request" assert out["error"]["message"] == "bad request"
@@ -44,7 +44,7 @@ def test_error_conversion_claude_to_openai() -> None:
reg = _make_registry() reg = _make_registry()
claude_error = {"type": "error", "error": {"type": "invalid_request_error", "message": "nope"}} claude_error = {"type": "error", "error": {"type": "invalid_request_error", "message": "nope"}}
out = reg.convert_error_response(claude_error, "CLAUDE", "OPENAI") out = reg.convert_error_response(claude_error, "claude:chat", "openai:chat")
assert isinstance(out.get("error"), dict) assert isinstance(out.get("error"), dict)
assert out["error"]["message"] == "nope" assert out["error"]["message"] == "nope"
@@ -63,7 +63,7 @@ def test_error_event_stream_openai_to_claude_via_registry() -> None:
# OpenAI 流式错误块 # OpenAI 流式错误块
chunk = {"error": {"message": "bad", "type": "invalid_request_error"}} chunk = {"error": {"message": "bad", "type": "invalid_request_error"}}
out = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", state=StreamState()) out = reg.convert_stream_chunk(chunk, "openai:chat", "claude:chat", state=StreamState())
assert isinstance(out, list) and out assert isinstance(out, list) and out
evt0 = cast(dict[str, Any], out[0]) evt0 = cast(dict[str, Any], out[0])
assert evt0.get("type") == "error" assert evt0.get("type") == "error"

View File

@@ -103,14 +103,17 @@ def test_gemini_request_parts_image_tool_and_unknown_drop() -> None:
}, },
{ {
"role": "model", "role": "model",
"parts": [ "parts": [{"function_call": {"name": "get_weather", "args": {"city": "SF"}}}],
{"function_call": {"name": "get_weather", "args": {"city": "SF"}}}
],
}, },
{ {
"role": "user", "role": "user",
"parts": [ "parts": [
{"function_response": {"name": "call_1", "response": {"result": {"temp_c": 20}}}} {
"function_response": {
"name": "call_1",
"response": {"result": {"temp_c": 20}},
}
}
], ],
}, },
] ]
@@ -194,16 +197,10 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
chunks = [ chunks = [
{ {
"candidates": [ "candidates": [{"content": {"parts": [{"text": "Hel"}], "role": "model"}, "index": 0}],
{"content": {"parts": [{"text": "Hel"}], "role": "model"}, "index": 0}
],
"modelVersion": "gemini-1.5", "modelVersion": "gemini-1.5",
}, },
{ {"candidates": [{"content": {"parts": [{"text": "lo"}], "role": "model"}, "index": 0}]},
"candidates": [
{"content": {"parts": [{"text": "lo"}], "role": "model"}, "index": 0}
]
},
{ {
"candidates": [ "candidates": [
{ {
@@ -226,7 +223,11 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
"index": 0, "index": 0,
} }
], ],
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3}, "usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3,
},
"modelVersion": "gemini-1.5", "modelVersion": "gemini-1.5",
}, },
] ]
@@ -237,8 +238,13 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, MessageStartEvent) for e in events) assert any(isinstance(e, MessageStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"] assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) and json.loads(e.input_delta) == {"city": "SF"} for e in events) assert any(
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events) isinstance(e, ToolCallDeltaEvent) and json.loads(e.input_delta) == {"city": "SF"}
for e in events
)
assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.END_TURN for e in events
)
state2 = StreamState() state2 = StreamState()
out_chunks: list[dict[str, Any]] = [] out_chunks: list[dict[str, Any]] = []
@@ -253,7 +259,9 @@ def test_gemini_stream_chunk_and_event_roundtrip_basic() -> None:
if c["candidates"][0]["content"]["parts"] if c["candidates"][0]["content"]["parts"]
and "functionCall" in c["candidates"][0]["content"]["parts"][0] and "functionCall" in c["candidates"][0]["content"]["parts"][0]
) )
assert tool_chunk["candidates"][0]["content"]["parts"][0]["functionCall"]["name"] == "get_weather" assert (
tool_chunk["candidates"][0]["content"]["parts"][0]["functionCall"]["name"] == "get_weather"
)
assert out_chunks[-1]["candidates"][0]["finishReason"] == "STOP" assert out_chunks[-1]["candidates"][0]["finishReason"] == "STOP"

View File

@@ -18,7 +18,6 @@ from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.registry import FormatConversionRegistry from src.core.api_format.conversion.registry import FormatConversionRegistry
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState
GOLDEN_DIR = Path(__file__).resolve().parent / "golden_data" GOLDEN_DIR = Path(__file__).resolve().parent / "golden_data"
INPUT_DIR = GOLDEN_DIR / "inputs" INPUT_DIR = GOLDEN_DIR / "inputs"
EXPECTED_DIR = GOLDEN_DIR / "expected" EXPECTED_DIR = GOLDEN_DIR / "expected"
@@ -53,12 +52,12 @@ def _make_registry() -> FormatConversionRegistry:
def test_golden_requests() -> None: def test_golden_requests() -> None:
reg = _make_registry() reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"] formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs = { inputs = {
"OPENAI": _load_json(INPUT_DIR / "request_openai.json"), "openai:chat": _load_json(INPUT_DIR / "request_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "request_claude.json"), "claude:chat": _load_json(INPUT_DIR / "request_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "request_gemini.json"), "gemini:chat": _load_json(INPUT_DIR / "request_gemini.json"),
} }
for source in formats: for source in formats:
@@ -72,12 +71,12 @@ def test_golden_requests() -> None:
def test_golden_responses() -> None: def test_golden_responses() -> None:
reg = _make_registry() reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"] formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs = { inputs = {
"OPENAI": _load_json(INPUT_DIR / "response_openai.json"), "openai:chat": _load_json(INPUT_DIR / "response_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "response_claude.json"), "claude:chat": _load_json(INPUT_DIR / "response_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "response_gemini.json"), "gemini:chat": _load_json(INPUT_DIR / "response_gemini.json"),
} }
for source in formats: for source in formats:
@@ -91,12 +90,12 @@ def test_golden_responses() -> None:
def test_golden_streams() -> None: def test_golden_streams() -> None:
reg = _make_registry() reg = _make_registry()
formats = ["OPENAI", "CLAUDE", "GEMINI"] formats = ["openai:chat", "claude:chat", "gemini:chat"]
inputs: dict[str, list[dict[str, Any]]] = { inputs: dict[str, list[dict[str, Any]]] = {
"OPENAI": _load_json(INPUT_DIR / "stream_openai.json"), "openai:chat": _load_json(INPUT_DIR / "stream_openai.json"),
"CLAUDE": _load_json(INPUT_DIR / "stream_claude.json"), "claude:chat": _load_json(INPUT_DIR / "stream_claude.json"),
"GEMINI": _load_json(INPUT_DIR / "stream_gemini.json"), "gemini:chat": _load_json(INPUT_DIR / "stream_gemini.json"),
} }
for source in formats: for source in formats:
@@ -106,7 +105,7 @@ def test_golden_streams() -> None:
expected = _load_json(EXPECTED_DIR / f"stream_{source}_to_{target}.json") expected = _load_json(EXPECTED_DIR / f"stream_{source}_to_{target}.json")
state = StreamState() state = StreamState()
if source == "GEMINI": if source == "gemini:chat":
state.message_id = "gemini_1" state.message_id = "gemini_1"
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []

View File

@@ -39,7 +39,6 @@ from src.core.api_format.conversion.internal import (
) )
from src.core.api_format.conversion.stream_state import StreamState from src.core.api_format.conversion.stream_state import StreamState
# ============================================================================ # ============================================================================
# Enum 类型测试 # Enum 类型测试
# ============================================================================ # ============================================================================
@@ -302,7 +301,9 @@ class TestInternalMessage:
role=Role.ASSISTANT, role=Role.ASSISTANT,
content=[ content=[
TextBlock(text="Let me check the weather"), TextBlock(text="Let me check the weather"),
ToolUseBlock(tool_id="t1", tool_name="get_weather", tool_input={"city": "Shanghai"}), ToolUseBlock(
tool_id="t1", tool_name="get_weather", tool_input={"city": "Shanghai"}
),
], ],
) )
assert msg.role == Role.ASSISTANT assert msg.role == Role.ASSISTANT

View File

@@ -297,7 +297,9 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
assert any(isinstance(e, ContentBlockStartEvent) for e in events) assert any(isinstance(e, ContentBlockStartEvent) for e in events)
assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"] assert [e.text_delta for e in events if isinstance(e, ContentDeltaEvent)] == ["Hel", "lo"]
assert any(isinstance(e, ToolCallDeltaEvent) for e in events) assert any(isinstance(e, ToolCallDeltaEvent) for e in events)
assert any(isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.TOOL_USE for e in events) assert any(
isinstance(e, MessageStopEvent) and e.stop_reason == StopReason.TOOL_USE for e in events
)
# internal events -> OpenAI chunks验证关键字段与 tool_calls index 稳定) # internal events -> OpenAI chunks验证关键字段与 tool_calls index 稳定)
state2 = StreamState() state2 = StreamState()
@@ -313,14 +315,20 @@ def test_openai_stream_chunk_and_event_roundtrip_basic() -> None:
# tool_calls start chunk # tool_calls start chunk
tool_start = next( tool_start = next(
c for c in out_chunks if c["choices"][0]["delta"].get("tool_calls") and c["choices"][0]["delta"]["tool_calls"][0]["function"].get("name") c
for c in out_chunks
if c["choices"][0]["delta"].get("tool_calls")
and c["choices"][0]["delta"]["tool_calls"][0]["function"].get("name")
) )
assert tool_start["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1" assert tool_start["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
assert tool_start["choices"][0]["delta"]["tool_calls"][0]["index"] == 0 assert tool_start["choices"][0]["delta"]["tool_calls"][0]["index"] == 0
# tool_calls delta chunkarguments 片段) # tool_calls delta chunkarguments 片段)
tool_delta = next( tool_delta = next(
c for c in out_chunks if c["choices"][0]["delta"].get("tool_calls") and "arguments" in c["choices"][0]["delta"]["tool_calls"][0]["function"] c
for c in out_chunks
if c["choices"][0]["delta"].get("tool_calls")
and "arguments" in c["choices"][0]["delta"]["tool_calls"][0]["function"]
) )
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1" assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["id"] == "call_1"
assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["index"] == 0 assert tool_delta["choices"][0]["delta"]["tool_calls"][0]["index"] == 0

View File

@@ -37,9 +37,9 @@ def _first_openai_choice_message(resp: dict[str, Any]) -> dict[str, Any]:
def test_registry_canonical_can_convert_full_stream() -> None: def test_registry_canonical_can_convert_full_stream() -> None:
reg = _make_registry() reg = _make_registry()
assert reg.can_convert_full("OPENAI", "CLAUDE", require_stream=True) is True assert reg.can_convert_full("openai:chat", "claude:chat", require_stream=True) is True
assert reg.can_convert_full("OPENAI", "GEMINI", require_stream=True) is True assert reg.can_convert_full("openai:chat", "gemini:chat", require_stream=True) is True
assert reg.can_convert_full("CLAUDE", "GEMINI", require_stream=True) is True assert reg.can_convert_full("claude:chat", "gemini:chat", require_stream=True) is True
def test_registry_canonical_request_openai_to_claude() -> None: def test_registry_canonical_request_openai_to_claude() -> None:
@@ -57,7 +57,7 @@ def test_registry_canonical_request_openai_to_claude() -> None:
"stream": True, "stream": True,
} }
claude_req = reg.convert_request(openai_req, "OPENAI", "CLAUDE") claude_req = reg.convert_request(openai_req, "openai:chat", "claude:chat")
assert claude_req["model"] == "gpt-4o-mini" assert claude_req["model"] == "gpt-4o-mini"
assert claude_req["system"] == "sys\n\ndev" assert claude_req["system"] == "sys\n\ndev"
assert claude_req["stream"] is True assert claude_req["stream"] is True
@@ -79,7 +79,7 @@ def test_registry_canonical_response_claude_to_openai() -> None:
"usage": {"input_tokens": 5, "output_tokens": 7}, "usage": {"input_tokens": 5, "output_tokens": 7},
} }
openai_resp = reg.convert_response(claude_resp, "CLAUDE", "OPENAI") openai_resp = reg.convert_response(claude_resp, "claude:chat", "openai:chat")
assert openai_resp["object"] == "chat.completion" assert openai_resp["object"] == "chat.completion"
msg = _first_openai_choice_message(openai_resp) msg = _first_openai_choice_message(openai_resp)
assert msg["role"] == "assistant" assert msg["role"] == "assistant"
@@ -94,11 +94,13 @@ def test_registry_canonical_stream_openai_to_claude() -> None:
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"created": 1, "created": 1,
"model": "gpt-4o-mini", "model": "gpt-4o-mini",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}], "choices": [
{"index": 0, "delta": {"role": "assistant", "content": "hi"}, "finish_reason": None}
],
} }
state = StreamState() state = StreamState()
out_events = reg.convert_stream_chunk(chunk, "OPENAI", "CLAUDE", state=state) out_events = reg.convert_stream_chunk(chunk, "openai:chat", "claude:chat", state=state)
assert isinstance(out_events, list) and out_events assert isinstance(out_events, list) and out_events
types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)] types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)]

View File

@@ -0,0 +1,41 @@
import pytest
from src.core.api_format.enums import ApiFamily, EndpointKind
from src.core.api_format.signature import (
EndpointSignature,
make_signature_key,
normalize_endpoint_signature,
normalize_signature_key,
parse_signature_key,
)
def test_make_signature_key_accepts_enums_and_strings() -> None:
assert make_signature_key(ApiFamily.OPENAI, EndpointKind.CHAT) == "openai:chat"
assert make_signature_key("OpenAI", "CHAT") == "openai:chat"
def test_parse_signature_key_roundtrip() -> None:
sig = parse_signature_key("OpenAI:CHAT")
assert sig.api_family == ApiFamily.OPENAI
assert sig.endpoint_kind == EndpointKind.CHAT
assert sig.key == "openai:chat"
def test_parse_signature_key_invalid_raises() -> None:
with pytest.raises(ValueError):
parse_signature_key("OPENAI") # missing ':'
def test_normalize_signature_key() -> None:
assert normalize_signature_key(" OpenAI:CHAT ") == "openai:chat"
def test_normalize_endpoint_signature_tuple_input() -> None:
sig = normalize_endpoint_signature((ApiFamily.GEMINI, EndpointKind.VIDEO))
assert sig == EndpointSignature(api_family=ApiFamily.GEMINI, endpoint_kind=EndpointKind.VIDEO)
def test_normalize_endpoint_signature_invalid_string_returns_default() -> None:
default = EndpointSignature(api_family=ApiFamily.CLAUDE, endpoint_kind=EndpointKind.CHAT)
assert normalize_endpoint_signature("not-a-signature", default=default) == default