fischer-agentkit/src/agentkit/llm/providers/gemini.py

490 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gemini Provider - 原生 Google Gemini API 支持"""
import json
import logging
import time
import httpx
from agentkit.core.exceptions import LLMProviderError
from agentkit.llm.protocol import (
LLMProvider,
LLMRequest,
LLMResponse,
StreamChunk,
TokenUsage,
ToolCall,
)
from agentkit.llm.retry import (
CircuitBreaker,
CircuitBreakerConfig,
RetryConfig,
RetryPolicy,
)
logger = logging.getLogger(__name__)
class _GeminiStreamContext:
"""Wraps an httpx streaming response context manager for use with retry/circuit breaker."""
def __init__(self, response_ctx, response):
self._response_ctx = response_ctx
self._response = response
async def __aenter__(self):
return self._response
async def __aexit__(self, exc_type, exc_val, exc_tb):
return await self._response_ctx.__aexit__(exc_type, exc_val, exc_tb)
class GeminiProvider(LLMProvider):
"""Google Gemini API 原生 Provider"""
def __init__(
self,
api_key: str,
model: str = "gemini-2.0-flash",
max_output_tokens: int = 4096,
base_url: str = "https://generativelanguage.googleapis.com",
timeout: float = 120.0,
safety_settings: list | None = None,
retry_config: RetryConfig | None = None,
circuit_breaker_config: CircuitBreakerConfig | None = None,
max_connections: int = 100,
max_keepalive_connections: int = 20,
keepalive_expiry: float = 30.0,
):
self._api_key = api_key
self._model = model
self._max_output_tokens = max_output_tokens
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._safety_settings = safety_settings
self._limits = httpx.Limits(
max_connections=max_connections,
max_keepalive_connections=max_keepalive_connections,
keepalive_expiry=keepalive_expiry,
)
self._client: httpx.AsyncClient | None = None
self._retry_policy = RetryPolicy(retry_config) if retry_config else None
self._circuit_breaker = (
CircuitBreaker(circuit_breaker_config, provider="gemini")
if circuit_breaker_config
else None
)
def _get_client(self) -> httpx.AsyncClient:
"""Lazy client initialization"""
if self._client is None:
self._client = httpx.AsyncClient(timeout=self._timeout, limits=self._limits)
return self._client
async def close(self) -> None:
"""关闭 HTTP 客户端连接池"""
if self._client is not None:
await self._client.aclose()
self._client = None
def _convert_messages(
self, messages: list[dict[str, str]]
) -> tuple[dict[str, object] | None, list[dict[str, object]]]:
"""将 OpenAI 风格消息转换为 Gemini 格式
Returns:
(system_instruction, contents)
"""
system_instruction: dict[str, object] | None = None
contents: list[dict[str, object]] = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
if role == "system":
system_instruction = {"parts": [{"text": content}]}
continue
if role == "user":
# Check if this is a tool result message
if msg.get("tool_call_id"):
# Tool response: role="user" with functionResponse part
tool_name = msg.get("name", "")
# If name not at top level, try to extract from content
if not tool_name and isinstance(content, str):
try:
parsed = json.loads(content)
tool_name = parsed.get("name", "")
except (json.JSONDecodeError, AttributeError):
pass
contents.append(
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": tool_name,
"response": {
"content": content,
},
},
}
],
}
)
else:
contents.append(
{
"role": "user",
"parts": [{"text": content}],
}
)
continue
if role == "assistant":
tool_calls = msg.get("tool_calls")
if tool_calls:
parts: list[dict[str, object]] = []
if content:
parts.append({"text": content})
for tc in tool_calls:
func = tc.get("function", {})
arguments = func.get("arguments", "{}")
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except json.JSONDecodeError:
arguments = {"raw": arguments}
parts.append(
{
"functionCall": {
"name": func.get("name", ""),
"args": arguments,
},
}
)
contents.append({"role": "model", "parts": parts})
else:
contents.append(
{
"role": "model",
"parts": [{"text": content}],
}
)
continue
if role == "tool":
# OpenAI format: {"role": "tool", "tool_call_id": "...", "content": "..."}
tool_name = msg.get("name", "")
tool_content = msg.get("content", "")
contents.append(
{
"role": "user",
"parts": [
{
"functionResponse": {
"name": tool_name,
"response": {
"content": tool_content,
},
},
}
],
}
)
return system_instruction, contents
def _convert_tools(self, tools: list[dict[str, object]]) -> list[dict[str, object]]:
"""将 OpenAI function 格式转换为 Gemini functionDeclarations"""
declarations = []
for tool in tools:
if tool.get("type") == "function":
func = tool.get("function", {})
declarations.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {"type": "object", "properties": {}}),
}
)
if not declarations:
return []
return [{"functionDeclarations": declarations}]
def _convert_tool_choice(self, tool_choice: str) -> dict[str, object] | None:
"""将 OpenAI tool_choice 格式转换为 Gemini toolConfig"""
if tool_choice == "auto":
return {"functionCallingConfig": {"mode": "AUTO"}}
elif tool_choice == "required":
return {"functionCallingConfig": {"mode": "ANY"}}
elif tool_choice and tool_choice not in ("none",):
return {"functionCallingConfig": {"mode": "AUTO"}}
if tool_choice == "none":
return {"functionCallingConfig": {"mode": "NONE"}}
return None
def _parse_response(self, data: dict[str, object], model: str) -> LLMResponse:
"""将 Gemini 响应转换为 LLMResponse"""
candidates = data.get("candidates", [])
text_parts: list[str] = []
tool_calls: list[ToolCall] = []
tool_call_index = 0
if candidates:
content = candidates[0].get("content", {})
parts = content.get("parts", [])
for part in parts:
if "text" in part:
text_parts.append(part["text"])
elif "functionCall" in part:
fc = part["functionCall"]
tool_calls.append(
ToolCall(
id=f"call_{tool_call_index}",
name=fc.get("name", ""),
arguments=fc.get("args", {}),
)
)
tool_call_index += 1
usage_metadata = data.get("usageMetadata", {})
usage = TokenUsage(
prompt_tokens=usage_metadata.get("promptTokenCount", 0),
completion_tokens=usage_metadata.get("candidatesTokenCount", 0),
)
return LLMResponse(
content="".join(text_parts),
model=data.get("modelVersion", model),
usage=usage,
tool_calls=tool_calls,
)
def _handle_error(self, status_code: int, resp_body: bytes) -> None:
"""处理 Gemini API 错误响应"""
try:
error_data = json.loads(resp_body)
error_info = error_data.get("error", {})
error_msg = error_info.get("message", f"HTTP {status_code}")
except (json.JSONDecodeError, AttributeError):
error_msg = f"HTTP {status_code}"
raise LLMProviderError("gemini", f"HTTP {status_code}: {error_msg}")
async def chat(self, request: LLMRequest) -> LLMResponse:
"""发送 chat 请求(带 retry + circuit breaker"""
if self._circuit_breaker and self._retry_policy:
return await self._circuit_breaker.execute(
self._retry_policy.execute, self._chat_impl, request
)
if self._retry_policy:
return await self._retry_policy.execute(self._chat_impl, request)
if self._circuit_breaker:
return await self._circuit_breaker.execute(self._chat_impl, request)
return await self._chat_impl(request)
async def _chat_impl(self, request: LLMRequest) -> LLMResponse:
client = self._get_client()
model = request.model or self._model
url = f"{self._base_url}/v1beta/models/{model}:generateContent?key={self._api_key}"
system_instruction, contents = self._convert_messages(request.messages)
payload: dict[str, object] = {
"contents": contents,
"generationConfig": {
"temperature": request.temperature,
"maxOutputTokens": request.max_tokens or self._max_output_tokens,
},
}
if system_instruction is not None:
payload["systemInstruction"] = system_instruction
if request.tools:
gemini_tools = self._convert_tools(request.tools)
if gemini_tools:
payload["tools"] = gemini_tools
tool_config = self._convert_tool_choice(request.tool_choice)
if tool_config is not None:
payload["toolConfig"] = tool_config
if self._safety_settings:
payload["safetySettings"] = self._safety_settings
start = time.monotonic()
try:
resp = await client.post(url, json=payload)
except httpx.HTTPError as e:
raise LLMProviderError("gemini", str(e)) from e
latency_ms = (time.monotonic() - start) * 1000
if resp.status_code != 200:
self._handle_error(resp.status_code, resp.content)
data = resp.json()
response = self._parse_response(data, model)
response.latency_ms = latency_ms
return response
async def chat_stream(self, request: LLMRequest):
"""Stream chat response using SSE带 retry + circuit breaker"""
if self._circuit_breaker and self._retry_policy:
ctx = await self._circuit_breaker.execute(
self._retry_policy.execute, self._open_stream, request
)
elif self._retry_policy:
ctx = await self._retry_policy.execute(self._open_stream, request)
elif self._circuit_breaker:
ctx = await self._circuit_breaker.execute(self._open_stream, request)
else:
ctx = await self._open_stream(request)
async with ctx as response:
async for chunk in self._iterate_stream(response, request):
yield chunk
async def _open_stream(self, request: LLMRequest):
"""Open the streaming HTTP connection; returns an async context manager."""
client = self._get_client()
model = request.model or self._model
url = f"{self._base_url}/v1beta/models/{model}:streamGenerateContent?key={self._api_key}&alt=sse"
system_instruction, contents = self._convert_messages(request.messages)
payload: dict[str, object] = {
"contents": contents,
"generationConfig": {
"temperature": request.temperature,
"maxOutputTokens": request.max_tokens or self._max_output_tokens,
},
}
if system_instruction is not None:
payload["systemInstruction"] = system_instruction
if request.tools:
gemini_tools = self._convert_tools(request.tools)
if gemini_tools:
payload["tools"] = gemini_tools
tool_config = self._convert_tool_choice(request.tool_choice)
if tool_config is not None:
payload["toolConfig"] = tool_config
if self._safety_settings:
payload["safetySettings"] = self._safety_settings
response_ctx = client.stream("POST", url, json=payload)
response = await response_ctx.__aenter__()
if response.status_code != 200:
error_body = await response.aread()
await response_ctx.__aexit__(None, None, None)
self._handle_error(response.status_code, error_body)
return _GeminiStreamContext(response_ctx, response)
async def _iterate_stream(self, response, request: LLMRequest):
"""Iterate over an already-open SSE stream and yield StreamChunks."""
accumulated_tool_calls: list[dict[str, object]] = []
model = request.model or self._model
async for line in response.aiter_lines():
line = line.strip()
if not line or not line.startswith("data: "):
continue
data_str = line[6:]
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
candidates = data.get("candidates", [])
if not candidates:
# Usage-only chunk
usage_metadata = data.get("usageMetadata")
if usage_metadata:
usage = TokenUsage(
prompt_tokens=usage_metadata.get("promptTokenCount", 0),
completion_tokens=usage_metadata.get("candidatesTokenCount", 0),
)
if accumulated_tool_calls:
tool_calls = [
ToolCall(
id=tc["id"],
name=tc["name"],
arguments=tc["arguments"],
)
for tc in accumulated_tool_calls
]
yield StreamChunk(
content="",
model=data.get("modelVersion", model),
tool_calls=tool_calls,
usage=usage,
is_final=True,
)
accumulated_tool_calls = []
else:
yield StreamChunk(
content="",
model=data.get("modelVersion", model),
usage=usage,
is_final=True,
)
continue
content = candidates[0].get("content", {})
parts = content.get("parts", [])
for part in parts:
if "text" in part:
text = part["text"]
if text:
yield StreamChunk(
content=text,
model=data.get("modelVersion", model),
)
elif "functionCall" in part:
fc = part["functionCall"]
accumulated_tool_calls.append(
{
"id": f"call_{len(accumulated_tool_calls)}",
"name": fc.get("name", ""),
"arguments": fc.get("args", {}),
}
)
# Check for finish reason
finish_reason = candidates[0].get("finishReason", "")
if finish_reason in ("STOP", "MAX_TOKENS") and accumulated_tool_calls:
tool_calls = [
ToolCall(
id=tc["id"],
name=tc["name"],
arguments=tc["arguments"],
)
for tc in accumulated_tool_calls
]
yield StreamChunk(
content="",
model=data.get("modelVersion", model),
tool_calls=tool_calls,
is_final=True,
)
accumulated_tool_calls = []
def get_model_info(self) -> dict[str, object]:
"""返回 Provider 和模型信息"""
return {
"provider": "gemini",
"model": self._model,
"max_output_tokens": self._max_output_tokens,
}