fix: 迁移 _call_gemini 至 google-genai SDK,修复原生 Gemini thought_signature 支持 (Fixes #440, Refs #421) (#444)

* fix: migrate _call_gemini to google-genai SDK for thought_signature support (Fixes #440, Refs #421)

- Replace deprecated google-generativeai with google-genai in llm_adapter.py
- Add base64 round-trip for thought_signature (bytes <-> str) for Gemini 3 multi-turn tool calls
- Fix to_gemini_declaration() to use JSON Schema lowercase types (object/string) for parameters_json_schema
- Add base64 decode error handling for robustness
- Add google-genai>=1.0.0 to requirements.txt; keep google-generativeai for analyzer/image_stock_extractor

* test: update test_gemini_declaration for JSON Schema lowercase types

* fix: use role="tool" for function response per Gemini API docs (review feedback)
This commit is contained in:
Alfred
2026-02-27 19:10:29 +08:00
committed by GitHub
parent ddd6aaa8e1
commit 38b399143e
5 changed files with 91 additions and 84 deletions

View File

@@ -81,7 +81,11 @@
- 兼容性:非 DeepSeek 提供商不受影响;用户无需配置,无破坏性变更
- 🐛 **Agent Reasoning 400 修复**Fixes #409
- 根因Gemini 3、DeepSeek 等 Reasoning 模型在工具调用响应中返回 `thought_signature`,多轮对话未回传导致代理返回 400
- 修复:`llm_adapter._call_openai` 解析并透传 `provider_specific_fields.thought_signature``executor` 在 assistant_msg 的 tool_calls 中写入该字段
- 修复OpenAI 兼容路径)`llm_adapter._call_openai` 解析并透传 `provider_specific_fields.thought_signature``executor` 在 assistant_msg 的 tool_calls 中写入该字段
- 修复(原生 Gemini 路径):`llm_adapter._call_gemini` 迁移至 `google-genai` 新 SDK`google-generativeai` SDK 不定义 Part.thought_signatureunknown fields 被静默丢弃);新 SDK 正确解析 Part 级别 `thought_signature`bytesbase64 编码后存入 `ToolCall.thought_signature`,回传时 base64 解码写回 Part
- 修复(工具声明类型):`registry.to_gemini_declaration()` 将 Protobuf 大写类型(`OBJECT`/`STRING`)更正为 JSON Schema 小写类型(`object`/`string`),以兼容新 SDK 的 `parameters_json_schema`
- 修复(命名空间工具名):`registry.execute` 支持 Gemini 命名空间工具名(如 `default_api:get_realtime_quote``get_realtime_quote`
- 依赖变更:新增 `google-genai>=1.0.0``google-generativeai>=0.8.0` 保留供 `analyzer.py``image_stock_extractor.py` 使用
- 兼容性:非 Reasoning 模型不受影响;与 LiteLLM Proxy 及其他 OpenAI 兼容代理兼容
- 🐛 **Agent 模式下报告页「相关资讯」为空**Issue #396
- 根因Agent 工具结果仅用于 LLM 上下文,未写入 `news_intel`,前端 `GET /api/v1/history/{query_id}/news` 查询不到数据

View File

@@ -26,7 +26,8 @@ numpy>=1.24.0 # 数值计算
json-repair>=0.55.1 # JSON 修复
# AI 分析
google-generativeai>=0.8.0 # Gemini API
google-generativeai>=0.8.0 # Gemini API (used by analyzer.py and image_stock_extractor.py)
google-genai>=1.0.0 # Gemini API new SDK (used by llm_adapter.py for agent tool-calling)
anthropic>=0.18.0 # Anthropic Claude API可选
openai>=1.0.0 # OpenAI 兼容 API可选支持 DeepSeek/通义千问等)

View File

@@ -6,6 +6,7 @@ Normalizes function-calling / tool-use across Gemini, OpenAI, and Anthropic
into a unified interface consumed by the AgentExecutor.
"""
import base64
import json
import logging
import time
@@ -105,7 +106,7 @@ class LLMToolAdapter:
config = config or get_config()
# Provider clients (lazy-initialized)
self._gemini_model = None
self._gemini_client = None
self._anthropic_client = None
self._openai_client = None
@@ -128,11 +129,10 @@ class LLMToolAdapter:
gemini_key = config.gemini_api_key
if gemini_key and not gemini_key.startswith("your_") and len(gemini_key) > 10:
try:
import google.generativeai as genai
genai.configure(api_key=gemini_key)
model_name = config.gemini_model or "gemini-2.5-flash"
self._gemini_model = genai.GenerativeModel(model_name=model_name)
from google import genai as google_genai
self._gemini_client = google_genai.Client(api_key=gemini_key)
self._gemini_available = True
model_name = config.gemini_model or "gemini-2.5-flash"
logger.info(f"Agent LLM: Gemini initialized (model={model_name})")
except Exception as e:
logger.warning(f"Agent LLM: Gemini init failed: {e}")
@@ -244,83 +244,84 @@ class LLMToolAdapter:
messages: List[Dict[str, Any]],
tools: List[dict],
) -> LLMResponse:
"""Call Gemini with function-calling support."""
import google.generativeai as genai
from google.generativeai.types import content_types
"""Call Gemini with function-calling support using google-genai SDK.
Uses the new google-genai SDK (google.genai) which supports thought_signature
at the Part level, required for Gemini 3 multi-turn tool calls.
"""
from google.genai import types as genai_types
config = self._config
model_name = config.gemini_model or "gemini-2.5-flash"
# Extract system instruction
# Build contents and extract system instruction
system_instruction = None
chat_messages = []
contents = []
for msg in messages:
if msg["role"] == "system":
system_instruction = msg["content"]
elif msg["role"] == "user":
chat_messages.append({"role": "user", "parts": [msg["content"]]})
contents.append(genai_types.Content(
role="user",
parts=[genai_types.Part.from_text(text=msg["content"])],
))
elif msg["role"] == "assistant":
parts = []
if msg.get("content"):
parts.append(msg["content"])
# Handle assistant tool_calls in history
parts.append(genai_types.Part.from_text(text=msg["content"]))
if msg.get("tool_calls"):
for tc in msg["tool_calls"]:
parts.append(genai.protos.Part(
function_call=genai.protos.FunctionCall(
sig_str = tc.get("thought_signature")
sig_bytes = None
if sig_str and isinstance(sig_str, str):
try:
sig_bytes = base64.b64decode(sig_str) or None
except Exception:
logger.debug("thought_signature base64 decode failed for '%s'; omitting", tc["name"])
if sig_bytes:
# Gemini 3 requires thought_signature (bytes) at Part level
parts.append(genai_types.Part(
function_call=genai_types.FunctionCall(
name=tc["name"],
args=tc["arguments"],
),
thought_signature=sig_bytes,
))
else:
parts.append(genai_types.Part.from_function_call(
name=tc["name"],
args=tc["arguments"]
)
))
chat_messages.append({"role": "model", "parts": parts})
args=tc["arguments"],
))
if parts:
contents.append(genai_types.Content(role="model", parts=parts))
elif msg["role"] == "tool":
# Tool result message
chat_messages.append({
"role": "user",
"parts": [genai.protos.Part(
function_response=genai.protos.FunctionResponse(
name=msg["name"],
response={"result": msg["content"]}
)
)]
})
contents.append(genai_types.Content(
role="tool",
parts=[genai_types.Part.from_function_response(
name=msg["name"],
response={"result": msg["content"]},
)],
))
# Build tool declarations
gemini_tools = None
# Build generation config (tools + system instruction + temperature)
gen_config_kwargs: Dict[str, Any] = {"temperature": config.gemini_temperature}
if tools:
function_declarations = []
for t in tools:
function_declarations.append(
genai.protos.FunctionDeclaration(
name=t["name"],
description=t["description"],
parameters=t.get("parameters")
)
function_declarations = [
genai_types.FunctionDeclaration(
name=t["name"],
description=t["description"],
parameters_json_schema=t.get("parameters"),
)
gemini_tools = [genai.protos.Tool(function_declarations=function_declarations)]
for t in tools
]
gen_config_kwargs["tools"] = [genai_types.Tool(function_declarations=function_declarations)]
if system_instruction:
gen_config_kwargs["system_instruction"] = system_instruction
# Create model with system instruction
model = genai.GenerativeModel(
model_name=model_name,
system_instruction=system_instruction,
tools=gemini_tools,
)
# Build contents
contents = []
for cm in chat_messages:
contents.append(genai.protos.Content(
role=cm["role"],
parts=[genai.protos.Part(text=p) if isinstance(p, str) else p for p in cm["parts"]]
))
generation_config = genai.types.GenerationConfig(
temperature=config.gemini_temperature,
)
response = model.generate_content(
response = self._gemini_client.models.generate_content(
model=model_name,
contents=contents,
generation_config=generation_config,
config=genai_types.GenerateContentConfig(**gen_config_kwargs),
)
# Parse response
@@ -329,24 +330,28 @@ class LLMToolAdapter:
if response.candidates and response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if hasattr(part, 'function_call') and part.function_call.name:
if part.function_call:
fc = part.function_call
args = dict(fc.args) if fc.args else {}
# Gemini 3 returns thought_signature as bytes; base64-encode for JSON-safe transport
sig_bytes = part.thought_signature
sig_str = base64.b64encode(sig_bytes).decode("ascii") if sig_bytes else None
tool_calls.append(ToolCall(
id=str(uuid.uuid4())[:8],
name=fc.name,
arguments=args,
thought_signature=sig_str,
))
elif hasattr(part, 'text') and part.text:
elif part.text:
text_content = (text_content or "") + part.text
# Extract usage
usage = {}
if hasattr(response, 'usage_metadata') and response.usage_metadata:
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = {
"prompt_tokens": getattr(response.usage_metadata, 'prompt_token_count', 0),
"completion_tokens": getattr(response.usage_metadata, 'candidates_token_count', 0),
"total_tokens": getattr(response.usage_metadata, 'total_token_count', 0),
"prompt_tokens": getattr(response.usage_metadata, "prompt_token_count", 0),
"completion_tokens": getattr(response.usage_metadata, "candidates_token_count", 0),
"total_tokens": getattr(response.usage_metadata, "total_token_count", 0),
}
return LLMResponse(

View File

@@ -64,24 +64,16 @@ class ToolDefinition:
def to_gemini_declaration(self) -> dict:
"""
Convert to Gemini FunctionDeclaration dict.
Convert to Gemini FunctionDeclaration dict (JSON Schema format).
Gemini uses a flat ``parameters`` dict with ``type: "OBJECT"``,
``properties``, and ``required`` keys.
Uses lowercase JSON Schema types ("object", "string", etc.) as required
by the google-genai SDK's ``parameters_json_schema`` field.
"""
properties: Dict[str, Any] = {}
required: List[str] = []
type_map = {
"string": "STRING",
"number": "NUMBER",
"integer": "INTEGER",
"boolean": "BOOLEAN",
"array": "ARRAY",
"object": "OBJECT",
}
for p in self.parameters:
prop: Dict[str, Any] = {
"type": type_map.get(p.type, "STRING"),
"type": p.type,
"description": p.description,
}
if p.enum:
@@ -93,7 +85,7 @@ class ToolDefinition:
"name": self.name,
"description": self.description,
"parameters": {
"type": "OBJECT",
"type": "object",
"properties": properties,
},
}
@@ -196,8 +188,13 @@ class ToolRegistry:
Returns the result as a JSON-serializable value.
Raises ``KeyError`` if tool not found.
Raises the handler's exception on execution failure.
Supports Gemini namespaced tool names (e.g. default_api:get_realtime_quote -> get_realtime_quote).
"""
tool_def = self._tools.get(name)
if tool_def is None and ":" in name:
# Gemini may return namespaced names like default_api:get_realtime_quote
tool_def = self._tools.get(name.split(":", 1)[-1])
if tool_def is None:
raise KeyError(f"Tool '{name}' not found in registry. Available: {self.list_names()}")

View File

@@ -167,9 +167,9 @@ class TestToolDefinitionSchemas(unittest.TestCase):
self.assertEqual(decl["name"], "quote_tool")
self.assertIn("description", decl)
params = decl["parameters"]
self.assertEqual(params["type"], "OBJECT")
self.assertEqual(params["type"], "object")
self.assertIn("stock_code", params["properties"])
self.assertEqual(params["properties"]["stock_code"]["type"], "STRING")
self.assertEqual(params["properties"]["stock_code"]["type"], "string")
self.assertIn("stock_code", params.get("required", []))
# Optional param should NOT be in required
self.assertNotIn("days", params.get("required", []))