返回 last30days-skill
providers.py
根目录 / skills / last30days / scripts / lib / providers.py
1 """Static provider catalog and runtime client implementations."""
2
3 from __future__ import annotations
4
5 import json
6 import os
7 import re
8 import sys
9 from typing import Any
10
11 from . import env, http, schema
12
13 GEMINI_FLASH_LITE = "gemini-3.1-flash-lite"
14 GEMINI_PRO = "gemini-3.1-pro-preview"
15 OPENAI_DEFAULT = "gpt-5.4-nano"
16 XAI_DEFAULT = "grok-4-1-fast"
17
18 GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
19 OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
20 XAI_RESPONSES_URL = "https://api.x.ai/v1/responses"
21 OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
22 # OpenRouter routes the Gemini Flash Lite tier as the -preview slug; that is the
23 # stable form on that routing layer even though native Gemini's GEMINI_FLASH_LITE
24 # constant is suffix-free. If GEMINI_FLASH_LITE moves to a non-preview stable ID,
25 # double-check that OpenRouter's slug still maps to the same upstream model.
26 OPENROUTER_DEFAULT = "google/gemini-3.1-flash-lite-preview"
27
28
29 class ReasoningClient:
30 """Shared interface for planner and rerank providers."""
31
32 name: str
33
34 def generate_text(
35 self,
36 model: str,
37 prompt: str,
38 *,
39 tools: list[dict[str, Any]] | None = None,
40 response_mime_type: str | None = None,
41 ) -> str:
42 raise NotImplementedError
43
44 def generate_json(
45 self,
46 model: str,
47 prompt: str,
48 *,
49 tools: list[dict[str, Any]] | None = None,
50 ) -> dict[str, Any]:
51 text = self.generate_text(model, prompt, tools=tools, response_mime_type="application/json")
52 return extract_json(text)
53
54
55 class GeminiClient(ReasoningClient):
56 name = "gemini"
57
58 def __init__(self, api_key: str):
59 self.api_key = api_key
60
61 def _generate_content(
62 self,
63 model: str,
64 prompt: str,
65 *,
66 tools: list[dict[str, Any]] | None = None,
67 response_mime_type: str | None = None,
68 ) -> dict[str, Any]:
69 body: dict[str, Any] = {
70 "contents": [{"parts": [{"text": prompt}]}],
71 "generationConfig": {"temperature": 0},
72 }
73 if response_mime_type:
74 body["generationConfig"]["responseMimeType"] = response_mime_type
75 if tools:
76 body["tools"] = tools
77 return http.post(
78 GEMINI_URL.format(model=model, api_key=self.api_key),
79 body,
80 headers={"Content-Type": "application/json"},
81 timeout=90,
82 )
83
84 def generate_text(
85 self,
86 model: str,
87 prompt: str,
88 *,
89 tools: list[dict[str, Any]] | None = None,
90 response_mime_type: str | None = None,
91 ) -> str:
92 payload = self._generate_content(
93 model,
94 prompt,
95 tools=tools,
96 response_mime_type=response_mime_type,
97 )
98 return extract_gemini_text(payload)
99
100 class OpenAIClient(ReasoningClient):
101 name = "openai"
102
103 def __init__(self, token: str):
104 self.token = token
105
106 def generate_text(
107 self,
108 model: str,
109 prompt: str,
110 *,
111 tools: list[dict[str, Any]] | None = None,
112 response_mime_type: str | None = None,
113 ) -> str:
114 del tools, response_mime_type
115 payload = {
116 "model": model,
117 "store": False,
118 "input": prompt,
119 "temperature": 0,
120 }
121 response = http.post(
122 os.environ.get("OPENAI_BASE_URL", OPENAI_RESPONSES_URL),
123 payload,
124 headers={
125 "Authorization": f"Bearer {self.token}",
126 "Content-Type": "application/json",
127 },
128 timeout=90,
129 )
130 return extract_openai_text(response)
131
132
133 class XAIClient(ReasoningClient):
134 name = "xai"
135
136 def __init__(self, api_key: str):
137 self.api_key = api_key
138
139 def generate_text(
140 self,
141 model: str,
142 prompt: str,
143 *,
144 tools: list[dict[str, Any]] | None = None,
145 response_mime_type: str | None = None,
146 ) -> str:
147 del tools, response_mime_type
148 payload = {
149 "model": model,
150 "input": [{"role": "user", "content": prompt}],
151 }
152 response = http.post(
153 os.environ.get("XAI_BASE_URL", XAI_RESPONSES_URL),
154 payload,
155 headers={
156 "Authorization": f"Bearer {self.api_key}",
157 "Content-Type": "application/json",
158 },
159 timeout=90,
160 )
161 return extract_openai_text(response)
162
163
164 class OpenRouterClient(ReasoningClient):
165 name = "openrouter"
166
167 def __init__(self, api_key: str):
168 self.api_key = api_key
169
170 def generate_text(
171 self,
172 model: str,
173 prompt: str,
174 *,
175 tools: list[dict[str, Any]] | None = None,
176 response_mime_type: str | None = None,
177 ) -> str:
178 del tools, response_mime_type
179 payload = {
180 "model": model,
181 "messages": [{"role": "user", "content": prompt}],
182 "temperature": 0,
183 }
184 response = http.post(
185 os.environ.get("OPENROUTER_BASE_URL", OPENROUTER_URL),
186 payload,
187 headers={
188 "Authorization": f"Bearer {self.api_key}",
189 "Content-Type": "application/json",
190 },
191 timeout=90,
192 )
193 return extract_openai_text(response)
194
195
196 _MODEL_DEFAULTS: dict[str, tuple[str, str]] = {
197 "gemini": (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE),
198 "openai": (OPENAI_DEFAULT, OPENAI_DEFAULT),
199 "xai": (XAI_DEFAULT, XAI_DEFAULT),
200 "openrouter": (OPENROUTER_DEFAULT, OPENROUTER_DEFAULT),
201 }
202
203
204 def _resolve_model_pins(config: dict[str, Any], depth: str, provider_name: str) -> tuple[str, str, str]:
205 """Resolve planner, rerank, and grounding model pins for a provider."""
206 default_planner, default_rerank = _MODEL_DEFAULTS.get(provider_name, (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE))
207 if depth == "deep" and provider_name == "gemini":
208 default_rerank = GEMINI_PRO
209
210 planner_model = config.get("LAST30DAYS_PLANNER_MODEL") or default_planner
211 rerank_model = config.get("LAST30DAYS_RERANK_MODEL") or default_rerank
212
213 if provider_name == "gemini":
214 _require_gemini_31(planner_model, role="planner")
215 _require_gemini_31(rerank_model, role="rerank")
216
217 return planner_model, rerank_model
218
219
220 def mock_runtime(config: dict[str, Any], depth: str) -> schema.ProviderRuntime:
221 """Resolve model pins for mock mode without requiring live credentials."""
222 provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "gemini").lower()
223 if provider_name == "auto":
224 provider_name = "gemini"
225 if provider_name not in _MODEL_DEFAULTS:
226 raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
227
228 planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
229 return schema.ProviderRuntime(
230 reasoning_provider=provider_name,
231 planner_model=planner_model,
232 rerank_model=rerank_model,
233
234 x_search_backend=_resolve_x_backend(config),
235 )
236
237
238 def resolve_runtime(config: dict[str, Any], depth: str) -> tuple[schema.ProviderRuntime, ReasoningClient | None]:
239 """Resolve the reasoning provider and pinned models."""
240 provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower()
241 google_key = config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
242 openai_token = config.get("OPENAI_API_KEY")
243 xai_key = config.get("XAI_API_KEY")
244
245 if provider_name == "auto":
246 if google_key:
247 provider_name = "gemini"
248 elif openai_token and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK:
249 provider_name = "openai"
250 elif xai_key:
251 provider_name = "xai"
252 elif config.get("OPENROUTER_API_KEY"):
253 provider_name = "openrouter"
254 else:
255 return schema.ProviderRuntime(
256 reasoning_provider="local",
257 planner_model="deterministic",
258 rerank_model="local-score",
259 x_search_backend=_resolve_x_backend(config),
260 ), None
261
262 planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
263
264 if provider_name == "gemini":
265 if not google_key:
266 raise RuntimeError("Gemini selected but no Google API key is configured.")
267 runtime = schema.ProviderRuntime(
268 reasoning_provider="gemini",
269 planner_model=planner_model,
270 rerank_model=rerank_model,
271
272 x_search_backend=_resolve_x_backend(config),
273 )
274 return runtime, GeminiClient(google_key)
275
276 if provider_name == "openai":
277 if not openai_token or config.get("OPENAI_AUTH_STATUS") != env.AUTH_STATUS_OK:
278 raise RuntimeError("OpenAI selected but no valid OpenAI auth is configured.")
279 runtime = schema.ProviderRuntime(
280 reasoning_provider="openai",
281 planner_model=planner_model,
282 rerank_model=rerank_model,
283
284 x_search_backend=_resolve_x_backend(config),
285 )
286 return runtime, OpenAIClient(
287 openai_token
288 )
289
290 if provider_name == "xai":
291 if not xai_key:
292 raise RuntimeError("xAI selected but XAI_API_KEY is not configured.")
293 runtime = schema.ProviderRuntime(
294 reasoning_provider="xai",
295 planner_model=planner_model,
296 rerank_model=rerank_model,
297
298 x_search_backend=_resolve_x_backend(config),
299 )
300 return runtime, XAIClient(xai_key)
301
302 if provider_name == "openrouter":
303 openrouter_key = config.get("OPENROUTER_API_KEY")
304 if not openrouter_key:
305 raise RuntimeError("OpenRouter selected but OPENROUTER_API_KEY is not configured.")
306 runtime = schema.ProviderRuntime(
307 reasoning_provider="openrouter",
308 planner_model=planner_model,
309 rerank_model=rerank_model,
310 x_search_backend=_resolve_x_backend(config),
311 )
312 return runtime, OpenRouterClient(openrouter_key)
313
314 raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
315
316
317 def _resolve_x_backend(config: dict[str, Any]) -> str | None:
318 preferred = (config.get(env.X_BACKEND_PIN_VAR) or "").lower()
319 if preferred in {"xai", "bird"}:
320 return preferred
321 return env.get_x_source(config)
322
323
324 def _require_gemini_31(model: str, *, role: str) -> None:
325 if model.startswith("gemini-3.1-"):
326 return
327 raise RuntimeError(
328 f"{role} must use a Gemini 3.1 model. Got: {model}"
329 )
330
331
332 def extract_json(text: str) -> dict[str, Any]:
333 """Extract the first JSON object from a model response."""
334 text = text.strip()
335 if not text:
336 raise ValueError("Expected JSON response, got empty text")
337 try:
338 return json.loads(text)
339 except json.JSONDecodeError:
340 match = re.search(r"\{[\s\S]*\}", text)
341 if not match:
342 raise
343 return json.loads(match.group(0))
344
345
346 def extract_gemini_text(payload: dict[str, Any]) -> str:
347 for candidate in payload.get("candidates", []):
348 content = candidate.get("content") or {}
349 for part in content.get("parts", []):
350 text = part.get("text")
351 if text:
352 return text
353 if payload:
354 print(f"[Providers] extract_gemini_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
355 return ""
356
357
358 def extract_openai_text(payload: dict[str, Any]) -> str:
359 if isinstance(payload.get("output_text"), str):
360 return payload["output_text"]
361 output = payload.get("output") or payload.get("choices") or []
362 for item in output:
363 if isinstance(item, str):
364 return item
365 if isinstance(item, dict):
366 if isinstance(item.get("text"), str):
367 return item["text"]
368 content = item.get("content") or []
369 if isinstance(content, list):
370 for part in content:
371 if isinstance(part, dict) and isinstance(part.get("text"), str):
372 return part["text"]
373 if isinstance(part, dict) and part.get("type") == "output_text" and isinstance(part.get("text"), str):
374 return part["text"]
375 message = item.get("message") or {}
376 if isinstance(message, dict) and isinstance(message.get("content"), str):
377 return message["content"]
378 if payload:
379 print(f"[Providers] extract_openai_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
380 return ""
381
381 lines PYTHON