"""Minimal shared client for the Keiro v2 API (kierolabs.space). Every free-search-tools package imports from here, so auth, retries and credit-cost behavior stay in one place. Auth: KEIRO_API_KEY env var (free key: https://platform.keirolabs.cloud/dashboard/billing). """ import os import time import requests BASE_URL = os.environ.get("KEIRO_BASE_URL", "https://kierolabs.space") FREE_KEY_URL = "https://platform.keirolabs.cloud/dashboard/billing" class KeiroError(RuntimeError): """API or network failure. `.status` carries the HTTP status when known.""" def __init__(self, message, status=None): super().__init__(message) self.status = status def api_key(): key = os.environ.get("KEIRO_API_KEY", "") if not key: raise KeiroError("No KEIRO_API_KEY set. Grab a free key: " + FREE_KEY_URL) return key def post(path, payload, timeout=30, retries=2): """Bearer-authenticated POST returning parsed JSON. Retries 429/5xx.""" last = None for attempt in range(retries + 1): try: r = requests.post( BASE_URL + path, json=payload, headers={"Authorization": "Bearer " + api_key()}, timeout=timeout, ) except requests.RequestException as e: last = KeiroError("network error: %s" % e) if attempt < retries: time.sleep(1.5 * (attempt + 1)) continue raise last if r.status_code == 200: data = r.json() # some Keiro endpoints return 200 with {"detail": "Not Found"} when a # documented endpoint is missing from the deployment — surface as 404 if isinstance(data, dict) and data.get("detail") == "Not Found" and "results" not in data: raise KeiroError( "Keiro 404: endpoint unavailable on this deployment (credit still charged)", status=404, ) return data try: msg = r.json().get("error") or r.text[:200] except ValueError: msg = r.text[:200] err = KeiroError("Keiro %s: %s" % (r.status_code, msg), status=r.status_code) if r.status_code in (429, 500, 502, 503, 504) and attempt < retries: time.sleep(1.5 * (attempt + 1)) continue raise err raise last def get(path, timeout=30, retries=2): """Bearer-authenticated GET returning parsed JSON.""" r = requests.get( BASE_URL + path, headers={"Authorization": "Bearer " + api_key()}, timeout=timeout, ) if r.status_code != 200: raise KeiroError("Keiro %s: %s" % (r.status_code, r.text[:200]), status=r.status_code) return r.json() def _clean(data, n=None): """Normalize the standard {results: [...]} search response into plain rows.""" rows = (data or {}).get("results") or [] out = [] for i, row in enumerate(rows, 1): if not row or not row.get("url"): continue out.append( { "position": row.get("position") or i, "title": (row.get("title") or "").strip(), "url": row["url"], "snippet": (row.get("snippet") or "").strip(), } ) return out[:n] if n is not None else out def google(query, max_results=10, country=None, language=None): """Raw Google search results (/api/v2/search/google). ~1 credit. Returns the raw response; rows carry title/url/snippet. """ payload = {"query": query, "maxResults": max_results} if country: payload["country"] = country if language: payload["language"] = language return post("/api/v2/search/google", payload) def search(query, max_results=10): """Always-fresh multi-engine web search (/api/v2/search/fast). ~1 credit.""" return post("/api/v2/search/fast", {"query": query, "maxResults": max_results}) def index_search(query): """Keiro's own web index (/api/v2/keiro). 1 credit; empty when index is cold.""" return post("/api/v2/keiro", {"query": query}) def content(query, max_results=3): """Search + clean page text (/api/v2/search/content). ~3 credits.""" return post( "/api/v2/search/content", {"query": query, "maxResults": max_results}, timeout=90, ) def extract(url, mode="light", schema=None, objective=None): """Structured extraction from a URL (/api/v2/data). ~2 credits.""" payload = {"url": url, "mode": mode} if schema is not None: payload["json_schema"] = schema if objective: payload["objective"] = objective return post("/api/v2/data", payload, timeout=90) def fetch(url, timeout=90): """Rendered page via stealth browser (/api/v2/browsers/fetch). 2 credits.""" return post("/api/v2/browsers/fetch", {"url": url}, timeout=timeout) def fetch_markdown(url): """Convenience: fetch() and return the rendered markdown body.""" return (fetch(url) or {}).get("content", "") def batch(queries): """Submit many queries as one async job (/api/v2/search/batch). 1 credit/query. Returns the job dict (carries an `id` to poll with batch_status()). """ return post("/api/v2/search/batch", {"queries": queries}, timeout=60) def batch_status(job_id): """Poll a batch job. Untested against live API — adjust path if it drifts.""" # ponytail: GET path guessed from docs; verify on first real batch job return get("/api/v2/search/batch/" + job_id)