feat: 跨格式 thinking/reasoning 透传与 Antigravity 适配器增强

- 内部表示层新增 ThinkingBlock,统一 Claude thinking / Gemini thought / OpenAI reasoning_content
- Claude/Gemini/OpenAI/OpenAI CLI normalizer 全面支持 thinking 内容的解析、流式处理和跨格式转换
- schema_utils 重写为完整的 JSON Schema 清洗逻辑($ref 展开、allOf 合并、anyOf 折叠、白名单过滤)
- Antigravity envelope 新增模型别名映射、Google Search 注入、thoughtSignature 注入、图像生成配置解析

Close #161
This commit is contained in:
fawney19
2026-02-10 02:05:35 +08:00
parent de40bd4705
commit 9f5dd6f658
8 changed files with 1402 additions and 89 deletions

View File

@@ -26,6 +26,7 @@ class Role(str, Enum):
class ContentType(str, Enum):
TEXT = "text"
THINKING = "thinking"
IMAGE = "image"
TOOL_USE = "tool_use"
TOOL_RESULT = "tool_result"
@@ -66,6 +67,16 @@ class TextBlock:
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ThinkingBlock:
"""思考过程内容块(对齐 Gemini thought:true / Claude thinking / OpenAI reasoning_content"""
type: ContentType = field(default=ContentType.THINKING, init=False)
thinking: str = ""
signature: str | None = None # Gemini thoughtSignature / Claude signature
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class ImageBlock:
"""图片内容块"""
@@ -113,7 +124,9 @@ class UnknownBlock:
extra: dict[str, Any] = field(default_factory=dict)
ContentBlock = TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
ContentBlock = (
TextBlock | ThinkingBlock | ImageBlock | ToolUseBlock | ToolResultBlock | UnknownBlock
)
@dataclass
@@ -277,6 +290,7 @@ __all__ = [
"ErrorType",
"ToolChoiceType",
"TextBlock",
"ThinkingBlock",
"ImageBlock",
"ToolUseBlock",
"ToolResultBlock",

View File

@@ -30,6 +30,7 @@ from src.core.api_format.conversion.internal import (
Role,
StopReason,
TextBlock,
ThinkingBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -258,6 +259,16 @@ class ClaudeNormalizer(FormatNormalizer):
content: list[dict[str, Any]] = []
for b in internal.content:
if isinstance(b, ThinkingBlock):
if b.thinking:
thinking_block: dict[str, Any] = {
"type": "thinking",
"thinking": b.thinking,
}
if b.signature:
thinking_block["signature"] = b.signature
content.append(thinking_block)
continue
if isinstance(b, TextBlock):
if b.text:
content.append({"type": "text", "text": b.text})
@@ -360,6 +371,12 @@ class ClaudeNormalizer(FormatNormalizer):
block: dict[str, Any] = block_raw if isinstance(block_raw, dict) else {}
btype = str(block.get("type") or "unknown")
if btype == "thinking":
events.append(
ContentBlockStartEvent(block_index=index, block_type=ContentType.THINKING)
)
return events
if btype == "text":
events.append(
ContentBlockStartEvent(block_index=index, block_type=ContentType.TEXT)
@@ -397,6 +414,25 @@ class ClaudeNormalizer(FormatNormalizer):
delta: dict[str, Any] = delta_raw if isinstance(delta_raw, dict) else {}
dtype = str(delta.get("type") or "unknown")
if dtype == "thinking_delta":
thinking = delta.get("thinking")
if thinking is None:
return events
events.append(ContentDeltaEvent(block_index=index, text_delta=str(thinking)))
return events
if dtype == "signature_delta":
sig = delta.get("signature")
if isinstance(sig, str) and sig:
events.append(
ContentDeltaEvent(
block_index=index,
text_delta="",
extra={"thought_signature": sig},
)
)
return events
if dtype == "text_delta":
text = delta.get("text")
if text is None:
@@ -484,6 +520,20 @@ class ClaudeNormalizer(FormatNormalizer):
return out
if isinstance(event, ContentBlockStartEvent):
# 记录 block_index → block_type 映射(用于 ContentDeltaEvent 区分类型)
ss[f"block_type_{event.block_index}"] = event.block_type.value
if event.block_type == ContentType.THINKING:
# 对齐 AM Claude streaming.rsthinking content block
out.append(
{
"type": "content_block_start",
"index": int(event.block_index),
"content_block": {"type": "thinking", "thinking": ""},
}
)
return out
if event.block_type == ContentType.TEXT:
out.append(
{
@@ -513,11 +563,32 @@ class ClaudeNormalizer(FormatNormalizer):
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
block_type = ss.get(f"block_type_{event.block_index}")
if block_type == ContentType.THINKING.value:
# 对齐 AMthinking_delta
out.append(
{
"type": "content_block_delta",
"index": int(event.block_index),
"delta": {"type": "thinking_delta", "thinking": event.text_delta},
}
)
else:
out.append(
{
"type": "content_block_delta",
"index": int(event.block_index),
"delta": {"type": "text_delta", "text": event.text_delta},
}
)
# 对齐 AMsignature_delta如果 extra 中有签名信息)
sig = event.extra.get("thought_signature") if event.extra else None
if isinstance(sig, str) and sig:
out.append(
{
"type": "content_block_delta",
"index": int(event.block_index),
"delta": {"type": "text_delta", "text": event.text_delta},
"delta": {"type": "signature_delta", "signature": sig},
}
)
return out
@@ -648,6 +719,18 @@ class ClaudeNormalizer(FormatNormalizer):
continue
btype = str(block.get("type") or "unknown")
if btype == "thinking":
thinking = str(block.get("thinking") or "")
signature = block.get("signature")
if thinking:
blocks.append(
ThinkingBlock(
thinking=thinking,
signature=str(signature) if signature else None,
)
)
continue
if btype == "text":
text = str(block.get("text") or "")
if text:

View File

@@ -34,6 +34,7 @@ from src.core.api_format.conversion.internal import (
Role,
StopReason,
TextBlock,
ThinkingBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -564,8 +565,10 @@ class GeminiNormalizer(FormatNormalizer):
state.message_id = state.message_id or "gemini"
ss["message_started"] = True
ss.setdefault("text_block_started", False)
ss.setdefault("thinking_block_started", False)
ss.setdefault("accumulated_text", "")
ss.setdefault("next_block_index", 1) # 0 预留给文本
ss.setdefault("accumulated_thinking", "")
ss.setdefault("next_block_index", 2) # 0 预留给 thinking, 1 预留给 text
events.append(MessageStartEvent(message_id=state.message_id, model=model))
candidates = chunk.get("candidates") or []
@@ -586,23 +589,68 @@ class GeminiNormalizer(FormatNormalizer):
continue
# text兼容delta 或累积)
# 对齐 AM检查 thought 标记,分流 thinking 和 regular text
text = part.get("text")
if isinstance(text, str) and text:
prev = str(ss.get("accumulated_text") or "")
if text.startswith(prev):
delta = text[len(prev) :]
ss["accumulated_text"] = text
else:
delta = text
ss["accumulated_text"] = prev + delta
is_thought = part.get("thought") is True
if delta:
if not ss.get("text_block_started"):
ss["text_block_started"] = True
events.append(
ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT)
if is_thought:
# Thinking content → block_index=0
prev = str(ss.get("accumulated_thinking") or "")
if text.startswith(prev):
delta = text[len(prev) :]
ss["accumulated_thinking"] = text
else:
delta = text
ss["accumulated_thinking"] = prev + delta
if delta:
if not ss.get("thinking_block_started"):
ss["thinking_block_started"] = True
events.append(
ContentBlockStartEvent(
block_index=0, block_type=ContentType.THINKING
)
)
events.append(ContentDeltaEvent(block_index=0, text_delta=delta))
else:
# Regular text → block_index=1
prev = str(ss.get("accumulated_text") or "")
if text.startswith(prev):
delta = text[len(prev) :]
ss["accumulated_text"] = text
else:
delta = text
ss["accumulated_text"] = prev + delta
if delta:
# 切换:先关闭 thinking blockClaude 协议要求顺序 stop/start
if ss.get("thinking_block_started") and not ss.get(
"thinking_block_stopped"
):
ss["thinking_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
if not ss.get("text_block_started"):
ss["text_block_started"] = True
events.append(
ContentBlockStartEvent(
block_index=1, block_type=ContentType.TEXT
)
)
events.append(ContentDeltaEvent(block_index=1, text_delta=delta))
# 提取 thoughtSignature对齐 AM缓存到 session
sig = part.get("thoughtSignature") or part.get("thought_signature")
if isinstance(sig, str) and sig and ss.get("thinking_block_started"):
# 仅在 thinking block 已开启时发射 signature delta
events.append(
ContentDeltaEvent(
block_index=0,
text_delta="",
extra={"thought_signature": sig},
)
events.append(ContentDeltaEvent(block_index=0, text_delta=delta))
)
continue
# functionCallstream response 常见 camelCase
@@ -611,12 +659,20 @@ class GeminiNormalizer(FormatNormalizer):
func_call = part.get("function_call")
if isinstance(func_call, dict):
# 关闭前面的 thinking/text block如果还开着
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
ss["thinking_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=1))
name = str(func_call.get("name") or "")
args = func_call.get("args")
if not isinstance(args, dict):
args = {}
block_index = int(ss.get("next_block_index") or 1)
block_index = int(ss.get("next_block_index") or 2)
ss["next_block_index"] = block_index + 1
events.append(
@@ -673,10 +729,13 @@ class GeminiNormalizer(FormatNormalizer):
if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
usage_info = self._usage_metadata_to_internal(chunk.get("usageMetadata"))
# 先补齐 content_block_stop仅 text block再发送 MessageStop
# 先补齐 content_block_stop所有已开启的 block再发送 MessageStop
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
ss["thinking_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
events.append(ContentBlockStopEvent(block_index=1))
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
if "error" in chunk:
@@ -711,9 +770,26 @@ class GeminiNormalizer(FormatNormalizer):
ss.setdefault("tool_blocks", {})
return out
if isinstance(event, ContentBlockStartEvent):
# 记录 block_index → block_type 映射
ss[f"block_type_{event.block_index}"] = event.block_type.value
if event.block_type == ContentType.THINKING:
ss["thinking_output_started"] = True
return out
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
out.append(base_chunk([{"text": event.text_delta}]))
# 检查 block_index → block_type 映射,区分 thinking 和 text
block_type = ss.get(f"block_type_{event.block_index}")
if block_type == ContentType.THINKING.value:
part: dict[str, Any] = {"text": event.text_delta, "thought": True}
out.append(base_chunk([part]))
else:
out.append(base_chunk([{"text": event.text_delta}]))
# thoughtSignature来自 Gemini upstream 或 其他格式转换)
sig = (event.extra or {}).get("thought_signature")
if isinstance(sig, str) and sig:
out.append(base_chunk([{"text": "", "thought": True, "thoughtSignature": sig}]))
return out
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.TOOL_USE:
@@ -1166,7 +1242,23 @@ class GeminiNormalizer(FormatNormalizer):
if "text" in part:
text = part.get("text")
if isinstance(text, str) and text:
blocks.append(TextBlock(text=text, extra=self._extract_extra(part, {"text"})))
is_thought = part.get("thought") is True
if is_thought:
sig = part.get("thoughtSignature") or part.get("thought_signature")
blocks.append(
ThinkingBlock(
thinking=text,
signature=sig if isinstance(sig, str) else None,
extra=self._extract_extra(
part,
{"text", "thought", "thoughtSignature", "thought_signature"},
),
)
)
else:
blocks.append(
TextBlock(text=text, extra=self._extract_extra(part, {"text"}))
)
continue
inline = part.get("inline_data")
@@ -1259,6 +1351,14 @@ class GeminiNormalizer(FormatNormalizer):
parts.append(part)
continue
if isinstance(b, ThinkingBlock):
if b.thinking:
thought_part: dict[str, Any] = {"text": b.thinking, "thought": True}
if b.signature:
thought_part["thoughtSignature"] = b.signature
parts.append(thought_part)
continue
if isinstance(b, TextBlock):
if b.text:
parts.append({"text": b.text})

View File

@@ -30,6 +30,7 @@ from src.core.api_format.conversion.internal import (
Role,
StopReason,
TextBlock,
ThinkingBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -330,7 +331,16 @@ class OpenAINormalizer(FormatNormalizer):
message: dict[str, Any] = {"role": "assistant"}
content_blocks, tool_blocks = self._split_blocks(internal.content)
thinking_blocks, content_blocks, tool_blocks = self._split_blocks(internal.content)
# 对齐 AMThinkingBlock → reasoning_content
if thinking_blocks:
reasoning_text = "".join(
b.thinking for b in thinking_blocks if isinstance(b, ThinkingBlock) and b.thinking
)
if reasoning_text:
message["reasoning_content"] = reasoning_text
content_value = self._blocks_to_openai_content(content_blocks)
if content_value is not None:
message["content"] = content_value
@@ -391,9 +401,10 @@ class OpenAINormalizer(FormatNormalizer):
if not state.model:
state.model = model
ss["message_started"] = True
ss.setdefault("thinking_block_started", False)
ss.setdefault("text_block_started", False)
ss.setdefault("tool_id_to_block_index", {})
ss.setdefault("next_block_index", 1) # 0 预留给 text block
ss.setdefault("next_block_index", 2) # 0=thinking, 1=text, 2+=tools
events.append(MessageStartEvent(message_id=msg_id, model=model))
choices = chunk.get("choices") or []
@@ -418,13 +429,27 @@ class OpenAINormalizer(FormatNormalizer):
if not isinstance(delta, dict):
delta = {}
# reasoning_content delta (thinking)
reasoning_delta = delta.get("reasoning_content")
if isinstance(reasoning_delta, str) and reasoning_delta:
if not ss.get("thinking_block_started"):
ss["thinking_block_started"] = True
events.append(
ContentBlockStartEvent(block_index=0, block_type=ContentType.THINKING)
)
events.append(ContentDeltaEvent(block_index=0, text_delta=reasoning_delta))
# content delta
content_delta = delta.get("content")
if isinstance(content_delta, str) and content_delta:
# 从 thinking 过渡到 text 时,先关闭 thinking block
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
ss["thinking_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
if not ss.get("text_block_started"):
ss["text_block_started"] = True
events.append(ContentBlockStartEvent(block_index=0, block_type=ContentType.TEXT))
events.append(ContentDeltaEvent(block_index=0, text_delta=content_delta))
events.append(ContentBlockStartEvent(block_index=1, block_type=ContentType.TEXT))
events.append(ContentDeltaEvent(block_index=1, text_delta=content_delta))
# tool_calls delta
tool_calls = delta.get("tool_calls")
@@ -469,10 +494,13 @@ class OpenAINormalizer(FormatNormalizer):
finish_reason = c0.get("finish_reason")
if finish_reason is not None:
stop_reason = self._FINISH_REASON_TO_STOP.get(str(finish_reason), StopReason.UNKNOWN)
# 先补齐 content_block_stop仅 text block),再发送 MessageStop
# 先补齐 content_block_stopthinking + text),再发送 MessageStop
if ss.get("thinking_block_started") and not ss.get("thinking_block_stopped"):
ss["thinking_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
if ss.get("text_block_started") and not ss.get("text_block_stopped"):
ss["text_block_stopped"] = True
events.append(ContentBlockStopEvent(block_index=0))
events.append(ContentBlockStopEvent(block_index=1))
# 解析 usage需要请求时设置 stream_options.include_usage: true
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
events.append(MessageStopEvent(stop_reason=stop_reason, usage=usage_info))
@@ -511,9 +539,23 @@ class OpenAINormalizer(FormatNormalizer):
out.append(base_chunk({"role": "assistant"}))
return out
# 记录 block_index → block_type 的映射(用于 ContentDeltaEvent 区分 thinking vs text
if isinstance(event, ContentBlockStartEvent):
ss[f"block_type_{event.block_index}"] = event.block_type.value
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.THINKING:
# Thinking block 开始OpenAI 格式无需显式开始事件
return out
if isinstance(event, ContentDeltaEvent):
if event.text_delta:
out.append(base_chunk({"content": event.text_delta}))
# 检查 block_index 对应的 block_typethinking vs text
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}))
else:
out.append(base_chunk({"content": event.text_delta}))
return out
if isinstance(event, ContentBlockStartEvent) and event.block_type == ContentType.TOOL_USE:
@@ -891,9 +933,23 @@ class OpenAINormalizer(FormatNormalizer):
dropped,
)
# 对齐 AM解析 reasoning_content → ThinkingBlock放在 content blocks 前面)
reasoning_content = msg.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, content_dropped = self._openai_content_to_blocks(msg.get("content"))
self._merge_dropped(dropped, content_dropped)
# 合并thinking blocks 放在前面(对齐 AM/Claude 的 thinking-first 约定)
if reasoning_blocks:
blocks = reasoning_blocks + blocks
# assistant tool_calls
if role_raw == "assistant":
tool_calls = msg.get("tool_calls") or []
@@ -1248,10 +1304,14 @@ class OpenAINormalizer(FormatNormalizer):
def _split_blocks(
self, blocks: list[ContentBlock]
) -> tuple[list[ContentBlock], list[ToolUseBlock]]:
) -> tuple[list[ThinkingBlock], list[ContentBlock], list[ToolUseBlock]]:
thinking_blocks: list[ThinkingBlock] = []
content_blocks: list[ContentBlock] = []
tool_blocks: list[ToolUseBlock] = []
for b in blocks:
if isinstance(b, ThinkingBlock):
thinking_blocks.append(b)
continue
if isinstance(b, ToolUseBlock):
tool_blocks.append(b)
continue
@@ -1261,7 +1321,7 @@ class OpenAINormalizer(FormatNormalizer):
if isinstance(b, UnknownBlock):
continue
content_blocks.append(b)
return content_blocks, tool_blocks
return thinking_blocks, content_blocks, tool_blocks
def _internal_message_to_openai_messages(self, msg: InternalMessage) -> list[dict[str, Any]]:
if msg.role == Role.USER:
@@ -1315,10 +1375,15 @@ class OpenAINormalizer(FormatNormalizer):
return out
def _assistant_message_to_openai(self, msg: InternalMessage) -> dict[str, Any]:
thinking_texts: list[str] = []
content_blocks: list[ContentBlock] = []
tool_blocks: list[ToolUseBlock] = []
for b in msg.content:
if isinstance(b, ThinkingBlock):
if b.thinking:
thinking_texts.append(b.thinking)
continue
if isinstance(b, ToolUseBlock):
tool_blocks.append(b)
continue
@@ -1329,6 +1394,11 @@ class OpenAINormalizer(FormatNormalizer):
content_blocks.append(b)
out: dict[str, Any] = {"role": "assistant"}
# 对齐 AMThinkingBlock → reasoning_content
if thinking_texts:
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 ""

View File

@@ -32,6 +32,7 @@ from src.core.api_format.conversion.internal import (
Role,
StopReason,
TextBlock,
ThinkingBlock,
ToolChoice,
ToolChoiceType,
ToolDefinition,
@@ -544,6 +545,16 @@ class OpenAICliNormalizer(FormatNormalizer):
self, event: ContentBlockStartEvent, state: StreamState, ss: dict[str, Any]
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
# 记录 block_index → block_type 映射
ss[f"block_type_{event.block_index}"] = event.block_type.value
# Thinking blockResponses API 目前无 reasoning stream 事件
if event.block_type == ContentType.THINKING:
# thinking 内容静默收集不输出Responses API 无标准 reasoning 字段)
ss.setdefault("thinking_text", "")
return out
# 工具调用块:输出 function_call 添加事件
if event.block_type == ContentType.TOOL_USE:
tool_id = event.tool_id or ""
@@ -626,6 +637,57 @@ class OpenAICliNormalizer(FormatNormalizer):
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
if event.text_delta:
# Thinking deltaResponses API reasoning summary 事件
block_type = ss.get(f"block_type_{event.block_index}")
if block_type == ContentType.THINKING.value:
# 首次 thinking delta -> 添加 reasoning output item
if not ss.get("reasoning_output_started"):
reasoning_output_index = int(ss.get("next_output_index") or 0)
ss["next_output_index"] = reasoning_output_index + 1
ss["reasoning_output_index"] = reasoning_output_index
ss["reasoning_output_started"] = True
reasoning_id = f"rs_{state.message_id or 'stream'}"
ss["reasoning_id"] = reasoning_id
ss.setdefault("output_order", []).append(
{
"kind": "reasoning",
"id": reasoning_id,
"output_index": reasoning_output_index,
}
)
out.append(
{
"type": "response.output_item.added",
"output_index": reasoning_output_index,
"item": {
"type": "reasoning",
"id": reasoning_id,
"summary": [],
},
}
)
# summary part 开始
out.append(
{
"type": "response.reasoning_summary_part.added",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"part": {"type": "summary_text", "text": ""},
}
)
ss["thinking_text"] = str(ss.get("thinking_text") or "") + event.text_delta
out.append(
{
"type": "response.reasoning_summary_text.delta",
"item_id": ss.get("reasoning_id", ""),
"output_index": ss.get("reasoning_output_index", 0),
"summary_index": 0,
"delta": event.text_delta,
}
)
return out
if not ss.get("message_output_started"):
output_index = int(ss.get("next_output_index") or 0)
ss["next_output_index"] = output_index + 1
@@ -658,6 +720,42 @@ class OpenAICliNormalizer(FormatNormalizer):
) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
final_text = str(ss.get("collected_text") or "")
final_thinking = str(ss.get("thinking_text") or "")
# 先关闭 reasoning summary如有
if ss.get("reasoning_output_started"):
reasoning_id = ss.get("reasoning_id", "")
reasoning_output_index = ss.get("reasoning_output_index", 0)
out.append(
{
"type": "response.reasoning_summary_text.done",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"text": final_thinking,
}
)
out.append(
{
"type": "response.reasoning_summary_part.done",
"item_id": reasoning_id,
"output_index": reasoning_output_index,
"summary_index": 0,
"part": {"type": "summary_text", "text": final_thinking},
}
)
out.append(
{
"type": "response.output_item.done",
"output_index": reasoning_output_index,
"item": {
"type": "reasoning",
"id": reasoning_id,
"summary": [{"type": "summary_text", "text": final_thinking}],
},
}
)
message_id = f"msg_{state.message_id or 'stream'}"
message_item = {
"type": "message",
@@ -677,11 +775,19 @@ class OpenAICliNormalizer(FormatNormalizer):
"item": message_item,
}
)
# 构建最终 content包含 thinking
final_content: list[ContentBlock] = []
if final_thinking:
final_content.append(ThinkingBlock(thinking=final_thinking))
if final_text:
final_content.append(TextBlock(text=final_text))
response_obj = self.response_from_internal(
InternalResponse(
id=state.message_id or "resp",
model=state.model or "",
content=[TextBlock(text=final_text)] if final_text else [],
content=final_content,
stop_reason=event.stop_reason or StopReason.END_TURN,
usage=event.usage or UsageInfo(),
)
@@ -704,7 +810,17 @@ class OpenAICliNormalizer(FormatNormalizer):
for entry in output_order:
if not isinstance(entry, dict):
continue
if entry.get("kind") == "message":
if entry.get("kind") == "reasoning":
thinking_text = str(ss.get("thinking_text") or "")
if thinking_text:
output_items.append(
{
"type": "reasoning",
"id": entry.get("id", ""),
"summary": [{"type": "summary_text", "text": thinking_text}],
}
)
elif entry.get("kind") == "message":
if message_item.get("content"):
output_items.append(message_item)
elif entry.get("kind") == "tool":

View File

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