diff --git a/src/api/antigravity.py b/src/api/antigravity.py index ba32e4a19..dad8613ad 100644 --- a/src/api/antigravity.py +++ b/src/api/antigravity.py @@ -4,6 +4,7 @@ """ import asyncio +import hashlib import json import uuid from datetime import datetime, timezone @@ -71,6 +72,145 @@ def build_antigravity_headers(access_token: str, model_name: str = "") -> Dict[s return headers +def _generate_stable_session_id(request_payload: Dict[str, Any]) -> str: + contents = request_payload.get("contents") + if isinstance(contents, list): + for content in contents: + if not isinstance(content, dict) or content.get("role") != "user": + continue + parts = content.get("parts") + if not isinstance(parts, list) or not parts: + continue + first_part = parts[0] + if not isinstance(first_part, dict): + continue + text = first_part.get("text") + if isinstance(text, str) and text: + digest = hashlib.sha256(text.encode("utf-8")).digest() + value = int.from_bytes(digest[:8], "big") & 0x7FFFFFFFFFFFFFFF + return f"-{value}" + + value = uuid.uuid4().int % 9_000_000_000_000_000_000 + return f"-{value}" + + +def _ensure_antigravity_session_id(payload: Dict[str, Any], model_name: str) -> None: + if "image" in (model_name or "").lower(): + return + + request_payload = payload.get("request") + if not isinstance(request_payload, dict): + return + + if request_payload.get("sessionId"): + return + + request_payload["sessionId"] = _generate_stable_session_id(request_payload) + + +def _empty_object_schema() -> Dict[str, Any]: + return {"type": "object", "properties": {}} + + +def _prepare_antigravity_tool(tool: Any, is_claude: bool) -> Any: + if not isinstance(tool, dict): + return tool + + normalized_tool = tool.copy() + + custom_tool = normalized_tool.get("custom") + if isinstance(custom_tool, dict): + normalized_custom = custom_tool.copy() + if "input_schema" not in normalized_custom: + schema = ( + normalized_custom.pop("parametersJsonSchema", None) + or normalized_custom.pop("parameters_json_schema", None) + or normalized_custom.get("parameters") + ) + normalized_custom["input_schema"] = schema or _empty_object_schema() + normalized_tool["custom"] = normalized_custom + + declarations_key = None + declarations = None + if isinstance(normalized_tool.get("functionDeclarations"), list): + declarations_key = "functionDeclarations" + declarations = normalized_tool.get("functionDeclarations") + elif isinstance(normalized_tool.get("function_declarations"), list): + declarations_key = "function_declarations" + declarations = normalized_tool.get("function_declarations") + + if isinstance(declarations, list) and declarations_key: + normalized_declarations = [] + for declaration in declarations: + if not isinstance(declaration, dict): + normalized_declarations.append(declaration) + continue + + normalized_declaration = declaration.copy() + schema = None + if "parametersJsonSchema" in normalized_declaration: + schema = normalized_declaration.pop("parametersJsonSchema") + elif "parameters_json_schema" in normalized_declaration: + schema = normalized_declaration.pop("parameters_json_schema") + elif "parameters" in normalized_declaration: + schema = normalized_declaration.get("parameters") + + if schema not in (None, {}, []): + normalized_declaration["parameters"] = schema + elif is_claude or "parameters" not in normalized_declaration: + normalized_declaration["parameters"] = _empty_object_schema() + + normalized_declarations.append(normalized_declaration) + + normalized_tool[declarations_key] = normalized_declarations + + return normalized_tool + + +def _prepare_antigravity_payload(payload: Dict[str, Any], model_name: str) -> Dict[str, Any]: + """Match Antigravity's upstream payload quirks before the HTTP request.""" + payload["userAgent"] = "antigravity" + if "image" in (model_name or "").lower(): + payload["requestType"] = "image_gen" + payload.setdefault( + "requestId", + f"image_gen/{int(datetime.now(timezone.utc).timestamp() * 1000)}/{uuid.uuid4()}/12", + ) + else: + payload["requestType"] = "agent" + payload.setdefault("requestId", f"agent-{uuid.uuid4()}") + + request_payload = payload.get("request") + if not isinstance(request_payload, dict): + return payload + + _ensure_antigravity_session_id(payload, model_name) + request_payload.pop("safetySettings", None) + + is_claude = "claude" in (model_name or "").lower() + tools = request_payload.get("tools") + if isinstance(tools, list): + request_payload["tools"] = [ + _prepare_antigravity_tool(tool, is_claude) + for tool in tools + ] + + if is_claude: + tool_config = request_payload.get("toolConfig") + if not isinstance(tool_config, dict): + tool_config = {} + request_payload["toolConfig"] = tool_config + + function_config = tool_config.get("functionCallingConfig") + if not isinstance(function_config, dict): + function_config = {} + tool_config["functionCallingConfig"] = function_config + + function_config["mode"] = "VALIDATED" + + return payload + + def _is_retryable_status(status_code: int, disable_error_codes: List[int]) -> bool: """统一判断是否属于可重试状态码。""" return status_code in (429, 503) or status_code in disable_error_codes @@ -167,6 +307,7 @@ async def stream_request( "project": project_id, "request": body.get("request", {}), } + _prepare_antigravity_payload(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: @@ -460,6 +601,7 @@ async def non_stream_request( "project": project_id, "request": body.get("request", {}), } + _prepare_antigravity_payload(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: @@ -842,4 +984,4 @@ async def fetch_quota_info(access_token: str) -> Dict[str, Any]: return { "success": False, "error": str(e) - } \ No newline at end of file + } diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py index 9a806cdb9..5c537b11c 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -15,8 +15,10 @@ from src.converter.utils import merge_system_messages from src.converter.thoughtSignature_fix import ( - encode_tool_id_with_signature, - decode_tool_id_and_signature + decode_tool_id_and_signature, + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, + SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) DEFAULT_TEMPERATURE = 0.4 @@ -194,6 +196,29 @@ def _anthropic_debug_enabled() -> bool: return str(os.getenv("ANTHROPIC_DEBUG", "true")).strip().lower() in _DEBUG_TRUE +def _cached_content_token_count(usage_metadata: Any) -> int: + if not isinstance(usage_metadata, dict): + return 0 + return int(usage_metadata.get("cachedContentTokenCount", 0) or 0) + + +def _anthropic_usage_from_metadata(usage_metadata: Any) -> Dict[str, int]: + if not isinstance(usage_metadata, dict): + return {"input_tokens": 0, "output_tokens": 0} + + prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0) + cached_tokens = _cached_content_token_count(usage_metadata) + usage = { + "input_tokens": max(prompt_tokens_total - cached_tokens, 0), + "output_tokens": int(usage_metadata.get("candidatesTokenCount", 0) or 0), + } + + if cached_tokens > 0: + usage["cache_read_input_tokens"] = cached_tokens + + return usage + + def _is_non_whitespace_text(value: Any) -> bool: """ 判断文本是否包含"非空白"内容。 @@ -253,7 +278,7 @@ def clean_json_schema(schema: Any) -> Any: "exclusiveMaximum", "exclusiveMinimum", "oneOf", "anyOf", "allOf", "const", "additionalItems", "contains", "patternProperties", "dependencies", "propertyNames", "if", "then", "else", - "contentEncoding", "contentMediaType", + "contentEncoding", "contentMediaType", "nullable", } validation_fields = { @@ -288,8 +313,6 @@ def clean_json_schema(schema: Any) -> Any: ] cleaned[key] = non_null_types[0] if non_null_types else "string" - if has_null: - cleaned["nullable"] = True continue if key == "description" and validations: @@ -308,6 +331,25 @@ def clean_json_schema(schema: Any) -> Any: if "properties" in cleaned and "type" not in cleaned: cleaned["type"] = "object" + if ( + isinstance(schema.get("properties"), dict) + and isinstance(cleaned.get("required"), list) + ): + nullable_fields = { + name + for name, prop in schema["properties"].items() + if isinstance(prop, dict) + and isinstance(prop.get("type"), list) + and any(str(t).lower() == "null" for t in prop["type"]) + } + if nullable_fields: + cleaned["required"] = [ + item for item in cleaned["required"] + if item not in nullable_fields + ] + if not cleaned["required"]: + cleaned.pop("required", None) + return cleaned @@ -335,7 +377,7 @@ def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[L { "name": name, "description": description, - "parameters": parameters, + "parametersJsonSchema": parameters, } ] } @@ -416,43 +458,11 @@ def convert_messages_to_contents( item_type = item.get("type") if item_type == "thinking": - if not include_thinking: - continue - - thinking_text = item.get("thinking", "") - if thinking_text is None: - thinking_text = "" - - part: Dict[str, Any] = { - "text": str(thinking_text), - "thought": True, - } - - # 如果有 thoughtsignature 则添加 - thoughtsignature = item.get("thoughtSignature") - if thoughtsignature: - part["thoughtSignature"] = thoughtsignature - - parts.append(part) + # 不把客户端回传的 thinking signature 再送给 Google。 + # 这些签名很容易在中转/换号/裁剪后变成 Corrupted thought signature。 + continue elif item_type == "redacted_thinking": - if not include_thinking: - continue - - thinking_text = item.get("thinking") - if thinking_text is None: - thinking_text = item.get("data", "") - - part_dict: Dict[str, Any] = { - "text": str(thinking_text or ""), - "thought": True, - } - - # 如果有 thoughtsignature 则添加 - thoughtsignature = item.get("thoughtSignature") - if thoughtsignature: - part_dict["thoughtSignature"] = thoughtsignature - - parts.append(part_dict) + continue elif item_type == "text": text = item.get("text", "") if _is_non_whitespace_text(text): @@ -470,7 +480,7 @@ def convert_messages_to_contents( ) elif item_type == "tool_use": encoded_id = item.get("id") or "" - original_id, thoughtsignature = decode_tool_id_and_signature(encoded_id) + original_id, _ = decode_tool_id_and_signature(encoded_id) fc_part: Dict[str, Any] = { "functionCall": { @@ -480,11 +490,7 @@ def convert_messages_to_contents( } } - # 如果提取到签名则添加,否则使用占位符以满足 Gemini API 要求 - if thoughtsignature: - fc_part["thoughtSignature"] = thoughtsignature - else: - fc_part["thoughtSignature"] = "skip_thought_signature_validator" + fc_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR parts.append(fc_part) elif item_type == "tool_result": @@ -827,6 +833,8 @@ def gemini_to_anthropic_response( # 处理 thinking 块 if part.get("thought") is True: + if is_skip_thought_signature_placeholder(part): + continue thinking_text = part.get("text", "") if thinking_text is None: thinking_text = "" @@ -843,7 +851,13 @@ def gemini_to_anthropic_response( # 处理文本块 if "text" in part: - content.append({"type": "text", "text": part.get("text", "")}) + text = part.get("text", "") + if ( + is_skip_thought_signature_placeholder(part) + or is_internal_placeholder_text(text) + ): + continue + content.append({"type": "text", "text": text}) continue # 处理工具调用 @@ -851,14 +865,10 @@ def gemini_to_anthropic_response( has_tool_use = True fc = part.get("functionCall", {}) or {} original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}" - thoughtsignature = part.get("thoughtSignature") - - # 对工具调用ID进行签名编码 - encoded_id = encode_tool_id_with_signature(original_id, thoughtsignature) content.append( { "type": "tool_use", - "id": encoded_id, + "id": original_id, "name": fc.get("name") or "", "input": _remove_nulls_for_tool_input(fc.get("args", {}) or {}), } @@ -894,8 +904,7 @@ def gemini_to_anthropic_response( stop_reason = "end_turn" # 提取 token 使用情况 - input_tokens = usage_metadata.get("promptTokenCount", 0) if isinstance(usage_metadata, dict) else 0 - output_tokens = usage_metadata.get("candidatesTokenCount", 0) if isinstance(usage_metadata, dict) else 0 + usage = _anthropic_usage_from_metadata(usage_metadata) # 构建 Anthropic 响应 message_id = f"msg_{uuid.uuid4().hex}" @@ -908,10 +917,7 @@ def gemini_to_anthropic_response( "content": content, "stop_reason": stop_reason, "stop_sequence": None, - "usage": { - "input_tokens": int(input_tokens or 0), - "output_tokens": int(output_tokens or 0), - }, + "usage": usage, } @@ -948,6 +954,7 @@ async def gemini_stream_to_anthropic_stream( has_tool_use = False input_tokens = 0 output_tokens = 0 + cached_input_tokens = 0 finish_reason: Optional[str] = None def _sse_event(event: str, data: Dict[str, Any]) -> bytes: @@ -967,6 +974,12 @@ def _close_block() -> Optional[bytes]: current_block_type = None return event + def _usage_payload() -> Dict[str, int]: + usage = {"input_tokens": input_tokens, "output_tokens": output_tokens} + if cached_input_tokens > 0: + usage["cache_read_input_tokens"] = cached_input_tokens + return usage + # 处理流式数据 try: async for chunk in gemini_stream: @@ -1014,9 +1027,16 @@ def _close_block() -> Optional[bytes]: usage = response["usageMetadata"] if isinstance(usage, dict): if "promptTokenCount" in usage: - input_tokens = int(usage.get("promptTokenCount", 0) or 0) + prompt_tokens_total = int(usage.get("promptTokenCount", 0) or 0) + input_tokens = max(prompt_tokens_total - cached_input_tokens, 0) if "candidatesTokenCount" in usage: output_tokens = int(usage.get("candidatesTokenCount", 0) or 0) + if "cachedContentTokenCount" in usage: + cached_input_tokens = int(usage.get("cachedContentTokenCount", 0) or 0) + input_tokens = max( + int(usage.get("promptTokenCount", 0) or 0) - cached_input_tokens, + 0, + ) # 发送 message_start(仅一次) if not message_start_sent: @@ -1033,7 +1053,7 @@ def _close_block() -> Optional[bytes]: "content": [], "stop_reason": None, "stop_sequence": None, - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + "usage": _usage_payload(), }, }, ) @@ -1045,6 +1065,8 @@ def _close_block() -> Optional[bytes]: # 处理 thinking 块 if part.get("thought") is True: + if is_skip_thought_signature_placeholder(part): + continue thinking_text = part.get("text", "") thoughtsignature = part.get("thoughtSignature") @@ -1106,6 +1128,11 @@ def _close_block() -> Optional[bytes]: # 处理文本块 if "text" in part: + if ( + is_skip_thought_signature_placeholder(part) + or is_internal_placeholder_text(part.get("text")) + ): + continue text = part.get("text", "") if isinstance(text, str) and not text.strip(): continue @@ -1147,15 +1174,14 @@ def _close_block() -> Optional[bytes]: has_tool_use = True fc = part.get("functionCall", {}) or {} original_id = fc.get("id") or f"toolu_{uuid.uuid4().hex}" - thoughtsignature = part.get("thoughtSignature") - tool_id = encode_tool_id_with_signature(original_id, thoughtsignature) + tool_id = original_id tool_name = fc.get("name") or "" tool_args = _remove_nulls_for_tool_input(fc.get("args", {}) or {}) if _anthropic_debug_enabled(): log.info( f"[ANTHROPIC][tool_use] 处理工具调用: name={tool_name}, " - f"id={tool_id}, has_signature={thoughtsignature is not None}" + f"id={tool_id}" ) current_block_index += 1 @@ -1230,9 +1256,7 @@ def _close_block() -> Optional[bytes]: { "type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, - "usage": { - "output_tokens": output_tokens, - }, + "usage": _usage_payload(), }, ) @@ -1254,11 +1278,11 @@ def _close_block() -> Optional[bytes]: "content": [], "stop_reason": None, "stop_sequence": None, - "usage": {"input_tokens": input_tokens, "output_tokens": output_tokens}, + "usage": _usage_payload(), }, }, ) yield _sse_event( "error", {"type": "error", "error": {"type": "api_error", "message": str(e)}}, - ) \ No newline at end of file + ) diff --git a/src/converter/gemini_fix.py b/src/converter/gemini_fix.py index 26bef1566..c27ca5667 100644 --- a/src/converter/gemini_fix.py +++ b/src/converter/gemini_fix.py @@ -3,10 +3,12 @@ 提供对 Gemini API 请求体和响应的标准化处理 ──────────────────────────────────────────────────────────────── """ +import json from math import e from typing import Any, Dict, Optional from log import log +from src.converter.thoughtSignature_fix import SKIP_THOUGHT_SIGNATURE_VALIDATOR # ==================== Gemini API 配置 ==================== @@ -19,11 +21,6 @@ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_IMAGE_HATE", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_IMAGE_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_IMAGE_HARASSMENT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_IMAGE_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, - {"category": "HARM_CATEGORY_JAILBREAK", "threshold": "BLOCK_NONE"}, ] LITE_SAFETY_SETTINGS = [ @@ -34,6 +31,330 @@ {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}, ] +def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None: + """Move fragile validation details into description instead of sending them raw.""" + if not hint: + return + desc = schema.get("description") + schema["description"] = f"{desc} ({hint})" if desc else hint + + +def _resolve_schema_ref(ref: str, root_schema: Dict[str, Any]) -> Optional[Dict[str, Any]]: + if not isinstance(ref, str) or not ref.startswith("#/"): + return None + + node: Any = root_schema + for part in ref[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + + return node if isinstance(node, dict) else None + + +def _clean_parameters_json_schema( + schema: Any, + root_schema: Optional[Dict[str, Any]] = None, + visited: Optional[set] = None, +) -> Any: + """Clean a tool schema for Code Assist's parametersJsonSchema field.""" + if isinstance(schema, list): + return [_clean_parameters_json_schema(item, root_schema, visited) for item in schema] + if not isinstance(schema, dict): + return schema + + if root_schema is None: + root_schema = schema + if visited is None: + visited = set() + + schema_id = id(schema) + if schema_id in visited: + return {"type": "object", "description": "circular reference"} + visited.add(schema_id) + + ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None) + if ref_key: + resolved = _resolve_schema_ref(schema[ref_key], root_schema) + if resolved: + merged = dict(resolved) + for key in ("description", "default"): + if key in schema: + merged[key] = schema[key] + schema = merged + + if "allOf" in schema: + result: Dict[str, Any] = {} + for item in schema.get("allOf") or []: + cleaned_item = _clean_parameters_json_schema(item, root_schema, visited) + if not isinstance(cleaned_item, dict): + continue + if "properties" in cleaned_item: + result.setdefault("properties", {}).update(cleaned_item["properties"]) + if "required" in cleaned_item: + result.setdefault("required", []).extend(cleaned_item["required"]) + for key, value in cleaned_item.items(): + if key not in ("properties", "required"): + result[key] = value + for key, value in schema.items(): + if key not in ("allOf", "properties", "required"): + result[key] = value + elif key in ("properties", "required") and key not in result: + result[key] = value + else: + result = dict(schema) + + if result.get("nullable") is True: + _append_schema_hint(result, "nullable") + + if "type" in result: + type_value = result["type"] + if isinstance(type_value, list): + non_null_types = [ + str(t).lower() + for t in type_value + if isinstance(t, str) and t.lower() != "null" + ] + if non_null_types: + result["type"] = non_null_types[0] + if any(str(t).lower() == "null" for t in type_value): + _append_schema_hint(result, "nullable") + else: + result["type"] = "string" + elif isinstance(type_value, str): + lower_type = type_value.lower() + if lower_type in {"string", "number", "integer", "boolean", "array", "object"}: + result["type"] = lower_type + elif lower_type == "null": + result["type"] = "string" + _append_schema_hint(result, "nullable") + else: + result.pop("type", None) + + if "anyOf" in result or "oneOf" in result: + union_key = "anyOf" if "anyOf" in result else "oneOf" + union_items = result.get(union_key) or [] + cleaned_items = [ + item for item in ( + _clean_parameters_json_schema(item, root_schema, visited) + for item in union_items + ) + if isinstance(item, dict) + ] + enum_values = [ + item.get("const") + for item in union_items + if isinstance(item, dict) and item.get("const") not in ("", None) + ] + if enum_values and len(enum_values) == len(union_items): + result["type"] = "string" + result["enum"] = [str(v) for v in enum_values] + else: + preferred = next( + ( + item for item in cleaned_items + if item.get("type") in ("object", "array") or item.get("properties") + ), + None, + ) + if preferred is None: + preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None) + if preferred: + original_description = result.get("description") + result.update(preferred) + if original_description: + _append_schema_hint(result, original_description) + result.pop("anyOf", None) + result.pop("oneOf", None) + + if result.get("type") == "array": + items = result.get("items") + if isinstance(items, list): + if items: + result["items"] = _clean_parameters_json_schema(items[0], root_schema, visited) + _append_schema_hint(result, "tuple schema simplified") + else: + result.pop("items", None) + elif isinstance(items, dict): + result["items"] = _clean_parameters_json_schema(items, root_schema, visited) + + validation_keys = { + "default", "minLength", "maxLength", "minimum", "maximum", + "minItems", "maxItems", "pattern", "format", "uniqueItems", + } + for key in list(result.keys()): + if key in validation_keys: + value = result.pop(key) + if value not in (None, "", {}, []): + _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}") + + unsupported_keys = { + "title", "$schema", "$id", "$ref", "ref", "strict", "nullable", + "exclusiveMaximum", "exclusiveMinimum", "additionalProperties", + "allOf", "anyOf", "oneOf", "$defs", "definitions", "example", + "examples", "readOnly", "writeOnly", "const", "additionalItems", + "contains", "patternProperties", "dependencies", "propertyNames", + "if", "then", "else", "contentEncoding", "contentMediaType", + } + for key in list(result.keys()): + if key in unsupported_keys or key.startswith("x-"): + del result[key] + + nullable_props = set() + if isinstance(result.get("properties"), dict): + cleaned_props = {} + for prop_name, prop_schema in result["properties"].items(): + if isinstance(prop_schema, dict): + prop_type = prop_schema.get("type") + if ( + prop_schema.get("nullable") is True + or ( + isinstance(prop_type, list) + and any(str(t).lower() == "null" for t in prop_type) + ) + ): + nullable_props.add(prop_name) + cleaned_props[prop_name] = _clean_parameters_json_schema(prop_schema, root_schema, visited) + result["properties"] = cleaned_props + + if "properties" in result and "type" not in result: + result["type"] = "object" + + if isinstance(result.get("required"), list): + prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None + required = [] + for item in result["required"]: + if not isinstance(item, str): + continue + if prop_names is not None and item not in prop_names: + continue + if item in nullable_props: + continue + if item not in required: + required.append(item) + if required: + result["required"] = required + else: + result.pop("required", None) + + return result + + +def _normalize_tools_for_internal_api(tools: Any) -> Any: + if not isinstance(tools, list): + return tools + + normalized_tools = [] + for tool in tools: + if not isinstance(tool, dict): + normalized_tools.append(tool) + continue + + normalized_tool = tool.copy() + declarations = normalized_tool.get("functionDeclarations") + if declarations is None: + declarations = normalized_tool.get("function_declarations") + if isinstance(declarations, list): + normalized_declarations = [] + for declaration in declarations: + if not isinstance(declaration, dict): + normalized_declarations.append(declaration) + continue + + normalized_declaration = declaration.copy() + if "parametersJsonSchema" in normalized_declaration: + schema = normalized_declaration["parametersJsonSchema"] + elif "parameters_json_schema" in normalized_declaration: + schema = normalized_declaration.pop("parameters_json_schema", None) + else: + schema = normalized_declaration.pop("parameters", None) + + normalized_declaration.pop("parameters", None) + normalized_declaration.pop("parameters_json_schema", None) + if schema not in (None, {}, []): + normalized_declaration["parametersJsonSchema"] = _clean_parameters_json_schema(schema) + else: + normalized_declaration.pop("parametersJsonSchema", None) + + normalized_declarations.append(normalized_declaration) + + normalized_tool.pop("function_declarations", None) + normalized_tool["functionDeclarations"] = normalized_declarations + + normalized_tools.append(normalized_tool) + + return normalized_tools + + +def _ensure_empty_tool_schema_for_claude(tools: Any, model_name: str) -> Any: + if "claude" not in (model_name or "").lower() or not isinstance(tools, list): + return tools + + normalized_tools = [] + for tool in tools: + if not isinstance(tool, dict): + normalized_tools.append(tool) + continue + + normalized_tool = tool.copy() + custom_tool = normalized_tool.get("custom") + if isinstance(custom_tool, dict) and "input_schema" not in custom_tool: + normalized_custom = custom_tool.copy() + normalized_custom["input_schema"] = {"type": "object", "properties": {}} + normalized_tool["custom"] = normalized_custom + + declarations = normalized_tool.get("functionDeclarations") + if declarations is None: + declarations = normalized_tool.get("function_declarations") + if isinstance(declarations, list): + normalized_declarations = [] + for declaration in declarations: + if not isinstance(declaration, dict): + normalized_declarations.append(declaration) + continue + normalized_declaration = declaration.copy() + if ( + "parametersJsonSchema" not in normalized_declaration + and "parameters_json_schema" in normalized_declaration + ): + normalized_declaration["parametersJsonSchema"] = normalized_declaration.pop("parameters_json_schema") + + if "parametersJsonSchema" not in normalized_declaration: + normalized_declaration["parametersJsonSchema"] = { + "type": "object", + "properties": {}, + } + normalized_declarations.append(normalized_declaration) + normalized_tool.pop("function_declarations", None) + normalized_tool["functionDeclarations"] = normalized_declarations + + normalized_tools.append(normalized_tool) + + return normalized_tools + + +def _should_skip_thought_signature(part: Dict[str, Any], model_name: str) -> bool: + if "claude" in (model_name or "").lower(): + return False + + return ( + "functionCall" in part + or "function_call" in part + or part.get("thought") is True + or "thoughtSignature" in part + or "thought_signature" in part + ) + + +def _normalize_part_thought_signature(part: Dict[str, Any], model_name: str) -> Dict[str, Any]: + normalized = part.copy() + if _should_skip_thought_signature(normalized, model_name): + normalized.pop("thought_signature", None) + normalized["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR + return normalized + + SUPPORTED_ASPECT_RATIOS = [ (1, 1), (2, 3), (3, 2), (3, 4), (4, 3), (4, 5), (5, 4), (9, 16), (16, 9), (21, 9), @@ -464,6 +785,10 @@ async def normalize_gemini_request( # ========== 公共处理 ========== # 1. 安全设置覆盖 + if "tools" in result: + result["tools"] = _normalize_tools_for_internal_api(result.get("tools")) + result["tools"] = _ensure_empty_tool_schema_for_claude(result.get("tools"), model) + if "lite" in model.lower(): result["safetySettings"] = LITE_SAFETY_SETTINGS else: @@ -495,7 +820,7 @@ async def normalize_gemini_request( ) if has_valid_value: - part = part.copy() + part = _normalize_part_thought_signature(part, model) # 修复 text 字段:确保是字符串而不是列表 if "text" in part: @@ -531,4 +856,4 @@ async def normalize_gemini_request( if generation_config: result["generationConfig"] = generation_config - return result \ No newline at end of file + return result diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index ac586e654..42f347fd3 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -11,14 +11,16 @@ from pypinyin import Style, lazy_pinyin from src.converter.thoughtSignature_fix import ( - encode_tool_id_with_signature, decode_tool_id_and_signature, + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, + SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) from src.converter.utils import merge_system_messages from log import log -def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Dict[str, int]: +def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]: """ 将Gemini的usageMetadata转换为OpenAI格式的usage字段 @@ -31,12 +33,33 @@ def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Dict[str, int]: if not usage_metadata: return None - return { - "prompt_tokens": usage_metadata.get("promptTokenCount", 0), - "completion_tokens": usage_metadata.get("candidatesTokenCount", 0), - "total_tokens": usage_metadata.get("totalTokenCount", 0), + prompt_tokens_total = int(usage_metadata.get("promptTokenCount", 0) or 0) + cached_tokens = int(usage_metadata.get("cachedContentTokenCount", 0) or 0) + prompt_tokens = max(prompt_tokens_total - cached_tokens, 0) + completion_tokens = int(usage_metadata.get("candidatesTokenCount", 0) or 0) + raw_total_tokens = int( + usage_metadata.get( + "totalTokenCount", + prompt_tokens_total + completion_tokens + int(usage_metadata.get("thoughtsTokenCount", 0) or 0), + ) + or 0 + ) + + usage = { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": max(raw_total_tokens - cached_tokens, prompt_tokens + completion_tokens), } + if cached_tokens > 0: + usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} + + reasoning_tokens = int(usage_metadata.get("thoughtsTokenCount", 0) or 0) + if reasoning_tokens > 0: + usage["completion_tokens_details"] = {"reasoning_tokens": reasoning_tokens} + + return usage + def _build_message_with_reasoning(role: str, content: str, reasoning_content: str) -> dict: """构建包含可选推理内容的消息对象""" @@ -541,6 +564,192 @@ def _clean_schema_for_gemini(schema: Any, root_schema: Optional[Dict[str, Any]] return result +def _append_schema_hint(schema: Dict[str, Any], hint: str) -> None: + """把不兼容的校验信息挪到 description 里,避免上游直接拒收。""" + if not hint: + return + desc = schema.get("description") + schema["description"] = f"{desc} ({hint})" if desc else hint + + +def _clean_schema_for_parameters_json_schema( + schema: Any, + root_schema: Optional[Dict[str, Any]] = None, + visited: Optional[set] = None, +) -> Any: + """ + 清理 JSON Schema,供 Gemini CLI 内部接口的 parametersJsonSchema 使用。 + + Code Assist 的内部接口更接近官方 Gemini CLI:工具参数应放在 + parametersJsonSchema 中,并保持 JSON Schema 的小写 type。 + """ + if not isinstance(schema, dict): + return schema + + if root_schema is None: + root_schema = schema + if visited is None: + visited = set() + + schema_id = id(schema) + if schema_id in visited: + return {"type": "object", "description": "(circular reference)"} + visited.add(schema_id) + + result: Dict[str, Any] + + ref_key = "$ref" if "$ref" in schema else ("ref" if "ref" in schema else None) + if ref_key: + resolved = _resolve_ref(schema[ref_key], root_schema) + if resolved: + import copy + result = copy.deepcopy(resolved) + for key in ("description", "default"): + if key in schema: + result[key] = schema[key] + schema = result + + if "allOf" in schema: + result = {} + for item in schema.get("allOf") or []: + cleaned_item = _clean_schema_for_parameters_json_schema(item, root_schema, visited) + if not isinstance(cleaned_item, dict): + continue + if "properties" in cleaned_item: + result.setdefault("properties", {}).update(cleaned_item["properties"]) + if "required" in cleaned_item: + result.setdefault("required", []).extend(cleaned_item["required"]) + for key, value in cleaned_item.items(): + if key not in ("properties", "required"): + result[key] = value + for key, value in schema.items(): + if key not in ("allOf", "properties", "required"): + result[key] = value + elif key in ("properties", "required") and key not in result: + result[key] = value + else: + result = dict(schema) + + if "type" in result: + type_value = result["type"] + if isinstance(type_value, list): + non_null_types = [t for t in type_value if isinstance(t, str) and t.lower() != "null"] + if non_null_types: + result["type"] = non_null_types[0] + if "null" in [str(t).lower() for t in type_value]: + _append_schema_hint(result, "nullable") + else: + result["type"] = "string" + elif isinstance(type_value, str): + lower_type = type_value.lower() + if lower_type in {"string", "number", "integer", "boolean", "array", "object", "null"}: + result["type"] = "string" if lower_type == "null" else lower_type + else: + del result["type"] + + if "anyOf" in result or "oneOf" in result: + union_key = "anyOf" if "anyOf" in result else "oneOf" + union_items = result.get(union_key) or [] + cleaned_items = [ + item for item in ( + _clean_schema_for_parameters_json_schema(item, root_schema, visited) + for item in union_items + ) + if isinstance(item, dict) + ] + enum_values = [ + item.get("const") + for item in union_items + if isinstance(item, dict) and item.get("const") not in ("", None) + ] + if enum_values and len(enum_values) == len(union_items): + result["type"] = "string" + result["enum"] = [str(v) for v in enum_values] + else: + preferred = next( + ( + item for item in cleaned_items + if item.get("type") in ("object", "array") or item.get("properties") + ), + None, + ) + if preferred is None: + preferred = next((item for item in cleaned_items if item.get("type") or item.get("enum")), None) + if preferred: + existing_description = result.get("description") + result.update(preferred) + if existing_description: + _append_schema_hint(result, existing_description) + result.pop("anyOf", None) + result.pop("oneOf", None) + + if result.get("type") == "array": + items = result.get("items") + if isinstance(items, list): + if items: + result["items"] = _clean_schema_for_parameters_json_schema(items[0], root_schema, visited) + _append_schema_hint(result, "tuple schema simplified") + else: + result.pop("items", None) + elif isinstance(items, dict): + result["items"] = _clean_schema_for_parameters_json_schema(items, root_schema, visited) + + validation_keys = { + "default", "minLength", "maxLength", "minimum", "maximum", + "minItems", "maxItems", "pattern", "format", "uniqueItems", + } + for key in list(result.keys()): + if key in validation_keys: + value = result.pop(key) + if value not in (None, "", {}, []): + _append_schema_hint(result, f"{key}: {json.dumps(value, ensure_ascii=False)}") + + unsupported_keys = { + "title", "$schema", "$id", "$ref", "ref", "strict", + "exclusiveMaximum", "exclusiveMinimum", "additionalProperties", + "allOf", "anyOf", "oneOf", "$defs", "definitions", "example", + "examples", "readOnly", "writeOnly", "const", "additionalItems", + "contains", "patternProperties", "dependencies", "propertyNames", + "if", "then", "else", "contentEncoding", "contentMediaType", + } + for key in list(result.keys()): + if key in unsupported_keys or key.startswith("x-"): + del result[key] + + nullable_props = set() + if "properties" in result and isinstance(result["properties"], dict): + cleaned_props = {} + for prop_name, prop_schema in result["properties"].items(): + if isinstance(prop_schema, dict): + prop_type = prop_schema.get("type") + if isinstance(prop_type, list) and any(str(t).lower() == "null" for t in prop_type): + nullable_props.add(prop_name) + cleaned_props[prop_name] = _clean_schema_for_parameters_json_schema(prop_schema, root_schema, visited) + result["properties"] = cleaned_props + + if "properties" in result and "type" not in result: + result["type"] = "object" + + if "required" in result and isinstance(result["required"], list): + prop_names = set(result.get("properties", {}).keys()) if isinstance(result.get("properties"), dict) else None + required = [] + for item in result["required"]: + if not isinstance(item, str): + continue + if prop_names is not None and item not in prop_names: + continue + if item in nullable_props: + continue + if item not in required: + required.append(item) + if required: + result["required"] = required + else: + result.pop("required", None) + + return result + + def fix_tool_call_args_types( args: Dict[str, Any], parameters_schema: Dict[str, Any] @@ -666,16 +875,20 @@ def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[ "description": function.get("description", ""), } - # 添加参数(如果有)- 根据模型选择不同的清理函数 + # 添加参数(如果有)- Gemini CLI 内部接口更适合 parametersJsonSchema if "parameters" in function: if is_claude_model: - cleaned_params = _clean_schema_for_claude(function["parameters"]) + cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"]) log.debug(f"[OPENAI2GEMINI] Using Claude schema cleaning for tool: {normalized_name}") else: - cleaned_params = _clean_schema_for_gemini(function["parameters"]) + cleaned_params = _clean_schema_for_parameters_json_schema(function["parameters"]) if cleaned_params: - declaration["parameters"] = cleaned_params + declaration["parametersJsonSchema"] = cleaned_params + elif is_claude_model: + declaration["parametersJsonSchema"] = {"type": "object", "properties": {}} + elif is_claude_model: + declaration["parametersJsonSchema"] = {"type": "object", "properties": {}} function_declarations.append(declaration) @@ -856,17 +1069,13 @@ def extract_tool_calls_from_parts( function_call = part["functionCall"] # 获取原始ID或生成新ID original_id = function_call.get("id") or f"call_{uuid.uuid4().hex[:24]}" - # 将thoughtSignature编码到ID中以便往返保留 - signature = part.get("thoughtSignature") - encoded_id = encode_tool_id_with_signature(original_id, signature) - # 获取参数并转换类型 args = function_call.get("args", {}) # 将字符串类型的值转回原始类型 args = _reverse_transform_args(args) tool_call = { - "id": encoded_id, + "id": original_id, "type": "function", "function": { "name": function_call.get("name", "nameless_function"), @@ -880,7 +1089,13 @@ def extract_tool_calls_from_parts( # 提取文本内容(排除 thinking tokens) elif "text" in part and not part.get("thought", False): - text_content += part["text"] + text = part["text"] + if ( + is_skip_thought_signature_placeholder(part) + or is_internal_placeholder_text(text) + ): + continue + text_content += text return tool_calls, text_content @@ -922,6 +1137,37 @@ def extract_images_from_content(content: Any) -> Dict[str, Any]: return result + +def _sanitize_openai_roundtrip_signatures(contents: List[Dict[str, Any]]) -> None: + """ + OpenAI-compatible clients may round-trip Gemini thinking signatures through + fields we do not fully control. Keep tool calls on the safe bypass sentinel + and drop signatures everywhere else to avoid Corrupted thought signature. + """ + for content in contents: + if not isinstance(content, dict): + continue + parts = content.get("parts") + if not isinstance(parts, list): + continue + + for index, part in enumerate(parts): + if not isinstance(part, dict): + continue + + sanitized_part = part.copy() + if "thoughtSignature" in sanitized_part: + if "functionCall" in sanitized_part or "function_call" in sanitized_part: + sanitized_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR + else: + sanitized_part.pop("thoughtSignature", None) + + if sanitized_part.get("thought") is True and not sanitized_part.get("thoughtSignature"): + sanitized_part.pop("thought", None) + + parts[index] = sanitized_part + + async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Dict[str, Any]: """ 将 OpenAI 格式请求体转换为 Gemini 格式请求体 @@ -960,8 +1206,8 @@ async def convert_openai_to_gemini_request(openai_request: Dict[str, Any]) -> Di func_name = tc.get("function", {}).get("name") or "" if encoded_id: # 解码获取原始ID和签名 - original_id, signature = decode_tool_id_and_signature(encoded_id) - tool_call_mapping[encoded_id] = (func_name, original_id, signature) + original_id, _ = decode_tool_id_and_signature(encoded_id) + tool_call_mapping[encoded_id] = (func_name, original_id, None) # 构建工具名称到参数 schema 的映射(用于类型修正) tool_schemas = {} @@ -1085,11 +1331,9 @@ def flush_pending_tool_parts(): } } - # 如果有thoughtSignature则添加,否则使用占位符以满足 Gemini API 要求 - if signature: - function_call_part["thoughtSignature"] = signature - else: - function_call_part["thoughtSignature"] = "skip_thought_signature_validator" + # OpenAI/RooCode 中转可能会改写或截断 tool_call_id,真实签名回传后容易触发 + # Corrupted thought signature。工具调用使用官方跳过校验占位符更稳。 + function_call_part["thoughtSignature"] = SKIP_THOUGHT_SIGNATURE_VALIDATOR parts.append(function_call_part) except (json.JSONDecodeError, KeyError) as e: @@ -1128,6 +1372,7 @@ def flush_pending_tool_parts(): # 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾) flush_pending_tool_parts() + _sanitize_openai_roundtrip_signatures(contents) # 构建生成配置 generation_config = {} @@ -1302,7 +1547,11 @@ def convert_gemini_to_openai_response( content_parts.append(f"\n```{label}\n{output}\n```\n") # 处理 thought(思考内容) - elif part.get("thought", False) and "text" in part: + elif ( + part.get("thought", False) + and "text" in part + and not is_skip_thought_signature_placeholder(part) + ): reasoning_parts.append(part["text"]) # 处理普通文本(非思考内容) @@ -1472,7 +1721,11 @@ def convert_gemini_to_openai_stream( content_parts.append(f"\n```{label}\n{output}\n```\n") # 处理 thought(思考内容) - elif part.get("thought", False) and "text" in part: + elif ( + part.get("thought", False) + and "text" in part + and not is_skip_thought_signature_placeholder(part) + ): reasoning_parts.append(part["text"]) # 处理普通文本(非思考内容) diff --git a/src/converter/thoughtSignature_fix.py b/src/converter/thoughtSignature_fix.py index caff9bfc4..cbeebdd2a 100644 --- a/src/converter/thoughtSignature_fix.py +++ b/src/converter/thoughtSignature_fix.py @@ -5,11 +5,30 @@ 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。 """ -from typing import Optional, Tuple +from typing import Any, Mapping, Optional, Tuple # 在工具调用ID中嵌入thoughtSignature的分隔符 # 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段 THOUGHT_SIGNATURE_SEPARATOR = "__thought__" +SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" +SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT = "..." + + +def is_internal_placeholder_text(text: Any) -> bool: + if not isinstance(text, str): + return False + return text.strip() in (SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT, "…") + + +def is_skip_thought_signature_placeholder(part: Mapping[str, Any]) -> bool: + """Return True for the internal placeholder that should not reach clients.""" + if not isinstance(part, Mapping): + return False + if part.get("thoughtSignature") != SKIP_THOUGHT_SIGNATURE_VALIDATOR: + return False + if "functionCall" in part or "function_call" in part or "functionResponse" in part: + return False + return is_internal_placeholder_text(part.get("text")) def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str: diff --git a/src/converter/utils.py b/src/converter/utils.py index 2d313adc0..81d511f64 100644 --- a/src/converter/utils.py +++ b/src/converter/utils.py @@ -1,5 +1,10 @@ from typing import Any, Dict +from src.converter.thoughtSignature_fix import ( + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, +) + def extract_content_and_reasoning(parts: list) -> tuple: """从Gemini响应部件中提取内容和推理内容 @@ -24,8 +29,13 @@ def extract_content_and_reasoning(parts: list) -> tuple: images = [] for part in parts: + if is_skip_thought_signature_placeholder(part): + continue + # 提取文本内容 text = part.get("text", "") + if is_internal_placeholder_text(text): + continue if text: if part.get("thought", False): reasoning_content += text @@ -234,4 +244,4 @@ async def merge_system_messages(request_body: Dict[str, Any]) -> Dict[str, Any]: # 更新messages列表(移除已处理的system消息) result["messages"] = remaining_messages - return result \ No newline at end of file + return result diff --git a/src/models.py b/src/models.py index 1e947a258..5eeec1e38 100644 --- a/src/models.py +++ b/src/models.py @@ -95,7 +95,7 @@ class OpenAIChatCompletionResponse(BaseModel): created: int model: str choices: List[OpenAIChatCompletionChoice] - usage: Optional[Dict[str, int]] = None + usage: Optional[Dict[str, Any]] = None system_fingerprint: Optional[str] = None @@ -196,6 +196,8 @@ class GeminiUsageMetadata(BaseModel): promptTokenCount: Optional[int] = None candidatesTokenCount: Optional[int] = None totalTokenCount: Optional[int] = None + cachedContentTokenCount: Optional[int] = None + thoughtsTokenCount: Optional[int] = None class GeminiResponse(BaseModel): @@ -252,6 +254,8 @@ class Config: class ClaudeUsage(BaseModel): input_tokens: int output_tokens: int + cache_creation_input_tokens: Optional[int] = None + cache_read_input_tokens: Optional[int] = None class ClaudeResponse(BaseModel):