From 7bc2989df0f1b6e56613b080464a9aadae39b1b6 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 02:48:00 +0800 Subject: [PATCH 01/25] Fix tool schema and cache usage handling --- src/api/antigravity.py | 41 ++++- src/converter/anthropic2gemini.py | 74 +++++++-- src/converter/gemini_fix.py | 261 +++++++++++++++++++++++++++++- src/converter/openai2gemini.py | 208 +++++++++++++++++++++++- src/models.py | 6 +- 5 files changed, 560 insertions(+), 30 deletions(-) diff --git a/src/api/antigravity.py b/src/api/antigravity.py index ba32e4a19..3de599886 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,42 @@ 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 _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 +204,7 @@ async def stream_request( "project": project_id, "request": body.get("request", {}), } + _ensure_antigravity_session_id(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: @@ -460,6 +498,7 @@ async def non_stream_request( "project": project_id, "request": body.get("request", {}), } + _ensure_antigravity_session_id(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: @@ -842,4 +881,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..df2cdc0e2 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -194,6 +194,28 @@ 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} + + usage = { + "input_tokens": int(usage_metadata.get("promptTokenCount", 0) or 0), + "output_tokens": int(usage_metadata.get("candidatesTokenCount", 0) or 0), + } + + cached_tokens = _cached_content_token_count(usage_metadata) + if cached_tokens > 0: + usage["cache_read_input_tokens"] = cached_tokens + + return usage + + def _is_non_whitespace_text(value: Any) -> bool: """ 判断文本是否包含"非空白"内容。 @@ -253,7 +275,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 +310,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 +328,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 +374,7 @@ def convert_tools(anthropic_tools: Optional[List[Dict[str, Any]]]) -> Optional[L { "name": name, "description": description, - "parameters": parameters, + "parametersJsonSchema": parameters, } ] } @@ -894,8 +933,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 +946,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 +983,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 +1003,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: @@ -1017,6 +1059,8 @@ def _close_block() -> Optional[bytes]: input_tokens = int(usage.get("promptTokenCount", 0) or 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) # 发送 message_start(仅一次) if not message_start_sent: @@ -1033,7 +1077,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(), }, }, ) @@ -1230,9 +1274,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 +1296,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..a181168c8 100644 --- a/src/converter/gemini_fix.py +++ b/src/converter/gemini_fix.py @@ -3,6 +3,7 @@ 提供对 Gemini API 请求体和响应的标准化处理 ──────────────────────────────────────────────────────────────── """ +import json from math import e from typing import Any, Dict, Optional @@ -19,11 +20,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 +30,256 @@ {"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 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"] + else: + schema = normalized_declaration.pop("parameters", None) + + normalized_declaration.pop("parameters", 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["functionDeclarations"] = normalized_declarations + + normalized_tools.append(normalized_tool) + + return normalized_tools + + 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 +710,9 @@ async def normalize_gemini_request( # ========== 公共处理 ========== # 1. 安全设置覆盖 + if "tools" in result: + result["tools"] = _normalize_tools_for_internal_api(result.get("tools")) + if "lite" in model.lower(): result["safetySettings"] = LITE_SAFETY_SETTINGS else: @@ -531,4 +780,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..b6cb2eddd 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -18,7 +18,7 @@ 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 +31,22 @@ def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Dict[str, int]: if not usage_metadata: return None - return { + usage = { "prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount", 0), "total_tokens": usage_metadata.get("totalTokenCount", 0), } + cached_tokens = int(usage_metadata.get("cachedContentTokenCount", 0) or 0) + 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 +551,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 +862,16 @@ 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 function_declarations.append(declaration) 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): From 69fb15736f116fbe100167e2a86d6ebfd609acd8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 18:48:26 +0000 Subject: [PATCH 02/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index 685f1cd1d..a3e6b5457 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=c2d54b5702ff6be716ef5d6865b0efdc99e26a15 -short_hash=c2d54b5 -message=Merge pull request #374 from kushaldotdev/fix/gemini-schema-ref-validation -date=2026-05-10 09:03:19 +0800 +full_hash=7bc2989df0f1b6e56613b080464a9aadae39b1b6 +short_hash=7bc2989 +message=Fix tool schema and cache usage handling +date=2026-05-20 02:48:00 +0800 From c951fe6ae7f9fb00c761c6538cb52e0e4fd97748 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 03:03:45 +0800 Subject: [PATCH 03/25] Avoid round-tripping thought signatures in OpenAI tools --- src/converter/openai2gemini.py | 20 +++++++------------- src/converter/thoughtSignature_fix.py | 1 + 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index b6cb2eddd..38b180fb0 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -11,8 +11,8 @@ from pypinyin import Style, lazy_pinyin from src.converter.thoughtSignature_fix import ( - encode_tool_id_with_signature, decode_tool_id_and_signature, + SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) from src.converter.utils import merge_system_messages @@ -1052,17 +1052,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"), @@ -1156,8 +1152,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 = {} @@ -1281,11 +1277,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: diff --git a/src/converter/thoughtSignature_fix.py b/src/converter/thoughtSignature_fix.py index caff9bfc4..e42ef2086 100644 --- a/src/converter/thoughtSignature_fix.py +++ b/src/converter/thoughtSignature_fix.py @@ -10,6 +10,7 @@ # 在工具调用ID中嵌入thoughtSignature的分隔符 # 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段 THOUGHT_SIGNATURE_SEPARATOR = "__thought__" +SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator" def encode_tool_id_with_signature(tool_id: str, signature: Optional[str]) -> str: From e2de90e1f616be10bd23b4d11a478fc8ce04b64d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 19:05:12 +0000 Subject: [PATCH 04/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index a3e6b5457..d01152d06 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=7bc2989df0f1b6e56613b080464a9aadae39b1b6 -short_hash=7bc2989 -message=Fix tool schema and cache usage handling -date=2026-05-20 02:48:00 +0800 +full_hash=c951fe6ae7f9fb00c761c6538cb52e0e4fd97748 +short_hash=c951fe6 +message=Avoid round-tripping thought signatures in OpenAI tools +date=2026-05-20 03:04:41 +0800 From 9cd629af6bc2b18e325e548aef7d170601498629 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 03:08:34 +0800 Subject: [PATCH 05/25] Sanitize OpenAI thought signatures before upstream requests --- src/converter/openai2gemini.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index 38b180fb0..778b6775d 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -1114,6 +1114,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 格式请求体 @@ -1318,6 +1349,7 @@ def flush_pending_tool_parts(): # 循环结束后,flush 剩余的 tool parts(如果消息列表以 tool 消息结尾) flush_pending_tool_parts() + _sanitize_openai_roundtrip_signatures(contents) # 构建生成配置 generation_config = {} From 4dd1bbd099a2ec7384c86a7ff7fdce8b1b3e56f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 19:08:59 +0000 Subject: [PATCH 06/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index d01152d06..c998d04bf 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=c951fe6ae7f9fb00c761c6538cb52e0e4fd97748 -short_hash=c951fe6 -message=Avoid round-tripping thought signatures in OpenAI tools -date=2026-05-20 03:04:41 +0800 +full_hash=9cd629af6bc2b18e325e548aef7d170601498629 +short_hash=9cd629a +message=Sanitize OpenAI thought signatures before upstream requests +date=2026-05-20 03:08:34 +0800 From ed382998443aa8deb9b5b9087e240ea550d99956 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 03:10:31 +0800 Subject: [PATCH 07/25] Sanitize Anthropic thought signatures before upstream requests --- src/converter/anthropic2gemini.py | 63 ++++++------------------------- 1 file changed, 11 insertions(+), 52 deletions(-) diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py index df2cdc0e2..156730553 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -15,8 +15,8 @@ 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, + SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) DEFAULT_TEMPERATURE = 0.4 @@ -455,43 +455,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): @@ -509,7 +477,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": { @@ -519,11 +487,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": @@ -890,14 +854,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 {}), } @@ -1191,15 +1151,14 @@ def _usage_payload() -> Dict[str, int]: 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 From 8e1263eb0b95d67df37862ccec15b9761d73267d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 19:10:54 +0000 Subject: [PATCH 08/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index c998d04bf..cb07a478c 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=9cd629af6bc2b18e325e548aef7d170601498629 -short_hash=9cd629a -message=Sanitize OpenAI thought signatures before upstream requests -date=2026-05-20 03:08:34 +0800 +full_hash=ed382998443aa8deb9b5b9087e240ea550d99956 +short_hash=ed38299 +message=Sanitize Anthropic thought signatures before upstream requests +date=2026-05-20 03:10:31 +0800 From 14fb6e9317b75f593e5bba49edc9ae5ae81c93c8 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 03:12:40 +0800 Subject: [PATCH 09/25] Normalize Gemini thought signatures before upstream requests --- src/converter/gemini_fix.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/converter/gemini_fix.py b/src/converter/gemini_fix.py index a181168c8..76115ec42 100644 --- a/src/converter/gemini_fix.py +++ b/src/converter/gemini_fix.py @@ -8,6 +8,7 @@ from typing import Any, Dict, Optional from log import log +from src.converter.thoughtSignature_fix import SKIP_THOUGHT_SIGNATURE_VALIDATOR # ==================== Gemini API 配置 ==================== @@ -280,6 +281,27 @@ def _normalize_tools_for_internal_api(tools: Any) -> Any: 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), @@ -744,7 +766,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: From d2b5a30d3276ee845d61bac2d6c3a0aa20588c87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 19:13:05 +0000 Subject: [PATCH 10/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index cb07a478c..912d0e4c5 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=ed382998443aa8deb9b5b9087e240ea550d99956 -short_hash=ed38299 -message=Sanitize Anthropic thought signatures before upstream requests -date=2026-05-20 03:10:31 +0800 +full_hash=14fb6e9317b75f593e5bba49edc9ae5ae81c93c8 +short_hash=14fb6e9 +message=Normalize Gemini thought signatures before upstream requests +date=2026-05-20 03:12:40 +0800 From 80e13a620a4c7c2b946c9a3c0202abef238eb5f1 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 06:03:18 +0800 Subject: [PATCH 11/25] Add cache-friendly session routing --- src/api/antigravity.py | 19 ++- src/api/geminicli.py | 25 ++-- src/credential_manager.py | 118 +++++++++++++++++- src/router/antigravity/anthropic.py | 6 +- src/router/antigravity/gemini.py | 17 ++- src/router/antigravity/openai.py | 8 +- src/router/geminicli/anthropic.py | 6 +- src/router/geminicli/gemini.py | 17 ++- src/router/geminicli/openai.py | 8 +- src/session_affinity.py | 181 ++++++++++++++++++++++++++++ 10 files changed, 375 insertions(+), 30 deletions(-) create mode 100644 src/session_affinity.py diff --git a/src/api/antigravity.py b/src/api/antigravity.py index 3de599886..e946a4bec 100644 --- a/src/api/antigravity.py +++ b/src/api/antigravity.py @@ -21,6 +21,7 @@ from src.credential_manager import credential_manager from src.httpx_client import stream_post_async, post_async from src.models import Model, model_to_dict +from src.session_affinity import extract_cache_session_key from src.utils import ANTIGRAVITY_USER_AGENT # 导入共同的基础功能 @@ -159,10 +160,11 @@ async def stream_request( Response对象(错误时)或 bytes流/str流(成功时) """ model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key ) if not cred_result: @@ -228,7 +230,8 @@ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: async def refresh_credential_fast(): nonlocal current_file, access_token, auth_headers, project_id, final_payload cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -294,7 +297,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -455,10 +459,11 @@ async def non_stream_request( log.debug("[ANTIGRAVITY] 使用传统非流式模式") model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key ) if not cred_result: @@ -522,7 +527,8 @@ def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: async def refresh_credential_fast(): nonlocal current_file, access_token, auth_headers, project_id, final_payload cred_result = await credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -626,7 +632,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="antigravity", model_name=model_name + mode="antigravity", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) diff --git a/src/api/geminicli.py b/src/api/geminicli.py index 4f5f4b36a..90dbb9f7d 100644 --- a/src/api/geminicli.py +++ b/src/api/geminicli.py @@ -23,6 +23,7 @@ from src.credential_manager import credential_manager from src.httpx_client import stream_post_async, post_async +from src.session_affinity import extract_cache_session_key # 导入共同的基础功能 from src.api.utils import ( @@ -134,10 +135,11 @@ async def stream_request( """ # 获取有效凭证 model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key ) if not cred_result: @@ -184,7 +186,8 @@ async def stream_request( async def refresh_credential_fast(): nonlocal current_file, credential_data, auth_headers, final_payload cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -253,7 +256,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -303,7 +307,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -425,10 +430,11 @@ async def non_stream_request( """ # 获取有效凭证 model_name = body.get("model", "") + session_key = extract_cache_session_key(body, headers) # 1. 获取有效凭证 cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key ) if not cred_result: @@ -473,7 +479,8 @@ async def non_stream_request( async def refresh_credential_fast(): nonlocal current_file, credential_data, auth_headers, final_payload cred_result = await credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, session_key=session_key, + exclude_credential=current_file ) if not cred_result: return None @@ -566,7 +573,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) @@ -631,7 +639,8 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: if next_cred_task is None and attempt < max_retries: next_cred_task = asyncio.create_task( credential_manager.get_valid_credential( - mode="geminicli", model_name=model_name + mode="geminicli", model_name=model_name, + session_key=session_key, exclude_credential=current_file ) ) diff --git a/src/credential_manager.py b/src/credential_manager.py index b99feb775..adb1dedaf 100644 --- a/src/credential_manager.py +++ b/src/credential_manager.py @@ -3,6 +3,7 @@ """ import asyncio +import os import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple @@ -12,6 +13,10 @@ from src.google_oauth_api import Credentials from src.storage_adapter import get_storage_adapter + +SESSION_BINDING_TTL_SECONDS = 3 * 60 * 60 + + class CredentialManager: """ 统一凭证管理器 @@ -22,6 +27,8 @@ def __init__(self): # 核心状态 self._initialized = False self._storage_adapter = None + self._session_bindings: Dict[str, Tuple[str, float]] = {} + self._session_lock = asyncio.Lock() # 并发控制(简化) # 后端数据库自行处理并发,credential_manager 不再使用本地锁 @@ -46,8 +53,85 @@ async def close(self): self._initialized = False log.debug("Credential manager closed") + def _session_binding_key( + self, mode: str, model_name: Optional[str], session_key: Optional[str] + ) -> Optional[str]: + if not session_key: + return None + return f"{mode}:{model_name or ''}:{session_key}" + + async def _get_session_binding(self, binding_key: str) -> Optional[str]: + async with self._session_lock: + binding = self._session_bindings.get(binding_key) + if not binding: + return None + filename, expires_at = binding + if expires_at <= time.time(): + self._session_bindings.pop(binding_key, None) + return None + return filename + + async def _remember_session_binding(self, binding_key: Optional[str], filename: str) -> None: + if not binding_key or not filename: + return + async with self._session_lock: + self._session_bindings[binding_key] = ( + os.path.basename(filename), + time.time() + SESSION_BINDING_TTL_SECONDS, + ) + + async def _forget_session_binding(self, binding_key: Optional[str]) -> None: + if not binding_key: + return + async with self._session_lock: + self._session_bindings.pop(binding_key, None) + + async def _get_bound_credential_if_available( + self, + filename: str, + *, + mode: str, + model_name: Optional[str], + exclude_credential: Optional[str], + ) -> Optional[Tuple[str, Dict[str, Any]]]: + if exclude_credential and os.path.basename(filename) == os.path.basename(exclude_credential): + return None + + state = await self._storage_adapter.get_credential_state(filename, mode=mode) + if state.get("disabled"): + return None + + model_lower = (model_name or "").lower() + if model_name: + cooldown_until = (state.get("model_cooldowns") or {}).get(model_name) + if cooldown_until is not None: + try: + if time.time() < float(cooldown_until): + return None + except (TypeError, ValueError): + return None + + if mode == "geminicli": + if "pro" in model_lower and state.get("tier") == "free": + return None + if "preview" in model_lower and state.get("preview") is False: + return None + + credential_data = await self._storage_adapter.get_credential(filename, mode=mode) + if not credential_data: + return None + + if mode == "antigravity": + credential_data["enable_credit"] = bool(state.get("enable_credit", False)) + + return os.path.basename(filename), credential_data + async def get_valid_credential( - self, mode: str = "geminicli", model_name: Optional[str] = None + self, + mode: str = "geminicli", + model_name: Optional[str] = None, + session_key: Optional[str] = None, + exclude_credential: Optional[str] = None, ) -> Optional[Tuple[str, Dict[str, Any]]]: """ 获取有效的凭证 - 随机负载均衡版 @@ -63,9 +147,35 @@ async def get_valid_credential( - antigravity: 完整模型名(如 "gemini-2.0-flash-exp") """ await self._ensure_initialized() + binding_key = self._session_binding_key(mode, model_name, session_key) + + if binding_key: + bound_filename = await self._get_session_binding(binding_key) + if bound_filename: + bound_result = await self._get_bound_credential_if_available( + bound_filename, + mode=mode, + model_name=model_name, + exclude_credential=exclude_credential, + ) + if bound_result: + filename, credential_data = bound_result + if await self._should_refresh_token(credential_data): + refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) + if refreshed_data: + log.debug(f"Session credential hit after refresh: credential={filename}, mode={mode}") + await self._remember_session_binding(binding_key, filename) + return filename, refreshed_data + await self._forget_session_binding(binding_key) + else: + log.debug(f"Session credential hit: credential={filename}, mode={mode}") + await self._remember_session_binding(binding_key, filename) + return filename, credential_data + else: + await self._forget_session_binding(binding_key) # 最多重试3次 - max_retries = 3 + max_retries = 20 if exclude_credential else 3 for attempt in range(max_retries): result = await self._storage_adapter._backend.get_next_available_credential( mode=mode, model_name=model_name @@ -78,6 +188,8 @@ async def get_valid_credential( return None filename, credential_data = result + if exclude_credential and os.path.basename(filename) == os.path.basename(exclude_credential): + continue # Token 刷新检查 if await self._should_refresh_token(credential_data): @@ -87,6 +199,7 @@ async def get_valid_credential( # 刷新成功,返回凭证 credential_data = refreshed_data log.debug(f"Token刷新成功: {filename} (mode={mode})") + await self._remember_session_binding(binding_key, filename) return filename, credential_data else: # 刷新失败(_refresh_token内部已自动禁用失效凭证) @@ -95,6 +208,7 @@ async def get_valid_credential( continue else: # Token有效,直接返回 + await self._remember_session_binding(binding_key, filename) return filename, credential_data # 重试次数用尽 diff --git a/src/router/antigravity/anthropic.py b/src/router/antigravity/anthropic.py index e608b8a60..76f4e499b 100644 --- a/src/router/antigravity/anthropic.py +++ b/src/router/antigravity/anthropic.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import ClaudeRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -66,6 +67,7 @@ @router.post("/antigravity/v1/messages") async def messages( claude_request: ClaudeRequest, + request: Request, _token: str = Depends(authenticate_bearer) ): """ @@ -79,6 +81,7 @@ async def messages( # 转换为字典 normalized_dict = model_to_dict(claude_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="anthropic"): @@ -114,7 +117,8 @@ async def messages( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/antigravity/gemini.py b/src/router/antigravity/gemini.py index 8398463c8..47806bd71 100644 --- a/src/router/antigravity/gemini.py +++ b/src/router/antigravity/gemini.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import GeminiRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -64,6 +65,7 @@ @router.post("/antigravity/v1/models/{model:path}:generateContent") async def generate_content( gemini_request: "GeminiRequest", + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -79,6 +81,7 @@ async def generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="gemini"): @@ -103,7 +106,8 @@ async def generate_content( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_dict.pop("model"), - "request": normalized_dict + "request": normalized_dict, + "cache_session_key": cache_session_key } # 调用 API 层的非流式请求 @@ -130,6 +134,7 @@ async def generate_content( @router.post("/antigravity/v1/models/{model:path}:streamGenerateContent") async def stream_generate_content( gemini_request: GeminiRequest, + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -145,6 +150,7 @@ async def stream_generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 处理模型名称和功能检测 use_fake_streaming = is_fake_streaming_model(model) @@ -164,7 +170,8 @@ async def fake_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } response = await non_stream_request(body=api_request) @@ -229,7 +236,8 @@ async def anti_truncation_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } max_attempts = await get_anti_truncation_max_attempts() @@ -315,7 +323,8 @@ async def normal_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 diff --git a/src/router/antigravity/openai.py b/src/router/antigravity/openai.py index f914bd873..8930055b0 100644 --- a/src/router/antigravity/openai.py +++ b/src/router/antigravity/openai.py @@ -16,7 +16,7 @@ import json # 第三方库 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse # 本地模块 - 配置和日志 @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import OpenAIChatCompletionRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -63,6 +64,7 @@ @router.post("/antigravity/v1/chat/completions") async def chat_completions( openai_request: OpenAIChatCompletionRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -76,6 +78,7 @@ async def chat_completions( # 转换为字典 normalized_dict = model_to_dict(openai_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="openai"): @@ -111,7 +114,8 @@ async def chat_completions( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/geminicli/anthropic.py b/src/router/geminicli/anthropic.py index f9f9e4dc2..c7e2f45c5 100644 --- a/src/router/geminicli/anthropic.py +++ b/src/router/geminicli/anthropic.py @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import ClaudeRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -66,6 +67,7 @@ @router.post("/v1/messages") async def messages( claude_request: ClaudeRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -79,6 +81,7 @@ async def messages( # 转换为字典 normalized_dict = model_to_dict(claude_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="anthropic"): @@ -114,7 +117,8 @@ async def messages( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/router/geminicli/gemini.py b/src/router/geminicli/gemini.py index cd21de033..dceb8962d 100644 --- a/src/router/geminicli/gemini.py +++ b/src/router/geminicli/gemini.py @@ -46,6 +46,7 @@ # 本地模块 - 数据模型 from src.models import GeminiRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -62,6 +63,7 @@ @router.post("/v1/models/{model:path}:generateContent") async def generate_content( gemini_request: "GeminiRequest", + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -77,6 +79,7 @@ async def generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="gemini"): @@ -101,7 +104,8 @@ async def generate_content( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_dict.pop("model"), - "request": normalized_dict + "request": normalized_dict, + "cache_session_key": cache_session_key } # 调用 API 层的非流式请求 @@ -127,6 +131,7 @@ async def generate_content( @router.post("/v1/models/{model:path}:streamGenerateContent") async def stream_generate_content( gemini_request: GeminiRequest, + request: Request, model: str = Path(..., description="Model name"), api_key: str = Depends(authenticate_gemini_flexible), ): @@ -142,6 +147,7 @@ async def stream_generate_content( # 转换为字典 normalized_dict = model_to_dict(gemini_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 处理模型名称和功能检测 use_fake_streaming = is_fake_streaming_model(model) @@ -161,7 +167,8 @@ async def fake_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } response = await non_stream_request(body=api_request) @@ -226,7 +233,8 @@ async def anti_truncation_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model") if "model" in normalized_req else real_model, - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } max_attempts = await get_anti_truncation_max_attempts() @@ -312,7 +320,8 @@ async def normal_stream_generator(): # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": normalized_req.pop("model"), - "request": normalized_req + "request": normalized_req, + "cache_session_key": cache_session_key } # 所有流式请求都使用非 native 模式(SSE格式)并展开 response 包装 diff --git a/src/router/geminicli/openai.py b/src/router/geminicli/openai.py index 353b72500..67f6cbe88 100644 --- a/src/router/geminicli/openai.py +++ b/src/router/geminicli/openai.py @@ -16,7 +16,7 @@ import json # 第三方库 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse # 本地模块 - 配置和日志 @@ -48,6 +48,7 @@ # 本地模块 - 数据模型 from src.models import OpenAIChatCompletionRequest, model_to_dict +from src.session_affinity import extract_cache_session_key # 本地模块 - 任务管理 from src.task_manager import create_managed_task @@ -63,6 +64,7 @@ @router.post("/v1/chat/completions") async def chat_completions( openai_request: OpenAIChatCompletionRequest, + request: Request, token: str = Depends(authenticate_bearer) ): """ @@ -76,6 +78,7 @@ async def chat_completions( # 转换为字典 normalized_dict = model_to_dict(openai_request) + cache_session_key = extract_cache_session_key(normalized_dict, request.headers) # 健康检查 if is_health_check_request(normalized_dict, format="openai"): @@ -111,7 +114,8 @@ async def chat_completions( # 准备API请求格式 - 提取model并将其他字段放入request中 api_request = { "model": gemini_dict.pop("model"), - "request": gemini_dict + "request": gemini_dict, + "cache_session_key": cache_session_key } # ========== 非流式请求 ========== diff --git a/src/session_affinity.py b/src/session_affinity.py new file mode 100644 index 000000000..40e1986a6 --- /dev/null +++ b/src/session_affinity.py @@ -0,0 +1,181 @@ +""" +Helpers for cache-friendly credential routing. + +The goal is simple: requests from the same chat/task should usually hit the +same Google account so upstream prefix cache has a better chance to work. +""" + +import hashlib +import json +import re +from typing import Any, Mapping, Optional + + +_CLAUDE_SESSION_RE = re.compile(r"_session_([a-fA-F0-9-]+)$") + + +def extract_cache_session_key( + payload: Optional[Mapping[str, Any]], + headers: Optional[Mapping[str, str]] = None, +) -> Optional[str]: + if not isinstance(payload, Mapping): + return None + + explicit = payload.get("cache_session_key") + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip() + + header_key = _session_key_from_headers(headers) + if header_key: + return header_key + + metadata_key = _session_key_from_metadata(payload.get("metadata")) + if metadata_key: + return metadata_key + + conversation_id = payload.get("conversation_id") + if isinstance(conversation_id, str) and conversation_id.strip(): + return f"conv:{conversation_id.strip()}" + + request_payload = payload.get("request") + if isinstance(request_payload, Mapping): + request_key = _session_key_from_gemini_like_payload(request_payload) + if request_key: + return request_key + + gemini_key = _session_key_from_gemini_like_payload(payload) + if gemini_key: + return gemini_key + + first_user_text = _first_user_text(payload) + if first_user_text: + digest = hashlib.sha256(first_user_text.encode("utf-8")).hexdigest()[:16] + return f"msg:{digest}" + + return None + + +def _session_key_from_headers(headers: Optional[Mapping[str, str]]) -> Optional[str]: + if headers is None: + return None + + header_names = ( + ("x-session-id", "header"), + ("session_id", "codex"), + ("x-amp-thread-id", "amp"), + ("x-client-request-id", "clientreq"), + ) + + for header_name, prefix in header_names: + value = _get_header(headers, header_name) + if value: + return f"{prefix}:{value}" + + return None + + +def _get_header(headers: Mapping[str, str], name: str) -> Optional[str]: + value = None + get_method = getattr(headers, "get", None) + if callable(get_method): + value = get_method(name) + if value is None: + value = get_method(name.lower()) + if value is None: + value = get_method(name.upper()) + if value is None: + return None + value = str(value).strip() + return value or None + + +def _session_key_from_metadata(metadata: Any) -> Optional[str]: + if not isinstance(metadata, Mapping): + return None + + user_id = metadata.get("user_id") + if not isinstance(user_id, str) or not user_id.strip(): + return None + + user_id = user_id.strip() + match = _CLAUDE_SESSION_RE.search(user_id) + if match: + return f"claude:{match.group(1)}" + + if user_id.startswith("{"): + try: + parsed = json.loads(user_id) + session_id = parsed.get("session_id") if isinstance(parsed, Mapping) else None + if isinstance(session_id, str) and session_id.strip(): + return f"claude:{session_id.strip()}" + except Exception: + pass + + return f"user:{user_id}" + + +def _session_key_from_gemini_like_payload(payload: Mapping[str, Any]) -> Optional[str]: + session_id = payload.get("sessionId") or payload.get("session_id") + if isinstance(session_id, str) and session_id.strip(): + return f"gemini-session:{session_id.strip()}" + if isinstance(session_id, int): + return f"gemini-session:{session_id}" + return None + + +def _first_user_text(payload: Mapping[str, Any]) -> Optional[str]: + messages = payload.get("messages") + if isinstance(messages, list): + for message in messages: + if not isinstance(message, Mapping) or message.get("role") != "user": + continue + text = _text_from_content(message.get("content")) + if text: + return text + + contents = payload.get("contents") + if isinstance(contents, list): + for content in contents: + if not isinstance(content, Mapping) or content.get("role") not in ("user", None): + continue + text = _text_from_parts(content.get("parts")) + if text: + return text + + request_payload = payload.get("request") + if isinstance(request_payload, Mapping): + return _first_user_text(request_payload) + + return None + + +def _text_from_content(content: Any) -> Optional[str]: + if isinstance(content, str): + return content.strip() or None + if isinstance(content, list): + texts = [] + for part in content: + if isinstance(part, str): + texts.append(part) + elif isinstance(part, Mapping): + text = part.get("text") + if not isinstance(text, str): + text = part.get("content") + if isinstance(text, str): + texts.append(text) + joined = " ".join(texts).strip() + return joined or None + return None + + +def _text_from_parts(parts: Any) -> Optional[str]: + if not isinstance(parts, list): + return None + texts = [] + for part in parts: + if isinstance(part, Mapping): + text = part.get("text") + if isinstance(text, str): + texts.append(text) + joined = " ".join(texts).strip() + return joined or None From 3fd7f05efbe4e3d63ff9779c89ed4982cfead158 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 22:06:03 +0000 Subject: [PATCH 12/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index 912d0e4c5..5b4aad811 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=14fb6e9317b75f593e5bba49edc9ae5ae81c93c8 -short_hash=14fb6e9 -message=Normalize Gemini thought signatures before upstream requests -date=2026-05-20 03:12:40 +0800 +full_hash=80e13a620a4c7c2b946c9a3c0202abef238eb5f1 +short_hash=80e13a6 +message=Add cache-friendly session routing +date=2026-05-20 06:05:35 +0800 From b1f5fe111033ba940e3150b0a695a6cb0d9521c5 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 06:12:38 +0800 Subject: [PATCH 13/25] Improve cache session routing --- src/credential_manager.py | 119 +++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/src/credential_manager.py b/src/credential_manager.py index adb1dedaf..63ded5ef2 100644 --- a/src/credential_manager.py +++ b/src/credential_manager.py @@ -3,6 +3,7 @@ """ import asyncio +import hashlib import os import time from datetime import datetime, timezone @@ -60,6 +61,9 @@ def _session_binding_key( return None return f"{mode}:{model_name or ''}:{session_key}" + def _session_log_id(self, binding_key: str) -> str: + return hashlib.sha256(binding_key.encode("utf-8")).hexdigest()[:12] + async def _get_session_binding(self, binding_key: str) -> Optional[str]: async with self._session_lock: binding = self._session_bindings.get(binding_key) @@ -126,6 +130,89 @@ async def _get_bound_credential_if_available( return os.path.basename(filename), credential_data + def _credential_state_allows_model( + self, + state: Dict[str, Any], + *, + mode: str, + model_name: Optional[str], + ) -> bool: + if state.get("disabled"): + return False + + model_lower = (model_name or "").lower() + if model_name: + cooldown_until = (state.get("model_cooldowns") or {}).get(model_name) + if cooldown_until is not None: + try: + if time.time() < float(cooldown_until): + return False + except (TypeError, ValueError): + return False + + if mode == "geminicli": + if "pro" in model_lower and state.get("tier") == "free": + return False + if "preview" in model_lower and state.get("preview") is False: + return False + + return True + + async def _get_session_routed_credential( + self, + *, + binding_key: str, + mode: str, + model_name: Optional[str], + exclude_credential: Optional[str], + ) -> Optional[Tuple[str, Dict[str, Any]]]: + states = await self._storage_adapter.get_all_credential_states(mode=mode) + if not states: + return None + + excluded = os.path.basename(exclude_credential) if exclude_credential else None + candidates: List[Tuple[str, Dict[str, Any]]] = [] + + for raw_filename, state in states.items(): + filename = os.path.basename(raw_filename) + if excluded and filename == excluded: + continue + if not self._credential_state_allows_model( + state, + mode=mode, + model_name=model_name, + ): + continue + candidates.append((filename, state)) + + if not candidates: + return None + + ordered_candidates = sorted( + candidates, + key=lambda item: hashlib.sha256( + f"{binding_key}\0{item[0]}".encode("utf-8") + ).digest(), + reverse=True, + ) + + for filename, state in ordered_candidates: + credential_data = await self._storage_adapter.get_credential(filename, mode=mode) + if not credential_data: + continue + + if mode == "antigravity": + credential_data["enable_credit"] = bool(state.get("enable_credit", False)) + + log.info( + "Session route selected: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) + return filename, credential_data + + return None + async def get_valid_credential( self, mode: str = "geminicli", @@ -163,17 +250,45 @@ async def get_valid_credential( if await self._should_refresh_token(credential_data): refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) if refreshed_data: - log.debug(f"Session credential hit after refresh: credential={filename}, mode={mode}") + log.info( + "Session route hit after refresh: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) await self._remember_session_binding(binding_key, filename) return filename, refreshed_data await self._forget_session_binding(binding_key) else: - log.debug(f"Session credential hit: credential={filename}, mode={mode}") + log.info( + "Session route hit: " + f"session={self._session_log_id(binding_key)}, " + f"credential={filename}, mode={mode}, model={model_name}" + ) await self._remember_session_binding(binding_key, filename) return filename, credential_data else: await self._forget_session_binding(binding_key) + routed_result = await self._get_session_routed_credential( + binding_key=binding_key, + mode=mode, + model_name=model_name, + exclude_credential=exclude_credential, + ) + if routed_result: + filename, credential_data = routed_result + if await self._should_refresh_token(credential_data): + log.debug(f"Token needs refresh: {filename} (mode={mode})") + refreshed_data = await self._refresh_token(credential_data, filename, mode=mode) + if refreshed_data: + log.debug(f"Token refreshed: {filename} (mode={mode})") + await self._remember_session_binding(binding_key, filename) + return filename, refreshed_data + await self._forget_session_binding(binding_key) + else: + await self._remember_session_binding(binding_key, filename) + return filename, credential_data + # 最多重试3次 max_retries = 20 if exclude_credential else 3 for attempt in range(max_retries): From fab19fe4d3a5a604b76ca2e6660fb47d6b3cad54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 May 2026 22:13:02 +0000 Subject: [PATCH 14/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index 5b4aad811..70c3c1731 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=80e13a620a4c7c2b946c9a3c0202abef238eb5f1 -short_hash=80e13a6 -message=Add cache-friendly session routing -date=2026-05-20 06:05:35 +0800 +full_hash=b1f5fe111033ba940e3150b0a695a6cb0d9521c5 +short_hash=b1f5fe1 +message=Improve cache session routing +date=2026-05-20 06:12:38 +0800 From 95069704ba7b2accbef66bab714066b73d6a9425 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 06:26:06 +0800 Subject: [PATCH 15/25] Filter internal thought placeholder [skip ci] --- src/converter/anthropic2gemini.py | 9 +++++++++ src/converter/openai2gemini.py | 19 ++++++++++++++++--- src/converter/thoughtSignature_fix.py | 18 +++++++++++++++++- src/converter/utils.py | 7 ++++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py index 156730553..2909801d7 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -16,6 +16,7 @@ from src.converter.thoughtSignature_fix import ( decode_tool_id_and_signature, + is_skip_thought_signature_placeholder, SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) @@ -830,6 +831,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 = "" @@ -846,6 +849,8 @@ def gemini_to_anthropic_response( # 处理文本块 if "text" in part: + if is_skip_thought_signature_placeholder(part): + continue content.append({"type": "text", "text": part.get("text", "")}) continue @@ -1049,6 +1054,8 @@ def _usage_payload() -> Dict[str, int]: # 处理 thinking 块 if part.get("thought") is True: + if is_skip_thought_signature_placeholder(part): + continue thinking_text = part.get("text", "") thoughtsignature = part.get("thoughtSignature") @@ -1110,6 +1117,8 @@ def _usage_payload() -> Dict[str, int]: # 处理文本块 if "text" in part: + if is_skip_thought_signature_placeholder(part): + continue text = part.get("text", "") if isinstance(text, str) and not text.strip(): continue diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index 778b6775d..3e025d89d 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -12,6 +12,7 @@ from src.converter.thoughtSignature_fix import ( decode_tool_id_and_signature, + is_skip_thought_signature_placeholder, SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) from src.converter.utils import merge_system_messages @@ -1071,7 +1072,11 @@ def extract_tool_calls_from_parts( tool_calls.append(tool_call) # 提取文本内容(排除 thinking tokens) - elif "text" in part and not part.get("thought", False): + elif ( + "text" in part + and not part.get("thought", False) + and not is_skip_thought_signature_placeholder(part) + ): text_content += part["text"] return tool_calls, text_content @@ -1524,7 +1529,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"]) # 处理普通文本(非思考内容) @@ -1694,7 +1703,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 e42ef2086..1720c5862 100644 --- a/src/converter/thoughtSignature_fix.py +++ b/src/converter/thoughtSignature_fix.py @@ -5,12 +5,28 @@ 这使得签名能够在客户端往返传输中保留,即使客户端会删除自定义字段。 """ -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_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 + text = part.get("text") + return ( + isinstance(text, str) + and text.strip() == SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_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..011577413 100644 --- a/src/converter/utils.py +++ b/src/converter/utils.py @@ -1,5 +1,7 @@ from typing import Any, Dict +from src.converter.thoughtSignature_fix import is_skip_thought_signature_placeholder + def extract_content_and_reasoning(parts: list) -> tuple: """从Gemini响应部件中提取内容和推理内容 @@ -24,6 +26,9 @@ 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 text: @@ -234,4 +239,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 From 8f604756620012d94028e52b07d40c55f8bb7bef Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 06:30:52 +0800 Subject: [PATCH 16/25] Filter plain tool placeholders [skip ci] --- src/converter/anthropic2gemini.py | 14 +++++++++++--- src/converter/openai2gemini.py | 15 +++++++++------ src/converter/thoughtSignature_fix.py | 12 +++++++----- src/converter/utils.py | 7 ++++++- 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py index 2909801d7..7c335f835 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -16,6 +16,7 @@ from src.converter.thoughtSignature_fix import ( decode_tool_id_and_signature, + is_internal_placeholder_text, is_skip_thought_signature_placeholder, SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) @@ -849,9 +850,13 @@ def gemini_to_anthropic_response( # 处理文本块 if "text" in part: - if is_skip_thought_signature_placeholder(part): + text = part.get("text", "") + if ( + is_skip_thought_signature_placeholder(part) + or is_internal_placeholder_text(text) + ): continue - content.append({"type": "text", "text": part.get("text", "")}) + content.append({"type": "text", "text": text}) continue # 处理工具调用 @@ -1117,7 +1122,10 @@ def _usage_payload() -> Dict[str, int]: # 处理文本块 if "text" in part: - if is_skip_thought_signature_placeholder(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(): diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index 3e025d89d..a4c584843 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -12,6 +12,7 @@ from src.converter.thoughtSignature_fix import ( decode_tool_id_and_signature, + is_internal_placeholder_text, is_skip_thought_signature_placeholder, SKIP_THOUGHT_SIGNATURE_VALIDATOR, ) @@ -1072,12 +1073,14 @@ def extract_tool_calls_from_parts( tool_calls.append(tool_call) # 提取文本内容(排除 thinking tokens) - elif ( - "text" in part - and not part.get("thought", False) - and not is_skip_thought_signature_placeholder(part) - ): - text_content += part["text"] + elif "text" in part and not part.get("thought", False): + 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 diff --git a/src/converter/thoughtSignature_fix.py b/src/converter/thoughtSignature_fix.py index 1720c5862..cbeebdd2a 100644 --- a/src/converter/thoughtSignature_fix.py +++ b/src/converter/thoughtSignature_fix.py @@ -14,6 +14,12 @@ 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): @@ -22,11 +28,7 @@ def is_skip_thought_signature_placeholder(part: Mapping[str, Any]) -> bool: return False if "functionCall" in part or "function_call" in part or "functionResponse" in part: return False - text = part.get("text") - return ( - isinstance(text, str) - and text.strip() == SKIP_THOUGHT_SIGNATURE_PLACEHOLDER_TEXT - ) + 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 011577413..81d511f64 100644 --- a/src/converter/utils.py +++ b/src/converter/utils.py @@ -1,6 +1,9 @@ from typing import Any, Dict -from src.converter.thoughtSignature_fix import is_skip_thought_signature_placeholder +from src.converter.thoughtSignature_fix import ( + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, +) def extract_content_and_reasoning(parts: list) -> tuple: @@ -31,6 +34,8 @@ def extract_content_and_reasoning(parts: list) -> tuple: # 提取文本内容 text = part.get("text", "") + if is_internal_placeholder_text(text): + continue if text: if part.get("thought", False): reasoning_content += text From 3d35b4db5bbd9be9e81fc6213a2d724cfc586e74 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 13:48:04 +0800 Subject: [PATCH 17/25] Report uncached input tokens [skip ci] --- src/converter/anthropic2gemini.py | 12 +++++++++--- src/converter/openai2gemini.py | 19 +++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/converter/anthropic2gemini.py b/src/converter/anthropic2gemini.py index 7c335f835..5c537b11c 100644 --- a/src/converter/anthropic2gemini.py +++ b/src/converter/anthropic2gemini.py @@ -206,12 +206,13 @@ 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": int(usage_metadata.get("promptTokenCount", 0) or 0), + "input_tokens": max(prompt_tokens_total - cached_tokens, 0), "output_tokens": int(usage_metadata.get("candidatesTokenCount", 0) or 0), } - cached_tokens = _cached_content_token_count(usage_metadata) if cached_tokens > 0: usage["cache_read_input_tokens"] = cached_tokens @@ -1026,11 +1027,16 @@ def _usage_payload() -> Dict[str, int]: 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: diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index a4c584843..ab3522d54 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -33,13 +33,24 @@ def _convert_usage_metadata(usage_metadata: Dict[str, Any]) -> Optional[Dict[str if not usage_metadata: return None + 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": usage_metadata.get("promptTokenCount", 0), - "completion_tokens": usage_metadata.get("candidatesTokenCount", 0), - "total_tokens": usage_metadata.get("totalTokenCount", 0), + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": max(raw_total_tokens - cached_tokens, prompt_tokens + completion_tokens), } - cached_tokens = int(usage_metadata.get("cachedContentTokenCount", 0) or 0) if cached_tokens > 0: usage["prompt_tokens_details"] = {"cached_tokens": cached_tokens} From 1a20ab6a5f495c24550f6bfd7f1f0f365a596907 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 16:39:56 +0800 Subject: [PATCH 18/25] Add empty Claude tool schema [skip ci] --- src/converter/gemini_fix.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/converter/gemini_fix.py b/src/converter/gemini_fix.py index 76115ec42..826646189 100644 --- a/src/converter/gemini_fix.py +++ b/src/converter/gemini_fix.py @@ -281,6 +281,38 @@ def _normalize_tools_for_internal_api(tools: Any) -> Any: 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() + declarations = normalized_tool.get("functionDeclarations") + 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: + normalized_declaration["parametersJsonSchema"] = { + "type": "object", + "properties": {}, + } + normalized_declarations.append(normalized_declaration) + 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 @@ -734,6 +766,7 @@ 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 From 8cd2ee71d5121bb78d0d975aef0714d581f56c7f Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 16:45:01 +0800 Subject: [PATCH 19/25] Handle Claude tools without schemas [skip ci] --- src/converter/gemini_fix.py | 21 +++++++++++++++++++++ src/converter/openai2gemini.py | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/src/converter/gemini_fix.py b/src/converter/gemini_fix.py index 826646189..c27ca5667 100644 --- a/src/converter/gemini_fix.py +++ b/src/converter/gemini_fix.py @@ -253,6 +253,8 @@ def _normalize_tools_for_internal_api(tools: Any) -> Any: 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: @@ -263,10 +265,13 @@ def _normalize_tools_for_internal_api(tools: Any) -> Any: 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: @@ -274,6 +279,7 @@ def _normalize_tools_for_internal_api(tools: Any) -> Any: normalized_declarations.append(normalized_declaration) + normalized_tool.pop("function_declarations", None) normalized_tool["functionDeclarations"] = normalized_declarations normalized_tools.append(normalized_tool) @@ -292,7 +298,15 @@ def _ensure_empty_tool_schema_for_claude(tools: Any, model_name: str) -> Any: 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: @@ -300,12 +314,19 @@ def _ensure_empty_tool_schema_for_claude(tools: Any, model_name: str) -> Any: 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) diff --git a/src/converter/openai2gemini.py b/src/converter/openai2gemini.py index ab3522d54..42f347fd3 100644 --- a/src/converter/openai2gemini.py +++ b/src/converter/openai2gemini.py @@ -885,6 +885,10 @@ def convert_openai_tools_to_gemini(openai_tools: List, model: str = "") -> List[ if 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) From cca7ef68810a187399d3227c903c95d565da82fd Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Wed, 20 May 2026 16:50:35 +0800 Subject: [PATCH 20/25] Align Antigravity tool payload schema [skip ci] --- src/api/antigravity.py | 107 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/src/api/antigravity.py b/src/api/antigravity.py index e946a4bec..fb79ae4d4 100644 --- a/src/api/antigravity.py +++ b/src/api/antigravity.py @@ -109,6 +109,109 @@ def _ensure_antigravity_session_id(payload: Dict[str, Any], model_name: str) -> 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 @@ -206,7 +309,7 @@ async def stream_request( "project": project_id, "request": body.get("request", {}), } - _ensure_antigravity_session_id(final_payload, model_name) + _prepare_antigravity_payload(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: @@ -503,7 +606,7 @@ async def non_stream_request( "project": project_id, "request": body.get("request", {}), } - _ensure_antigravity_session_id(final_payload, model_name) + _prepare_antigravity_payload(final_payload, model_name) # 仅当凭证明确开启积分消耗时注入 enabledCreditTypes def apply_enabled_credit_types(cred_data: Dict[str, Any]) -> None: From 87f533e0eff5df18f71542cc46475babb5458472 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Thu, 21 May 2026 01:42:28 +0800 Subject: [PATCH 21/25] Add Antigravity credit summary UI [skip ci] --- front/antigravity_credit_summary_preview.html | 385 ++++++++++++++++++ front/common.js | 218 +++++++++- front/control_panel.html | 219 +++++++++- front/control_panel_mobile.html | 204 +++++++++- src/google_oauth_api.py | 34 ++ src/panel/creds.py | 252 +++++++++++- 6 files changed, 1305 insertions(+), 7 deletions(-) create mode 100644 front/antigravity_credit_summary_preview.html diff --git a/front/antigravity_credit_summary_preview.html b/front/antigravity_credit_summary_preview.html new file mode 100644 index 000000000..8b11ef5b0 --- /dev/null +++ b/front/antigravity_credit_summary_preview.html @@ -0,0 +1,385 @@ + + + + + + + Antigravity 总额度 UI 预览 + + + + +
+

Antigravity 总额度 UI 预览

+ + +
+ + + + + diff --git a/front/common.js b/front/common.js index 12c75df94..a6e9134d5 100644 --- a/front/common.js +++ b/front/common.js @@ -37,7 +37,12 @@ const AppState = { usageStatsData: {}, // 冷却倒计时 - cooldownTimerInterval: null + cooldownTimerInterval: null, + + // Antigravity total quota UI + antigravityCreditSummaryTimer: null, + antigravityCreditSummaryLoaded: false, + antigravityCreditSummaryLoading: false }; // ===================================================================== @@ -1035,7 +1040,10 @@ function switchTab(tabName) { // 标签页数据加载(从动画中分离出来) function triggerTabDataLoad(tabName) { if (tabName === 'manage') AppState.creds.refresh(); - if (tabName === 'antigravity-manage') AppState.antigravityCreds.refresh(); + if (tabName === 'antigravity-manage') { + AppState.antigravityCreds.refresh(); + startAntigravityCreditSummaryAutoRefresh(true); + } if (tabName === 'config') loadConfig(); if (tabName === 'logs') connectWebSocket(); } @@ -1455,7 +1463,211 @@ async function downloadAllCreds() { } // Antigravity凭证管理 -function refreshAntigravityCredsList() { AppState.antigravityCreds.refresh(); } +function refreshAntigravityCredsList() { + AppState.antigravityCreds.refresh(); + startAntigravityCreditSummaryAutoRefresh(false); + refreshAntigravityCreditSummary(true); +} + +function getAntigravityCreditSummaryElement(id) { + return document.getElementById(id); +} + +function setAntigravityCreditSummaryText(id, text) { + const element = getAntigravityCreditSummaryElement(id); + if (element) element.textContent = text; +} + +function formatAntigravityCreditNumber(value) { + if (value === null || value === undefined || value === '' || Number.isNaN(Number(value))) { + return '--'; + } + + return Number(value).toLocaleString('zh-CN', { + maximumFractionDigits: 2 + }); +} + +function formatAntigravityCreditPercent(value) { + if (value === null || value === undefined || value === '' || Number.isNaN(Number(value))) { + return '--%'; + } + + return `${Number(value).toLocaleString('zh-CN', { + maximumFractionDigits: 2 + })}%`; +} + +function formatAntigravitySummaryTime(timestamp) { + if (!timestamp) return '刚刚'; + + return new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); +} + +function getAntigravityQuotaColor(percent) { + const safePercent = Number(percent); + if (safePercent >= 50) return '#28a745'; + if (safePercent >= 20) return '#ffc107'; + return '#dc3545'; +} + +function renderAntigravityCreditModelList(modelSummaries) { + const container = getAntigravityCreditSummaryElement('antigravityCreditModelList'); + if (!container) return; + + if (!Array.isArray(modelSummaries) || modelSummaries.length === 0) { + container.innerHTML = '
暂无分模型额度数据
'; + return; + } + + container.innerHTML = modelSummaries.map(modelInfo => { + const percent = modelInfo.remaining_percent; + const safePercent = percent === null || percent === undefined + ? 0 + : Math.max(0, Math.min(100, Number(percent))); + const percentText = formatAntigravityCreditPercent(percent); + const accountCount = modelInfo.account_count || 0; + const exhaustedAccounts = modelInfo.exhausted_accounts || 0; + const color = getAntigravityQuotaColor(safePercent); + const modelName = escapeHtml(modelInfo.model || 'unknown'); + + return ` +
+
+ ${modelName} + ${percentText} +
+
+
+
+
覆盖 ${accountCount} 个启用号 · ${exhaustedAccounts} 个已耗尽
+
+ `; + }).join(''); +} + +function updateAntigravityCreditSummaryUI(data) { + const creditsText = formatAntigravityCreditNumber(data.total_remaining_credits); + const enabledAccounts = data.enabled_accounts || 0; + const creditAccounts = data.credit_accounts || 0; + const quotaAccounts = data.quota_accounts || 0; + const failedAccounts = data.failed_accounts || 0; + + setAntigravityCreditSummaryText('antigravityCreditRemainingAmount', creditsText); + setAntigravityCreditSummaryText('antigravityCreditEnabledAccounts', enabledAccounts.toString()); + + if (enabledAccounts > 0) { + setAntigravityCreditSummaryText( + 'antigravityCreditSummaryCompact', + `${creditsText} 积分 · 启用 ${enabledAccounts} 个` + ); + } else { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '没有启用凭证'); + } + + renderAntigravityCreditModelList(data.model_summaries || []); + + const refreshTime = formatAntigravitySummaryTime(data.updated_at); + const metaParts = [ + `启用 ${enabledAccounts} 个`, + `积分数据 ${creditAccounts} 个`, + `额度数据 ${quotaAccounts} 个`, + `刷新 ${refreshTime}` + ]; + if (failedAccounts > 0) metaParts.push(`失败 ${failedAccounts} 个`); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', metaParts.join(' · ')); + + const meta = getAntigravityCreditSummaryElement('antigravityCreditSummaryMeta'); + if (meta && Array.isArray(data.errors) && data.errors.length > 0) { + meta.title = data.errors.map(item => `${item.filename}: ${item.error}`).join('\n'); + } else if (meta) { + meta.removeAttribute('title'); + } + + AppState.antigravityCreditSummaryLoaded = true; +} + +function setAntigravityCreditSummaryLoading(isLoading) { + AppState.antigravityCreditSummaryLoading = isLoading; + const refreshBtn = getAntigravityCreditSummaryElement('antigravityCreditSummaryRefreshBtn'); + if (refreshBtn) { + refreshBtn.disabled = isLoading; + refreshBtn.textContent = isLoading ? '刷新中...' : '手动刷新'; + } + + if (isLoading && !AppState.antigravityCreditSummaryLoaded) { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '正在获取...'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', '正在获取启用凭证额度...'); + } +} + +function toggleAntigravityCreditSummary() { + const body = getAntigravityCreditSummaryElement('antigravityCreditSummaryBody'); + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!body || !card) return; + + const shouldOpen = body.style.display !== 'block'; + body.style.display = shouldOpen ? 'block' : 'none'; + card.classList.toggle('expanded', shouldOpen); + card.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false'); + setAntigravityCreditSummaryText('antigravityCreditSummaryToggleIcon', shouldOpen ? '收起' : '展开'); + + if (shouldOpen && !AppState.antigravityCreditSummaryLoaded) { + refreshAntigravityCreditSummary(false); + } +} + +function startAntigravityCreditSummaryAutoRefresh(fetchNow = false) { + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!card) return; + + if (!AppState.antigravityCreditSummaryTimer) { + AppState.antigravityCreditSummaryTimer = setInterval(() => { + refreshAntigravityCreditSummary(true); + }, 10 * 60 * 1000); + } + + if (fetchNow) { + refreshAntigravityCreditSummary(true); + } +} + +async function refreshAntigravityCreditSummary(silent = false) { + const card = getAntigravityCreditSummaryElement('antigravityCreditSummaryCard'); + if (!card || AppState.antigravityCreditSummaryLoading) return; + + setAntigravityCreditSummaryLoading(true); + + try { + const response = await fetch('./creds/antigravity-credit-summary', { + method: 'GET', + headers: getAuthHeaders() + }); + const data = await response.json(); + + if (response.ok && data.success) { + updateAntigravityCreditSummaryUI(data); + if (!silent) showStatus('Antigravity 总额度已刷新', 'success'); + } else { + const errorMsg = data.error || data.detail || '获取总额度失败'; + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '获取失败'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', errorMsg); + if (!silent) showStatus(errorMsg, 'error'); + } + } catch (error) { + setAntigravityCreditSummaryText('antigravityCreditSummaryCompact', '获取失败'); + setAntigravityCreditSummaryText('antigravityCreditSummaryMeta', error.message); + if (!silent) showStatus(`获取总额度失败: ${error.message}`, 'error'); + } finally { + setAntigravityCreditSummaryLoading(false); + } +} + function applyAntigravityStatusFilter() { AppState.antigravityCreds.applyStatusFilter(); } function changeAntigravityPage(direction) { AppState.antigravityCreds.changePage(direction); } function changeAntigravityPageSize() { AppState.antigravityCreds.changePageSize(); } diff --git a/front/control_panel.html b/front/control_panel.html index 11bc986dd..4374b7f94 100644 --- a/front/control_panel.html +++ b/front/control_panel.html @@ -638,6 +638,190 @@ box-shadow: 0 2px 8px rgba(23, 162, 184, 0.15); } + .quota-summary-card { + background: #ffffff; + border: 1px solid #dce3ea; + border-left: 4px solid #17a2b8; + border-radius: 8px; + margin: 0 0 20px 0; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + .quota-summary-header { + width: 100%; + border: 0; + background: #f8fbfd; + color: #333; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + text-align: left; + } + + .quota-summary-title { + display: block; + font-size: 15px; + font-weight: 700; + } + + .quota-summary-subtitle { + display: block; + color: #667085; + font-size: 12px; + margin-top: 3px; + } + + .quota-summary-compact { + color: #0c5460; + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-summary-toggle { + color: #666; + font-size: 12px; + margin-left: 8px; + white-space: nowrap; + } + + .quota-summary-body { + border-top: 1px solid #edf0f3; + padding: 14px 16px 16px; + } + + .quota-summary-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; + } + + .quota-summary-metric { + background: #f8f9fa; + border-radius: 6px; + padding: 10px; + } + + .quota-summary-metric strong { + color: #222; + display: block; + font-size: 22px; + line-height: 1.2; + word-break: break-word; + } + + .quota-summary-metric span { + color: #667085; + display: block; + font-size: 12px; + margin-top: 4px; + } + + .quota-model-list { + display: grid; + gap: 8px; + margin-top: 14px; + } + + .quota-model-item { + background: #ffffff; + border: 1px solid #edf0f3; + border-radius: 6px; + padding: 10px; + } + + .quota-model-row { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + } + + .quota-model-name { + color: #333; + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quota-model-percent { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-model-bar { + background: #e9ecef; + border-radius: 999px; + height: 7px; + margin: 8px 0 6px; + overflow: hidden; + } + + .quota-model-fill { + height: 100%; + transition: width 0.25s ease; + width: 0; + } + + .quota-model-meta, + .quota-model-empty { + color: #667085; + font-size: 12px; + } + + .quota-summary-footer { + align-items: center; + display: flex; + gap: 10px; + justify-content: space-between; + margin-top: 10px; + } + + .quota-summary-meta { + color: #667085; + font-size: 12px; + } + + .quota-summary-refresh { + background: #17a2b8; + border: 0; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 13px; + padding: 7px 10px; + white-space: nowrap; + } + + .quota-summary-refresh:disabled { + background: #9ab7c0; + cursor: not-allowed; + } + + @media (max-width: 640px) { + .quota-summary-header, + .quota-summary-footer { + align-items: flex-start; + flex-direction: column; + } + + .quota-summary-compact { + white-space: normal; + } + + .quota-summary-metrics { + grid-template-columns: 1fr; + } + } + .manage-actions { margin-bottom: 20px; display: flex; @@ -1886,6 +2070,39 @@

Antigravity凭证文件管理

+ +
@@ -2397,4 +2614,4 @@

📞 联系我们

- \ No newline at end of file + diff --git a/front/control_panel_mobile.html b/front/control_panel_mobile.html index ec18ea0d8..6f75d1110 100644 --- a/front/control_panel_mobile.html +++ b/front/control_panel_mobile.html @@ -767,6 +767,175 @@ } /* 批量操作按钮禁用状态 */ + .quota-summary-card { + background: #ffffff; + border: 1px solid #dce3ea; + border-left: 4px solid #17a2b8; + border-radius: 8px; + margin: 0 0 15px 0; + overflow: hidden; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + + .quota-summary-header { + width: 100%; + border: 0; + background: #f8fbfd; + color: #333; + cursor: pointer; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 12px; + text-align: left; + } + + .quota-summary-title { + display: block; + font-size: 15px; + font-weight: 700; + } + + .quota-summary-subtitle { + display: block; + color: #667085; + font-size: 12px; + margin-top: 3px; + } + + .quota-summary-compact { + color: #0c5460; + display: block; + font-size: 12px; + font-weight: 700; + margin-bottom: 4px; + text-align: right; + } + + .quota-summary-toggle { + color: #666; + display: block; + font-size: 12px; + text-align: right; + } + + .quota-summary-body { + border-top: 1px solid #edf0f3; + padding: 12px; + } + + .quota-summary-metrics { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-bottom: 10px; + } + + .quota-summary-metric { + background: #f8f9fa; + border-radius: 6px; + padding: 10px; + } + + .quota-summary-metric strong { + color: #222; + display: block; + font-size: 20px; + line-height: 1.2; + word-break: break-word; + } + + .quota-summary-metric span { + color: #667085; + display: block; + font-size: 12px; + margin-top: 4px; + } + + .quota-model-list { + display: grid; + gap: 8px; + margin-top: 12px; + } + + .quota-model-item { + background: #ffffff; + border: 1px solid #edf0f3; + border-radius: 6px; + padding: 10px; + } + + .quota-model-row { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; + } + + .quota-model-name { + color: #333; + font-size: 13px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .quota-model-percent { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + } + + .quota-model-bar { + background: #e9ecef; + border-radius: 999px; + height: 7px; + margin: 8px 0 6px; + overflow: hidden; + } + + .quota-model-fill { + height: 100%; + transition: width 0.25s ease; + width: 0; + } + + .quota-model-meta, + .quota-model-empty { + color: #667085; + font-size: 12px; + } + + .quota-summary-footer { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + } + + .quota-summary-meta { + color: #667085; + font-size: 12px; + } + + .quota-summary-refresh { + background: #17a2b8; + border: 0; + border-radius: 4px; + color: white; + cursor: pointer; + font-size: 13px; + padding: 8px 10px; + width: 100%; + } + + .quota-summary-refresh:disabled { + background: #9ab7c0; + cursor: not-allowed; + } + .btn-small:disabled { background-color: #e9ecef !important; color: #6c757d !important; @@ -1578,6 +1747,39 @@

Antigravity凭证文件管理

+ +
@@ -2122,4 +2324,4 @@

📞 联系我们

- \ No newline at end of file + diff --git a/src/google_oauth_api.py b/src/google_oauth_api.py index f2681ddfe..d59f22451 100644 --- a/src/google_oauth_api.py +++ b/src/google_oauth_api.py @@ -631,6 +631,40 @@ def _map_raw_tier(raw_tier: Optional[str]) -> Optional[str]: return None, subscription_tier +async def fetch_credit_amount( + access_token: str, + user_agent: str, + api_base_url: str, +) -> Optional[int]: + """ + Fetch remaining credits with loadCodeAssist only. + + This is intentionally read-only and does not fall back to onboardUser, so it + is safe for background UI refreshes. + """ + headers = { + 'User-Agent': user_agent, + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip' + } + + raw_credit_amount = None + + try: + _, _, raw_credit_amount = await _try_load_code_assist(api_base_url, headers) + if raw_credit_amount is None: + return None + + return int(raw_credit_amount) + except (TypeError, ValueError): + log.warning(f"[fetch_credit_amount] Invalid credit_amount: {raw_credit_amount}") + return None + except Exception as e: + log.warning(f"[fetch_credit_amount] Failed: {type(e).__name__}: {e}") + return None + + async def _try_load_code_assist( api_base_url: str, headers: dict diff --git a/src/panel/creds.py b/src/panel/creds.py index 9f64df0a0..84b6c1413 100644 --- a/src/panel/creds.py +++ b/src/panel/creds.py @@ -8,7 +8,7 @@ import os import time import zipfile -from typing import Any, List +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Response from fastapi.responses import JSONResponse @@ -22,7 +22,7 @@ from src.storage_adapter import get_storage_adapter from src.utils import verify_panel_token, GEMINICLI_USER_AGENT, ANTIGRAVITY_USER_AGENT from src.api.antigravity import fetch_quota_info -from src.google_oauth_api import Credentials, fetch_project_id_and_tier +from src.google_oauth_api import Credentials, fetch_credit_amount, fetch_project_id_and_tier from config import get_code_assist_endpoint, get_antigravity_api_url from .utils import validate_mode @@ -102,6 +102,42 @@ async def clear_all_model_cooldowns_for_credential( log.warning(f"清空模型CD时出错: {filename} (mode={mode}), error={e}") +def _normalize_credit_amount(value: Any) -> Optional[float]: + """Return a numeric credit amount when the provider sends one.""" + if value is None: + return None + + try: + return float(value) + except (TypeError, ValueError): + try: + return float(str(value).replace(",", "").strip()) + except (TypeError, ValueError): + return None + + +def _average_quota_remaining(models: Dict[str, Any]) -> Optional[float]: + """Average model remaining fractions for one credential.""" + remaining_values: List[float] = [] + + for quota_data in models.values(): + if not isinstance(quota_data, dict): + continue + + remaining = quota_data.get("remaining") + try: + remaining_float = float(remaining) + except (TypeError, ValueError): + continue + + remaining_values.append(max(0.0, min(1.0, remaining_float))) + + if not remaining_values: + return None + + return sum(remaining_values) / len(remaining_values) + + async def upload_credentials_common( files: List[UploadFile], mode: str = "geminicli" ) -> JSONResponse: @@ -1213,6 +1249,218 @@ async def get_credential_quota( raise HTTPException(status_code=500, detail=f"获取额度失败: {str(e)}") +@router.get("/antigravity-credit-summary") +async def get_antigravity_credit_summary( + token: str = Depends(verify_panel_token), +): + """ + Return aggregate Antigravity quota and credit data for enabled credentials. + """ + try: + storage_adapter = await get_storage_adapter() + all_states = await storage_adapter.get_all_credential_states(mode="antigravity") + enabled_filenames = [ + filename + for filename, state in all_states.items() + if not state.get("disabled", False) + ] + + if not enabled_filenames: + return JSONResponse(content={ + "success": True, + "enabled_accounts": 0, + "checked_accounts": 0, + "failed_accounts": 0, + "credit_accounts": 0, + "quota_accounts": 0, + "total_remaining_credits": 0, + "remaining_fraction": None, + "remaining_percent": None, + "model_summaries": [], + "updated_at": time.time(), + "errors": [], + }) + + api_base_url = await get_antigravity_api_url() + semaphore = asyncio.Semaphore(5) + + async def fetch_one(filename: str) -> Dict[str, Any]: + result: Dict[str, Any] = { + "filename": os.path.basename(filename), + "success": False, + "credit_amount": None, + "quota_remaining_fraction": None, + "quota_model_count": 0, + "models": {}, + "error": None, + } + + async with semaphore: + try: + credential_data = await storage_adapter.get_credential( + filename, mode="antigravity" + ) + if not credential_data: + result["error"] = "credential not found" + return result + + creds = Credentials.from_dict(credential_data) + token_refreshed = await creds.refresh_if_needed() + updated_data = creds.to_dict() + + if token_refreshed or updated_data != credential_data: + await storage_adapter.store_credential( + filename, updated_data, mode="antigravity" + ) + credential_data = updated_data + + access_token = ( + credential_data.get("access_token") + or credential_data.get("token") + or creds.access_token + ) + if not access_token: + result["error"] = "missing access token" + return result + + quota_info, credit_amount = await asyncio.gather( + fetch_quota_info(access_token), + fetch_credit_amount( + access_token=access_token, + user_agent=ANTIGRAVITY_USER_AGENT, + api_base_url=api_base_url, + ), + ) + + normalized_credit = _normalize_credit_amount(credit_amount) + if normalized_credit is not None: + result["credit_amount"] = normalized_credit + + if quota_info.get("success"): + models = quota_info.get("models", {}) or {} + result["quota_model_count"] = len(models) + result["quota_remaining_fraction"] = _average_quota_remaining(models) + + model_entries: Dict[str, Any] = {} + for model_name, quota_data in models.items(): + if not isinstance(quota_data, dict): + continue + + try: + remaining_float = float(quota_data.get("remaining")) + except (TypeError, ValueError): + continue + + remaining_float = max(0.0, min(1.0, remaining_float)) + model_entries[model_name] = { + "remaining_fraction": remaining_float, + "remaining_percent": round(remaining_float * 100, 2), + } + + result["models"] = model_entries + else: + result["error"] = quota_info.get("error", "quota fetch failed") + + result["success"] = ( + result["credit_amount"] is not None + or result["quota_remaining_fraction"] is not None + ) + return result + + except Exception as e: + result["error"] = str(e) + log.warning( + f"[ANTIGRAVITY SUMMARY] Failed to fetch {filename}: {e}" + ) + return result + + results = await asyncio.gather(*(fetch_one(filename) for filename in enabled_filenames)) + + quota_fractions = [ + item["quota_remaining_fraction"] + for item in results + if item.get("quota_remaining_fraction") is not None + ] + credit_amounts = [ + item["credit_amount"] + for item in results + if item.get("credit_amount") is not None + ] + + remaining_fraction = ( + sum(quota_fractions) / len(quota_fractions) + if quota_fractions + else None + ) + total_remaining_credits = sum(credit_amounts) if credit_amounts else None + + model_buckets: Dict[str, Dict[str, Any]] = {} + for item in results: + for model_name, model_data in (item.get("models") or {}).items(): + remaining_fraction = model_data.get("remaining_fraction") + if remaining_fraction is None: + continue + + bucket = model_buckets.setdefault( + model_name, + { + "remaining_values": [], + }, + ) + bucket["remaining_values"].append(remaining_fraction) + + model_summaries = [] + for model_name, bucket in model_buckets.items(): + remaining_values = bucket["remaining_values"] + if not remaining_values: + continue + + average_remaining = sum(remaining_values) / len(remaining_values) + exhausted_accounts = sum(1 for value in remaining_values if value <= 0) + + model_summaries.append({ + "model": model_name, + "account_count": len(remaining_values), + "exhausted_accounts": exhausted_accounts, + "remaining_fraction": average_remaining, + "remaining_percent": round(average_remaining * 100, 2), + }) + + model_summaries.sort(key=lambda item: (item["remaining_percent"], item["model"])) + + failed_results = [item for item in results if not item.get("success")] + errors = [ + { + "filename": item.get("filename"), + "error": item.get("error") or "no quota or credit data", + } + for item in failed_results + ][:10] + + return JSONResponse(content={ + "success": True, + "enabled_accounts": len(enabled_filenames), + "checked_accounts": len(results), + "failed_accounts": len(failed_results), + "credit_accounts": len(credit_amounts), + "quota_accounts": len(quota_fractions), + "total_remaining_credits": total_remaining_credits, + "remaining_fraction": remaining_fraction, + "remaining_percent": ( + round(remaining_fraction * 100, 2) + if remaining_fraction is not None + else None + ), + "model_summaries": model_summaries, + "updated_at": time.time(), + "errors": errors, + }) + + except Exception as e: + log.error(f"[ANTIGRAVITY SUMMARY] Failed to build summary: {e}") + raise HTTPException(status_code=500, detail=f"获取总额度失败: {str(e)}") + + @router.post("/configure-preview/{filename}") async def configure_preview_channel( filename: str, From 608a80b4191ea4bc0927facf93a1ec1669476d9b Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Thu, 21 May 2026 01:49:24 +0800 Subject: [PATCH 22/25] Fix Antigravity summary script cache [skip ci] --- front/common.js | 4 ++++ front/control_panel.html | 2 +- front/control_panel_mobile.html | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/front/common.js b/front/common.js index a6e9134d5..2725de592 100644 --- a/front/common.js +++ b/front/common.js @@ -1668,6 +1668,10 @@ async function refreshAntigravityCreditSummary(silent = false) { } } +window.toggleAntigravityCreditSummary = toggleAntigravityCreditSummary; +window.refreshAntigravityCreditSummary = refreshAntigravityCreditSummary; +window.startAntigravityCreditSummaryAutoRefresh = startAntigravityCreditSummaryAutoRefresh; + function applyAntigravityStatusFilter() { AppState.antigravityCreds.applyStatusFilter(); } function changeAntigravityPage(direction) { AppState.antigravityCreds.changePage(direction); } function changeAntigravityPageSize() { AppState.antigravityCreds.changePageSize(); } diff --git a/front/control_panel.html b/front/control_panel.html index 4374b7f94..13730695d 100644 --- a/front/control_panel.html +++ b/front/control_panel.html @@ -2610,7 +2610,7 @@

📞 联系我们

- + diff --git a/front/control_panel_mobile.html b/front/control_panel_mobile.html index 6f75d1110..f1161706e 100644 --- a/front/control_panel_mobile.html +++ b/front/control_panel_mobile.html @@ -2321,7 +2321,7 @@

📞 联系我们

- + From d7ebfad641b57800c619e64391d42bfc53d6b22a Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Thu, 21 May 2026 01:57:24 +0800 Subject: [PATCH 23/25] Move Antigravity credit summary above stats [skip ci] --- front/control_panel.html | 33 +++++++++++++++++---------------- front/control_panel_mobile.html | 33 +++++++++++++++++---------------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/front/control_panel.html b/front/control_panel.html index 13730695d..3b1f96e08 100644 --- a/front/control_panel.html +++ b/front/control_panel.html @@ -2054,22 +2054,7 @@

Antigravity凭证文件管理

- -
-
- 0 - 总计 -
-
- 0 - 正常 -
-
- 0 - 禁用 -
-
- + + +
+
+ 0 + 总计 +
+
+ 0 + 正常 +
+
+ 0 + 禁用 +
+
+
diff --git a/front/control_panel_mobile.html b/front/control_panel_mobile.html index f1161706e..1b42f25ef 100644 --- a/front/control_panel_mobile.html +++ b/front/control_panel_mobile.html @@ -1731,22 +1731,7 @@

Antigravity凭证文件管理

- -
-
- 0 - 总计 -
-
- 0 - 正常 -
-
- 0 - 禁用 -
-
- + + +
+
+ 0 + 总计 +
+
+ 0 + 正常 +
+
+ 0 + 禁用 +
+
+
From 0754cab3b96221094983df4090ca3a573c682b57 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Thu, 21 May 2026 14:57:42 +0800 Subject: [PATCH 24/25] chore: update version.txt [skip ci] --- version.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/version.txt b/version.txt index c3961c4c1..ad8c1269b 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,4 @@ -full_hash=9cb0487442f5fa4f24a50088bfd361f2fdb964f2 -short_hash=9cb0487 -message=Merge pull request #377 from STA1N156/codex/roocode-newapi-compat -date=2026-05-20 19:01:35 +0800 +full_hash=9b8ad0ef5d207083c812d54c5de7f015c8d7b011 +short_hash=9b8ad0e +message=Merge upstream master into cache routing PR [skip ci] +date=2026-05-21 14:57:26 +0800 From b8964df6260d5fe95e374bd9bd29deda53b6e3b1 Mon Sep 17 00:00:00 2001 From: STA1N156 Date: Sat, 23 May 2026 03:23:39 +0800 Subject: [PATCH 25/25] fix: return 461 for empty model output --- src/api/antigravity.py | 47 ++++++---- src/api/empty_output.py | 188 ++++++++++++++++++++++++++++++++++++++++ src/api/geminicli.py | 35 +++++++- src/api/utils.py | 11 +-- 4 files changed, 259 insertions(+), 22 deletions(-) create mode 100644 src/api/empty_output.py diff --git a/src/api/antigravity.py b/src/api/antigravity.py index fb79ae4d4..08cb813cf 100644 --- a/src/api/antigravity.py +++ b/src/api/antigravity.py @@ -18,6 +18,11 @@ ) from log import log +from src.api.empty_output import ( + build_empty_model_output_response, + is_empty_model_output, + stream_chunk_has_visible_output, +) from src.credential_manager import credential_manager from src.httpx_client import stream_post_async, post_async from src.models import Model, model_to_dict @@ -364,6 +369,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: for attempt in range(max_retries + 1): success_recorded = False # 标记是否已记录成功 need_retry = False # 标记是否需要重试 + buffered_chunks = [] try: async for chunk in stream_post_async( @@ -439,21 +445,29 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: return else: # 不是Response,说明是真流,直接yield返回 + # 记录原始chunk内容(用于调试) + if isinstance(chunk, bytes): + log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") + else: + log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") + # 只在第一个chunk时记录成功 if not success_recorded: + buffered_chunks.append(chunk) + if not stream_chunk_has_visible_output(chunk): + continue + await record_api_call_success( credential_manager, current_file, mode="antigravity", model_name=model_name ) success_recorded = True log.debug(f"[ANTIGRAVITY STREAM] 开始接收流式响应,模型: {model_name}") - # 记录原始chunk内容(用于调试) - if isinstance(chunk, bytes): - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(bytes): {chunk}") + for buffered_chunk in buffered_chunks: + yield buffered_chunk + buffered_chunks = [] else: - log.debug(f"[ANTIGRAVITY STREAM RAW] chunk(str): {chunk}") - - yield chunk + yield chunk # 流式请求完成,检查结果 if success_recorded: @@ -472,11 +486,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: need_retry = True else: log.error(f"[ANTIGRAVITY STREAM] 空回复达到最大重试次数") - yield Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) + yield build_empty_model_output_response() return # 统一处理重试 @@ -673,6 +683,15 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 成功 if status_code == 200: + if is_empty_model_output(response.content): + log.warning(f"[ANTIGRAVITY] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="antigravity", model_name=model_name, + error_message="可能触发外审导致空回" + ) + return build_empty_model_output_response() + # 检查是否为空回复 if not response.content or len(response.content) == 0: log.warning(f"[ANTIGRAVITY] 收到200响应但内容为空,凭证: {current_file}") @@ -688,11 +707,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: need_retry = True else: log.error(f"[ANTIGRAVITY] 空回复达到最大重试次数") - return Response( - content=json.dumps({"error": "服务返回空回复"}), - status_code=500, - media_type="application/json" - ) + return build_empty_model_output_response() else: # 正常响应 await record_api_call_success( diff --git a/src/api/empty_output.py b/src/api/empty_output.py new file mode 100644 index 000000000..ed27b334d --- /dev/null +++ b/src/api/empty_output.py @@ -0,0 +1,188 @@ +import json +from typing import Any, Mapping + +from fastapi import Response + +from src.converter.thoughtSignature_fix import ( + is_internal_placeholder_text, + is_skip_thought_signature_placeholder, +) + + +EMPTY_MODEL_OUTPUT_STATUS_CODE = 461 +EMPTY_MODEL_OUTPUT_MESSAGE = "可能触发外审导致空回" +EMPTY_MODEL_OUTPUT_STATUS = "EMPTY_MODEL_OUTPUT" + +_STRUCTURED_OUTPUT_KEYS = ( + "functionCall", + "function_call", + "functionResponse", + "function_response", + "inlineData", + "inline_data", + "fileData", + "file_data", + "executableCode", + "executable_code", + "codeExecutionResult", + "code_execution_result", +) + + +def build_empty_model_output_response() -> Response: + return Response( + content=json.dumps( + { + "error": { + "code": EMPTY_MODEL_OUTPUT_STATUS_CODE, + "message": EMPTY_MODEL_OUTPUT_MESSAGE, + "status": EMPTY_MODEL_OUTPUT_STATUS, + } + }, + ensure_ascii=False, + ), + status_code=EMPTY_MODEL_OUTPUT_STATUS_CODE, + media_type="application/json", + ) + + +def is_empty_model_output(raw_content: Any) -> bool: + if raw_content is None: + return True + + if isinstance(raw_content, bytes): + content_text = raw_content.decode("utf-8", errors="ignore") + elif isinstance(raw_content, str): + content_text = raw_content + else: + content_text = str(raw_content) + + if not content_text.strip(): + return True + + try: + payload = json.loads(content_text) + except (TypeError, ValueError): + return False + + return is_empty_model_output_payload(payload) + + +def is_empty_model_output_payload(payload: Any) -> bool: + response_data = _get_response_data(payload) + if response_data is None: + return False + + candidates = response_data.get("candidates") + if not isinstance(candidates, list) or not candidates: + return True + + return not any(_candidate_has_visible_output(candidate) for candidate in candidates) + + +def has_visible_model_output_payload(payload: Any) -> bool: + response_data = _get_response_data(payload) + if response_data is None: + return False + + candidates = response_data.get("candidates") + if not isinstance(candidates, list) or not candidates: + return False + + return any(_candidate_has_visible_output(candidate) for candidate in candidates) + + +def stream_chunk_has_visible_output(chunk: Any) -> bool: + if chunk is None: + return False + + if isinstance(chunk, bytes): + chunk_text = chunk.decode("utf-8", errors="ignore") + elif isinstance(chunk, str): + chunk_text = chunk + else: + chunk_text = str(chunk) + + for payload_text in _iter_stream_payloads(chunk_text): + try: + payload = json.loads(payload_text) + except (TypeError, ValueError): + continue + + if has_visible_model_output_payload(payload): + return True + + return False + + +def _get_response_data(payload: Any) -> Mapping[str, Any] | None: + if not isinstance(payload, Mapping): + return None + + if payload.get("error"): + return None + + response_data = payload.get("response") if isinstance(payload.get("response"), Mapping) else payload + if not isinstance(response_data, Mapping) or response_data.get("error"): + return None + + return response_data + + +def _iter_stream_payloads(chunk_text: str): + stripped = chunk_text.strip() + if not stripped: + return + + if stripped.startswith("data:"): + for line in chunk_text.splitlines(): + line = line.strip() + if not line.startswith("data:"): + continue + payload_text = line[5:].strip() + if payload_text and payload_text != "[DONE]": + yield payload_text + return + + if stripped != "[DONE]": + yield stripped + + +def _candidate_has_visible_output(candidate: Any) -> bool: + if not isinstance(candidate, Mapping): + return False + + content = candidate.get("content") + if not isinstance(content, Mapping): + return False + + parts = content.get("parts") + if not isinstance(parts, list) or not parts: + return False + + for part in parts: + if not isinstance(part, Mapping): + continue + + if is_skip_thought_signature_placeholder(part): + continue + + if any(key in part for key in _STRUCTURED_OUTPUT_KEYS): + return True + + if part.get("thought") is True: + continue + + if "text" not in part: + continue + + text = part.get("text") + if isinstance(text, str): + if is_internal_placeholder_text(text): + continue + if text.strip(): + return True + elif text is not None: + return True + + return False diff --git a/src/api/geminicli.py b/src/api/geminicli.py index 90dbb9f7d..b11079ca9 100644 --- a/src/api/geminicli.py +++ b/src/api/geminicli.py @@ -22,6 +22,11 @@ from log import log from src.credential_manager import credential_manager +from src.api.empty_output import ( + build_empty_model_output_response, + is_empty_model_output, + stream_chunk_has_visible_output, +) from src.httpx_client import stream_post_async, post_async from src.session_affinity import extract_cache_session_key @@ -220,6 +225,7 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: for attempt in range(max_retries + 1): success_recorded = False # 标记是否已记录成功 need_retry = False # 标记是否需要重试 + buffered_chunks = [] try: async for chunk in stream_post_async( @@ -334,19 +340,37 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 不是Response,说明是真流,直接yield返回 # 只在第一个chunk时记录成功 if not success_recorded: + buffered_chunks.append(chunk) + if not stream_chunk_has_visible_output(chunk): + continue + await record_api_call_success( credential_manager, current_file, mode="geminicli", model_name=model_name ) success_recorded = True log.debug(f"[GEMINICLI STREAM] 开始接收流式响应,模型: {model_name}") - yield chunk + for buffered_chunk in buffered_chunks: + yield buffered_chunk + buffered_chunks = [] + else: + yield chunk # 流式请求完成,检查结果 if success_recorded: log.debug(f"[GEMINICLI STREAM] 流式响应完成,模型: {model_name}") return + if not need_retry: + log.warning(f"[GEMINICLI STREAM] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="geminicli", model_name=model_name, + error_message="可能触发外审导致空回" + ) + yield build_empty_model_output_response() + return + # 统一处理重试 if need_retry: # 如果已经是最后一次尝试,不再重试,直接返回错误 @@ -523,6 +547,15 @@ def apply_cred_result(cred_result: Tuple[str, Dict[str, Any]]) -> bool: # 成功 if status_code == 200: + if is_empty_model_output(response.content): + log.warning(f"[NON-STREAM] Model returned empty output, credential: {current_file}") + await record_api_call_error( + credential_manager, current_file, 461, + None, mode="geminicli", model_name=model_name, + error_message="可能触发外审导致空回" + ) + return build_empty_model_output_response() + await record_api_call_success( credential_manager, current_file, mode="geminicli", model_name=model_name ) diff --git a/src/api/utils.py b/src/api/utils.py index c90dace7d..c257c8d62 100644 --- a/src/api/utils.py +++ b/src/api/utils.py @@ -18,6 +18,7 @@ get_retry_429_max_retries, ) from log import log +from src.api.empty_output import build_empty_model_output_response, is_empty_model_output_payload from src.credential_manager import CredentialManager @@ -390,11 +391,7 @@ async def collect_streaming_response(stream_generator) -> Response: # 如果没有收集到任何数据,返回错误 if not has_data: log.error(f"[STREAM COLLECTOR] No data collected from stream after {line_count} lines") - return Response( - content=json.dumps({"error": "No data collected from stream"}), - status_code=500, - media_type="application/json" - ) + return build_empty_model_output_response() # 组装最终的parts final_parts = [] @@ -433,6 +430,10 @@ async def collect_streaming_response(stream_generator) -> Response: merged_response = merged_response["response"] # 返回纯JSON格式 + if is_empty_model_output_payload(merged_response): + log.warning("[STREAM COLLECTOR] Collected stream contains empty model output") + return build_empty_model_output_response() + return Response( content=json.dumps(merged_response, ensure_ascii=False).encode('utf-8'), status_code=200,