返回 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 _ENDPOINT_PATHS = {
30 OPENAI_RESPONSES_URL: "/responses",
31 XAI_RESPONSES_URL: "/responses",
32 OPENROUTER_URL: "/chat/completions",
33 }
34
35
36 def resolve_endpoint(env_var: str, default_url: str) -> str:
37 """Resolve a ``*_BASE_URL`` override into a full endpoint URL.
38
39 By the convention every OpenAI-compatible provider documents, ``*_BASE_URL``
40 names the API root (``https://host/v1``) and the client appends the endpoint
41 path. This module historically required the full endpoint URL instead, so a
42 value copied from a provider's setup guide POSTed to the API root and failed.
43
44 Accept both forms: an API root gets the endpoint path appended, and a value
45 that already ends with the endpoint path is used unchanged.
46 """
47 override = os.environ.get(env_var, "").strip()
48 if not override:
49 return default_url
50 override = override.rstrip("/")
51 path = _ENDPOINT_PATHS[default_url]
52 if override.endswith(path):
53 return override
54 return override + path
55
56
57 class ReasoningClient:
58 """Shared interface for planner and rerank providers."""
59
60 name: str
61
62 def generate_text(
63 self,
64 model: str,
65 prompt: str,
66 *,
67 tools: list[dict[str, Any]] | None = None,
68 response_mime_type: str | None = None,
69 ) -> str:
70 raise NotImplementedError
71
72 def generate_json(
73 self,
74 model: str,
75 prompt: str,
76 *,
77 tools: list[dict[str, Any]] | None = None,
78 ) -> dict[str, Any]:
79 text = self.generate_text(model, prompt, tools=tools, response_mime_type="application/json")
80 return extract_json(text)
81
82
83 class GeminiClient(ReasoningClient):
84 name = "gemini"
85
86 def __init__(self, api_key: str):
87 self.api_key = api_key
88
89 def _generate_content(
90 self,
91 model: str,
92 prompt: str,
93 *,
94 tools: list[dict[str, Any]] | None = None,
95 response_mime_type: str | None = None,
96 ) -> dict[str, Any]:
97 body: dict[str, Any] = {
98 "contents": [{"parts": [{"text": prompt}]}],
99 "generationConfig": {"temperature": 0},
100 }
101 if response_mime_type:
102 body["generationConfig"]["responseMimeType"] = response_mime_type
103 if tools:
104 body["tools"] = tools
105 return http.post(
106 GEMINI_URL.format(model=model, api_key=self.api_key),
107 body,
108 headers={"Content-Type": "application/json"},
109 timeout=90,
110 )
111
112 def generate_text(
113 self,
114 model: str,
115 prompt: str,
116 *,
117 tools: list[dict[str, Any]] | None = None,
118 response_mime_type: str | None = None,
119 ) -> str:
120 payload = self._generate_content(
121 model,
122 prompt,
123 tools=tools,
124 response_mime_type=response_mime_type,
125 )
126 return extract_gemini_text(payload)
127
128 class OpenAIClient(ReasoningClient):
129 name = "openai"
130
131 def __init__(self, token: str):
132 self.token = token
133
134 def generate_text(
135 self,
136 model: str,
137 prompt: str,
138 *,
139 tools: list[dict[str, Any]] | None = None,
140 response_mime_type: str | None = None,
141 ) -> str:
142 del tools, response_mime_type
143 payload = {
144 "model": model,
145 "store": False,
146 "input": prompt,
147 "temperature": 0,
148 }
149 response = http.post(
150 resolve_endpoint("OPENAI_BASE_URL", OPENAI_RESPONSES_URL),
151 payload,
152 headers={
153 "Authorization": f"Bearer {self.token}",
154 "Content-Type": "application/json",
155 },
156 timeout=90,
157 )
158 return extract_openai_text(response)
159
160
161 class XAIClient(ReasoningClient):
162 name = "xai"
163
164 def __init__(self, api_key: str):
165 self.api_key = api_key
166
167 def generate_text(
168 self,
169 model: str,
170 prompt: str,
171 *,
172 tools: list[dict[str, Any]] | None = None,
173 response_mime_type: str | None = None,
174 ) -> str:
175 del tools, response_mime_type
176 payload = {
177 "model": model,
178 "input": [{"role": "user", "content": prompt}],
179 }
180 response = http.post(
181 resolve_endpoint("XAI_BASE_URL", XAI_RESPONSES_URL),
182 payload,
183 headers={
184 "Authorization": f"Bearer {self.api_key}",
185 "Content-Type": "application/json",
186 },
187 timeout=90,
188 )
189 return extract_openai_text(response)
190
191
192 class OpenRouterClient(ReasoningClient):
193 name = "openrouter"
194
195 def __init__(self, api_key: str):
196 self.api_key = api_key
197
198 def generate_text(
199 self,
200 model: str,
201 prompt: str,
202 *,
203 tools: list[dict[str, Any]] | None = None,
204 response_mime_type: str | None = None,
205 ) -> str:
206 del tools, response_mime_type
207 payload = {
208 "model": model,
209 "messages": [{"role": "user", "content": prompt}],
210 "temperature": 0,
211 }
212 response = http.post(
213 resolve_endpoint("OPENROUTER_BASE_URL", OPENROUTER_URL),
214 payload,
215 headers={
216 "Authorization": f"Bearer {self.api_key}",
217 "Content-Type": "application/json",
218 },
219 timeout=90,
220 )
221 return extract_openai_text(response)
222
223
224 _MODEL_DEFAULTS: dict[str, tuple[str, str]] = {
225 "gemini": (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE),
226 "openai": (OPENAI_DEFAULT, OPENAI_DEFAULT),
227 "xai": (XAI_DEFAULT, XAI_DEFAULT),
228 "openrouter": (OPENROUTER_DEFAULT, OPENROUTER_DEFAULT),
229 }
230
231
232 def _resolve_model_pins(config: dict[str, Any], depth: str, provider_name: str) -> tuple[str, str, str]:
233 """Resolve planner, rerank, and grounding model pins for a provider."""
234 default_planner, default_rerank = _MODEL_DEFAULTS.get(provider_name, (GEMINI_FLASH_LITE, GEMINI_FLASH_LITE))
235 if depth == "deep" and provider_name == "gemini":
236 default_rerank = GEMINI_PRO
237
238 planner_model = config.get("LAST30DAYS_PLANNER_MODEL") or default_planner
239 rerank_model = config.get("LAST30DAYS_RERANK_MODEL") or default_rerank
240
241 if provider_name == "gemini":
242 _require_gemini_31(planner_model, role="planner")
243 _require_gemini_31(rerank_model, role="rerank")
244
245 return planner_model, rerank_model
246
247
248 def mock_runtime(config: dict[str, Any], depth: str) -> schema.ProviderRuntime:
249 """Resolve model pins for mock mode without requiring live credentials."""
250 provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "gemini").lower()
251 if provider_name == "auto":
252 provider_name = "gemini"
253 if provider_name not in _MODEL_DEFAULTS:
254 raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
255
256 planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
257 return schema.ProviderRuntime(
258 reasoning_provider=provider_name,
259 planner_model=planner_model,
260 rerank_model=rerank_model,
261
262 x_search_backend=_resolve_x_backend(config),
263 )
264
265
266 def resolve_runtime(config: dict[str, Any], depth: str) -> tuple[schema.ProviderRuntime, ReasoningClient | None]:
267 """Resolve the reasoning provider and pinned models."""
268 provider_name = (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower()
269 google_key = config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
270 openai_token = config.get("OPENAI_API_KEY")
271 xai_key = config.get("XAI_API_KEY")
272
273 if provider_name == "auto":
274 if google_key:
275 provider_name = "gemini"
276 elif openai_token and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK:
277 provider_name = "openai"
278 elif xai_key:
279 provider_name = "xai"
280 elif config.get("OPENROUTER_API_KEY"):
281 provider_name = "openrouter"
282 else:
283 return schema.ProviderRuntime(
284 reasoning_provider="local",
285 planner_model="deterministic",
286 rerank_model="local-score",
287 x_search_backend=_resolve_x_backend(config),
288 ), None
289
290 planner_model, rerank_model = _resolve_model_pins(config, depth, provider_name)
291
292 if provider_name == "gemini":
293 if not google_key:
294 raise RuntimeError("Gemini selected but no Google API key is configured.")
295 runtime = schema.ProviderRuntime(
296 reasoning_provider="gemini",
297 planner_model=planner_model,
298 rerank_model=rerank_model,
299
300 x_search_backend=_resolve_x_backend(config),
301 )
302 return runtime, GeminiClient(google_key)
303
304 if provider_name == "openai":
305 if not openai_token or config.get("OPENAI_AUTH_STATUS") != env.AUTH_STATUS_OK:
306 raise RuntimeError("OpenAI selected but no valid OpenAI auth is configured.")
307 runtime = schema.ProviderRuntime(
308 reasoning_provider="openai",
309 planner_model=planner_model,
310 rerank_model=rerank_model,
311
312 x_search_backend=_resolve_x_backend(config),
313 )
314 return runtime, OpenAIClient(
315 openai_token
316 )
317
318 if provider_name == "xai":
319 if not xai_key:
320 raise RuntimeError("xAI selected but XAI_API_KEY is not configured.")
321 runtime = schema.ProviderRuntime(
322 reasoning_provider="xai",
323 planner_model=planner_model,
324 rerank_model=rerank_model,
325
326 x_search_backend=_resolve_x_backend(config),
327 )
328 return runtime, XAIClient(xai_key)
329
330 if provider_name == "openrouter":
331 openrouter_key = config.get("OPENROUTER_API_KEY")
332 if not openrouter_key:
333 raise RuntimeError("OpenRouter selected but OPENROUTER_API_KEY is not configured.")
334 runtime = schema.ProviderRuntime(
335 reasoning_provider="openrouter",
336 planner_model=planner_model,
337 rerank_model=rerank_model,
338 x_search_backend=_resolve_x_backend(config),
339 )
340 return runtime, OpenRouterClient(openrouter_key)
341
342 raise RuntimeError(f"Unsupported reasoning provider: {provider_name}")
343
344
345 def _resolve_x_backend(config: dict[str, Any]) -> str | None:
346 """Resolve the X backend for runtime fetch.
347
348 Delegates to env.get_x_source which handles:
349 - Any known pin (X_BACKEND_KNOWN) exclusively: returns pin if available, None otherwise
350 - Unpinned: walks auto-chain (X_BACKEND_ORDER) only, never auto-selects opt-in backends
351 """
352 return env.get_x_source(config)
353
354
355 def _require_gemini_31(model: str, *, role: str) -> None:
356 if model.startswith("gemini-3.1-"):
357 return
358 raise RuntimeError(
359 f"{role} must use a Gemini 3.1 model. Got: {model}"
360 )
361
362
363 def extract_json(text: str) -> dict[str, Any]:
364 """Extract the first JSON object from a model response."""
365 text = text.strip()
366 if not text:
367 raise ValueError("Expected JSON response, got empty text")
368 try:
369 return json.loads(text)
370 except json.JSONDecodeError:
371 match = re.search(r"\{[\s\S]*\}", text)
372 if not match:
373 raise
374 return json.loads(match.group(0))
375
376
377 def extract_gemini_text(payload: dict[str, Any]) -> str:
378 for candidate in payload.get("candidates", []):
379 content = candidate.get("content") or {}
380 for part in content.get("parts", []):
381 text = part.get("text")
382 if text:
383 return text
384 if payload:
385 print(f"[Providers] extract_gemini_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
386 return ""
387
388
389 def extract_openai_text(payload: dict[str, Any]) -> str:
390 if isinstance(payload.get("output_text"), str):
391 return payload["output_text"]
392 output = payload.get("output") or payload.get("choices") or []
393 for item in output:
394 if isinstance(item, str):
395 return item
396 if isinstance(item, dict):
397 if isinstance(item.get("text"), str):
398 return item["text"]
399 content = item.get("content") or []
400 if isinstance(content, list):
401 for part in content:
402 if isinstance(part, dict) and isinstance(part.get("text"), str):
403 return part["text"]
404 if isinstance(part, dict) and part.get("type") == "output_text" and isinstance(part.get("text"), str):
405 return part["text"]
406 message = item.get("message") or {}
407 if isinstance(message, dict) and isinstance(message.get("content"), str):
408 return message["content"]
409 if payload:
410 print(f"[Providers] extract_openai_text: no text in payload keys: {list(payload.keys())}", file=sys.stderr)
411 return ""
412
412 lines PYTHON