serpkite 0.1.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,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .benchmarks/
8
+ build/
9
+ dist/
serpkite-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SerpKite
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,295 @@
1
+ Metadata-Version: 2.5
2
+ Name: serpkite
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for SerpKite, the Google search API built for AI agents: clean JSON or Markdown results.
5
+ Project-URL: Homepage, https://serpkite.com
6
+ Project-URL: Documentation, https://serpkite.com/docs/sdks/python
7
+ Project-URL: Repository, https://github.com/serpkite/serpkite-python
8
+ Author-email: SerpKite <support@serpkite.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,api,google,llm,markdown,search,serp,serpkite
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: eval-type-backport>=0.2; python_version < '3.10'
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: pydantic>=2.6
28
+ Requires-Dist: typing-extensions>=4.7
29
+ Provides-Extra: crewai
30
+ Requires-Dist: crewai>=0.80; (python_version >= '3.10') and extra == 'crewai'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # serpkite
34
+
35
+ The official Python SDK for [SerpKite](https://serpkite.com), the Google search API built for AI
36
+ agents. You get clean JSON or Markdown, typed pydantic models, sync and asyncio clients, a native
37
+ CrewAI tool, and credits that never expire.
38
+
39
+ ```bash
40
+ pip install serpkite
41
+ ```
42
+
43
+ It needs Python 3.9+ and depends only on `httpx` and `pydantic` v2.
44
+
45
+ ## Quickstart
46
+
47
+ Create a key at [app.serpkite.com/keys](https://app.serpkite.com/keys) and export it:
48
+
49
+ ```bash
50
+ export SERPKITE_API_KEY=skt_live_...
51
+ ```
52
+
53
+ ```python
54
+ from serpkite import SerpKite
55
+
56
+ sk = SerpKite() # reads SERPKITE_API_KEY
57
+ res = sk.search("best espresso machine", country="us")
58
+ print(res.results[0].title, res.meta.credits_used)
59
+
60
+ md = sk.search("best espresso machine", format="markdown") # str, token-lean for LLMs
61
+ ```
62
+
63
+ Every response shares one envelope: `request` (the normalised request), `results` (the vertical's
64
+ primary list), vertical-specific extras (`ai_overview`, `answer_box`, `knowledge_graph`,
65
+ `people_also_ask`, `related_searches`, `top_stories`, `places`, `ads` on search) and `meta`
66
+ (`request_id`, `credits_used`, `cached`, `latency_ms`, …). All keys are snake_case.
67
+
68
+ Models keep unknown fields (`extra="allow"`), so new API fields never break an older SDK. You can
69
+ read them with `res.model_extra` or `res.model_dump()`.
70
+
71
+ ## Client options
72
+
73
+ ```python
74
+ sk = SerpKite(
75
+ api_key="skt_live_...", # default: SERPKITE_API_KEY
76
+ base_url="https://api.serpkite.com", # default: SERPKITE_BASE_URL or the public API
77
+ timeout=60.0, # seconds or an httpx.Timeout
78
+ max_retries=2, # 429 / 5xx / connection errors
79
+ http_client=httpx.Client(proxy="http://..."), # optional, bring your own
80
+ default_headers={"X-Request-Id": "..."},
81
+ )
82
+
83
+ with SerpKite() as sk: # closes the HTTP client on exit
84
+ ...
85
+ ```
86
+
87
+ ## Methods
88
+
89
+ Every query vertical takes `q` plus optional keyword parameters: `country`, `language`,
90
+ `location`, `uule`, `num`, `page`, `time` (`hour|day|week|month|year`), `tbs`, `device`, `safe`,
91
+ `autocorrect`, `ai_overview`, `ads`, `max_age`, `engine` (see
92
+ [Search engines & fallback](#search-engines--fallback)) and `include_content` (search only).
93
+
94
+ | Method | Endpoint | Returns |
95
+ | --- | --- | --- |
96
+ | `sk.search(q, **params)` | `/v1/search` | `SearchResponse` |
97
+ | `sk.images(q, **params)` | `/v1/images` | `ImagesResponse` |
98
+ | `sk.videos(q, **params)` | `/v1/videos` | `VideosResponse` |
99
+ | `sk.news(q, **params)` | `/v1/news` | `NewsResponse` |
100
+ | `sk.maps(q, ll="@40.7,-74.0,14z", **params)` | `/v1/maps` | `PlacesResponse` |
101
+ | `sk.places(q, **params)` | `/v1/places` | `PlacesResponse` |
102
+ | `sk.reviews(place_id=… \| cid=… \| fid=…, sort=, page_token=, num=)` | `/v1/reviews` | `ReviewsResponse` |
103
+ | `sk.shopping(q, **params)` | `/v1/shopping` | `ShoppingResponse` |
104
+ | `sk.scholar(q, **params)` | `/v1/scholar` | `ScholarResponse` |
105
+ | `sk.patents(q, **params)` | `/v1/patents` | `PatentsResponse` |
106
+ | `sk.autocomplete(q, **params)` | `/v1/autocomplete` | `AutocompleteResponse` |
107
+ | `sk.lens(url)` | `/v1/lens` | `LensResponse` |
108
+ | `sk.ai_mode(q, **params)` | `/v1/ai-mode` | `AIModeResponse` (`answer`, `markdown`, `results`) |
109
+ | `sk.webpage(url, include_html=False)` | `/v1/webpage` | `WebpageResponse` (`markdown`, `text`, `metadata`) |
110
+ | `sk.rank(q, domain, num=100)` | `/v1/rank` | `RankResponse` (`position` or `None`, `matches`) |
111
+ | `sk.account()` | `/v1/account` | `Account` (balance, limits, month usage) |
112
+ | `sk.batches.create / get / wait` | `/v1/batches` | see [Batches](#batches) |
113
+ | `sk.request(endpoint, params)` | `/v1/<endpoint>` | low-level: `str` for Markdown, else `dict` |
114
+
115
+ The output format changes the return type, and the overloads make type checkers follow it:
116
+
117
+ ```python
118
+ sk.search("q") # SearchResponse
119
+ sk.search("q", format="markdown") # str
120
+ sk.search("q", format="compact") # dict: lean results + meta
121
+ sk.search("q", fields="results.title,results.link,ai_overview") # dict: projected
122
+ sk.webpage("https://example.com", format="markdown") # str
123
+ ```
124
+
125
+ Some more examples:
126
+
127
+ ```python
128
+ # Top 5 results with their pages as Markdown (+1 credit per fetched page)
129
+ res = sk.search("vector databases comparison", include_content=5)
130
+ for r in res.results:
131
+ print(r.title, len(r.content or ""))
132
+
133
+ # Reviews, paged
134
+ page = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", sort="newest")
135
+ nxt = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", page_token=page.next_page_token)
136
+
137
+ # Where does example.com rank?
138
+ r = sk.rank("espresso machine", "example.com")
139
+ print(r.position, r.checked)
140
+
141
+ # Accept a cached result up to an hour old (half the credits on a hit)
142
+ sk.news("fed rate decision", max_age=3600)
143
+ ```
144
+
145
+ ## Search engines & fallback
146
+
147
+ By default every request is answered by Google only (`engine="google"`); SerpKite already fails
148
+ over across its own proxy pools. Opt in to other providers with `engine`:
149
+
150
+ ```python
151
+ # Fall back to other enabled providers when Google is blocked or times out
152
+ res = sk.search("best espresso machine", engine="auto")
153
+ print(res.meta.engine) # "google", or e.g. "brave" if Google was unavailable
154
+ for step in res.meta.route or []: # absent on cache hits
155
+ print(step.provider, step.outcome, step.ms) # google blocked 812 / brave ok 431
156
+
157
+ # Only these providers, in this order (any sequence of names works)
158
+ sk.news("espresso", engine=["google", "brave"])
159
+ ```
160
+
161
+ - `engine` is `"google"` (default), `"auto"`, `"consensus"`, one provider (`"brave"`, `"bing"`,
162
+ `"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a list. `"auto"` and `"consensus"` can't be combined
163
+ with other names; unknown names, or a provider that doesn't serve the endpoint, raise
164
+ `BadRequestError` (`invalid_request`).
165
+ - `engine="consensus"` (`search` only) asks several independent indexes in parallel, merges the
166
+ results by URL and ranks them by agreement: each result has `sources` (e.g.
167
+ `["google", "brave"]`), `meta.engine` is `"consensus"`, and it costs the sum of one page per
168
+ provider that returned results.
169
+ - `meta.engine` names the provider that answered; `meta.route` lists each attempt as a
170
+ `RouteStep` (`provider`, `outcome`, `ms`). `request.engine` echoes what you asked for.
171
+ - Credits (`meta.credits_used`) follow the answering provider's price.
172
+
173
+ ## Async
174
+
175
+ `AsyncSerpKite` has the same surface, built on `httpx.AsyncClient`:
176
+
177
+ ```python
178
+ import asyncio
179
+ from serpkite import AsyncSerpKite
180
+
181
+ async def main() -> None:
182
+ async with AsyncSerpKite() as sk:
183
+ res, news = await asyncio.gather(
184
+ sk.search("best espresso machine"),
185
+ sk.news("espresso prices"),
186
+ )
187
+ print(res.results[0].link, news.results[0].source)
188
+
189
+ asyncio.run(main())
190
+ ```
191
+
192
+ ## Errors
193
+
194
+ Every API error raises a `serpkite.SerpKiteError` with `status`, `code`, `message` and
195
+ `request_id`. The class depends on the HTTP status:
196
+
197
+ | Exception | Status | Typical `code` |
198
+ | --- | --- | --- |
199
+ | `BadRequestError` | 400 | `invalid_request` |
200
+ | `AuthenticationError` | 401 | `unauthorized` |
201
+ | `InsufficientCreditsError` | 402 | `insufficient_credits` |
202
+ | `PermissionDeniedError` | 403 | `spend_cap_reached`, `key_limit_reached`, `forbidden` |
203
+ | `NotFoundError` | 404 | `not_found` |
204
+ | `RateLimitError` | 429 | `rate_limited`, `daily_limit_reached` (has `.retry_after`) |
205
+ | `APIError` | 5xx | `upstream_error`, `upstream_blocked`, `upstream_timeout`, `unavailable` (all 503) |
206
+ | `APIConnectionError` / `APITimeoutError` | none | the request never got a response |
207
+
208
+ ```python
209
+ import serpkite
210
+
211
+ try:
212
+ sk.search("espresso")
213
+ except serpkite.InsufficientCreditsError:
214
+ print("Top up at https://app.serpkite.com/billing")
215
+ except serpkite.SerpKiteError as e:
216
+ print(e.status, e.code, e.message, e.request_id)
217
+ ```
218
+
219
+ Failed, empty and blocked searches are not billed. The client retries 429s, 5xx responses and
220
+ connection errors up to `max_retries` times. It uses exponential backoff with jitter and honours
221
+ `Retry-After`. It never retries `daily_limit_reached`. Batch creation is retried only when the
222
+ request provably never reached the server, unless you pass `idempotency_key`.
223
+
224
+ ## Batches
225
+
226
+ Batches run at half price. You queue 1-100 requests for one endpoint, then poll for the results
227
+ or receive a signed webhook:
228
+
229
+ ```python
230
+ job = sk.batches.create(
231
+ endpoint="search",
232
+ requests=[{"q": "a"}, {"q": "b", "country": "de"}],
233
+ webhook_url="https://example.com/hooks/serpkite", # optional
234
+ )
235
+ for entry in job.batches: # one entry per request, in order: Batch or ErrorResponse
236
+ if isinstance(entry, serpkite.types.Batch):
237
+ done = sk.batches.wait(str(entry.id), timeout=300, poll_interval=2)
238
+ if done.status == "done":
239
+ res = serpkite.types.SearchResponse.model_validate(done.result)
240
+ ```
241
+
242
+ `batches.wait` raises `BatchTimeoutError` (a `TimeoutError`) when the timeout expires. Results
243
+ are kept for 24 hours.
244
+
245
+ Pass `idempotency_key=` (e.g. a UUID you store with the batch) to make `create` safe to retry: for
246
+ 24 hours the server replays the first response for the same key and body, so the client then also
247
+ retries 5xx and connection errors.
248
+
249
+ Webhook deliveries are always signed. Check one with the raw body:
250
+
251
+ ```python
252
+ from serpkite import verify_webhook
253
+
254
+ if not verify_webhook(os.environ["SERPKITE_WEBHOOK_SECRET"], request.get_data(), request.headers):
255
+ abort(401)
256
+ ```
257
+
258
+ ## CrewAI
259
+
260
+ ```bash
261
+ pip install "serpkite[crewai]"
262
+ ```
263
+
264
+ ```python
265
+ from crewai import Agent
266
+ from serpkite.crewai import SerpKiteSearchTool
267
+
268
+ tool = SerpKiteSearchTool() # optional: endpoint="news", country="de", num=10, engine="auto"
269
+ agent = Agent(role="Researcher", goal="Find current facts", backstory="...", tools=[tool])
270
+ ```
271
+
272
+ The tool returns Markdown, which uses far fewer tokens than JSON. The agent can set `query`,
273
+ `num`, `country`, `language` and `time`. `endpoint` can be `search`, `news`, `images`, `videos`,
274
+ `maps`, `places`, `shopping`, `scholar`, `patents`, `autocomplete` or `ai-mode`. `engine` (you set
275
+ it, not the agent) opts in to [fallback providers](#search-engines--fallback).
276
+
277
+ ## LangChain
278
+
279
+ See [`langchain-serpkite`](https://pypi.org/project/langchain-serpkite/) for LangChain tools, a
280
+ retriever and a document loader.
281
+
282
+ ## Development
283
+
284
+ ```bash
285
+ uv sync
286
+ uv run pytest
287
+ uv run ruff check . && uv run ruff format --check .
288
+ uv run mypy
289
+ ./scripts/gen_models.sh # regenerate src/serpkite/_models.py from backend/api/serp-api.yaml
290
+ uv run --with "crewai>=0.80" pytest tests/test_crewai.py # optional CrewAI tests
291
+ ```
292
+
293
+ ## License
294
+
295
+ MIT
@@ -0,0 +1,263 @@
1
+ # serpkite
2
+
3
+ The official Python SDK for [SerpKite](https://serpkite.com), the Google search API built for AI
4
+ agents. You get clean JSON or Markdown, typed pydantic models, sync and asyncio clients, a native
5
+ CrewAI tool, and credits that never expire.
6
+
7
+ ```bash
8
+ pip install serpkite
9
+ ```
10
+
11
+ It needs Python 3.9+ and depends only on `httpx` and `pydantic` v2.
12
+
13
+ ## Quickstart
14
+
15
+ Create a key at [app.serpkite.com/keys](https://app.serpkite.com/keys) and export it:
16
+
17
+ ```bash
18
+ export SERPKITE_API_KEY=skt_live_...
19
+ ```
20
+
21
+ ```python
22
+ from serpkite import SerpKite
23
+
24
+ sk = SerpKite() # reads SERPKITE_API_KEY
25
+ res = sk.search("best espresso machine", country="us")
26
+ print(res.results[0].title, res.meta.credits_used)
27
+
28
+ md = sk.search("best espresso machine", format="markdown") # str, token-lean for LLMs
29
+ ```
30
+
31
+ Every response shares one envelope: `request` (the normalised request), `results` (the vertical's
32
+ primary list), vertical-specific extras (`ai_overview`, `answer_box`, `knowledge_graph`,
33
+ `people_also_ask`, `related_searches`, `top_stories`, `places`, `ads` on search) and `meta`
34
+ (`request_id`, `credits_used`, `cached`, `latency_ms`, …). All keys are snake_case.
35
+
36
+ Models keep unknown fields (`extra="allow"`), so new API fields never break an older SDK. You can
37
+ read them with `res.model_extra` or `res.model_dump()`.
38
+
39
+ ## Client options
40
+
41
+ ```python
42
+ sk = SerpKite(
43
+ api_key="skt_live_...", # default: SERPKITE_API_KEY
44
+ base_url="https://api.serpkite.com", # default: SERPKITE_BASE_URL or the public API
45
+ timeout=60.0, # seconds or an httpx.Timeout
46
+ max_retries=2, # 429 / 5xx / connection errors
47
+ http_client=httpx.Client(proxy="http://..."), # optional, bring your own
48
+ default_headers={"X-Request-Id": "..."},
49
+ )
50
+
51
+ with SerpKite() as sk: # closes the HTTP client on exit
52
+ ...
53
+ ```
54
+
55
+ ## Methods
56
+
57
+ Every query vertical takes `q` plus optional keyword parameters: `country`, `language`,
58
+ `location`, `uule`, `num`, `page`, `time` (`hour|day|week|month|year`), `tbs`, `device`, `safe`,
59
+ `autocorrect`, `ai_overview`, `ads`, `max_age`, `engine` (see
60
+ [Search engines & fallback](#search-engines--fallback)) and `include_content` (search only).
61
+
62
+ | Method | Endpoint | Returns |
63
+ | --- | --- | --- |
64
+ | `sk.search(q, **params)` | `/v1/search` | `SearchResponse` |
65
+ | `sk.images(q, **params)` | `/v1/images` | `ImagesResponse` |
66
+ | `sk.videos(q, **params)` | `/v1/videos` | `VideosResponse` |
67
+ | `sk.news(q, **params)` | `/v1/news` | `NewsResponse` |
68
+ | `sk.maps(q, ll="@40.7,-74.0,14z", **params)` | `/v1/maps` | `PlacesResponse` |
69
+ | `sk.places(q, **params)` | `/v1/places` | `PlacesResponse` |
70
+ | `sk.reviews(place_id=… \| cid=… \| fid=…, sort=, page_token=, num=)` | `/v1/reviews` | `ReviewsResponse` |
71
+ | `sk.shopping(q, **params)` | `/v1/shopping` | `ShoppingResponse` |
72
+ | `sk.scholar(q, **params)` | `/v1/scholar` | `ScholarResponse` |
73
+ | `sk.patents(q, **params)` | `/v1/patents` | `PatentsResponse` |
74
+ | `sk.autocomplete(q, **params)` | `/v1/autocomplete` | `AutocompleteResponse` |
75
+ | `sk.lens(url)` | `/v1/lens` | `LensResponse` |
76
+ | `sk.ai_mode(q, **params)` | `/v1/ai-mode` | `AIModeResponse` (`answer`, `markdown`, `results`) |
77
+ | `sk.webpage(url, include_html=False)` | `/v1/webpage` | `WebpageResponse` (`markdown`, `text`, `metadata`) |
78
+ | `sk.rank(q, domain, num=100)` | `/v1/rank` | `RankResponse` (`position` or `None`, `matches`) |
79
+ | `sk.account()` | `/v1/account` | `Account` (balance, limits, month usage) |
80
+ | `sk.batches.create / get / wait` | `/v1/batches` | see [Batches](#batches) |
81
+ | `sk.request(endpoint, params)` | `/v1/<endpoint>` | low-level: `str` for Markdown, else `dict` |
82
+
83
+ The output format changes the return type, and the overloads make type checkers follow it:
84
+
85
+ ```python
86
+ sk.search("q") # SearchResponse
87
+ sk.search("q", format="markdown") # str
88
+ sk.search("q", format="compact") # dict: lean results + meta
89
+ sk.search("q", fields="results.title,results.link,ai_overview") # dict: projected
90
+ sk.webpage("https://example.com", format="markdown") # str
91
+ ```
92
+
93
+ Some more examples:
94
+
95
+ ```python
96
+ # Top 5 results with their pages as Markdown (+1 credit per fetched page)
97
+ res = sk.search("vector databases comparison", include_content=5)
98
+ for r in res.results:
99
+ print(r.title, len(r.content or ""))
100
+
101
+ # Reviews, paged
102
+ page = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", sort="newest")
103
+ nxt = sk.reviews(place_id="ChIJN1t_tDeuEmsRUsoyG83frY4", page_token=page.next_page_token)
104
+
105
+ # Where does example.com rank?
106
+ r = sk.rank("espresso machine", "example.com")
107
+ print(r.position, r.checked)
108
+
109
+ # Accept a cached result up to an hour old (half the credits on a hit)
110
+ sk.news("fed rate decision", max_age=3600)
111
+ ```
112
+
113
+ ## Search engines & fallback
114
+
115
+ By default every request is answered by Google only (`engine="google"`); SerpKite already fails
116
+ over across its own proxy pools. Opt in to other providers with `engine`:
117
+
118
+ ```python
119
+ # Fall back to other enabled providers when Google is blocked or times out
120
+ res = sk.search("best espresso machine", engine="auto")
121
+ print(res.meta.engine) # "google", or e.g. "brave" if Google was unavailable
122
+ for step in res.meta.route or []: # absent on cache hits
123
+ print(step.provider, step.outcome, step.ms) # google blocked 812 / brave ok 431
124
+
125
+ # Only these providers, in this order (any sequence of names works)
126
+ sk.news("espresso", engine=["google", "brave"])
127
+ ```
128
+
129
+ - `engine` is `"google"` (default), `"auto"`, `"consensus"`, one provider (`"brave"`, `"bing"`,
130
+ `"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a list. `"auto"` and `"consensus"` can't be combined
131
+ with other names; unknown names, or a provider that doesn't serve the endpoint, raise
132
+ `BadRequestError` (`invalid_request`).
133
+ - `engine="consensus"` (`search` only) asks several independent indexes in parallel, merges the
134
+ results by URL and ranks them by agreement: each result has `sources` (e.g.
135
+ `["google", "brave"]`), `meta.engine` is `"consensus"`, and it costs the sum of one page per
136
+ provider that returned results.
137
+ - `meta.engine` names the provider that answered; `meta.route` lists each attempt as a
138
+ `RouteStep` (`provider`, `outcome`, `ms`). `request.engine` echoes what you asked for.
139
+ - Credits (`meta.credits_used`) follow the answering provider's price.
140
+
141
+ ## Async
142
+
143
+ `AsyncSerpKite` has the same surface, built on `httpx.AsyncClient`:
144
+
145
+ ```python
146
+ import asyncio
147
+ from serpkite import AsyncSerpKite
148
+
149
+ async def main() -> None:
150
+ async with AsyncSerpKite() as sk:
151
+ res, news = await asyncio.gather(
152
+ sk.search("best espresso machine"),
153
+ sk.news("espresso prices"),
154
+ )
155
+ print(res.results[0].link, news.results[0].source)
156
+
157
+ asyncio.run(main())
158
+ ```
159
+
160
+ ## Errors
161
+
162
+ Every API error raises a `serpkite.SerpKiteError` with `status`, `code`, `message` and
163
+ `request_id`. The class depends on the HTTP status:
164
+
165
+ | Exception | Status | Typical `code` |
166
+ | --- | --- | --- |
167
+ | `BadRequestError` | 400 | `invalid_request` |
168
+ | `AuthenticationError` | 401 | `unauthorized` |
169
+ | `InsufficientCreditsError` | 402 | `insufficient_credits` |
170
+ | `PermissionDeniedError` | 403 | `spend_cap_reached`, `key_limit_reached`, `forbidden` |
171
+ | `NotFoundError` | 404 | `not_found` |
172
+ | `RateLimitError` | 429 | `rate_limited`, `daily_limit_reached` (has `.retry_after`) |
173
+ | `APIError` | 5xx | `upstream_error`, `upstream_blocked`, `upstream_timeout`, `unavailable` (all 503) |
174
+ | `APIConnectionError` / `APITimeoutError` | none | the request never got a response |
175
+
176
+ ```python
177
+ import serpkite
178
+
179
+ try:
180
+ sk.search("espresso")
181
+ except serpkite.InsufficientCreditsError:
182
+ print("Top up at https://app.serpkite.com/billing")
183
+ except serpkite.SerpKiteError as e:
184
+ print(e.status, e.code, e.message, e.request_id)
185
+ ```
186
+
187
+ Failed, empty and blocked searches are not billed. The client retries 429s, 5xx responses and
188
+ connection errors up to `max_retries` times. It uses exponential backoff with jitter and honours
189
+ `Retry-After`. It never retries `daily_limit_reached`. Batch creation is retried only when the
190
+ request provably never reached the server, unless you pass `idempotency_key`.
191
+
192
+ ## Batches
193
+
194
+ Batches run at half price. You queue 1-100 requests for one endpoint, then poll for the results
195
+ or receive a signed webhook:
196
+
197
+ ```python
198
+ job = sk.batches.create(
199
+ endpoint="search",
200
+ requests=[{"q": "a"}, {"q": "b", "country": "de"}],
201
+ webhook_url="https://example.com/hooks/serpkite", # optional
202
+ )
203
+ for entry in job.batches: # one entry per request, in order: Batch or ErrorResponse
204
+ if isinstance(entry, serpkite.types.Batch):
205
+ done = sk.batches.wait(str(entry.id), timeout=300, poll_interval=2)
206
+ if done.status == "done":
207
+ res = serpkite.types.SearchResponse.model_validate(done.result)
208
+ ```
209
+
210
+ `batches.wait` raises `BatchTimeoutError` (a `TimeoutError`) when the timeout expires. Results
211
+ are kept for 24 hours.
212
+
213
+ Pass `idempotency_key=` (e.g. a UUID you store with the batch) to make `create` safe to retry: for
214
+ 24 hours the server replays the first response for the same key and body, so the client then also
215
+ retries 5xx and connection errors.
216
+
217
+ Webhook deliveries are always signed. Check one with the raw body:
218
+
219
+ ```python
220
+ from serpkite import verify_webhook
221
+
222
+ if not verify_webhook(os.environ["SERPKITE_WEBHOOK_SECRET"], request.get_data(), request.headers):
223
+ abort(401)
224
+ ```
225
+
226
+ ## CrewAI
227
+
228
+ ```bash
229
+ pip install "serpkite[crewai]"
230
+ ```
231
+
232
+ ```python
233
+ from crewai import Agent
234
+ from serpkite.crewai import SerpKiteSearchTool
235
+
236
+ tool = SerpKiteSearchTool() # optional: endpoint="news", country="de", num=10, engine="auto"
237
+ agent = Agent(role="Researcher", goal="Find current facts", backstory="...", tools=[tool])
238
+ ```
239
+
240
+ The tool returns Markdown, which uses far fewer tokens than JSON. The agent can set `query`,
241
+ `num`, `country`, `language` and `time`. `endpoint` can be `search`, `news`, `images`, `videos`,
242
+ `maps`, `places`, `shopping`, `scholar`, `patents`, `autocomplete` or `ai-mode`. `engine` (you set
243
+ it, not the agent) opts in to [fallback providers](#search-engines--fallback).
244
+
245
+ ## LangChain
246
+
247
+ See [`langchain-serpkite`](https://pypi.org/project/langchain-serpkite/) for LangChain tools, a
248
+ retriever and a document loader.
249
+
250
+ ## Development
251
+
252
+ ```bash
253
+ uv sync
254
+ uv run pytest
255
+ uv run ruff check . && uv run ruff format --check .
256
+ uv run mypy
257
+ ./scripts/gen_models.sh # regenerate src/serpkite/_models.py from backend/api/serp-api.yaml
258
+ uv run --with "crewai>=0.80" pytest tests/test_crewai.py # optional CrewAI tests
259
+ ```
260
+
261
+ ## License
262
+
263
+ MIT
@@ -0,0 +1,93 @@
1
+ [project]
2
+ name = "serpkite"
3
+ version = "0.1.0"
4
+ description = "Official Python SDK for SerpKite, the Google search API built for AI agents: clean JSON or Markdown results."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.9"
9
+ authors = [{ name = "SerpKite", email = "support@serpkite.com" }]
10
+ keywords = ["serpkite", "google", "search", "serp", "api", "ai", "agents", "llm", "markdown"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27",
27
+ "pydantic>=2.6",
28
+ "typing-extensions>=4.7",
29
+ # Lets pydantic evaluate `X | None` annotations in the generated models on Python 3.9.
30
+ "eval-type-backport>=0.2; python_version < '3.10'",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ crewai = ["crewai>=0.80; python_version >= '3.10'"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://serpkite.com"
38
+ Documentation = "https://serpkite.com/docs/sdks/python"
39
+ Repository = "https://github.com/serpkite/serpkite-python"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8",
44
+ "pytest-asyncio>=0.23",
45
+ "respx>=0.21",
46
+ "ruff>=0.6",
47
+ "mypy>=1.10",
48
+ "datamodel-code-generator>=0.30; python_version >= '3.10'",
49
+ ]
50
+
51
+ [build-system]
52
+ requires = ["hatchling>=1.26"]
53
+ build-backend = "hatchling.build"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["src/serpkite"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = ["src/serpkite", "tests", "README.md", "LICENSE", "pyproject.toml"]
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ asyncio_mode = "auto"
64
+
65
+ [tool.ruff]
66
+ target-version = "py39"
67
+ line-length = 110
68
+ src = ["src", "tests"]
69
+ extend-exclude = ["src/serpkite/_models.py", "*.md"]
70
+
71
+ [tool.ruff.lint]
72
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
73
+ ignore = ["UP006", "UP007", "UP035", "UP045"]
74
+
75
+ [tool.mypy]
76
+ python_version = "3.10"
77
+ strict = true
78
+ files = ["src/serpkite", "tests"]
79
+ plugins = ["pydantic.mypy"]
80
+
81
+ [[tool.mypy.overrides]]
82
+ module = ["crewai", "crewai.*"]
83
+ ignore_missing_imports = true
84
+
85
+ [[tool.mypy.overrides]]
86
+ # The public overloads pin each method's return type; the shared _vertical helper returns Any.
87
+ module = ["serpkite._client"]
88
+ warn_return_any = false
89
+
90
+ [[tool.mypy.overrides]]
91
+ module = ["serpkite.crewai"]
92
+ disallow_subclassing_any = false
93
+ warn_unused_ignores = false