mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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",
|
||||
|
||||
@@ -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.rs:thinking 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:
|
||||
# 对齐 AM:thinking_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},
|
||||
}
|
||||
)
|
||||
# 对齐 AM:signature_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:
|
||||
|
||||
@@ -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 block(Claude 协议要求顺序 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
|
||||
|
||||
# functionCall(stream 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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 对齐 AM:ThinkingBlock → 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_stop(thinking + 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_type(thinking vs text)
|
||||
block_type = ss.get(f"block_type_{event.block_index}")
|
||||
if block_type == ContentType.THINKING.value:
|
||||
# 对齐 AM:thinking 内容输出为 reasoning_content
|
||||
out.append(base_chunk({"reasoning_content": event.text_delta, "content": None}))
|
||||
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"}
|
||||
|
||||
# 对齐 AM:ThinkingBlock → 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 ""
|
||||
|
||||
|
||||
@@ -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 block:Responses 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 delta:Responses 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":
|
||||
|
||||
@@ -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 #952:MCP 工具可能在任意嵌套层级定义 $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__ = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,6 +13,10 @@ wire format:
|
||||
- JSON Schema 禁止字段清洗
|
||||
- Antigravity System Instruction 注入
|
||||
- Signature 错误检测
|
||||
- Model alias mapping(preview → 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"
|
||||
|
||||
# --- 合并请求体中已有的 imageConfig(body 可以覆盖除 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. 移除 safetySettings(v1internal 不支持)
|
||||
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 → camelCase(Gemini 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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user