Merge pull request #163 from AAEE86/master

fix: Antigravity 跨格式转换链 tool call ID 完整传递
This commit is contained in:
fawney19
2026-02-10 10:27:43 +08:00
committed by GitHub
4 changed files with 186 additions and 47 deletions

View File

@@ -106,7 +106,8 @@ class ToolResultBlock:
"""工具结果内容块"""
type: ContentType = field(default=ContentType.TOOL_RESULT, init=False)
tool_use_id: str = "" # 对应的 ToolUseBlock.tool_id
tool_use_id: str = "" # 对应的 ToolUseBlock.tool_id(用于 Claude/Antigravity id 字段)
tool_name: str | None = None # 工具名称(用于 Gemini function_response.name 字段)
# 工具输出可能是纯文本,也可能是结构化 JSONGemini functionResponse 等)
output: Any = None
content_text: str | None = None

View File

@@ -480,14 +480,14 @@ class GeminiNormalizer(FormatNormalizer):
parts.append({"text": b.text})
continue
if isinstance(b, ToolUseBlock):
parts.append(
{
"functionCall": {
"name": b.tool_name,
"args": b.tool_input or {},
}
}
)
fc: dict[str, Any] = {
"name": b.tool_name,
"args": b.tool_input or {},
}
# 保留 tool_id 用于 Claude/Antigravity 兼容
if b.tool_id:
fc["id"] = b.tool_id
parts.append({"functionCall": fc})
continue
if isinstance(b, ImageBlock):
if b.data and b.media_type:
@@ -672,6 +672,10 @@ class GeminiNormalizer(FormatNormalizer):
if not isinstance(args, dict):
args = {}
# 优先使用 Antigravity/Claude 注入的 id
fc_id = func_call.get("id")
tool_id = fc_id if isinstance(fc_id, str) and fc_id else None
block_index = int(ss.get("next_block_index") or 2)
ss["next_block_index"] = block_index + 1
@@ -679,7 +683,7 @@ class GeminiNormalizer(FormatNormalizer):
ContentBlockStartEvent(
block_index=block_index,
block_type=ContentType.TOOL_USE,
tool_id=None,
tool_id=tool_id,
tool_name=name or None,
)
)
@@ -687,7 +691,7 @@ class GeminiNormalizer(FormatNormalizer):
events.append(
ToolCallDeltaEvent(
block_index=block_index,
tool_id="",
tool_id=tool_id or "",
input_delta=json.dumps(args, ensure_ascii=False),
)
)
@@ -801,6 +805,7 @@ class GeminiNormalizer(FormatNormalizer):
tool_blocks[int(event.block_index)] = {
"name": event.tool_name or "",
"json": "",
"id": event.tool_id or "", # 保留 tool_id 用于 Claude/Antigravity 兼容
}
return out
@@ -842,6 +847,7 @@ class GeminiNormalizer(FormatNormalizer):
name = str(entry.get("name") or "")
raw_json = str(entry.get("json") or "")
tool_id = str(entry.get("id") or "")
args: dict[str, Any] = {}
if raw_json:
try:
@@ -851,7 +857,11 @@ class GeminiNormalizer(FormatNormalizer):
except json.JSONDecodeError:
args = {}
out.append(base_chunk([{"functionCall": {"name": name, "args": args}}]))
fc: dict[str, Any] = {"name": name, "args": args}
# 保留 tool_id 用于 Claude/Antigravity 兼容
if tool_id:
fc["id"] = tool_id
out.append(base_chunk([{"functionCall": fc}]))
return out
if isinstance(event, MessageStopEvent):
@@ -1286,9 +1296,15 @@ class GeminiNormalizer(FormatNormalizer):
args = func_call.get("args")
if not isinstance(args, dict):
args = {}
# 优先使用 Antigravity/Claude 注入的 id回退到生成的 id
fc_id = func_call.get("id")
if isinstance(fc_id, str) and fc_id:
tool_id = fc_id
else:
tool_id = f"toolu_{name}" if name else "toolu_0"
blocks.append(
ToolUseBlock(
tool_id=f"toolu_{name}" if name else "toolu_0",
tool_id=tool_id,
tool_name=name,
tool_input=args,
extra={"gemini": part},
@@ -1314,9 +1330,17 @@ class GeminiNormalizer(FormatNormalizer):
else:
output = response
# 优先使用 Antigravity/Claude 注入的 id回退到 name
fr_id = func_resp.get("id")
if isinstance(fr_id, str) and fr_id:
tool_use_id = fr_id
else:
tool_use_id = name
blocks.append(
ToolResultBlock(
tool_use_id=name,
tool_use_id=tool_use_id,
tool_name=name or None, # 保留工具名称用于输出
output=output,
content_text=content_text,
is_error=False,
@@ -1372,11 +1396,16 @@ class GeminiNormalizer(FormatNormalizer):
continue
if isinstance(b, ToolUseBlock) and role == "model":
parts.append({"function_call": {"name": b.tool_name, "args": b.tool_input or {}}})
fc: dict[str, Any] = {"name": b.tool_name, "args": b.tool_input or {}}
# 保留 tool_id 用于 Claude/Antigravity 兼容
if b.tool_id:
fc["id"] = b.tool_id
parts.append({"function_call": fc})
continue
if isinstance(b, ToolResultBlock) and role == "user":
# 兼容旧转换器:name 直接使用 tool_use_idresponse 固定包一层 result
# name 使用 tool_name工具名称id 使用 tool_use_idcall id
# 如果没有 tool_name回退到 tool_use_id 以保持向后兼容
value: Any
if b.content_text is not None:
value = b.content_text
@@ -1385,14 +1414,16 @@ class GeminiNormalizer(FormatNormalizer):
else:
value = b.output
parts.append(
{
"function_response": {
"name": b.tool_use_id,
"response": {"result": value},
}
}
)
# 优先使用 tool_name 作为 name回退到 tool_use_id
name = b.tool_name if b.tool_name else b.tool_use_id
fr: dict[str, Any] = {
"name": name,
"response": {"result": value},
}
# 保留 tool_use_id 作为 id 用于 Claude/Antigravity 兼容
if b.tool_use_id:
fr["id"] = b.tool_use_id
parts.append({"function_response": fr})
continue
return {"role": role, "parts": parts}

View File

@@ -74,6 +74,11 @@ MIN_SIGNATURE_LENGTH = 50 # 与 Antigravity-Manager 对齐
# ============== Thinking Budget ==============
THINKING_BUDGET_AUTO_CAP = 24576
THINKING_BUDGET_DEFAULT_INJECT = 24576 # 对齐 AM wrapper.rs (was 16000)
# 给输出留的空间(对齐 Antigravity-Manager普通模型 8192图像模型 2048
OUTPUT_OVERHEAD = 8192
OUTPUT_OVERHEAD_IMAGE = 2048
# 模型最大输出限制(防止超限)
MODEL_MAX_OUTPUT_LIMIT = 65536
# 包含这些关键字的模型会自动注入 thinkingConfig如果缺失
THINKING_MODELS_AUTO_INJECT_KEYWORDS = ("thinking", "gemini-2.0-pro", "gemini-3-pro")
@@ -191,7 +196,10 @@ __all__ = [
"IMAGE_GEN_UPSTREAM_MODEL",
"MIN_SIGNATURE_LENGTH",
"MODEL_ALIAS_MAP",
"MODEL_MAX_OUTPUT_LIMIT",
"NETWORKING_TOOL_KEYWORDS",
"OUTPUT_OVERHEAD",
"OUTPUT_OVERHEAD_IMAGE",
"PROD_BASE_URL",
"REQUEST_USER_AGENT",
"RETRY_429_BASE_SECONDS",

View File

@@ -30,7 +30,10 @@ from src.services.provider.adapters.antigravity.constants import (
IMAGE_ASPECT_RATIO_SUFFIXES,
IMAGE_GEN_UPSTREAM_MODEL,
MODEL_ALIAS_MAP,
MODEL_MAX_OUTPUT_LIMIT,
NETWORKING_TOOL_KEYWORDS,
OUTPUT_OVERHEAD,
OUTPUT_OVERHEAD_IMAGE,
)
from src.services.provider.adapters.antigravity.constants import (
REQUEST_USER_AGENT as ANTIGRAVITY_REQUEST_USER_AGENT,
@@ -139,6 +142,10 @@ def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -
Google v1internal 在目标模型为 Claude 时要求 functionCall 带有 id 字段,
但标准 Gemini 协议不包含此字段。对齐 AM wrapper.rs #1522。
算法:
1. 第一遍:扫描所有 functionCall为没有 id 的生成 id并记录 (name, id) 队列
2. 第二遍:扫描所有 functionResponse从对应 name 的队列中取出 id 使用
"""
if "claude" not in model.lower():
return
@@ -147,6 +154,14 @@ def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -
if not isinstance(contents, list):
return
# 第一遍:收集所有 functionCall 的 ID按 name 分组,保持顺序)
# name -> [id1, id2, ...] 每个调用的 ID 按出现顺序排列
call_ids_by_name: dict[str, list[str]] = {}
# 所有 call ID 按原始出现顺序(用于 fallback 匹配)
all_call_ids_ordered: list[str] = []
# 用于生成新 ID 的计数器(全局,确保唯一性)
name_counters: dict[str, int] = {}
for content in contents:
if not isinstance(content, dict):
continue
@@ -154,41 +169,98 @@ def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -
if not isinstance(parts, list):
continue
# 每条消息维护独立的计数器(确保 Call 和 Response 生成匹配的 ID
name_counters: dict[str, int] = {}
for part in parts:
if not isinstance(part, dict):
continue
fc = part.get("functionCall") or part.get("function_call")
if isinstance(fc, dict):
name = fc.get("name", "")
if not isinstance(name, str) or not name:
name = "unknown"
fc_id = fc.get("id")
if fc_id is None:
# 生成新 ID
count = name_counters.get(name, 0)
fc_id = f"call_{name}_{count}"
fc["id"] = fc_id
name_counters[name] = count + 1
# 记录这个 call 的 ID供后续 response 使用
if name not in call_ids_by_name:
call_ids_by_name[name] = []
call_ids_by_name[name].append(fc_id)
# 同时按原始出现顺序记录(用于 fallback
all_call_ids_ordered.append(fc_id)
# 第二遍:为 functionResponse 分配匹配的 ID
# 使用索引追踪每个 name 已消费到第几个 ID
response_index_by_name: dict[str, int] = {}
# 已使用的 call ID 集合
used_call_ids: set[str] = set()
# fallback 用的有序索引
fallback_index = 0
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
# 1. functionCallAssistant 请求调用工具)
fc = part.get("functionCall")
if isinstance(fc, dict) and fc.get("id") is None:
name = fc.get("name", "unknown")
if not isinstance(name, str):
name = "unknown"
count = name_counters.get(name, 0)
fc["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
# 2. functionResponseUser 回复工具结果)
fr = part.get("functionResponse")
fr = part.get("functionResponse") or part.get("function_response")
if isinstance(fr, dict) and fr.get("id") is None:
name = fr.get("name", "unknown")
if not isinstance(name, str):
name = fr.get("name", "")
if not isinstance(name, str) or not name:
name = "unknown"
count = name_counters.get(name, 0)
fr["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
assigned_id: str | None = None
# 优先:从对应 name 的 call ID 队列中取出下一个 ID
call_ids = call_ids_by_name.get(name, [])
idx = response_index_by_name.get(name, 0)
while idx < len(call_ids):
cid = call_ids[idx]
idx += 1
if cid not in used_call_ids:
assigned_id = cid
used_call_ids.add(cid)
break
response_index_by_name[name] = idx
# Fallback如果 name 是 unknown原本为空尝试使用下一个未使用的 call ID按原始出现顺序
if assigned_id is None and name == "unknown":
while fallback_index < len(all_call_ids_ordered):
cid = all_call_ids_ordered[fallback_index]
fallback_index += 1
if cid not in used_call_ids:
assigned_id = cid
used_call_ids.add(cid)
break
if assigned_id is not None:
fr["id"] = assigned_id
else:
# 没有匹配的 call ID异常情况生成一个新 ID
count = name_counters.get(name, 0)
fr["id"] = f"call_{name}_{count}"
name_counters[name] = count + 1
def _process_thinking_budget(inner_request: dict[str, Any], model: str) -> None:
"""处理 Thinking Budget自动注入 + Auto Cap。
"""处理 Thinking Budget自动注入 + Auto Cap + maxOutputTokens 约束
对齐 AM wrapper.rs
对齐 AM wrapper.rs + CLIProxyAPI 混合方案
- 对 flash/pro/thinking 模型处理 thinkingConfig
- 自动注入 thinkingConfig对已知需要 thinking 的模型)
- Auto Capbudget 超过 24576 时裁剪
- Claude 要求maxOutputTokens 必须 > thinkingBudget
- 优先增加 maxOutputTokens超限时才减少 budget
"""
lower_model = model.lower()
if not any(kw in lower_model for kw in ("flash", "pro", "thinking")):
@@ -214,8 +286,35 @@ def _process_thinking_budget(inner_request: dict[str, Any], model: str) -> None:
return
budget = thinking_config.get("thinkingBudget")
if isinstance(budget, int) and budget > THINKING_BUDGET_AUTO_CAP:
thinking_config["thinkingBudget"] = THINKING_BUDGET_AUTO_CAP
if not isinstance(budget, int):
return
# 1. 限制 budget 上限
if budget > THINKING_BUDGET_AUTO_CAP:
budget = THINKING_BUDGET_AUTO_CAP
thinking_config["thinkingBudget"] = budget
# 2. 确保 maxOutputTokens > thinkingBudget
current_max = gen_config.get("maxOutputTokens")
# 根据模型类型选择增量(对齐 Antigravity-Manager
overhead = OUTPUT_OVERHEAD_IMAGE if "-image" in lower_model else OUTPUT_OVERHEAD
# 计算理想的 maxOutputTokens
ideal_max = budget + overhead
# 3. 确保不超过模型限制
if ideal_max > MODEL_MAX_OUTPUT_LIMIT:
# 超限时:减少 budget 而不是超限(对齐 CLIProxyAPI 策略)
ideal_max = MODEL_MAX_OUTPUT_LIMIT
# 确保 budget < ideal_max保留 overhead 空间给输出
max_budget = ideal_max - overhead
if budget > max_budget:
thinking_config["thinkingBudget"] = max_budget
# 4. 应用修正
if current_max is None or (isinstance(current_max, int) and current_max <= budget):
gen_config["maxOutputTokens"] = ideal_max
def _clean_tool_declarations(inner_request: dict[str, Any]) -> None: