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.
- keirolabs_api-1.0.0/LICENSE +21 -0
- keirolabs_api-1.0.0/PKG-INFO +119 -0
- keirolabs_api-1.0.0/README.md +99 -0
- keirolabs_api-1.0.0/keirolabs/__init__.py +23 -0
- keirolabs_api-1.0.0/keirolabs/client.py +167 -0
- keirolabs_api-1.0.0/keirolabs/compat.py +95 -0
- keirolabs_api-1.0.0/keirolabs/tools.py +142 -0
- keirolabs_api-1.0.0/keirolabs_api.egg-info/PKG-INFO +119 -0
- keirolabs_api-1.0.0/keirolabs_api.egg-info/SOURCES.txt +12 -0
- keirolabs_api-1.0.0/keirolabs_api.egg-info/dependency_links.txt +1 -0
- keirolabs_api-1.0.0/keirolabs_api.egg-info/top_level.txt +1 -0
- keirolabs_api-1.0.0/pyproject.toml +29 -0
- keirolabs_api-1.0.0/setup.cfg +4 -0
- keirolabs_api-1.0.0/tests/test_keirolabs.py +168 -0
|
@@ -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
|
+
|
|
@@ -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,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()
|