keirolabs-api 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keirolabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: keirolabs-api
3
+ Version: 1.0.0
4
+ Summary: Web search for AI agents. One-line search, prompt-ready web context, and web search for any OpenAI or Anthropic client.
5
+ Author-email: Keirolabs <team@keirolabs.cloud>
6
+ License: MIT
7
+ Project-URL: Homepage, https://keirolabs.cloud
8
+ Project-URL: Documentation, https://platform.keirolabs.cloud/docs
9
+ Project-URL: Source, https://github.com/Keirolabs-API/keirolabs-python
10
+ Keywords: web search,search api,ai agents,llm,rag,tavily alternative,openai,anthropic,tool calling
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # keirolabs-api
22
+
23
+ Web search for AI agents, in one line. No dependencies.
24
+
25
+ ```bash
26
+ pip install keirolabs-api
27
+ export KEIRO_API_KEY=keiro_... # free key, 750 credits: https://keirolabs.cloud
28
+ ```
29
+
30
+ ```python
31
+ from keirolabs import search
32
+
33
+ search("latest llama release")
34
+ # [{"title": ..., "url": ..., "snippet": ..., "content": ...}, ...]
35
+ ```
36
+
37
+ ## Give any LLM web search
38
+
39
+ Wrap your OpenAI or Anthropic client. The model searches when it needs to, and you get a normal response back.
40
+
41
+ ```python
42
+ from openai import OpenAI
43
+ from keirolabs import wrap
44
+
45
+ client = wrap(OpenAI())
46
+ client.chat.completions.create(
47
+ model="gpt-4.1",
48
+ messages=[{"role": "user", "content": "What did Nvidia report this quarter?"}],
49
+ )
50
+ ```
51
+
52
+ ```python
53
+ from anthropic import Anthropic
54
+ client = wrap(Anthropic())
55
+ ```
56
+
57
+ Any client with the OpenAI chat completions API should work, such as OpenRouter or Groq (tested on Groq). Your own tools keep working: if the model calls one of them, you get the response back as usual. The model gets up to 3 searches per call (`max_steps`). Sync clients only for now (`OpenAI()`, `Anthropic()`, not the async ones). Streaming calls (`stream=True`) go straight to the model without search.
58
+
59
+ ## Prompt-ready web context
60
+
61
+ Searches, reads the pages, and returns the passages most likely to answer the question, packed into your token budget.
62
+
63
+ ```python
64
+ from keirolabs import context
65
+
66
+ prompt = f"""Answer using these sources.
67
+
68
+ {context("who won the 2022 world cup", max_tokens=1500)}
69
+
70
+ Question: who won the 2022 world cup?"""
71
+ ```
72
+
73
+ It runs on Keiro deep search, which scores 96.5% on SimpleQA.
74
+
75
+ ## Switching from Tavily
76
+
77
+ Change one import. The methods and response keys match Tavily's.
78
+
79
+ ```diff
80
+ - from tavily import TavilyClient
81
+ + from keirolabs.compat import TavilyClient
82
+ ```
83
+
84
+ `search`, `get_search_context`, `qna_search` and `extract` are supported. Differences: `score` comes from the result's rank, domain filters are applied after the search (so you can get fewer results than you asked for), and `images` is always empty. A Tavily key is ignored: pass a Keiro key or set `KEIRO_API_KEY`.
85
+
86
+ ## Everything else
87
+
88
+ ```python
89
+ from keirolabs import search, deep, answer, md, tool, run_tool
90
+
91
+ search("query", mode="lite") # cheapest: $0.10 per 1,000 on paid plans
92
+ search("query", mode="fast") # fast live results
93
+ search("query", mode="content") # full page markdown for each result
94
+ deep("query", budget=2000) # ranked evidence + confidence
95
+ answer("query") # {"answer": ..., "sources": [...]}
96
+ md("https://example.com") # any page as clean text
97
+
98
+ tool() # web_search tool for OpenAI-style APIs
99
+ tool("anthropic") # ...or Anthropic, "gemini", "json"
100
+ run_tool(args) # run a model's web_search call, get the tool result text
101
+ ```
102
+
103
+ Use a client object for a different key, base URL or timeout:
104
+
105
+ ```python
106
+ from keirolabs import Keiro
107
+ k = Keiro(api_key="keiro_...", timeout=60)
108
+ k.search("query")
109
+ ```
110
+
111
+ Errors raise `keirolabs.KeiroError` with `.status` (401 bad key, 402 out of credits, 429 rate limited).
112
+
113
+ Results are lists of dicts. The full API response (credits used, latency) is on `.raw`.
114
+
115
+ ## Pricing
116
+
117
+ Free: 750 credits at signup. Lite costs 1 credit on free, and $0.10 per 1,000 searches on paid plans. [Pricing](https://keirolabs.cloud/pricing).
118
+
119
+ MIT license.
@@ -0,0 +1,99 @@
1
+ # keirolabs-api
2
+
3
+ Web search for AI agents, in one line. No dependencies.
4
+
5
+ ```bash
6
+ pip install keirolabs-api
7
+ export KEIRO_API_KEY=keiro_... # free key, 750 credits: https://keirolabs.cloud
8
+ ```
9
+
10
+ ```python
11
+ from keirolabs import search
12
+
13
+ search("latest llama release")
14
+ # [{"title": ..., "url": ..., "snippet": ..., "content": ...}, ...]
15
+ ```
16
+
17
+ ## Give any LLM web search
18
+
19
+ Wrap your OpenAI or Anthropic client. The model searches when it needs to, and you get a normal response back.
20
+
21
+ ```python
22
+ from openai import OpenAI
23
+ from keirolabs import wrap
24
+
25
+ client = wrap(OpenAI())
26
+ client.chat.completions.create(
27
+ model="gpt-4.1",
28
+ messages=[{"role": "user", "content": "What did Nvidia report this quarter?"}],
29
+ )
30
+ ```
31
+
32
+ ```python
33
+ from anthropic import Anthropic
34
+ client = wrap(Anthropic())
35
+ ```
36
+
37
+ Any client with the OpenAI chat completions API should work, such as OpenRouter or Groq (tested on Groq). Your own tools keep working: if the model calls one of them, you get the response back as usual. The model gets up to 3 searches per call (`max_steps`). Sync clients only for now (`OpenAI()`, `Anthropic()`, not the async ones). Streaming calls (`stream=True`) go straight to the model without search.
38
+
39
+ ## Prompt-ready web context
40
+
41
+ Searches, reads the pages, and returns the passages most likely to answer the question, packed into your token budget.
42
+
43
+ ```python
44
+ from keirolabs import context
45
+
46
+ prompt = f"""Answer using these sources.
47
+
48
+ {context("who won the 2022 world cup", max_tokens=1500)}
49
+
50
+ Question: who won the 2022 world cup?"""
51
+ ```
52
+
53
+ It runs on Keiro deep search, which scores 96.5% on SimpleQA.
54
+
55
+ ## Switching from Tavily
56
+
57
+ Change one import. The methods and response keys match Tavily's.
58
+
59
+ ```diff
60
+ - from tavily import TavilyClient
61
+ + from keirolabs.compat import TavilyClient
62
+ ```
63
+
64
+ `search`, `get_search_context`, `qna_search` and `extract` are supported. Differences: `score` comes from the result's rank, domain filters are applied after the search (so you can get fewer results than you asked for), and `images` is always empty. A Tavily key is ignored: pass a Keiro key or set `KEIRO_API_KEY`.
65
+
66
+ ## Everything else
67
+
68
+ ```python
69
+ from keirolabs import search, deep, answer, md, tool, run_tool
70
+
71
+ search("query", mode="lite") # cheapest: $0.10 per 1,000 on paid plans
72
+ search("query", mode="fast") # fast live results
73
+ search("query", mode="content") # full page markdown for each result
74
+ deep("query", budget=2000) # ranked evidence + confidence
75
+ answer("query") # {"answer": ..., "sources": [...]}
76
+ md("https://example.com") # any page as clean text
77
+
78
+ tool() # web_search tool for OpenAI-style APIs
79
+ tool("anthropic") # ...or Anthropic, "gemini", "json"
80
+ run_tool(args) # run a model's web_search call, get the tool result text
81
+ ```
82
+
83
+ Use a client object for a different key, base URL or timeout:
84
+
85
+ ```python
86
+ from keirolabs import Keiro
87
+ k = Keiro(api_key="keiro_...", timeout=60)
88
+ k.search("query")
89
+ ```
90
+
91
+ Errors raise `keirolabs.KeiroError` with `.status` (401 bad key, 402 out of credits, 429 rate limited).
92
+
93
+ Results are lists of dicts. The full API response (credits used, latency) is on `.raw`.
94
+
95
+ ## Pricing
96
+
97
+ Free: 750 credits at signup. Lite costs 1 credit on free, and $0.10 per 1,000 searches on paid plans. [Pricing](https://keirolabs.cloud/pricing).
98
+
99
+ MIT license.
@@ -0,0 +1,23 @@
1
+ """Keiro: web search for AI agents. https://keirolabs.cloud
2
+
3
+ from keirolabs import search, context, wrap
4
+ """
5
+ from .client import (
6
+ Keiro,
7
+ KeiroError,
8
+ Results,
9
+ __version__,
10
+ answer,
11
+ context,
12
+ deep,
13
+ extract,
14
+ md,
15
+ search,
16
+ )
17
+ from .tools import run_tool, tool, wrap
18
+
19
+ __all__ = [
20
+ "Keiro", "KeiroError", "Results", "__version__",
21
+ "search", "deep", "answer", "extract", "md", "context",
22
+ "tool", "run_tool", "wrap",
23
+ ]
@@ -0,0 +1,167 @@
1
+ """Keiro web search API client. Standard library only."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Iterable
9
+
10
+ __version__ = "1.0.0"
11
+
12
+ DEFAULT_BASE_URL = "https://api.keirolabs.cloud"
13
+ _SEARCH_PATHS = {"lite": "search/lite", "fast": "search/fast", "content": "search/content"}
14
+
15
+
16
+ class KeiroError(Exception):
17
+ """Raised for any non-2xx response. `status` is the HTTP code (0 = network error)."""
18
+
19
+ def __init__(self, message: str, status: int = 0, body: Any = None):
20
+ super().__init__(f"{message} (HTTP {status})" if status else message)
21
+ self.status = status
22
+ self.body = body
23
+
24
+
25
+ class Results(list):
26
+ """A list of result dicts. `.raw` holds the full API response (credits, latency, ...)."""
27
+
28
+ raw: dict
29
+
30
+
31
+ def _result(r: dict) -> dict:
32
+ # One shape for every endpoint: title, url, snippet, content.
33
+ return {
34
+ "title": r.get("title") or "",
35
+ "url": r.get("url") or "",
36
+ "snippet": r.get("snippet") or r.get("excerpt") or "",
37
+ "content": r.get("markdown_content") or r.get("content") or "",
38
+ }
39
+
40
+
41
+ class Keiro:
42
+ """Client for api.keirolabs.cloud.
43
+
44
+ >>> k = Keiro() # reads KEIRO_API_KEY
45
+ >>> k.search("latest llama release")
46
+ """
47
+
48
+ def __init__(self, api_key: str | None = None, base_url: str | None = None, timeout: float = 120):
49
+ self.api_key = api_key or os.environ.get("KEIRO_API_KEY")
50
+ if not self.api_key:
51
+ raise KeiroError("No API key. Pass api_key=... or set KEIRO_API_KEY (free key: https://keirolabs.cloud)")
52
+ self.base_url = (base_url or os.environ.get("KEIRO_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
53
+ self.timeout = timeout
54
+
55
+ # ── transport ────────────────────────────────────────────────────────────
56
+ def request(self, path: str, body: dict) -> dict:
57
+ """POST /api/v2/<path> and return the decoded JSON. Use this for any endpoint or param the helpers don't cover."""
58
+ req = urllib.request.Request(
59
+ f"{self.base_url}/api/v2/{path.lstrip('/')}",
60
+ data=json.dumps({k: v for k, v in body.items() if v is not None}).encode(),
61
+ headers={
62
+ "Authorization": f"Bearer {self.api_key}",
63
+ "Content-Type": "application/json",
64
+ "User-Agent": f"keirolabs-python/{__version__}",
65
+ },
66
+ method="POST",
67
+ )
68
+ try:
69
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
70
+ return json.loads(r.read() or b"{}")
71
+ except urllib.error.HTTPError as e:
72
+ raw = e.read().decode(errors="replace")
73
+ try:
74
+ data = json.loads(raw)
75
+ msg = data.get("message") or data.get("error") or raw
76
+ except ValueError:
77
+ data, msg = raw, raw or e.reason
78
+ raise KeiroError(str(msg), e.code, data) from None
79
+ except urllib.error.URLError as e:
80
+ raise KeiroError(f"Network error: {e.reason}") from None
81
+
82
+ # ── endpoints ────────────────────────────────────────────────────────────
83
+ def search(self, query: str, *, mode: str = "lite", max_results: int = 10, **params: Any) -> Results:
84
+ """Web search. mode: "lite" (cheapest, snippets), "fast", or "content" (full page markdown)."""
85
+ if mode not in _SEARCH_PATHS:
86
+ raise ValueError(f"mode must be one of {list(_SEARCH_PATHS)}")
87
+ data = self.request(_SEARCH_PATHS[mode], {"query": query, "maxResults": max_results, **params})
88
+ out = Results(_result(r) for r in data.get("results") or [])
89
+ out.raw = data
90
+ return out
91
+
92
+ def deep(self, query: str, *, budget: int | None = None, content: bool | None = None, **params: Any) -> dict:
93
+ """Deep search: searches, reads the pages and returns the passages most likely to hold the answer.
94
+
95
+ Returns the API response: `evidence` (best passages, packed into `budget` tokens),
96
+ `results` (ranked pages), `confidence` and `answer_status`.
97
+ """
98
+ return self.request("deep", {"query": query, "budget": budget, "content": content, **params})
99
+
100
+ def answer(self, query: str, **params: Any) -> dict:
101
+ """A written answer with sources: {"answer": str, "sources": [url, ...], ...}."""
102
+ data = self.request("answer", {"query": query, "stream": False, **params})
103
+ return data.get("data", data)
104
+
105
+ def extract(self, urls: str | Iterable[str], **params: Any) -> Results:
106
+ """Clean content from one or more URLs."""
107
+ body = {"url": urls} if isinstance(urls, str) else {"urls": list(urls)}
108
+ data = self.request("extract", {**body, **params})
109
+ out = Results({**_result(r), "error": r.get("error")} for r in data.get("results") or [])
110
+ out.raw = data
111
+ return out
112
+
113
+ def md(self, url: str) -> str:
114
+ """Any URL as clean markdown/text."""
115
+ res = self.extract(url)
116
+ if not res or res[0].get("error"):
117
+ raise KeiroError(f"Could not extract {url}: {res[0].get('error') if res else 'no result'}")
118
+ return res[0]["content"]
119
+
120
+ def context(self, query: str, max_tokens: int = 2000) -> str:
121
+ """Prompt-ready web context for `query`, at most ~max_tokens. Paste it straight into a prompt."""
122
+ budget = max(100, min(int(max_tokens), 20000))
123
+ data = self.deep(query, budget=budget)
124
+ blocks = [
125
+ f"[{i}] {e.get('title') or e.get('url')}\n{e.get('url')}\n{(e.get('text') or '').strip()}"
126
+ for i, e in enumerate(data.get("evidence") or [], 1)
127
+ ]
128
+ if not blocks: # nothing scored as evidence: fall back to the ranked pages
129
+ blocks = [
130
+ f"[{i}] {r.get('title')}\n{r.get('url')}\n{(r.get('excerpt') or r.get('snippet') or '').strip()}"
131
+ for i, r in enumerate(data.get("results") or [], 1)
132
+ ]
133
+ return "\n\n".join(blocks)
134
+
135
+
136
+ _default: Keiro | None = None
137
+
138
+
139
+ def _client() -> Keiro:
140
+ global _default
141
+ if _default is None:
142
+ _default = Keiro()
143
+ return _default
144
+
145
+
146
+ def search(query: str, **kw: Any) -> Results:
147
+ return _client().search(query, **kw)
148
+
149
+
150
+ def deep(query: str, **kw: Any) -> dict:
151
+ return _client().deep(query, **kw)
152
+
153
+
154
+ def answer(query: str, **kw: Any) -> dict:
155
+ return _client().answer(query, **kw)
156
+
157
+
158
+ def extract(urls: str | Iterable[str], **kw: Any) -> Results:
159
+ return _client().extract(urls, **kw)
160
+
161
+
162
+ def md(url: str) -> str:
163
+ return _client().md(url)
164
+
165
+
166
+ def context(query: str, max_tokens: int = 2000) -> str:
167
+ return _client().context(query, max_tokens)
@@ -0,0 +1,95 @@
1
+ """Drop-in replacement for the Tavily Python client.
2
+
3
+ - from tavily import TavilyClient
4
+ + from keirolabs.compat import TavilyClient
5
+
6
+ Same methods and response shapes, backed by Keiro. Reads KEIRO_API_KEY; a Tavily key ("tvly-...") is ignored.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from typing import Any, Iterable
13
+ from urllib.parse import urlparse
14
+
15
+ from .client import Keiro
16
+
17
+
18
+ def _host(url: str) -> str:
19
+ h = urlparse(url).netloc.lower()
20
+ return h[4:] if h.startswith("www.") else h
21
+
22
+
23
+ def _match(url: str, domains: Iterable[str]) -> bool:
24
+ h = _host(url)
25
+ return any(h == d or h.endswith("." + d) for d in (_host("//" + d.lower()) for d in domains))
26
+
27
+
28
+ class TavilyClient:
29
+ def __init__(self, api_key: str | None = None, **_: Any):
30
+ self._k = Keiro(api_key=api_key if api_key and api_key.startswith("keiro_") else None)
31
+
32
+ def search(
33
+ self,
34
+ query: str,
35
+ search_depth: str = "basic",
36
+ max_results: int = 5,
37
+ include_answer: bool | str = False,
38
+ include_raw_content: bool | str = False,
39
+ include_domains: Iterable[str] | None = None,
40
+ exclude_domains: Iterable[str] | None = None,
41
+ **_: Any,
42
+ ) -> dict:
43
+ t0 = time.time()
44
+ mode = "content" if include_raw_content or search_depth == "advanced" else "lite"
45
+ # Domain filters are applied to the results, so ask for extra results to filter from.
46
+ want = max_results * 3 if include_domains or exclude_domains else max_results
47
+ rows = self._k.search(query, mode=mode, max_results=want)
48
+ if include_domains:
49
+ rows = [r for r in rows if _match(r["url"], include_domains)]
50
+ if exclude_domains:
51
+ rows = [r for r in rows if not _match(r["url"], exclude_domains)]
52
+ rows = rows[:max_results]
53
+ results = [
54
+ {
55
+ "title": r["title"],
56
+ "url": r["url"],
57
+ "content": r["snippet"] or r["content"][:500],
58
+ # ponytail: Keiro returns rank, not a relevance score. Rank-derived so sorting by score keeps order.
59
+ "score": round(1 - i / (len(rows) + 1), 4),
60
+ "raw_content": r["content"] or None if include_raw_content else None,
61
+ }
62
+ for i, r in enumerate(rows)
63
+ ]
64
+ answer = self._k.answer(query).get("answer") if include_answer else None
65
+ return {
66
+ "query": query,
67
+ "follow_up_questions": None,
68
+ "answer": answer,
69
+ "images": [],
70
+ "results": results,
71
+ "response_time": round(time.time() - t0, 2),
72
+ }
73
+
74
+ def get_search_context(self, query: str, max_tokens: int = 4000, **kw: Any) -> str:
75
+ """JSON string of [{url, content}], like Tavily."""
76
+ res = self.search(query, **kw)["results"]
77
+ out, used = [], 0
78
+ for r in res:
79
+ used += len(r["content"]) // 4
80
+ if used > max_tokens:
81
+ break
82
+ out.append({"url": r["url"], "content": r["content"]})
83
+ return json.dumps(out)
84
+
85
+ def qna_search(self, query: str, **_: Any) -> str:
86
+ return self._k.answer(query).get("answer") or ""
87
+
88
+ def extract(self, urls: str | Iterable[str], **_: Any) -> dict:
89
+ t0 = time.time()
90
+ rows = self._k.extract(urls)
91
+ return {
92
+ "results": [{"url": r["url"], "raw_content": r["content"], "images": []} for r in rows if not r.get("error")],
93
+ "failed_results": [{"url": r["url"], "error": r["error"]} for r in rows if r.get("error")],
94
+ "response_time": round(time.time() - t0, 2),
95
+ }
@@ -0,0 +1,142 @@
1
+ """Web search as an LLM tool: a schema for every major format, and `wrap()` to add it to any client."""
2
+ from __future__ import annotations
3
+
4
+ import inspect
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+ from .client import Keiro, _client
10
+
11
+ NAME = "web_search"
12
+ FINAL_NUDGE = "Search limit reached. Answer now using the results above."
13
+ DESCRIPTION = (
14
+ "Search the web for current information. Use it for anything recent, anything after your "
15
+ "training cutoff, or anything that needs a source. Results are live: don't add a year to the query "
16
+ "unless the user gave one. Returns ranked results with title, url and text."
17
+ )
18
+ PARAMETERS = {
19
+ "type": "object",
20
+ "properties": {"query": {"type": "string", "description": "What to search for, written like a search engine query."}},
21
+ # ponytail: no "required". Some providers (Groq) hard-fail the whole request when a model names the
22
+ # argument differently; run_tool accepts q/search_query/any string instead and tells the model if it's empty.
23
+ }
24
+
25
+
26
+ def tool(format: str = "openai") -> dict:
27
+ """The web_search tool definition.
28
+
29
+ format: "openai" (also OpenRouter, Groq, Together, vLLM...), "anthropic", "gemini", or "json" (bare schema).
30
+ """
31
+ if format == "openai":
32
+ return {"type": "function", "function": {"name": NAME, "description": DESCRIPTION, "parameters": PARAMETERS}}
33
+ if format == "anthropic":
34
+ return {"name": NAME, "description": DESCRIPTION, "input_schema": PARAMETERS}
35
+ if format in ("gemini", "json"):
36
+ return {"name": NAME, "description": DESCRIPTION, "parameters": PARAMETERS}
37
+ raise ValueError('format must be "openai", "anthropic", "gemini" or "json"')
38
+
39
+
40
+ def run_tool(args: dict | str, *, mode: str = "lite", client: Keiro | None = None) -> str:
41
+ """Run a web_search call from a model and return the text to send back as the tool result."""
42
+ if isinstance(args, str):
43
+ args = json.loads(args or "{}")
44
+ args = args or {}
45
+ query = args.get("query") or args.get("q") or args.get("search_query") or next((v for v in args.values() if isinstance(v, str)), "")
46
+ query = query.strip()
47
+ if not query:
48
+ return "Error: query is required."
49
+ k = client or _client()
50
+ # Models assume their training year; telling them today's date keeps them from searching stale years.
51
+ head = f"Web results for {query!r}, searched {datetime.now(timezone.utc):%Y-%m-%d} (UTC):\n\n"
52
+ try:
53
+ if mode == "deep":
54
+ return head + (k.context(query, 1500) or "No results.")
55
+ rows = k.search(query, mode=mode, max_results=8)
56
+ except Exception as e: # the model should see the failure, not crash the loop
57
+ return f"Search failed: {e}"
58
+ return head + ("\n\n".join(f"{i}. {r['title']}\n{r['url']}\n{r['snippet'] or r['content'][:500]}" for i, r in enumerate(rows, 1)) or "No results.")
59
+
60
+
61
+ class _Proxy:
62
+ """Looks like `target`, except for the attributes in `overrides`."""
63
+
64
+ def __init__(self, target: Any, **overrides: Any):
65
+ self._target, self._overrides = target, overrides
66
+
67
+ def __getattr__(self, name: str) -> Any:
68
+ return self._overrides[name] if name in self._overrides else getattr(self._target, name)
69
+
70
+
71
+ def _dump(obj: Any) -> Any:
72
+ return obj.model_dump(exclude_none=True) if hasattr(obj, "model_dump") else obj
73
+
74
+
75
+ def wrap(client: Any, *, mode: str = "lite", max_steps: int = 3, keiro: Keiro | None = None) -> Any:
76
+ """Give an OpenAI or Anthropic client web search. Every create() call can now search on its own.
77
+
78
+ >>> client = wrap(OpenAI())
79
+ >>> client.chat.completions.create(model="gpt-4.1", messages=[{"role": "user", "content": "news on X today?"}])
80
+
81
+ The model decides when to search; searches run here and the final answer comes back as a normal response.
82
+ Your own tools are left alone: if the model calls one of them, the response is returned to you as usual.
83
+ mode: "lite" (cheapest) or "deep" (best evidence, 96.5% on SimpleQA).
84
+ Streaming calls (stream=True) pass through without search.
85
+ """
86
+ run = lambda args: run_tool(args, mode=mode, client=keiro) # noqa: E731
87
+
88
+ if hasattr(client, "chat") and hasattr(client.chat, "completions"):
89
+ orig = client.chat.completions.create
90
+ if inspect.iscoroutinefunction(orig):
91
+ # ponytail: sync clients only for now; add an async loop when someone asks.
92
+ raise TypeError("wrap() supports the sync OpenAI client. Use OpenAI(), not AsyncOpenAI().")
93
+
94
+ def create(**kw: Any) -> Any:
95
+ if kw.get("stream"):
96
+ return orig(**kw)
97
+ own = list(kw.pop("tools", None) or [])
98
+ messages = list(kw.pop("messages"))
99
+ for step in range(max_steps + 1):
100
+ # Final round: the tool stays defined (history references it) but calls are off, so the model must answer.
101
+ last = step == max_steps
102
+ extra = {"tool_choice": "none"} if last and step else {}
103
+ resp = orig(messages=messages, tools=own + [tool("openai")], **extra, **kw)
104
+ msg = resp.choices[0].message
105
+ calls = msg.tool_calls or []
106
+ if last or not calls or any(c.function.name != NAME for c in calls):
107
+ return resp
108
+ messages.append(_dump(msg))
109
+ messages += [{"role": "tool", "tool_call_id": c.id, "content": run(c.function.arguments)} for c in calls]
110
+ if step + 1 == max_steps:
111
+ messages.append({"role": "user", "content": FINAL_NUDGE})
112
+ return resp
113
+
114
+ return _Proxy(client, chat=_Proxy(client.chat, completions=_Proxy(client.chat.completions, create=create)))
115
+
116
+ if hasattr(client, "messages") and hasattr(client.messages, "create"):
117
+ orig = client.messages.create
118
+ if inspect.iscoroutinefunction(orig):
119
+ raise TypeError("wrap() supports the sync Anthropic client. Use Anthropic(), not AsyncAnthropic().")
120
+
121
+ def create(**kw: Any) -> Any:
122
+ if kw.get("stream"):
123
+ return orig(**kw)
124
+ own = list(kw.pop("tools", None) or [])
125
+ messages = list(kw.pop("messages"))
126
+ for step in range(max_steps + 1):
127
+ last = step == max_steps
128
+ extra = {"tool_choice": {"type": "none"}} if last and step else {}
129
+ resp = orig(messages=messages, tools=own + [tool("anthropic")], **extra, **kw)
130
+ uses = [b for b in resp.content if getattr(b, "type", None) == "tool_use"]
131
+ if last or resp.stop_reason != "tool_use" or not uses or any(b.name != NAME for b in uses):
132
+ return resp
133
+ messages.append({"role": "assistant", "content": [_dump(b) for b in resp.content]})
134
+ results = [{"type": "tool_result", "tool_use_id": b.id, "content": run(b.input)} for b in uses]
135
+ if step + 1 == max_steps:
136
+ results.append({"type": "text", "text": FINAL_NUDGE})
137
+ messages.append({"role": "user", "content": results})
138
+ return resp
139
+
140
+ return _Proxy(client, messages=_Proxy(client.messages, create=create))
141
+
142
+ raise TypeError("wrap() takes an OpenAI or Anthropic client (or any OpenAI-compatible client).")
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: keirolabs-api
3
+ Version: 1.0.0
4
+ Summary: Web search for AI agents. One-line search, prompt-ready web context, and web search for any OpenAI or Anthropic client.
5
+ Author-email: Keirolabs <team@keirolabs.cloud>
6
+ License: MIT
7
+ Project-URL: Homepage, https://keirolabs.cloud
8
+ Project-URL: Documentation, https://platform.keirolabs.cloud/docs
9
+ Project-URL: Source, https://github.com/Keirolabs-API/keirolabs-python
10
+ Keywords: web search,search api,ai agents,llm,rag,tavily alternative,openai,anthropic,tool calling
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Dynamic: license-file
20
+
21
+ # keirolabs-api
22
+
23
+ Web search for AI agents, in one line. No dependencies.
24
+
25
+ ```bash
26
+ pip install keirolabs-api
27
+ export KEIRO_API_KEY=keiro_... # free key, 750 credits: https://keirolabs.cloud
28
+ ```
29
+
30
+ ```python
31
+ from keirolabs import search
32
+
33
+ search("latest llama release")
34
+ # [{"title": ..., "url": ..., "snippet": ..., "content": ...}, ...]
35
+ ```
36
+
37
+ ## Give any LLM web search
38
+
39
+ Wrap your OpenAI or Anthropic client. The model searches when it needs to, and you get a normal response back.
40
+
41
+ ```python
42
+ from openai import OpenAI
43
+ from keirolabs import wrap
44
+
45
+ client = wrap(OpenAI())
46
+ client.chat.completions.create(
47
+ model="gpt-4.1",
48
+ messages=[{"role": "user", "content": "What did Nvidia report this quarter?"}],
49
+ )
50
+ ```
51
+
52
+ ```python
53
+ from anthropic import Anthropic
54
+ client = wrap(Anthropic())
55
+ ```
56
+
57
+ Any client with the OpenAI chat completions API should work, such as OpenRouter or Groq (tested on Groq). Your own tools keep working: if the model calls one of them, you get the response back as usual. The model gets up to 3 searches per call (`max_steps`). Sync clients only for now (`OpenAI()`, `Anthropic()`, not the async ones). Streaming calls (`stream=True`) go straight to the model without search.
58
+
59
+ ## Prompt-ready web context
60
+
61
+ Searches, reads the pages, and returns the passages most likely to answer the question, packed into your token budget.
62
+
63
+ ```python
64
+ from keirolabs import context
65
+
66
+ prompt = f"""Answer using these sources.
67
+
68
+ {context("who won the 2022 world cup", max_tokens=1500)}
69
+
70
+ Question: who won the 2022 world cup?"""
71
+ ```
72
+
73
+ It runs on Keiro deep search, which scores 96.5% on SimpleQA.
74
+
75
+ ## Switching from Tavily
76
+
77
+ Change one import. The methods and response keys match Tavily's.
78
+
79
+ ```diff
80
+ - from tavily import TavilyClient
81
+ + from keirolabs.compat import TavilyClient
82
+ ```
83
+
84
+ `search`, `get_search_context`, `qna_search` and `extract` are supported. Differences: `score` comes from the result's rank, domain filters are applied after the search (so you can get fewer results than you asked for), and `images` is always empty. A Tavily key is ignored: pass a Keiro key or set `KEIRO_API_KEY`.
85
+
86
+ ## Everything else
87
+
88
+ ```python
89
+ from keirolabs import search, deep, answer, md, tool, run_tool
90
+
91
+ search("query", mode="lite") # cheapest: $0.10 per 1,000 on paid plans
92
+ search("query", mode="fast") # fast live results
93
+ search("query", mode="content") # full page markdown for each result
94
+ deep("query", budget=2000) # ranked evidence + confidence
95
+ answer("query") # {"answer": ..., "sources": [...]}
96
+ md("https://example.com") # any page as clean text
97
+
98
+ tool() # web_search tool for OpenAI-style APIs
99
+ tool("anthropic") # ...or Anthropic, "gemini", "json"
100
+ run_tool(args) # run a model's web_search call, get the tool result text
101
+ ```
102
+
103
+ Use a client object for a different key, base URL or timeout:
104
+
105
+ ```python
106
+ from keirolabs import Keiro
107
+ k = Keiro(api_key="keiro_...", timeout=60)
108
+ k.search("query")
109
+ ```
110
+
111
+ Errors raise `keirolabs.KeiroError` with `.status` (401 bad key, 402 out of credits, 429 rate limited).
112
+
113
+ Results are lists of dicts. The full API response (credits used, latency) is on `.raw`.
114
+
115
+ ## Pricing
116
+
117
+ Free: 750 credits at signup. Lite costs 1 credit on free, and $0.10 per 1,000 searches on paid plans. [Pricing](https://keirolabs.cloud/pricing).
118
+
119
+ MIT license.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ keirolabs/__init__.py
5
+ keirolabs/client.py
6
+ keirolabs/compat.py
7
+ keirolabs/tools.py
8
+ keirolabs_api.egg-info/PKG-INFO
9
+ keirolabs_api.egg-info/SOURCES.txt
10
+ keirolabs_api.egg-info/dependency_links.txt
11
+ keirolabs_api.egg-info/top_level.txt
12
+ tests/test_keirolabs.py
@@ -0,0 +1 @@
1
+ keirolabs
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keirolabs-api"
7
+ version = "1.0.0"
8
+ description = "Web search for AI agents. One-line search, prompt-ready web context, and web search for any OpenAI or Anthropic client."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Keirolabs", email = "team@keirolabs.cloud" }]
13
+ keywords = ["web search", "search api", "ai agents", "llm", "rag", "tavily alternative", "openai", "anthropic", "tool calling"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.urls]
24
+ Homepage = "https://keirolabs.cloud"
25
+ Documentation = "https://platform.keirolabs.cloud/docs"
26
+ Source = "https://github.com/Keirolabs-API/keirolabs-python"
27
+
28
+ [tool.setuptools]
29
+ packages = ["keirolabs"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,168 @@
1
+ """Offline tests (fake transport, fake LLM clients). Run: python -m unittest discover tests
2
+ Set KEIRO_API_KEY to also run the live smoke test."""
3
+ import json
4
+ import os
5
+ import unittest
6
+ from types import SimpleNamespace as NS
7
+
8
+ import keirolabs
9
+ import keirolabs.tools
10
+ from keirolabs import Keiro, tool, wrap
11
+ from keirolabs.compat import TavilyClient
12
+
13
+ LITE = {"results": [
14
+ {"title": "A", "url": "https://www.a.com/x", "snippet": "sa"},
15
+ {"title": "B", "url": "https://docs.b.org/y", "snippet": "sb"},
16
+ {"title": "C", "url": "https://c.net/z", "snippet": "sc"},
17
+ ], "creditsCharged": 0.0375}
18
+ DEEP = {"evidence": [{"title": "T", "url": "https://t.com", "text": "evidence text"}], "results": []}
19
+
20
+
21
+ class FakeKeiro(Keiro):
22
+ def __init__(self):
23
+ super().__init__(api_key="keiro_test")
24
+ self.calls = []
25
+
26
+ def request(self, path, body):
27
+ self.calls.append((path, body))
28
+ if path.startswith("search/"):
29
+ return LITE
30
+ if path == "deep":
31
+ return DEEP
32
+ if path == "answer":
33
+ return {"data": {"answer": "42", "sources": ["https://t.com"]}}
34
+ if path == "extract":
35
+ return {"results": [{"url": body.get("url"), "content": "page text", "error": None}]}
36
+ raise AssertionError(path)
37
+
38
+
39
+ class ClientTests(unittest.TestCase):
40
+ def test_search_normalizes_and_keeps_raw(self):
41
+ k = FakeKeiro()
42
+ r = k.search("q")
43
+ self.assertEqual(r[0], {"title": "A", "url": "https://www.a.com/x", "snippet": "sa", "content": ""})
44
+ self.assertEqual(r.raw["creditsCharged"], 0.0375)
45
+ self.assertEqual(k.calls[0], ("search/lite", {"query": "q", "maxResults": 10}))
46
+ with self.assertRaises(ValueError):
47
+ k.search("q", mode="nope")
48
+
49
+ def test_context_clamps_budget_and_formats(self):
50
+ k = FakeKeiro()
51
+ out = k.context("q", max_tokens=50)
52
+ self.assertEqual(k.calls[0][1]["budget"], 100)
53
+ self.assertEqual(out, "[1] T\nhttps://t.com\nevidence text")
54
+
55
+ def test_answer_md(self):
56
+ k = FakeKeiro()
57
+ self.assertEqual(k.answer("q")["answer"], "42")
58
+ self.assertEqual(k.calls[0][1]["stream"], False)
59
+ self.assertEqual(k.md("https://x.com"), "page text")
60
+
61
+ def test_missing_key(self):
62
+ old = os.environ.pop("KEIRO_API_KEY", None)
63
+ try:
64
+ with self.assertRaises(keirolabs.KeiroError):
65
+ Keiro()
66
+ finally:
67
+ if old:
68
+ os.environ["KEIRO_API_KEY"] = old
69
+
70
+
71
+ class ToolTests(unittest.TestCase):
72
+ def test_formats(self):
73
+ self.assertEqual(tool()["function"]["name"], "web_search")
74
+ self.assertIn("input_schema", tool("anthropic"))
75
+ self.assertEqual(keirolabs.run_tool('{"query":""}', client=FakeKeiro()), "Error: query is required.")
76
+ self.assertIn("https://www.a.com/x", keirolabs.run_tool({"query": "q"}, client=FakeKeiro()))
77
+
78
+
79
+ def oa_resp(content=None, calls=None):
80
+ msg = NS(content=content, tool_calls=calls, model_dump=lambda **_: {"role": "assistant", "tool_calls": "..."})
81
+ return NS(choices=[NS(message=msg)])
82
+
83
+
84
+ class FakeOpenAI:
85
+ def __init__(self, script):
86
+ self.script, self.seen = list(script), []
87
+ self.chat = NS(completions=NS(create=self._create))
88
+ self.models = "untouched"
89
+
90
+ def _create(self, **kw):
91
+ self.seen.append(kw)
92
+ return self.script.pop(0)
93
+
94
+
95
+ class WrapTests(unittest.TestCase):
96
+ def test_openai_loop(self):
97
+ call = NS(id="c1", function=NS(name="web_search", arguments='{"query":"q"}'))
98
+ fake = FakeOpenAI([oa_resp(calls=[call]), oa_resp(content="done")])
99
+ c = wrap(fake, keiro=FakeKeiro())
100
+ r = c.chat.completions.create(model="m", messages=[{"role": "user", "content": "hi"}])
101
+ self.assertEqual(r.choices[0].message.content, "done")
102
+ self.assertEqual(c.models, "untouched")
103
+ second = fake.seen[1]["messages"]
104
+ self.assertEqual(second[-1]["role"], "tool")
105
+ self.assertIn("https://www.a.com/x", second[-1]["content"])
106
+
107
+ def test_openai_user_tool_passthrough_and_last_step(self):
108
+ mine = NS(id="c1", function=NS(name="my_tool", arguments="{}"))
109
+ fake = FakeOpenAI([oa_resp(calls=[mine])])
110
+ r = wrap(fake, keiro=FakeKeiro()).chat.completions.create(model="m", messages=[], tools=[{"x": 1}])
111
+ self.assertIs(r.choices[0].message.tool_calls[0], mine)
112
+ self.assertEqual(len(fake.seen[0]["tools"]), 2)
113
+ # last round forbids tool calls and the model is nudged to answer
114
+ call = NS(id="c1", function=NS(name="web_search", arguments='{"query":"q"}'))
115
+ fake = FakeOpenAI([oa_resp(calls=[call]), oa_resp(content="x")])
116
+ wrap(fake, max_steps=1, keiro=FakeKeiro()).chat.completions.create(model="m", messages=[])
117
+ self.assertNotIn("tool_choice", fake.seen[0])
118
+ self.assertEqual(fake.seen[1]["tool_choice"], "none")
119
+ self.assertEqual(fake.seen[1]["messages"][-1]["content"], keirolabs.tools.FINAL_NUDGE)
120
+
121
+ def test_anthropic_loop(self):
122
+ use = NS(type="tool_use", id="t1", name="web_search", input={"query": "q"},
123
+ model_dump=lambda **_: {"type": "tool_use", "id": "t1"})
124
+ script = [NS(stop_reason="tool_use", content=[use]), NS(stop_reason="end_turn", content=[NS(type="text", text="ok")])]
125
+ seen = []
126
+ fake = NS(messages=NS(create=lambda **kw: (seen.append(kw), script.pop(0))[1]))
127
+ r = wrap(fake, keiro=FakeKeiro()).messages.create(model="m", max_tokens=10, messages=[{"role": "user", "content": "hi"}])
128
+ self.assertEqual(r.content[0].text, "ok")
129
+ self.assertEqual(seen[1]["messages"][-1]["content"][0]["type"], "tool_result")
130
+
131
+ def test_rejects_unknown(self):
132
+ with self.assertRaises(TypeError):
133
+ wrap(object())
134
+
135
+
136
+ class CompatTests(unittest.TestCase):
137
+ def test_tavily_shape(self):
138
+ t = TavilyClient.__new__(TavilyClient)
139
+ t._k = FakeKeiro()
140
+ r = t.search("q", max_results=2, include_answer=True)
141
+ self.assertEqual(set(r), {"query", "follow_up_questions", "answer", "images", "results", "response_time"})
142
+ self.assertEqual(len(r["results"]), 2)
143
+ self.assertEqual(r["answer"], "42")
144
+ self.assertGreater(r["results"][0]["score"], r["results"][1]["score"])
145
+ only = t.search("q", include_domains=["b.org"])["results"]
146
+ self.assertEqual([x["url"] for x in only], ["https://docs.b.org/y"])
147
+ none_a = t.search("q", exclude_domains=["a.com"])["results"]
148
+ self.assertNotIn("https://www.a.com/x", [x["url"] for x in none_a])
149
+ self.assertEqual(json.loads(t.get_search_context("q"))[0]["url"], "https://www.a.com/x")
150
+ self.assertEqual(t.extract("https://x.com")["results"][0]["raw_content"], "page text")
151
+
152
+
153
+ @unittest.skipUnless(os.environ.get("KEIRO_API_KEY"), "set KEIRO_API_KEY for the live test")
154
+ class LiveTests(unittest.TestCase):
155
+ def test_live(self):
156
+ k = Keiro()
157
+ r = k.search("who won the 2022 world cup")
158
+ self.assertTrue(r and r[0]["url"].startswith("http"))
159
+ self.assertIn("http", k.context("who won the 2022 world cup", 300))
160
+ self.assertTrue(k.answer("capital of france")["answer"])
161
+ self.assertTrue(k.md("https://example.com"))
162
+ with self.assertRaises(keirolabs.KeiroError) as e:
163
+ Keiro(api_key="keiro_bad").search("x")
164
+ self.assertEqual(e.exception.status, 401)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ unittest.main()