mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
@@ -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_signature,unknown fields 被静默丢弃);新 SDK 正确解析 Part 级别 `thought_signature`(bytes),base64 编码后存入 `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` 查询不到数据
|
||||
|
||||
@@ -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/通义千问等)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
|
||||
@@ -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", []))
|
||||
|
||||
Reference in New Issue
Block a user