refactor: 完善跨格式 normalizer 转换精度,统一 CLI 流式 buffer flush 逻辑

- OpenAI normalizer: 修正 file/image 内容块的标准格式解析与输出,
  assistant 有 tool_calls 时 content 输出 null,非流式 tool_calls
  不再包含 index 字段,保留 system_fingerprint/service_tier roundtrip
- OpenAI CLI normalizer: 支持 reasoning/ThinkingConfig 双向转换,
  补全 parallel_tool_calls/required tool_choice,input_file 解析,
  function_call_output.output 强制字符串化,流式事件补全 item_id/
  output_index/content_index 字段
- Gemini normalizer: 扩展 finishReason 映射,自动检测 tool_use
  stop_reason,保留 safetySettings/cachedContent/generationConfig
  额外字段 roundtrip,error_from_internal 使用精确 HTTP status code
- Claude normalizer: 修正 tool_choice type="tool" 输出,扩展
  extra 提取白名单
- internal.py: 为所有 dataclass 补充跨格式映射文档和修改须知
- stream_bridge: aggregator 新增 open_count/final_count 诊断属性,
  build() 时 flush 未关闭的 open blocks
- CLI handler: 提取 _flush_buffer_with_conversion 统一 prefetch/
  stream 两条路径的 buffer + SSE parser flush 逻辑
- upstream_stream_bridge: 新增事件类型计数和聚合器状态诊断日志
- 新增 fixtures 和测试: roundtrip/to_internal/cross_format/error/
  stream 等多维度转换测试
This commit is contained in:
fawney19
2026-02-20 19:45:14 +08:00
parent d7f0a555c0
commit 37758c2032
26 changed files with 5441 additions and 125 deletions

View File

@@ -1,13 +1,19 @@
"""
格式转换内部表示Internal / Canonical Format
该模块定义 Hub-and-Spoke 架构的“中间表示法”,用于把不同 Provider 的请求/响应/流式事件
该模块定义 Hub-and-Spoke 架构的"中间表示法",用于把不同 Provider 的请求/响应/流式事件
统一映射到稳定的内部结构,再转换为目标格式。
设计原则:
- 类型安全:尽量用 dataclass + Enum 表达语义,便于 IDE/静态检查
- 可扩展:未知/不可逆字段写入 extra/raw避免静默丢失
- 兼容优先UnknownBlock 在内部保留,但默认在输出阶段丢弃(可观测、可随时调整策略)
字段修改须知:
- 本文件是所有 normalizer 的共享契约,修改字段语义会同时影响所有格式的输入输出
- 每个字段的注释标注了各格式的映射关系OpenAI/Claude/Gemini
- 修改前请检查 tests/core/api_format/conversion/ 下的 roundtrip + schema 测试
- 新增字段应标注 "可选" 并给默认值,避免破坏现有 normalizer
"""
from dataclasses import dataclass, field
@@ -62,7 +68,13 @@ class ErrorType(str, Enum):
@dataclass
class TextBlock:
"""文本内容块"""
"""文本内容块
Format mapping:
OpenAI: message.content (string) / content[].type="text"
Claude: content[].type="text"
Gemini: parts[].text
"""
type: ContentType = field(default=ContentType.TEXT, init=False)
text: str = ""
@@ -71,30 +83,52 @@ class TextBlock:
@dataclass
class ThinkingBlock:
"""思考过程内容块(对齐 Gemini thought:true / Claude thinking / OpenAI reasoning_content"""
"""思考过程内容块
Format mapping:
OpenAI: message.reasoning_content / delta.reasoning_content
Claude: content[].type="thinking" (thinking + signature)
Gemini: parts[].thought=true (text + thoughtSignature)
"""
type: ContentType = field(default=ContentType.THINKING, init=False)
thinking: str = ""
signature: str | None = None # Gemini thoughtSignature / Claude signature
# Claude signature / Gemini thoughtSignature; OpenAI 无对应字段
signature: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ImageBlock:
"""图片内容块"""
"""图片内容块
Format mapping:
OpenAI: content[].type="image_url" -> image_url.url (URL or data:mime;base64,...)
Claude: content[].type="image" -> source.type="base64" | source.type="url"
Gemini: parts[].inlineData (base64) / parts[].fileData (URI)
"""
type: ContentType = field(default=ContentType.IMAGE, init=False)
# base64 编码的图片数据(二选一)
data: str | None = None
media_type: str | None = None
# 或者 URL 引用
url: str | None = None
data: str | None = None # base64 encoded image data (mutually exclusive with url)
media_type: str | None = None # MIME type, e.g. "image/png"
url: str | None = None # URL reference (mutually exclusive with data)
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolUseBlock:
"""工具调用内容块"""
"""工具调用内容块
Format mapping:
OpenAI: message.tool_calls[].id / .function.name / .function.arguments(JSON str)
Claude: content[].type="tool_use" -> id / name / input(dict)
Gemini: parts[].functionCall -> name / args(dict); id 由 normalizer 生成
Contract:
tool_id: roundtrip 保留; OpenAI/Claude 原生提供, Gemini 由 normalizer 合成
tool_name: 必须非空
tool_input: 已解析的 dict (非 JSON 字符串)
"""
type: ContentType = field(default=ContentType.TOOL_USE, init=False)
tool_id: str = ""
@@ -105,12 +139,23 @@ class ToolUseBlock:
@dataclass
class ToolResultBlock:
"""工具结果内容块"""
"""工具结果内容块
Format mapping:
OpenAI: role="tool" message -> tool_call_id + content(string)
Claude: content[].type="tool_result" -> tool_use_id + content
Gemini: parts[].functionResponse -> name + response(dict)
Contract:
tool_use_id: 关联 ToolUseBlock.tool_id; OpenAI/Claude 必须非空
tool_name: Gemini functionResponse.name 需要; OpenAI/Claude 可为 None
output: 结构化输出 (dict/list); 与 content_text 二选一
content_text: 纯文本输出; 与 output 二选一
"""
type: ContentType = field(default=ContentType.TOOL_RESULT, init=False)
tool_use_id: str = "" # 对应的 ToolUseBlock.tool_id用于 Claude/Antigravity id 字段)
tool_name: str | None = None # 工具名称(用于 Gemini function_response.name 字段)
# 工具输出可能是纯文本,也可能是结构化 JSONGemini functionResponse 等)
tool_use_id: str = ""
tool_name: str | None = None
output: Any = None
content_text: str | None = None
is_error: bool = False
@@ -119,11 +164,17 @@ class ToolResultBlock:
@dataclass
class FileBlock:
"""文件内容块PDF、文档等"""
"""文件内容块PDF、文档等
Format mapping:
OpenAI: content[].type="file" -> file.file_data(data URL) / file.file_id
Claude: content[].type="document" -> source.type="base64" / source.type="url"
Gemini: parts[].fileData -> fileUri + mimeType
"""
type: ContentType = field(default=ContentType.FILE, init=False)
data: str | None = None # base64 编码
media_type: str | None = None
data: str | None = None # base64 encoded file data
media_type: str | None = None # MIME type
file_id: str | None = None # OpenAI file reference
file_url: str | None = None # Gemini fileData URI
filename: str | None = None
@@ -132,12 +183,18 @@ class FileBlock:
@dataclass
class AudioBlock:
"""音频内容块"""
"""音频内容块
Format mapping:
OpenAI: content[].type="input_audio" -> input_audio.data + input_audio.format
Claude: content[].type="audio" (planned)
Gemini: parts[].inlineData (audio MIME)
"""
type: ContentType = field(default=ContentType.AUDIO, init=False)
data: str | None = None # base64 编码
media_type: str | None = None # 完整 MIME(如 audio/mp3
format: str | None = None # 简短格式名(如 mp3, wav
data: str | None = None # base64 encoded audio data
media_type: str | None = None # full MIME (e.g. audio/mp3)
format: str | None = None # short format name (e.g. mp3, wav)
extra: dict[str, Any] = field(default_factory=dict)
@@ -200,19 +257,29 @@ class ToolChoice:
@dataclass
class InstructionSegment:
"""系统/开发者指令段(用于保留 OpenAI system/developer 结构与顺序)"""
"""系统/开发者指令段
role: Role # 仅允许 Role.SYSTEM / Role.DEVELOPER
OpenAI 区分 system/developer 两种 role, Claude/Gemini 只有 system string.
instructions 列表保留 OpenAI 的 role 语义和顺序, system 字段是 join 后的纯文本兜底.
"""
role: Role # Role.SYSTEM / Role.DEVELOPER only
text: str = ""
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ThinkingConfig:
"""统一的思考/推理配置(对齐 Claude thinking / Gemini thinkingConfig / OpenAI reasoning_effort"""
"""统一的思考/推理配置
Format mapping:
OpenAI: reasoning_effort ("low"/"medium"/"high") -> budget_tokens via lookup table
Claude: thinking.type="enabled" + thinking.budget_tokens
Gemini: generationConfig.thinkingConfig.thinkingBudget
"""
enabled: bool = False
budget_tokens: int | None = None # None = provider 默认
budget_tokens: int | None = None # None = provider default
extra: dict[str, Any] = field(default_factory=dict)
@@ -227,7 +294,16 @@ class ResponseFormatConfig:
@dataclass
class InternalRequest:
"""统一的请求表示"""
"""统一的请求表示
Format mapping (key fields):
model: OpenAI/Claude body.model; Gemini URL path param
instructions: OpenAI system/developer messages; Claude/Gemini -> join to system string
system: instructions join fallback; Claude system param; Gemini systemInstruction
max_tokens: OpenAI max_tokens/max_completion_tokens; Claude max_tokens; Gemini maxOutputTokens
tools: OpenAI tools[].function; Claude tools[]; Gemini tools[].functionDeclarations
tool_choice: OpenAI tool_choice; Claude tool_choice; Gemini toolConfig.functionCallingConfig
"""
model: str
messages: list[InternalMessage]
@@ -297,7 +373,15 @@ class UsageInfo:
@dataclass
class InternalResponse:
"""统一的响应表示"""
"""统一的响应表示
Format mapping:
id: OpenAI id; Claude id; Gemini (none, synthesized)
model: OpenAI model; Claude model; Gemini model (from metadata)
content: OpenAI choices[0].message; Claude content[]; Gemini candidates[0].content.parts
stop_reason: OpenAI finish_reason; Claude stop_reason; Gemini finishReason
usage: OpenAI usage; Claude usage; Gemini usageMetadata
"""
id: str
model: str

View File

@@ -171,7 +171,25 @@ class ClaudeNormalizer(FormatNormalizer):
tools=tools,
tool_choice=tool_choice,
thinking=thinking,
extra={"claude": self._extract_extra(request, {"messages"})},
extra={
"claude": self._extract_extra(
request,
{
"model",
"messages",
"system",
"max_tokens",
"temperature",
"top_p",
"top_k",
"stop_sequences",
"stream",
"tools",
"tool_choice",
"thinking",
},
)
},
)
if dropped:
@@ -1151,7 +1169,7 @@ class ClaudeNormalizer(FormatNormalizer):
if tool_choice.type == ToolChoiceType.REQUIRED:
return {"type": "any"}
if tool_choice.type == ToolChoiceType.TOOL:
return {"type": "tool_use", "name": tool_choice.tool_name or ""}
return {"type": "tool", "name": tool_choice.tool_name or ""}
return {"type": "auto"}
def _internal_message_to_claude(self, msg: InternalMessage) -> dict[str, Any]:

View File

@@ -204,6 +204,11 @@ class GeminiNormalizer(FormatNormalizer):
"MAX_TOKENS": StopReason.MAX_TOKENS,
"SAFETY": StopReason.CONTENT_FILTERED,
"RECITATION": StopReason.CONTENT_FILTERED,
"LANGUAGE": StopReason.CONTENT_FILTERED,
"BLOCKLIST": StopReason.CONTENT_FILTERED,
"PROHIBITED_CONTENT": StopReason.CONTENT_FILTERED,
"SPII": StopReason.CONTENT_FILTERED,
"IMAGE_SAFETY": StopReason.CONTENT_FILTERED,
"MALFORMED_FUNCTION_CALL": StopReason.TOOL_USE,
"OTHER": StopReason.UNKNOWN,
}
@@ -221,6 +226,29 @@ class GeminiNormalizer(FormatNormalizer):
ErrorType.UNKNOWN: "INTERNAL",
}
# generationConfig 中已被标准化提取的 key其余写入 extra 以便 roundtrip 保留
_GC_KNOWN_KEYS: set[str] = {
"max_output_tokens",
"maxOutputTokens",
"temperature",
"top_p",
"topP",
"top_k",
"topK",
"stop_sequences",
"stopSequences",
"response_modalities",
"responseModalities",
"thinking_config",
"thinkingConfig",
"candidate_count",
"candidateCount",
"response_mime_type",
"responseMimeType",
"response_schema",
"responseSchema",
}
# =========================
# Requests
# =========================
@@ -281,7 +309,48 @@ class GeminiNormalizer(FormatNormalizer):
)
# 构建 extra保留原始 gemini 字段
extra: dict[str, Any] = {"gemini": self._extract_extra(request, {"contents"})}
extra: dict[str, Any] = {
"gemini": self._extract_extra(
request,
{
"model",
"contents",
"system_instruction",
"systemInstruction",
"generation_config",
"generationConfig",
"tools",
"tool_config",
"toolConfig",
"stream",
"safetySettings",
"safety_settings",
"cachedContent",
"cached_content",
},
)
}
# 保留 generationConfig 中未被标准化的额外字段seed, presencePenalty 等)
raw_gc = (
request.get("generation_config")
if "generation_config" in request
else request.get("generationConfig")
)
if isinstance(raw_gc, dict):
gc_extra = {k: v for k, v in raw_gc.items() if k not in self._GC_KNOWN_KEYS}
if gc_extra:
extra.setdefault("gemini", {})["generation_config_extra"] = gc_extra
# 保留 safetySettings原样透传
raw_safety = request.get("safetySettings") or request.get("safety_settings")
if raw_safety:
extra.setdefault("gemini", {})["safety_settings"] = raw_safety
# 保留 cachedContent原样透传
raw_cached = request.get("cachedContent") or request.get("cached_content")
if raw_cached:
extra.setdefault("gemini", {})["cached_content"] = raw_cached
# 保留 generationConfig 中的特殊字段responseModalities, thinkingConfig 等)
# 这些字段在 _get_generation_config 中已提取,需要单独存储以便转换时使用
@@ -505,6 +574,14 @@ class GeminiNormalizer(FormatNormalizer):
if "thinking_config" in orig_gc and "thinkingConfig" not in generation_config:
generation_config["thinkingConfig"] = orig_gc["thinking_config"]
# 恢复 generationConfig 中未被标准化的额外字段
# (seed, presencePenalty, frequencyPenalty, logprobs, speechConfig, mediaResolution 等)
gc_extra = gemini_extra.get("generation_config_extra")
if isinstance(gc_extra, dict):
for k, v in gc_extra.items():
if k not in generation_config:
generation_config[k] = v
raw_contents: list[dict[str, Any]] = []
last_idx = len(internal.messages) - 1
for idx, msg in enumerate(internal.messages):
@@ -570,6 +647,15 @@ class GeminiNormalizer(FormatNormalizer):
if tool_config:
result["tool_config"] = tool_config
# 恢复 safetySettings 和 cachedContentGemini -> Gemini 透传)
if isinstance(gemini_extra, dict):
safety = gemini_extra.get("safety_settings")
if safety:
result["safetySettings"] = safety
cached = gemini_extra.get("cached_content")
if cached:
result["cachedContent"] = cached
return result
# =========================
@@ -577,7 +663,7 @@ class GeminiNormalizer(FormatNormalizer):
# =========================
def response_to_internal(self, response: dict[str, Any]) -> InternalResponse:
rid = str(response.get("id") or "")
rid = str(response.get("responseId") or response.get("id") or "")
model = str(response.get("modelVersion") or response.get("model") or "")
candidates = response.get("candidates") or []
@@ -594,6 +680,11 @@ class GeminiNormalizer(FormatNormalizer):
if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
# Gemini returns finishReason=STOP for function calls; detect tool use from content
has_tool_use = any(isinstance(b, ToolUseBlock) for b in blocks)
if has_tool_use and stop_reason != StopReason.TOOL_USE:
stop_reason = StopReason.TOOL_USE
usage_info = self._usage_metadata_to_internal(response.get("usageMetadata"))
extra: dict[str, Any] = {}
@@ -1147,8 +1238,20 @@ class GeminiNormalizer(FormatNormalizer):
def error_from_internal(self, internal: InternalError) -> dict[str, Any]:
status = self._ERROR_TYPE_TO_GEMINI_STATUS.get(internal.type, "INTERNAL")
code_map: dict[ErrorType, int] = {
ErrorType.INVALID_REQUEST: 400,
ErrorType.AUTHENTICATION: 401,
ErrorType.PERMISSION_DENIED: 403,
ErrorType.NOT_FOUND: 404,
ErrorType.RATE_LIMIT: 429,
ErrorType.OVERLOADED: 503,
ErrorType.CONTENT_FILTERED: 400,
ErrorType.CONTEXT_LENGTH_EXCEEDED: 400,
ErrorType.SERVER_ERROR: 500,
ErrorType.UNKNOWN: 500,
}
payload: dict[str, Any] = {
"code": 400 if internal.type == ErrorType.INVALID_REQUEST else 500,
"code": code_map.get(internal.type, 500),
"message": internal.message,
"status": status,
}

View File

@@ -376,6 +376,14 @@ class OpenAINormalizer(FormatNormalizer):
extra: dict[str, Any] = {}
# 保留 system_fingerprint / service_tier 以便 roundtrip 还原
sys_fp = response.get("system_fingerprint")
if sys_fp is not None:
extra.setdefault("openai", {})["system_fingerprint"] = sys_fp
svc_tier = response.get("service_tier")
if svc_tier is not None:
extra.setdefault("openai", {})["service_tier"] = svc_tier
choices = response.get("choices") or []
if isinstance(choices, list) and len(choices) > 1:
extra.setdefault("openai", {})["choices"] = choices
@@ -384,8 +392,22 @@ class OpenAINormalizer(FormatNormalizer):
message = choice0.get("message") if isinstance(choice0, dict) else None
message = message if isinstance(message, dict) else {}
# reasoning_content -> ThinkingBlock
reasoning_content = message.get("reasoning_content")
reasoning_blocks: list[ContentBlock] = []
if (
isinstance(reasoning_content, str)
and reasoning_content
and reasoning_content != "[undefined]"
):
reasoning_blocks.append(ThinkingBlock(thinking=reasoning_content))
blocks, dropped = self._openai_content_to_blocks(message.get("content"))
# thinking blocks first (align with Claude thinking-first convention)
if reasoning_blocks:
blocks = reasoning_blocks + blocks
# tool_calls -> ToolUseBlock
tool_calls = message.get("tool_calls") or []
if isinstance(tool_calls, list):
@@ -432,6 +454,7 @@ class OpenAINormalizer(FormatNormalizer):
"object": "chat.completion",
"created": int(time.time()),
"model": model_name,
"system_fingerprint": None,
"choices": [],
}
@@ -448,10 +471,13 @@ class OpenAINormalizer(FormatNormalizer):
message["reasoning_content"] = reasoning_text
content_value = self._blocks_to_openai_content(content_blocks)
# assistant 有 tool_calls 时 content 允许为 null否则回退为空字符串
if content_value is not None:
message["content"] = content_value
else:
elif tool_blocks:
message["content"] = None
else:
message["content"] = ""
if tool_blocks:
message["tool_calls"] = [
@@ -492,11 +518,17 @@ class OpenAINormalizer(FormatNormalizer):
usage_out["completion_tokens_details"] = ctd
out["usage"] = usage_out
return out
# 还原 system_fingerprint / service_tierroundtrip 保留)
openai_resp_extra = internal.extra.get("openai", {})
if isinstance(openai_resp_extra, dict):
sfp = openai_resp_extra.get("system_fingerprint")
if sfp is not None:
out["system_fingerprint"] = sfp
st = openai_resp_extra.get("service_tier")
if st is not None:
out["service_tier"] = st
# =========================
# Streaming
# =========================
return out
def stream_chunk_to_internal(
self, chunk: dict[str, Any], state: StreamState
@@ -732,7 +764,7 @@ class OpenAINormalizer(FormatNormalizer):
block_type = ss.get(f"block_type_{event.block_index}")
if block_type == ContentType.THINKING.value:
# 对齐 AMthinking 内容输出为 reasoning_content
out.append(base_chunk({"reasoning_content": event.text_delta, "content": None}))
out.append(base_chunk({"reasoning_content": event.text_delta}))
else:
out.append(base_chunk({"content": event.text_delta}))
return out
@@ -808,20 +840,12 @@ class OpenAINormalizer(FormatNormalizer):
if isinstance(event, ToolCallDeltaEvent):
tool_index = self._ensure_tool_call_index(ss, event.tool_id)
out.append(
base_chunk(
{
"tool_calls": [
{
"index": tool_index,
"id": event.tool_id,
"type": "function",
"function": {"arguments": event.input_delta},
}
]
}
)
)
# 后续 delta 只需 index + function.argumentsid/type 仅在 ContentBlockStartEvent 首次发送
tc_delta: dict[str, Any] = {
"index": tool_index,
"function": {"arguments": event.input_delta},
}
out.append(base_chunk({"tool_calls": [tc_delta]}))
return out
if isinstance(event, MessageStopEvent):
@@ -1107,7 +1131,7 @@ class OpenAINormalizer(FormatNormalizer):
InternalMessage(
role=Role.USER,
content=[tr_block],
extra=self._extract_extra(msg, {"role", "content"}),
extra=self._extract_extra(msg, {"role", "content", "tool_call_id"}),
),
dropped,
)
@@ -1202,24 +1226,38 @@ class OpenAINormalizer(FormatNormalizer):
continue
if ptype == "file":
file_data = part.get("file_data")
file_id = part.get("file_id")
if isinstance(file_data, dict):
blocks.append(
FileBlock(
data=file_data.get("data"),
media_type=file_data.get("mime_type"),
filename=file_data.get("filename"),
extra=self._extract_extra(part, {"type", "file_data"}),
# 标准 OpenAI 格式: {"type": "file", "file": {"file_id": ...}}
# 或 {"type": "file", "file": {"file_data": "data:mime;base64,...", "filename": ...}}
file_obj = part.get("file")
if isinstance(file_obj, dict):
fid = file_obj.get("file_id")
fdata = file_obj.get("file_data")
fname = file_obj.get("filename")
if isinstance(fdata, str) and fdata:
# file_data 是 data URL 格式: "data:mime;base64,..."
mime_type = None
raw_data = fdata
if fdata.startswith("data:") and ";base64," in fdata:
header, raw_data = fdata.split(";base64,", 1)
mime_type = header[len("data:") :]
blocks.append(
FileBlock(
data=raw_data,
media_type=mime_type,
filename=fname,
extra=self._extract_extra(part, {"type", "file"}),
)
)
)
elif isinstance(file_id, str) and file_id:
blocks.append(
FileBlock(
file_id=file_id,
extra=self._extract_extra(part, {"type", "file_id"}),
elif isinstance(fid, str) and fid:
blocks.append(
FileBlock(
file_id=fid,
filename=fname,
extra=self._extract_extra(part, {"type", "file"}),
)
)
)
else:
blocks.append(UnknownBlock(raw_type="file", payload=part))
else:
blocks.append(UnknownBlock(raw_type="file", payload=part))
continue
@@ -1511,36 +1549,27 @@ class OpenAINormalizer(FormatNormalizer):
text_parts.append(b.text)
continue
if isinstance(b, ImageBlock):
# 区分两种图片来源:
# 1. URL 引用OpenAI 原生格式)-> multipart content
# 2. base64 内嵌数据(格式转换来的)-> markdown 格式
if b.url and not b.data:
# OpenAI 原生格式URL 引用的图片,使用 multipart content
parts.append({"type": "image_url", "image_url": {"url": b.url}})
elif b.data and b.media_type:
# 格式转换来的图片base64 内嵌),使用 markdown 格式
if b.data and b.media_type:
# base64 data -> data URL in image_url format
data_url = f"data:{b.media_type};base64,{b.data}"
text_parts.append(f"![image]({data_url})")
parts.append({"type": "image_url", "image_url": {"url": data_url}})
elif b.url:
# 有 URL 也有 data优先使用 URL
parts.append({"type": "image_url", "image_url": {"url": b.url}})
continue
if isinstance(b, FileBlock):
if b.data and b.media_type:
# OpenAI file content part
file_part: dict[str, Any] = {
"type": "file",
"file_data": {
"mime_type": b.media_type,
"data": b.data,
},
}
# 标准 OpenAI 格式: {"type": "file", "file": {"file_data": "data:mime;base64,...", "filename": ...}}
data_url = f"data:{b.media_type};base64,{b.data}"
file_inner: dict[str, Any] = {"file_data": data_url}
if b.filename:
file_part["file_data"]["filename"] = b.filename
parts.append(file_part)
file_inner["filename"] = b.filename
parts.append({"type": "file", "file": file_inner})
elif b.file_id:
parts.append({"type": "file", "file_id": b.file_id})
file_inner_id: dict[str, Any] = {"file_id": b.file_id}
if b.filename:
file_inner_id["filename"] = b.filename
parts.append({"type": "file", "file": file_inner_id})
elif b.file_url:
# 回退为文本描述
text_parts.append(f"[File: {b.file_url}]")
@@ -1576,8 +1605,9 @@ class OpenAINormalizer(FormatNormalizer):
if text_parts:
return "\n".join(text_parts)
# OpenAI content 可以是空字符串;但作为响应 message.content 通常允许为 ""/None。
return ""
# 无任何内容:返回 None让调用方决定是输出 null 还是空字符串
# assistant 有 tool_calls 时 content 应为 nulluser 消息 content 可为空字符串)
return None
def _split_blocks(
self, blocks: list[ContentBlock]
@@ -1677,7 +1707,13 @@ class OpenAINormalizer(FormatNormalizer):
out["reasoning_content"] = "".join(thinking_texts)
content_value = self._blocks_to_openai_content(content_blocks)
out["content"] = content_value if content_value is not None else ""
# assistant 有 tool_calls 时 content 允许为 null否则回退为空字符串
if content_value is not None:
out["content"] = content_value
elif tool_blocks:
out["content"] = None
else:
out["content"] = ""
if tool_blocks:
out["tool_calls"] = [
@@ -1704,8 +1740,8 @@ class OpenAINormalizer(FormatNormalizer):
}
def _tool_use_block_to_openai_call(self, block: ToolUseBlock, index: int) -> dict[str, Any]:
# index 参数仅用于 fallback id 生成;非流式响应的 tool_calls 数组不应包含 index 字段
return {
"index": index,
"id": block.tool_id or f"call_{index}",
"type": "function",
"function": {

View File

@@ -17,12 +17,15 @@ from typing import Any
from src.core.api_format.conversion.field_mappings import (
ERROR_TYPE_MAPPINGS,
REASONING_EFFORT_TO_THINKING_BUDGET,
RETRYABLE_ERROR_TYPES,
THINKING_BUDGET_TO_REASONING_EFFORT,
)
from src.core.api_format.conversion.internal import (
ContentBlock,
ContentType,
ErrorType,
FileBlock,
FormatCapabilities,
ImageBlock,
InstructionSegment,
@@ -34,6 +37,7 @@ from src.core.api_format.conversion.internal import (
StopReason,
TextBlock,
ThinkingBlock,
ThinkingConfig,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -125,6 +129,24 @@ class OpenAICliNormalizer(FormatNormalizer):
max_tokens = self._optional_int(request.get("max_output_tokens", request.get("max_tokens")))
# parallel_tool_calls
parallel_tool_calls: bool | None = None
ptc = request.get("parallel_tool_calls")
if ptc is not None:
parallel_tool_calls = bool(ptc)
# reasoning -> ThinkingConfig (Responses API uses reasoning.effort)
thinking: ThinkingConfig | None = None
reasoning = request.get("reasoning")
if isinstance(reasoning, dict):
effort = reasoning.get("effort")
if isinstance(effort, str) and effort in REASONING_EFFORT_TO_THINKING_BUDGET:
thinking = ThinkingConfig(
enabled=True,
budget_tokens=REASONING_EFFORT_TO_THINKING_BUDGET[effort],
extra={"reasoning_effort": effort, "reasoning": reasoning},
)
internal = InternalRequest(
model=model,
messages=messages,
@@ -137,7 +159,28 @@ class OpenAICliNormalizer(FormatNormalizer):
stream=bool(request.get("stream") or False),
tools=tools,
tool_choice=tool_choice,
extra={"openai_cli": self._extract_extra(request, {"input"})},
thinking=thinking,
parallel_tool_calls=parallel_tool_calls,
extra={
"openai_cli": self._extract_extra(
request,
{
"model",
"input",
"instructions",
"max_output_tokens",
"max_tokens",
"temperature",
"top_p",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
},
)
},
)
return internal
@@ -204,6 +247,26 @@ class OpenAICliNormalizer(FormatNormalizer):
if internal.tool_choice:
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
# thinking -> reasoning (Responses API)
if internal.thinking and internal.thinking.enabled:
# 优先还原原始 reasoning 对象
original_reasoning = internal.thinking.extra.get("reasoning")
if isinstance(original_reasoning, dict):
result["reasoning"] = original_reasoning
else:
effort = internal.thinking.extra.get("reasoning_effort")
if not effort and internal.thinking.budget_tokens is not None:
for threshold, level in THINKING_BUDGET_TO_REASONING_EFFORT:
if internal.thinking.budget_tokens <= threshold:
effort = level
break
if effort:
result["reasoning"] = {"effort": effort}
# parallel_tool_calls
if internal.parallel_tool_calls is not None:
result["parallel_tool_calls"] = internal.parallel_tool_calls
# 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原)
handled_keys = {
"model",
@@ -217,6 +280,8 @@ class OpenAICliNormalizer(FormatNormalizer):
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"reasoning",
}
for key, value in openai_cli_extra.items():
if key not in handled_keys and key not in result:
@@ -291,7 +356,25 @@ class OpenAICliNormalizer(FormatNormalizer):
) -> dict[str, Any]:
output_items: list[dict[str, Any]] = []
# 构建 output itemsmessage文本function_call工具调用
# 构建 output itemsreasoning思考message文本function_call工具调用
# 按 Responses API 顺序reasoning -> message -> function_call
rs_idx = 0
for block in internal.content:
if isinstance(block, ThinkingBlock) and block.thinking:
rs_id = (
f"rs_{internal.id or 'resp'}"
if rs_idx == 0
else f"rs_{internal.id or 'resp'}_{rs_idx}"
)
output_items.append(
{
"type": "reasoning",
"id": rs_id,
"summary": [{"type": "summary_text", "text": block.thinking}],
}
)
rs_idx += 1
text = self._collapse_internal_text(internal.content)
if text:
output_items.append(
@@ -897,6 +980,7 @@ class OpenAICliNormalizer(FormatNormalizer):
{
"type": "response.content_part.added",
"sequence_number": self._next_seq(ss),
"item_id": message_id,
"output_index": output_index,
"content_index": 0,
"part": {"type": "output_text", "text": "", "annotations": []},
@@ -904,10 +988,15 @@ class OpenAICliNormalizer(FormatNormalizer):
)
ss["text_started"] = True
ss["collected_text"] = str(ss.get("collected_text") or "") + event.text_delta
message_id = f"msg_{state.message_id or 'stream'}"
output_index = ss.get("message_output_index") or 0
out.append(
{
"type": "response.output_text.delta",
"sequence_number": self._next_seq(ss),
"item_id": message_id,
"output_index": output_index,
"content_index": 0,
"delta": event.text_delta,
}
)
@@ -970,10 +1059,14 @@ class OpenAICliNormalizer(FormatNormalizer):
),
}
if ss.get("text_started"):
msg_output_index = ss.get("message_output_index") or 0
out.append(
{
"type": "response.output_text.done",
"sequence_number": self._next_seq(ss),
"item_id": message_id,
"output_index": msg_output_index,
"content_index": 0,
"text": final_text,
}
)
@@ -982,7 +1075,8 @@ class OpenAICliNormalizer(FormatNormalizer):
{
"type": "response.content_part.done",
"sequence_number": self._next_seq(ss),
"output_index": ss.get("message_output_index") or 0,
"item_id": message_id,
"output_index": msg_output_index,
"content_index": 0,
"part": {"type": "output_text", "text": final_text, "annotations": []},
}
@@ -1151,6 +1245,7 @@ class OpenAICliNormalizer(FormatNormalizer):
(blocks, extra, has_tool_use): 内容块列表、extra 信息、是否包含工具调用
"""
text_parts: list[str] = []
thinking_blocks: list[ContentBlock] = []
blocks: list[ContentBlock] = []
has_tool_use = False
@@ -1199,13 +1294,30 @@ class OpenAICliNormalizer(FormatNormalizer):
if item_type in ("output_text", "text") and isinstance(item.get("text"), str):
text_parts.append(item.get("text") or "")
continue
if item_type == "reasoning":
# reasoning output item -> ThinkingBlock
summary = item.get("summary")
summary_parts: list[str] = []
if isinstance(summary, list):
for s in summary:
if isinstance(s, dict) and s.get("type") == "summary_text":
t = s.get("text")
if isinstance(t, str) and t:
summary_parts.append(t)
thinking_text = "\n".join(summary_parts)
if thinking_text:
thinking_blocks.append(ThinkingBlock(thinking=thinking_text))
continue
# 兼容:部分实现可能直接给 output_text
if not text_parts and isinstance(payload.get("output_text"), str):
text_parts.append(payload.get("output_text") or "")
# 文本块放在前面,工具调用在后(与 Claude 的 content 顺序一致)
# thinking 在前,文本在中,工具调用在后(与 Claude 的 content 顺序一致)
result_blocks: list[ContentBlock] = []
result_blocks.extend(thinking_blocks)
text = "".join(text_parts)
if text:
result_blocks.append(TextBlock(text=text))
@@ -1399,6 +1511,24 @@ class OpenAICliNormalizer(FormatNormalizer):
else:
blocks.append(UnknownBlock(raw_type=ptype, payload=part))
continue
if ptype == "input_file":
file_data = part.get("file_data")
file_id = part.get("file_id")
filename = part.get("filename")
fb = FileBlock(filename=filename)
if isinstance(file_data, str) and file_data:
# file_data 是 data URL: "data:mime;base64,..."
if file_data.startswith("data:") and ";base64," in file_data:
header, _, data = file_data.partition(",")
fb.media_type = header.split(";")[0].split(":", 1)[-1]
fb.data = data
else:
fb.data = file_data
elif isinstance(file_id, str) and file_id:
fb.file_id = file_id
fb.extra = self._extract_extra(part, {"type", "file_data", "file_id", "filename"})
blocks.append(fb)
continue
blocks.append(UnknownBlock(raw_type=ptype or "unknown", payload=part))
return blocks
@@ -1428,15 +1558,20 @@ class OpenAICliNormalizer(FormatNormalizer):
continue
if isinstance(block, ToolResultBlock):
# Responses API function_call_output.output 必须是字符串
if block.content_text is not None:
output_str = block.content_text
elif isinstance(block.output, str):
output_str = block.output
elif block.output is not None:
output_str = json.dumps(block.output, ensure_ascii=False)
else:
output_str = ""
out.append(
{
"type": "function_call_output",
"call_id": block.tool_use_id,
"output": (
block.content_text
if block.content_text is not None
else block.output
),
"output": output_str,
}
)
continue
@@ -1567,6 +1702,11 @@ class OpenAICliNormalizer(FormatNormalizer):
return ToolChoice(
type=ToolChoiceType.AUTO, extra={"openai_cli": {"tool_choice": tool_choice}}
)
if tool_choice == "required":
return ToolChoice(
type=ToolChoiceType.REQUIRED,
extra={"openai_cli": {"tool_choice": tool_choice}},
)
return ToolChoice(type=ToolChoiceType.AUTO, extra={"raw": tool_choice})
if isinstance(tool_choice, dict):

View File

@@ -166,7 +166,30 @@ class InternalStreamAggregator:
self._open.clear()
continue
@property
def open_count(self) -> int:
"""当前未关闭的 block 数量。"""
return len(self._open)
@property
def final_count(self) -> int:
"""已完成的 block 数量。"""
return len(self._final)
@property
def usage(self) -> UsageInfo | None:
return self._usage
@property
def stop_reason(self) -> StopReason | None:
return self._stop_reason
def build(self) -> InternalResponse:
# Flush remaining open blocks (best-effort) in case MessageStopEvent was never received.
for idx, b in list(self._open.items()):
self._final.setdefault(idx, b.finalize())
self._open.clear()
rid = self._id or self._fallback_id
model = self._model or self._fallback_model
content = [self._final[k] for k in sorted(self._final.keys())]