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__ = [

View File

@@ -73,10 +73,86 @@ MIN_SIGNATURE_LENGTH = 50 # 与 Antigravity-Manager 对齐
# ============== Thinking Budget ==============
THINKING_BUDGET_AUTO_CAP = 24576
THINKING_BUDGET_DEFAULT_INJECT = 16000
THINKING_BUDGET_DEFAULT_INJECT = 24576 # 对齐 AM wrapper.rs (was 16000)
# 包含这些关键字的模型会自动注入 thinkingConfig如果缺失
THINKING_MODELS_AUTO_INJECT_KEYWORDS = ("thinking", "gemini-2.0-pro", "gemini-3-pro")
# ============== Model Alias Mapping ==============
# 对齐 AM common_utils.rs: 将预览/别名映射回上游物理模型名
MODEL_ALIAS_MAP: dict[str, str] = {
"gemini-3-pro-preview": "gemini-3-pro-high",
"gemini-3-pro-image-preview": "gemini-3-pro-image",
"gemini-3-flash-preview": "gemini-3-flash",
}
# ============== Google Search (Grounding) ==============
# 对齐 AM common_utils.rs: 仅 gemini-2.5-flash 支持 googleSearch tool
WEB_SEARCH_MODEL = "gemini-2.5-flash"
# 联网工具检测关键字(对齐 AM detects_networking_tool
NETWORKING_TOOL_KEYWORDS = frozenset(
{
"web_search",
"google_search",
"web_search_20250305",
"google_search_retrieval",
}
)
# ============== Image Generation ==============
# 上游图像生成模型的固定名称
IMAGE_GEN_UPSTREAM_MODEL = "gemini-3-pro-image"
# 模型后缀 → 宽高比映射
IMAGE_ASPECT_RATIO_SUFFIXES: dict[str, str] = {
"-21x9": "21:9",
"-21-9": "21:9",
"-16x9": "16:9",
"-16-9": "16:9",
"-9x16": "9:16",
"-9-16": "9:16",
"-4x3": "4:3",
"-4-3": "4:3",
"-3x4": "3:4",
"-3-4": "3:4",
"-3x2": "3:2",
"-3-2": "3:2",
"-2x3": "2:3",
"-2-3": "2:3",
"-5x4": "5:4",
"-5-4": "5:4",
"-4x5": "4:5",
"-4-5": "4:5",
"-1x1": "1:1",
"-1-1": "1:1",
}
# 标准宽高比字符串(用于直接匹配 size 参数)
STANDARD_ASPECT_RATIOS = frozenset(
{
"21:9",
"16:9",
"9:16",
"4:3",
"3:4",
"3:2",
"2:3",
"5:4",
"4:5",
"1:1",
}
)
# 宽高比容差匹配表:(ratio, label)
ASPECT_RATIO_TABLE: tuple[tuple[float, str], ...] = (
(21.0 / 9.0, "21:9"),
(16.0 / 9.0, "16:9"),
(4.0 / 3.0, "4:3"),
(3.0 / 4.0, "3:4"),
(9.0 / 16.0, "9:16"),
(3.0 / 2.0, "3:2"),
(2.0 / 3.0, "2:3"),
(5.0 / 4.0, "5:4"),
(4.0 / 5.0, "4:5"),
(1.0, "1:1"),
)
# ============== Retry ==============
RETRY_429_BASE_SECONDS = 5.0
RETRY_503_BASE_SECONDS = 10.0
@@ -107,10 +183,15 @@ ANTIGRAVITY_SYSTEM_INSTRUCTION = (
__all__ = [
"ANTIGRAVITY_SYSTEM_INSTRUCTION",
"ASPECT_RATIO_TABLE",
"DAILY_BASE_URL",
"DUMMY_THOUGHT_SIGNATURE",
"HTTP_USER_AGENT",
"IMAGE_ASPECT_RATIO_SUFFIXES",
"IMAGE_GEN_UPSTREAM_MODEL",
"MIN_SIGNATURE_LENGTH",
"MODEL_ALIAS_MAP",
"NETWORKING_TOOL_KEYWORDS",
"PROD_BASE_URL",
"REQUEST_USER_AGENT",
"RETRY_429_BASE_SECONDS",
@@ -119,12 +200,14 @@ __all__ = [
"RETRY_503_MAX_SECONDS",
"SANDBOX_BASE_URL",
"SIGNATURE_ERROR_KEYWORDS",
"STANDARD_ASPECT_RATIOS",
"THINKING_BUDGET_AUTO_CAP",
"THINKING_BUDGET_DEFAULT_INJECT",
"THINKING_MODELS_AUTO_INJECT_KEYWORDS",
"URL_UNAVAILABLE_TTL_SECONDS",
"V1INTERNAL_PATH_TEMPLATE",
"VERSION_FETCH_URL",
"WEB_SEARCH_MODEL",
"get_http_user_agent",
"parse_version_string",
"update_user_agent_version",

View File

@@ -13,6 +13,10 @@ wire format:
- JSON Schema 禁止字段清洗
- Antigravity System Instruction 注入
- Signature 错误检测
- Model alias mappingpreview → physical
- Google Search (grounding) 注入
- thoughtSignature 注入到 functionCall parts
- Image generation config 注入aspectRatio / imageSize
"""
from __future__ import annotations
@@ -22,20 +26,85 @@ from typing import Any
from src.services.provider.adapters.antigravity.constants import (
ANTIGRAVITY_SYSTEM_INSTRUCTION,
ASPECT_RATIO_TABLE,
IMAGE_ASPECT_RATIO_SUFFIXES,
IMAGE_GEN_UPSTREAM_MODEL,
MODEL_ALIAS_MAP,
NETWORKING_TOOL_KEYWORDS,
)
from src.services.provider.adapters.antigravity.constants import (
REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT,
)
from src.services.provider.adapters.antigravity.constants import (
SIGNATURE_ERROR_KEYWORDS,
STANDARD_ASPECT_RATIOS,
THINKING_BUDGET_AUTO_CAP,
THINKING_BUDGET_DEFAULT_INJECT,
THINKING_MODELS_AUTO_INJECT_KEYWORDS,
WEB_SEARCH_MODEL,
get_http_user_agent,
)
from src.services.provider.adapters.antigravity.url_availability import url_availability
from src.services.provider.request_context import get_selected_base_url
# ---------------------------------------------------------------------------
# Key normalization: snake_case → camelCase
# ---------------------------------------------------------------------------
# The Gemini normalizer (gemini.py) outputs snake_case keys that mirror protobuf
# field names (e.g. "generation_config", "function_declarations"). However, the
# Antigravity v1internal JSON protocol (like the REST Gemini API) uses camelCase
# for all field names. A mismatch causes downstream processing functions in this
# module to silently skip keys or create duplicate entries.
#
# We normalise once at the entry point of wrap_v1internal_request so that every
# subsequent helper can safely assume camelCase.
_TOP_LEVEL_KEY_RENAMES: dict[str, str] = {
"system_instruction": "systemInstruction",
"generation_config": "generationConfig",
"tool_config": "toolConfig",
# safety_settings 在 wrap_v1internal_request 入口处已 pop无需映射
}
_GENERATION_CONFIG_KEY_RENAMES: dict[str, str] = {
"max_output_tokens": "maxOutputTokens",
"stop_sequences": "stopSequences",
"top_p": "topP",
"top_k": "topK",
"thinking_config": "thinkingConfig",
"response_modalities": "responseModalities",
"response_mime_type": "responseMimeType",
}
def _normalize_to_camel_case(body: dict[str, Any]) -> None:
"""In-place normalise known Gemini snake_case keys to their camelCase form.
This must be called **before** any other processing so that all helpers
in this module can consistently use camelCase lookups.
"""
# 1. Top-level keys
for snake, camel in _TOP_LEVEL_KEY_RENAMES.items():
if snake in body and camel not in body:
body[camel] = body.pop(snake)
# 2. Inside tools: function_declarations → functionDeclarations
tools = body.get("tools")
if isinstance(tools, list):
for tool in tools:
if isinstance(tool, dict):
if "function_declarations" in tool and "functionDeclarations" not in tool:
tool["functionDeclarations"] = tool.pop("function_declarations")
# 3. Inside generationConfig: normalise sub-keys
gc = body.get("generationConfig")
if isinstance(gc, dict):
for snake, camel in _GENERATION_CONFIG_KEY_RENAMES.items():
if snake in gc and camel not in gc:
gc[camel] = gc.pop(snake)
# ---------------------------------------------------------------------------
# Request body 预处理工具函数(对齐 AM wrapper.rs / common_utils.rs
# ---------------------------------------------------------------------------
@@ -164,18 +233,26 @@ def _clean_tool_declarations(inner_request: dict[str, Any]) -> None:
for tool in tools:
if not isinstance(tool, dict):
continue
decls = tool.get("functionDeclarations")
# 支持 camelCase + snake_case
decls_key = (
"functionDeclarations"
if "functionDeclarations" in tool
else "function_declarations" if "function_declarations" in tool else None
)
if decls_key is None:
continue
decls = tool.get(decls_key)
if not isinstance(decls, list):
continue
# 1. 过滤搜索关键字函数
# 1. 过滤搜索关键字函数(对齐 NETWORKING_TOOL_KEYWORDS
decls[:] = [
d
for d in decls
if not (
isinstance(d, dict)
and isinstance(d.get("name"), str)
and d["name"] in ("web_search", "google_search")
and d["name"] in NETWORKING_TOOL_KEYWORDS
)
]
@@ -203,6 +280,296 @@ def _clean_json_schema(schema: dict[str, Any]) -> None:
clean_gemini_schema(schema)
# ---------------------------------------------------------------------------
# Model alias mapping对齐 AM common_utils.rs
# ---------------------------------------------------------------------------
def _resolve_model_alias(model: str) -> str:
"""将预览/别名模型名映射回上游物理模型名。
对齐 AM common_utils.rs resolve_request_config
- gemini-3-pro-preview → gemini-3-pro-high
- gemini-3-pro-image-preview → gemini-3-pro-image
- gemini-3-flash-preview → gemini-3-flash
- 同时剥离 -online 后缀
"""
resolved = model.rstrip()
# 剥离 -online 后缀(联网意图由 tools 检测,不依赖后缀传递到上游)
resolved = resolved.removesuffix("-online")
return MODEL_ALIAS_MAP.get(resolved, resolved)
# ---------------------------------------------------------------------------
# Google Search (Grounding) 检测与注入(对齐 AM common_utils.rs
# ---------------------------------------------------------------------------
def _detect_networking_tools(inner_request: dict[str, Any]) -> bool:
"""检测请求中是否包含联网/搜索工具声明。
对齐 AM common_utils.rs detects_networking_tool支持多种声明风格
1. Claude/Anthropic 直发风格: {"name": "web_search"} / {"type": "web_search_20250305"}
2. OpenAI 嵌套风格: {"type": "function", "function": {"name": "web_search"}}
3. Gemini 原生风格: {"functionDeclarations": [{"name": "web_search"}]}
4. Gemini googleSearch 声明: {"googleSearch": {}}
"""
tools = inner_request.get("tools")
if not isinstance(tools, list):
return False
for tool in tools:
if not isinstance(tool, dict):
continue
# 1. 直发风格: name / type 字段
name = tool.get("name")
if isinstance(name, str) and name in NETWORKING_TOOL_KEYWORDS:
return True
type_ = tool.get("type")
if isinstance(type_, str) and type_ in NETWORKING_TOOL_KEYWORDS:
return True
# 2. OpenAI 嵌套风格
func = tool.get("function")
if isinstance(func, dict):
fn_name = func.get("name")
if isinstance(fn_name, str) and fn_name in NETWORKING_TOOL_KEYWORDS:
return True
# 3. Gemini functionDeclarations 风格(支持 camelCase + snake_case
decls = tool.get("functionDeclarations")
if decls is None:
decls = tool.get("function_declarations")
if isinstance(decls, list):
for decl in decls:
if isinstance(decl, dict):
decl_name = decl.get("name")
if isinstance(decl_name, str) and decl_name in NETWORKING_TOOL_KEYWORDS:
return True
# 4. Gemini googleSearch / googleSearchRetrieval 声明
if tool.get("googleSearch") is not None or tool.get("googleSearchRetrieval") is not None:
return True
return False
def _detect_online_suffix(model: str) -> bool:
"""检测模型名是否含 -online 后缀(联网意图)。"""
return model.rstrip().endswith("-online")
def _inject_google_search_tool(inner_request: dict[str, Any]) -> None:
"""注入 googleSearch tool 到请求中。
对齐 AM common_utils.rs inject_google_search_tool
- 如果已有 functionDeclarations跳过v1internal 不支持混用)
- 先清理已有的 googleSearch / googleSearchRetrieval
- 注入 {"googleSearch": {}}
"""
tools = inner_request.setdefault("tools", [])
if not isinstance(tools, list):
inner_request["tools"] = [{"googleSearch": {}}]
return
# 如果已有 functionDeclarations不注入v1internal 不支持混用 search 和 functions
has_functions = any(
isinstance(t, dict) and ("functionDeclarations" in t or "function_declarations" in t)
for t in tools
)
if has_functions:
return
# 清理已存在的 googleSearch / googleSearchRetrieval避免重复
tools[:] = [
t
for t in tools
if not (isinstance(t, dict) and ("googleSearch" in t or "googleSearchRetrieval" in t))
]
# 注入
tools.append({"googleSearch": {}})
# ---------------------------------------------------------------------------
# thoughtSignature 注入到 functionCall parts对齐 AM wrapper.rs
# ---------------------------------------------------------------------------
def _inject_thought_signatures(inner_request: dict[str, Any], session_id: str | None) -> None:
"""为 functionCall parts 注入 thoughtSignature从 session signature cache
对齐 AM wrapper.rs当 functionCall part 缺少 thoughtSignature 时,
从 session cache 中恢复签名,确保 thinking 模型的多轮 tool call 连续性。
"""
if not session_id:
return
try:
from src.services.provider.adapters.antigravity.signature_cache import signature_cache
except Exception:
return
cached_sig = signature_cache.get_session_signature(session_id)
if not cached_sig:
return
contents = inner_request.get("contents")
if not isinstance(contents, list):
return
for content in contents:
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
for part in parts:
if not isinstance(part, dict):
continue
# 只处理有 functionCall 且缺少 thoughtSignature 的 part
if "functionCall" in part and part.get("thoughtSignature") is None:
part["thoughtSignature"] = cached_sig
# ---------------------------------------------------------------------------
# Image Generation Config对齐 AM common_utils.rs parse_image_config_with_params
# ---------------------------------------------------------------------------
def _calculate_aspect_ratio(size: str) -> str:
""""WIDTHxHEIGHT""W:H" 字符串计算宽高比。
对齐 AM common_utils.rs calculate_aspect_ratio_from_size
1. 先检查是否已是标准比例字符串 (如 "16:9")
2. 解析 WIDTHxHEIGHT 并容差匹配
3. 默认返回 "1:1"
"""
if size in STANDARD_ASPECT_RATIOS:
return size
if "x" in size:
try:
w_str, h_str = size.split("x", 1)
width, height = float(w_str), float(h_str)
if width > 0 and height > 0:
ratio = width / height
for target_ratio, label in ASPECT_RATIO_TABLE:
if abs(ratio - target_ratio) < 0.05:
return label
except (ValueError, ZeroDivisionError):
pass
return "1:1"
def _parse_image_config(
model: str,
inner_request: dict[str, Any],
) -> tuple[dict[str, Any], str]:
"""解析图像生成配置,返回 (imageConfig, clean_model_name)。
对齐 AM common_utils.rs parse_image_config_with_params + resolve_request_config
1. 从请求体中提取 OpenAI 风格 size / quality 参数(优先)
2. 回退到模型后缀解析 (如 -16x9, -4k)
3. 合并请求体中的 generationConfig.imageConfig如果存在
4. 上游模型固定为 "gemini-3-pro-image"
"""
# 提取 OpenAI 风格参数(可能由跨格式转换层注入到请求根部)
size = inner_request.pop("size", None)
quality = inner_request.pop("quality", None)
if not isinstance(size, str):
size = None
if not isinstance(quality, str):
quality = None
# --- 解析 aspectRatio ---
aspect_ratio = "1:1"
if size:
aspect_ratio = _calculate_aspect_ratio(size)
else:
lower_model = model.lower()
for suffix, ratio in IMAGE_ASPECT_RATIO_SUFFIXES.items():
if suffix in lower_model:
aspect_ratio = ratio
break
config: dict[str, Any] = {"aspectRatio": aspect_ratio}
# --- 解析 imageSize ---
if quality:
q_lower = quality.lower()
if q_lower in ("hd", "4k"):
config["imageSize"] = "4K"
elif q_lower in ("medium", "2k"):
config["imageSize"] = "2K"
elif q_lower in ("standard", "1k"):
config["imageSize"] = "1K"
else:
lower_model = model.lower()
if "-4k" in lower_model or "-hd" in lower_model:
config["imageSize"] = "4K"
elif "-2k" in lower_model:
config["imageSize"] = "2K"
# --- 合并请求体中已有的 imageConfigbody 可以覆盖除 imageSize 降级外的字段) ---
gen_config = inner_request.get("generationConfig")
if isinstance(gen_config, dict):
body_image_config = gen_config.get("imageConfig")
if isinstance(body_image_config, dict):
for key, value in body_image_config.items():
# 防止 body 降级 inferred imageSize对齐 AM 的 shield 逻辑)
if (
key == "imageSize"
and (value == "1K" or value is None)
and "imageSize" in config
):
continue
config[key] = value
return config, IMAGE_GEN_UPSTREAM_MODEL
def _apply_image_gen_config(inner_request: dict[str, Any], image_config: dict[str, Any]) -> None:
"""将 imageConfig 应用到请求的 generationConfig 中。
对齐 AM wrapper.rs 的图像生成处理:
- 移除 tools / systemInstruction
- 确保 contents 中每个 content 有 role 字段
- 清理 generationConfig 中与图像生成冲突的字段
- 注入 imageConfig
- 处理图像思维模式(默认 disabled
"""
# 移除不兼容字段_normalize_to_camel_case 已统一 key仅需 camelCase
for key in ("tools", "toolConfig", "systemInstruction"):
inner_request.pop(key, None)
# 确保 contents 中每个 content 有 role 字段
contents = inner_request.get("contents")
if isinstance(contents, list):
for content in contents:
if isinstance(content, dict) and "role" not in content:
content["role"] = "user"
# 清理 generationConfig
gen_config = inner_request.setdefault("generationConfig", {})
if not isinstance(gen_config, dict):
gen_config = {}
inner_request["generationConfig"] = gen_config
# 移除与图像生成冲突的字段_normalize_to_camel_case 已统一 key
for key in ("responseMimeType", "responseModalities"):
gen_config.pop(key, None)
# 注入 imageConfig
gen_config["imageConfig"] = image_config
# 图像思维模式:默认 disabled对齐 AM wrapper.rs image_thinking_mode
gen_config["thinkingConfig"] = {"includeThoughts": False}
def _compact_contents(inner_request: dict[str, Any]) -> None:
"""Strip invalid parts, drop empty contents, merge consecutive same-role.
@@ -345,57 +712,89 @@ def wrap_v1internal_request(
) -> dict[str, Any]:
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
处理流程(对齐 AM wrapper.rs
1. 移除 model(移到顶层)
2. 移除 safetySettingsv1internal 不支持)
3. 深度清理 [undefined] 字符串
4. Claude model tool ID 注入(图像生成模型跳过)
5. Thinking budget 处理
6. 工具声明清洗(图像生成模型跳过)
7. System Instruction 注入(图像生成模型跳过)
8. 清理空 parts 的 contents 并合并连续同角色条目
9. 注入 sessionId对齐 CLIProxyAPI
10. 构建 v1internal 信封
处理流程(对齐 AM wrapper.rs + common_utils.rs
1. 移除 model / safetySettings
2. 深度清理 [undefined] 字符串
3. 模型别名映射preview → physical
4. 联网检测 + -online 后缀检测
5. 图像生成检测 + imageConfig 解析
6. Claude model tool ID 注入
7. thoughtSignature 注入到 functionCall parts
8. Thinking budget 处理
9. 工具声明清洗
10. Google Search 注入(联网请求)
11. System Instruction 注入
12. 清理空 parts 的 contents 并合并连续同角色条目
13. 注入 sessionId
14. 构建 v1internal 信封
"""
from src.api.handlers.gemini.image_gen import is_image_gen_model
inner_request = dict(gemini_request)
inner_request.pop("model", None)
inner_request.pop("safetySettings", None)
inner_request.pop("safety_settings", None)
is_image_gen = is_image_gen_model(model)
# 0. 统一 snake_case → camelCaseGemini normalizer 输出 snake_case
# v1internal 以及本模块所有 helper 均使用 camelCase
_normalize_to_camel_case(inner_request)
# 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request)
if not is_image_gen:
# 2. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, model)
# 2. 模型别名映射(对齐 AM common_utils.rs
has_online_suffix = _detect_online_suffix(model)
final_model = _resolve_model_alias(model)
# 3. Thinking budget 处理
_process_thinking_budget(inner_request, model)
# 3. 联网检测(对齐 AM-online 后缀或客户端声明了联网工具)
has_networking = has_online_suffix or _detect_networking_tools(inner_request)
# 4. 图像生成检测 + imageConfig 解析
is_image_gen = is_image_gen_model(final_model)
if is_image_gen:
# 解析 imageConfig 并确定上游模型名
image_config, final_model = _parse_image_config(final_model, inner_request)
_apply_image_gen_config(inner_request, image_config)
request_type = "image_gen"
# 图像生成不需要联网
has_networking = False
else:
# 5. Claude tool ID 注入
_inject_claude_tool_ids_request(inner_request, final_model)
# 6. thoughtSignature 注入到 functionCall parts对齐 AM wrapper.rs
session_id = inner_request.get("sessionId")
if not isinstance(session_id, str):
# 提前生成 sessionId 用于 signature 查找
session_id = _generate_stable_session_id(inner_request)
inner_request["sessionId"] = session_id
_inject_thought_signatures(inner_request, session_id)
# 7. Thinking budget 处理(图像生成和普通请求都需要)
_process_thinking_budget(inner_request, final_model)
if not is_image_gen:
# 4. 工具声明清洗
# 8. 工具声明清洗
_clean_tool_declarations(inner_request)
# 5. System Instruction 注入
_inject_system_instruction(inner_request)
else:
# 图像生成模型:对齐 AM wrapper.rs移除不兼容字段
inner_request.pop("tools", None)
inner_request.pop("toolConfig", None)
inner_request.pop("tool_config", None)
inner_request.pop("systemInstruction", None)
inner_request.pop("system_instruction", None)
request_type = "image_gen"
# 9. Google Search 注入(对齐 AM common_utils.rs
if has_networking:
# 仅 gemini-2.5-flash 支持 googleSearch对齐 AM其他模型降级到 2.5-flash
if final_model != WEB_SEARCH_MODEL:
final_model = WEB_SEARCH_MODEL
_inject_google_search_tool(inner_request)
request_type = "web_search"
# 6. 清理空 parts 的 contents 并合并连续同角色条目
# 跨格式转换(如 Responses API reasoning 块)可能产生空 parts 的 content
# Gemini API 要求每个 content 至少有一个有效 part并且严格交替 user/model 角色。
# 10. System Instruction 注入
_inject_system_instruction(inner_request)
# 11. 清理空 parts 的 contents 并合并连续同角色条目
# 跨格式转换(如 Responses API reasoning 块)可能产生空 parts 的 content
# Gemini API 要求每个 content 至少有一个有效 part并且严格交替 user/model 角色。
_compact_contents(inner_request)
# 7. 注入 sessionId对齐 CLIProxyAPI/sub2api
# 12. 注入 sessionId如果还没有的话
if "sessionId" not in inner_request:
inner_request["sessionId"] = _generate_stable_session_id(inner_request)
@@ -403,7 +802,7 @@ def wrap_v1internal_request(
"project": project_id,
"requestId": f"agent-{uuid.uuid4()}",
"request": inner_request,
"model": model,
"model": final_model,
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
"requestType": request_type,
}