Coverage for agentos/models/backends/gemini.py: 24%

153 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +0800

1""" 

2AgentOS v0.70 — Google Gemini Provider 全集成。 

3基因来源: Google AI Studio SDK + Vertex AI 

4支持: Gemini 2.5 Pro/Flash、Vision、System Instruction、Streaming、Token Counting、Safety Settings。 

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11from dataclasses import dataclass, field 

12from typing import AsyncIterator 

13 

14import httpx 

15 

16from agentos.models.router import ModelResponse, ModelSpec 

17from agentos.core.context import AgentContext 

18from agentos.tools.base import ToolCall 

19 

20 

21# ── Gemini Public API Endpoint ────────────────── 

22GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta" 

23 

24# Prebuilt Gemini model specs 

25GEMINI_MODELS: dict[str, ModelSpec] = { 

26 "gemini-2.5-pro": ModelSpec( 

27 provider="gemini", 

28 model_id="gemini-2.5-pro-exp-03-25", 

29 context_window=1_048_576, 

30 cost_per_1m_input=1.25, 

31 cost_per_1m_output=10.00, 

32 ), 

33 "gemini-2.5-flash": ModelSpec( 

34 provider="gemini", 

35 model_id="gemini-2.5-flash-preview-04-17", 

36 context_window=1_048_576, 

37 cost_per_1m_input=0.15, 

38 cost_per_1m_output=0.60, 

39 ), 

40 "gemini-2.0-flash": ModelSpec( 

41 provider="gemini", 

42 model_id="gemini-2.0-flash", 

43 context_window=1_048_576, 

44 cost_per_1m_input=0.10, 

45 cost_per_1m_output=0.40, 

46 ), 

47} 

48 

49 

50@dataclass 

51class GeminiSafetySetting: 

52 """安全过滤配置。""" 

53 

54 category: str # HARM_CATEGORY_HARASSMENT | HATE_SPEECH | SEXUALLY_EXPLICIT | DANGEROUS_CONTENT 

55 threshold: str = "BLOCK_ONLY_HIGH" # BLOCK_NONE | BLOCK_ONLY_HIGH | BLOCK_MEDIUM_AND_ABOVE | BLOCK_LOW_AND_ABOVE 

56 

57 

58@dataclass 

59class GeminiConfig: 

60 """Gemini调用配置。""" 

61 

62 api_key: str = "" 

63 temperature: float = 0.7 

64 top_p: float = 0.95 

65 top_k: int = 40 

66 max_output_tokens: int = 8192 

67 safety_settings: list[GeminiSafetySetting] = field(default_factory=lambda: [ 

68 GeminiSafetySetting("HARM_CATEGORY_HARASSMENT", "BLOCK_ONLY_HIGH"), 

69 GeminiSafetySetting("HARM_CATEGORY_HATE_SPEECH", "BLOCK_ONLY_HIGH"), 

70 GeminiSafetySetting("HARM_CATEGORY_SEXUALLY_EXPLICIT", "BLOCK_ONLY_HIGH"), 

71 GeminiSafetySetting("HARM_CATEGORY_DANGEROUS_CONTENT", "BLOCK_ONLY_HIGH"), 

72 ]) 

73 

74 

75# ── Tool Declaration Helpers ───────────────────── 

76 

77def _convert_tools_to_gemini(openai_tools: list[dict]) -> list[dict]: 

78 """将OpenAI格式的tools转换为Gemini functionDeclarations。""" 

79 declarations = [] 

80 for tool in openai_tools: 

81 if tool.get("type") != "function": 

82 continue 

83 func = tool.get("function", {}) 

84 declarations.append({ 

85 "name": func.get("name", ""), 

86 "description": func.get("description", ""), 

87 "parameters": func.get("parameters", {}), 

88 }) 

89 return [{"function_declarations": declarations}] if declarations else [] 

90 

91 

92def _convert_gemini_tool_calls(parts: list[dict]) -> list[ToolCall]: 

93 """将Gemini functionCall parts转为ToolCall列表。""" 

94 tool_calls = [] 

95 for part in parts: 

96 fc = part.get("functionCall") 

97 if not fc: 

98 continue 

99 args = fc.get("args", {}) 

100 if isinstance(args, str): 

101 try: 

102 args = json.loads(args) 

103 except json.JSONDecodeError: 

104 args = {} 

105 tool_calls.append(ToolCall( 

106 id=fc.get("name", "unknown"), 

107 name=fc.get("name", "unknown"), 

108 arguments=args, 

109 )) 

110 return tool_calls 

111 

112 

113# ── Core Gemini Client ─────────────────────────── 

114 

115class GeminiClient: 

116 """ 

117 Google Gemini API 客户端。 

118 支持: chat/completions、Vision多模态、Streaming、System Instruction。 

119 """ 

120 

121 def __init__( 

122 self, 

123 config: GeminiConfig | None = None, 

124 http_client: httpx.AsyncClient | None = None, 

125 ): 

126 self.config = config or GeminiConfig() 

127 self._http = http_client or httpx.AsyncClient(timeout=180) 

128 self._owned_http = http_client is None 

129 

130 @property 

131 def api_key(self) -> str: 

132 return self.config.api_key or os.environ.get("GEMINI_API_KEY", "") 

133 

134 async def close(self): 

135 if self._owned_http: 

136 await self._http.aclose() 

137 

138 async def call( 

139 self, 

140 spec: ModelSpec, 

141 context: AgentContext, 

142 ) -> ModelResponse: 

143 """同步调用Gemini API。""" 

144 contents, system_instruction = self._build_gemini_contents(context) 

145 body = { 

146 "contents": contents, 

147 "generationConfig": { 

148 "temperature": self.config.temperature, 

149 "topP": self.config.top_p, 

150 "topK": self.config.top_k, 

151 "maxOutputTokens": self.config.max_output_tokens, 

152 }, 

153 "safetySettings": [ 

154 {"category": s.category, "threshold": s.threshold} 

155 for s in self.config.safety_settings 

156 ], 

157 } 

158 if system_instruction: 

159 body["systemInstruction"] = system_instruction 

160 

161 if context.tools: 

162 body["tools"] = _convert_tools_to_gemini(context.tools) 

163 

164 url = f"{GEMINI_API_BASE}/models/{spec.model_id}:generateContent?key={self.api_key}" 

165 resp = await self._http.post(url, json=body) 

166 resp.raise_for_status() 

167 data = resp.json() 

168 

169 return self._parse_response(data) 

170 

171 async def call_stream( 

172 self, 

173 spec: ModelSpec, 

174 context: AgentContext, 

175 ) -> AsyncIterator[dict]: 

176 """流式调用Gemini API,逐个yield chunk。""" 

177 contents, system_instruction = self._build_gemini_contents(context) 

178 body = { 

179 "contents": contents, 

180 "generationConfig": { 

181 "temperature": self.config.temperature, 

182 "topP": self.config.top_p, 

183 "topK": self.config.top_k, 

184 "maxOutputTokens": self.config.max_output_tokens, 

185 }, 

186 "safetySettings": [ 

187 {"category": s.category, "threshold": s.threshold} 

188 for s in self.config.safety_settings 

189 ], 

190 } 

191 if system_instruction: 

192 body["systemInstruction"] = system_instruction 

193 

194 url = f"{GEMINI_API_BASE}/models/{spec.model_id}:streamGenerateContent?alt=sse&key={self.api_key}" 

195 async with self._http.stream("POST", url, json=body) as resp: 

196 resp.raise_for_status() 

197 async for line in resp.aiter_lines(): 

198 line = line.strip() 

199 if not line or not line.startswith("data: "): 

200 continue 

201 data_str = line[6:] 

202 if data_str == "[DONE]": 

203 break 

204 try: 

205 chunk = json.loads(data_str) 

206 except json.JSONDecodeError: 

207 continue 

208 # skip safety / promptFeedback 

209 if "candidates" not in chunk: 

210 continue 

211 yield chunk 

212 

213 async def call_with_image( 

214 self, 

215 spec: ModelSpec, 

216 prompt: str, 

217 image_data: bytes, 

218 mime_type: str = "image/jpeg", 

219 ) -> ModelResponse: 

220 """Vision多模态调用。image_data为base64之前的内容。""" 

221 import base64 

222 b64 = base64.b64encode(image_data).decode() 

223 contents = [{ 

224 "role": "user", 

225 "parts": [ 

226 {"text": prompt}, 

227 {"inlineData": {"mimeType": mime_type, "data": b64}}, 

228 ], 

229 }] 

230 body = { 

231 "contents": contents, 

232 "generationConfig": { 

233 "temperature": self.config.temperature, 

234 "maxOutputTokens": self.config.max_output_tokens, 

235 }, 

236 } 

237 url = f"{GEMINI_API_BASE}/models/{spec.model_id}:generateContent?key={self.api_key}" 

238 resp = await self._http.post(url, json=body) 

239 resp.raise_for_status() 

240 return self._parse_response(resp.json()) 

241 

242 async def count_tokens(self, spec: ModelSpec, context: AgentContext) -> dict: 

243 """使用Gemini API统计输入/输出token数。""" 

244 contents, _ = self._build_gemini_contents(context) 

245 url = f"{GEMINI_API_BASE}/models/{spec.model_id}:countTokens?key={self.api_key}" 

246 resp = await self._http.post(url, json={"contents": contents}) 

247 resp.raise_for_status() 

248 data = resp.json() 

249 return { 

250 "total_tokens": data.get("totalTokens", 0), 

251 "prompt_tokens": data.get("totalTokens", 0), # Gemini不区分输入输出 

252 "model": spec.model_id, 

253 } 

254 

255 # ── Internal helpers ────────────────────────── 

256 

257 def _build_gemini_contents(self, context: AgentContext) -> tuple[list[dict], dict | None]: 

258 """将AgentContext转为Gemini contents格式。""" 

259 contents = [] 

260 system_instruction = None 

261 

262 for msg in context.messages: 

263 role = self._map_role(msg.role) 

264 parts = [] 

265 

266 # system prompt → systemInstruction 

267 if msg.role == "system": 

268 system_instruction = {"parts": [{"text": msg.content}]} 

269 continue 

270 

271 # text content 

272 if msg.content: 

273 parts.append({"text": msg.content}) 

274 

275 # tool calls from assistant 

276 if msg.tool_calls: 

277 for tc in msg.tool_calls: 

278 parts.append({ 

279 "functionCall": { 

280 "name": tc.name, 

281 "args": tc.arguments, 

282 } 

283 }) 

284 

285 # tool results 

286 if msg.role == "tool" and msg.tool_call_id: 

287 # Gemini uses functionResponse in user role 

288 parts.append({ 

289 "functionResponse": { 

290 "name": msg.tool_call_id, 

291 "response": {"content": msg.content}, 

292 } 

293 }) 

294 

295 if parts: 

296 contents.append({"role": role, "parts": parts}) 

297 

298 # Ensure there's at least a user message 

299 if not contents: 

300 contents = [{"role": "user", "parts": [{"text": context.current_task or ""}]}] 

301 

302 return contents, system_instruction 

303 

304 def _map_role(self, role: str) -> str: 

305 mapping = { 

306 "user": "user", 

307 "assistant": "model", 

308 "system": "user", # handled separately via systemInstruction 

309 "tool": "user", # functionResponse must be in user turn 

310 } 

311 return mapping.get(role, "user") 

312 

313 def _parse_response(self, data: dict) -> ModelResponse: 

314 """解析Gemini API响应为ModelResponse。""" 

315 candidates = data.get("candidates", []) 

316 if not candidates: 

317 # Safety blocked 

318 block_reason = data.get("promptFeedback", {}).get("blockReason", "unknown") 

319 return ModelResponse(content=f"[SAFETY_BLOCKED] {block_reason}") 

320 

321 candidate = candidates[0] 

322 content = candidate.get("content", {}) 

323 parts = content.get("parts", []) 

324 

325 text_parts = [] 

326 tool_calls = [] 

327 

328 for part in parts: 

329 if "text" in part: 

330 text_parts.append(part["text"]) 

331 if "functionCall" in part: 

332 fc = part["functionCall"] 

333 args = fc.get("args", {}) 

334 if isinstance(args, str): 

335 try: 

336 args = json.loads(args) 

337 except json.JSONDecodeError: 

338 args = {} 

339 tool_calls.append(ToolCall( 

340 id=fc.get("name", "unknown"), 

341 name=fc.get("name", "unknown"), 

342 arguments=args, 

343 )) 

344 

345 return ModelResponse( 

346 content="\n".join(text_parts), 

347 tool_calls=tool_calls, 

348 )