mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 09:50:21 +08:00
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:
@@ -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 字段)
|
||||
# 工具输出可能是纯文本,也可能是结构化 JSON(Gemini 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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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 和 cachedContent(Gemini -> 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,
|
||||
}
|
||||
|
||||
@@ -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_tier(roundtrip 保留)
|
||||
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:
|
||||
# 对齐 AM:thinking 内容输出为 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.arguments;id/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"")
|
||||
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 应为 null;user 消息 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": {
|
||||
|
||||
@@ -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 items:message(文本)和 function_call(工具调用)
|
||||
# 构建 output items:reasoning(思考)、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):
|
||||
|
||||
@@ -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())]
|
||||
|
||||
Reference in New Issue
Block a user