Files
Aether/_deprecated_py_src/services/provider/adapters/antigravity/envelope.py
T

1119 lines
42 KiB
Python
Raw Normal View History

"""Antigravity v1internal request/response envelope helpers.
Antigravity reuses the `gemini:chat` endpoint signature but wraps the actual
wire format:
- Request: V1InternalRequest (top-level metadata + nested GeminiRequest)
- Response: V1InternalResponse (top-level responseId + nested GeminiResponse)
对齐 Antigravity-Manager wrapper.rs 的处理逻辑:
- Claude model tool ID 注入(request + response)
- Thinking budget capping (Auto cap 24576)
- [undefined] 字符串深度清理
- parametersJsonSchema → parameters 重命名
- 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
import uuid
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_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,
)
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,
)
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)
# 4. Inside contents → parts: function_call/function_response → camelCase
contents = body.get("contents")
if isinstance(contents, list):
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
if "function_call" in part and "functionCall" not in part:
part["functionCall"] = part.pop("function_call")
if "function_response" in part and "functionResponse" not in part:
part["functionResponse"] = part.pop("function_response")
# ---------------------------------------------------------------------------
# Request body 预处理工具函数(对齐 AM wrapper.rs / common_utils.rs)
# ---------------------------------------------------------------------------
def _deep_clean_undefined(obj: Any) -> None:
"""In-place 递归清理 '[undefined]' 字符串值。
Cherry Studio 等客户端会在请求中注入 '[undefined]' 字符串,
可能导致上游 API 解析错误。
"""
if isinstance(obj, dict):
keys_to_remove = [k for k, v in obj.items() if v == "[undefined]"]
for k in keys_to_remove:
del obj[k]
for v in obj.values():
if isinstance(v, (dict, list)):
_deep_clean_undefined(v)
elif isinstance(obj, list):
i = 0
while i < len(obj):
if obj[i] == "[undefined]":
obj.pop(i)
else:
if isinstance(obj[i], (dict, list)):
_deep_clean_undefined(obj[i])
i += 1
def _inject_claude_tool_ids_request(inner_request: dict[str, Any], model: str) -> None:
"""为 Claude 模型注入 functionCall/functionResponse 的 id 字段。
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
contents = inner_request.get("contents")
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
parts = content.get("parts")
if not isinstance(parts, list):
continue
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 not (isinstance(fc_id, str) and fc_id):
# 生成新 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
fr = part.get("functionResponse") or part.get("function_response")
if isinstance(fr, dict) and not (isinstance(fr.get("id"), str) and fr.get("id")):
name = fr.get("name", "")
if not isinstance(name, str) or not name:
name = "unknown"
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 + maxOutputTokens 约束。
对齐 AM wrapper.rs + CLIProxyAPI 混合方案:
- 对 flash/pro/thinking 模型处理 thinkingConfig
- 自动注入 thinkingConfig(对已知需要 thinking 的模型)
- Auto Cap:budget 超过 24576 时裁剪
- Claude 要求:maxOutputTokens 必须 > thinkingBudget
- 优先增加 maxOutputTokens;超限时才减少 budget
"""
lower_model = model.lower()
if not any(kw in lower_model for kw in ("flash", "pro", "thinking")):
return
# 确保 generationConfig 存在
gen_config = inner_request.setdefault("generationConfig", {})
if not isinstance(gen_config, dict):
return
# 自动注入 thinkingConfig(对已知需要 thinking 的模型)
if gen_config.get("thinkingConfig") is None:
should_inject = any(kw in lower_model for kw in THINKING_MODELS_AUTO_INJECT_KEYWORDS)
if should_inject:
gen_config["thinkingConfig"] = {
"includeThoughts": True,
"thinkingBudget": THINKING_BUDGET_DEFAULT_INJECT,
}
# Auto Cap
thinking_config = gen_config.get("thinkingConfig")
if not isinstance(thinking_config, dict):
return
budget = thinking_config.get("thinkingBudget")
if not isinstance(budget, int) or budget <= 0:
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:
"""清洗工具声明:重命名字段 + 移除禁止的 Schema 字段 + 过滤搜索声明。
对齐 AM wrapper.rs:
- parametersJsonSchema → parameters(Gemini CLI 兼容)
- 移除 Gemini 不支持的 Schema 字段(multipleOf 等)
- 过滤 web_search / google_search 工具声明
"""
tools = inner_request.get("tools")
if not isinstance(tools, list):
return
for tool in tools:
if not isinstance(tool, dict):
continue
# 支持 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. 过滤搜索关键字函数(对齐 NETWORKING_TOOL_KEYWORDS)
decls[:] = [
d
for d in decls
if not (
isinstance(d, dict)
and isinstance(d.get("name"), str)
and d["name"] in NETWORKING_TOOL_KEYWORDS
)
]
# 2. 重命名 + 清洗
for decl in decls:
if not isinstance(decl, dict):
continue
# parametersJsonSchema → parameters
if "parametersJsonSchema" in decl:
params = decl.pop("parametersJsonSchema")
if isinstance(params, dict):
_clean_json_schema(params)
decl["parameters"] = params
elif "parameters" in decl:
params = decl["parameters"]
if isinstance(params, dict):
_clean_json_schema(params)
def _clean_json_schema(schema: dict[str, Any]) -> None:
"""递归移除 Gemini 不支持的 JSON Schema 字段。"""
from src.core.api_format.schema_utils import clean_gemini_schema
clean_gemini_schema(schema)
# ---------------------------------------------------------------------------
# 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:
"""Inject thought signatures into functionCall parts.
Prefer tool-specific signatures (tool_use_id -> thoughtSignature), then fall back
to a session-level signature when available.
"""
try:
from src.core.api_format.conversion.thinking_cache import signature_cache
except Exception:
return
session_sig: str | None = None
if session_id:
session_sig = signature_cache.get_session_signature(session_id)
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
fc = part.get("functionCall") or part.get("function_call")
if not isinstance(fc, dict):
continue
# Normalize existing signature aliases to thoughtSignature (if present).
sig_val: str | None = None
ts = part.get("thoughtSignature")
if isinstance(ts, str) and ts.strip():
sig_val = ts.strip()
if ts != sig_val:
part["thoughtSignature"] = sig_val
else:
tss = part.get("thought_signature")
if isinstance(tss, str) and tss.strip():
sig_val = tss.strip()
part["thoughtSignature"] = sig_val
part.pop("thought_signature", None)
else:
legacy = part.get("signature")
if isinstance(legacy, str) and legacy.strip():
sig_val = legacy.strip()
part["thoughtSignature"] = sig_val
part.pop("signature", None)
if sig_val:
continue
tool_id = fc.get("id")
tool_sig: str | None = None
if isinstance(tool_id, str) and tool_id:
tool_sig = signature_cache.get_tool_signature(tool_id)
chosen = tool_sig or session_sig
if not chosen:
continue
part["thoughtSignature"] = chosen
# Avoid sending duplicate aliases once we inject.
part.pop("thought_signature", None)
if part.get("signature") in (None, ""):
part.pop("signature", None)
# ---------------------------------------------------------------------------
# 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.
Delegates to :func:`compact_gemini_contents` from the Gemini normalizer to
avoid duplicating the validation logic.
This function modifies *inner_request* in-place.
"""
from src.core.api_format.conversion.normalizers.gemini import compact_gemini_contents
contents = inner_request.get("contents")
if not isinstance(contents, list):
return
result = compact_gemini_contents(contents)
inner_request["contents"] = result
def _inject_system_instruction(inner_request: dict[str, Any]) -> None:
"""注入 Antigravity 身份系统指令。
对齐 AM wrapper.rs:
- 如果已有 systemInstruction:在前面插入(避免重复)
- 如果没有:创建新的
- 补全 role: user(Gemini API 要求)
注意:Gemini API 使用 protobuf oneof,`system_instruction`(snake_case)和
`systemInstruction`(camelCase)是同一个字段,只能设置其中一个。
需要先统一到 camelCase 再处理。
"""
# 统一 snake_case 到 camelCase(避免 oneof 冲突)
if "system_instruction" in inner_request:
snake_value = inner_request.pop("system_instruction")
# 仅当 camelCase 不存在时才迁移
if "systemInstruction" not in inner_request:
inner_request["systemInstruction"] = snake_value
system_instruction = inner_request.get("systemInstruction")
if isinstance(system_instruction, dict):
# 补全 role
if "role" not in system_instruction:
system_instruction["role"] = "user"
parts = system_instruction.get("parts")
if isinstance(parts, list):
# 检查是否已包含 Antigravity 身份(避免重复注入)
has_antigravity = False
if parts and isinstance(parts[0], dict):
text = parts[0].get("text", "")
if isinstance(text, str) and "You are Antigravity" in text:
has_antigravity = True
if not has_antigravity:
parts.insert(0, {"text": ANTIGRAVITY_SYSTEM_INSTRUCTION})
else:
# 没有 systemInstruction,创建新的
inner_request["systemInstruction"] = {
"role": "user",
"parts": [{"text": ANTIGRAVITY_SYSTEM_INSTRUCTION}],
}
# ---------------------------------------------------------------------------
# Response 后处理工具函数
# ---------------------------------------------------------------------------
def _inject_claude_tool_ids_response(response: dict[str, Any], model: str) -> None:
"""为 Claude 模型的响应注入 functionCall 的 id 字段。
对齐 AM wrapper.rs inject_ids_to_response:
让下游客户端(如 OpenCode/Vercel AI SDK)能感知 tool call ID,
并在下一轮对话中原样带回。
"""
if "claude" not in model.lower():
return
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for candidate in candidates:
if not isinstance(candidate, dict):
continue
content = candidate.get("content")
if not isinstance(content, dict):
continue
parts = content.get("parts")
if not isinstance(parts, list):
continue
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) and not (isinstance(fc.get("id"), str) and fc.get("id")):
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
# ---------------------------------------------------------------------------
# Core wrap / unwrap 函数
# ---------------------------------------------------------------------------
def _generate_stable_session_id(inner_request: dict[str, Any]) -> str:
"""生成稳定的 sessionId(对齐 CLIProxyAPI generateStableSessionID)。
基于请求内容的哈希生成确定性 sessionId,相同内容的请求会得到相同的 sessionId,
有助于上游维持会话上下文。
"""
import hashlib
import json
try:
# 使用 contents 字段生成稳定哈希(与 CLIProxyAPI 对齐)
contents = inner_request.get("contents")
if contents:
raw = json.dumps(contents, sort_keys=True, separators=(",", ":"))
else:
raw = json.dumps(inner_request, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
return f"session-{digest}"
except Exception:
return f"session-{uuid.uuid4().hex[:32]}"
def wrap_v1internal_request(
gemini_request: dict[str, Any],
*,
project_id: str,
model: str,
request_type: str = "agent",
) -> dict[str, Any]:
"""Wrap a GeminiRequest into Antigravity V1InternalRequest.
处理流程(对齐 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.core.video_utils 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)
# 0. 统一 snake_case → camelCase(Gemini normalizer 输出 snake_case,但
# v1internal 以及本模块所有 helper 均使用 camelCase)
_normalize_to_camel_case(inner_request)
# 1. 深度清理 [undefined]
_deep_clean_undefined(inner_request)
# 2. 剥离 -online 后缀(联网意图由 tools 检测,不依赖后缀传递到上游)
has_online_suffix = _detect_online_suffix(model)
final_model = model.rstrip().removesuffix("-online")
# 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:
# 8. 工具声明清洗
_clean_tool_declarations(inner_request)
# 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"
# 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)
# 12. 注入 sessionId(如果还没有的话)
if "sessionId" not in inner_request:
inner_request["sessionId"] = _generate_stable_session_id(inner_request)
return {
"project": project_id,
"requestId": f"agent-{uuid.uuid4()}",
"request": inner_request,
"model": final_model,
"userAgent": ANTIGRAVITY_REQUEST_USER_AGENT,
"requestType": request_type,
}
def unwrap_v1internal_response(response: dict[str, Any]) -> dict[str, Any]:
"""Unwrap Antigravity V1InternalResponse into a GeminiResponse-like dict."""
inner = response.get("response")
if isinstance(inner, dict):
unwrapped = dict(inner)
resp_id = response.get("responseId")
if resp_id is not None:
unwrapped["_v1internal_response_id"] = resp_id
return unwrapped
return response
def cache_thought_signatures(model: str, response: dict[str, Any]) -> None:
"""Best-effort cache for Antigravity thought signatures.
同时缓存到 legacy (text) 层和 tool (Layer 1) 层。
"""
try:
from src.core.api_format.conversion.thinking_cache import signature_cache
except Exception:
return
try:
candidates = response.get("candidates")
if not isinstance(candidates, list):
return
for cand in candidates:
if not isinstance(cand, dict):
continue
content = cand.get("content")
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
# 缓存 thinking signature(legacy text 层)
text = part.get("text")
sig = (
part.get("thoughtSignature")
or part.get("thought_signature")
or part.get("signature")
)
if isinstance(text, str) and text and isinstance(sig, str) and sig:
signature_cache.cache(model, text, sig)
# 缓存 tool call signature(Layer 1)
fc = part.get("functionCall") or part.get("function_call")
if isinstance(fc, dict) and isinstance(sig, str) and sig:
tool_id = fc.get("id")
if isinstance(tool_id, str) and tool_id:
signature_cache.cache_tool_signature(tool_id, sig)
except Exception:
# Never fail request path due to cache issues.
return
def is_signature_error(status_code: int, error_body: str) -> bool:
"""检测 400 错误是否为 thinking signature 相关错误。
对齐 AM handlers/common.rs:用于判断是否应该移除 thinking 配置后重试。
"""
if status_code != 400:
return False
return any(kw in error_body for kw in SIGNATURE_ERROR_KEYWORDS)
# ---------------------------------------------------------------------------
# Envelope 类(Provider Hook 接口)
# ---------------------------------------------------------------------------
class AntigravityV1InternalEnvelope:
"""Provider envelope hooks for Antigravity v1internal wrapper."""
name = "antigravity:v1internal"
def extra_headers(self) -> dict[str, str] | None:
from src.services.provider.adapters.antigravity.constants import (
get_v1internal_extra_headers,
)
return get_v1internal_extra_headers()
def wrap_request(
self,
request_body: dict[str, Any],
*,
model: str,
url_model: str | None,
decrypted_auth_config: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
from src.core.logger import logger as _envelope_logger
project_id = (decrypted_auth_config or {}).get("project_id")
if not isinstance(project_id, str) or not project_id:
from src.core.exceptions import ProviderNotAvailableException
raise ProviderNotAvailableException(
"Antigravity OAuth 配置缺少 project_id,请重新授权",
provider_name="antigravity",
upstream_response="missing auth_config.project_id",
)
wrapped = wrap_v1internal_request(
request_body,
project_id=project_id,
model=model,
)
# Debug: 打印 v1internal 请求的关键字段(不打印完整 body 避免日志过大)
_envelope_logger.debug(
"[Antigravity Envelope] model={}, project_id={}, requestType={}, "
"userAgent={}, has_sessionId={}, has_systemInstruction={}, "
"has_contents={}, has_generationConfig={}",
wrapped.get("model"),
str(wrapped.get("project", ""))[:8] + "...",
wrapped.get("requestType"),
wrapped.get("userAgent"),
"sessionId" in (wrapped.get("request") or {}),
"systemInstruction" in (wrapped.get("request") or {}),
"contents" in (wrapped.get("request") or {}),
"generationConfig" in (wrapped.get("request") or {}),
)
# Antigravity's model lives in the request body, not the URL path.
return wrapped, None
def unwrap_response(self, data: Any) -> Any:
if isinstance(data, dict):
return unwrap_v1internal_response(data)
return data
def postprocess_unwrapped_response(self, *, model: str, data: Any) -> None:
if isinstance(data, dict):
# Claude model: 注入 tool call ID(对齐 AM wrapper.rs inject_ids_to_response)
_inject_claude_tool_ids_response(data, model)
cache_thought_signatures(model, data)
def capture_selected_base_url(self) -> str | None:
return get_selected_base_url()
def on_http_status(self, *, base_url: str | None, status_code: int) -> None:
if not base_url:
return
if 200 <= status_code < 300:
url_availability.mark_success(base_url)
elif status_code in (404, 408, 429) or 500 <= status_code < 600:
url_availability.mark_unavailable(base_url)
def on_connection_error(self, *, base_url: str | None, exc: Exception) -> None: # noqa: ARG002
if not base_url:
return
url_availability.mark_unavailable(base_url)
def force_stream_rewrite(self) -> bool:
# Streaming must be rewritten even when endpoint signature matches, because
# Antigravity wraps chunks in v1internal envelope.
return True
antigravity_v1internal_envelope = AntigravityV1InternalEnvelope()
__all__ = [
"AntigravityV1InternalEnvelope",
"antigravity_v1internal_envelope",
"cache_thought_signatures",
"is_signature_error",
"unwrap_v1internal_response",
"wrap_v1internal_request",
]