serpkite 0.6.0 → 0.8.0
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.
- package/README.md +134 -8
- package/dist/index.cjs +311 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1363 -53
- package/dist/index.d.ts +1363 -53
- package/dist/index.js +311 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,12 +94,19 @@ vertical-specific extras and `meta` (`request_id`, `credits_used`, `cached`, `la
|
|
|
94
94
|
| `patents(params)` | `POST /v1/patents` | `SearchParams` | `PatentsResponse` |
|
|
95
95
|
| `autocomplete(params)` | `POST /v1/autocomplete` | `SearchParams` | `AutocompleteResponse` (`results[].value`) |
|
|
96
96
|
| `webpage(params)` | `POST /v1/webpage` | `WebpageParams` (`url`, `include_html`) | `WebpageResponse` (`markdown`, `text`, `metadata`) |
|
|
97
|
+
| `chatgpt(params)` | `POST /v1/chatgpt` | `ChatGPTParams` (`prompt`, `web_search`, `country`, `track`, `include_html`) | `AssistantResponse` |
|
|
98
|
+
| `gemini` / `copilot` / `aiMode` / `braveAi(params)` | `POST /v1/gemini` / `copilot` / `ai_mode` / `brave_ai` | `AssistantParams` (`prompt`, `country`, `language`, `track`, `include_html`) | `AssistantResponse` (`text`, `markdown`, `sources`, `search_queries`, `products`, `places`, `mentions`) |
|
|
97
99
|
| `rank(params)` | `POST /v1/rank` | `RankParams` (`q`, `domain`, `num`: 10\|20\|30\|50\|100) | `RankResponse` (`position` or `null`, `matches`, `checked`) |
|
|
98
|
-
| `
|
|
100
|
+
| `answer(params)` | `POST /v1/answer` | `AnswerParams` (`q`, `depth`: `fast`\|`standard`, `num_sources` 1-5, `output_schema`, search options) | `AnswerResponse` (`answer`, `citations`, `results`, `output`) |
|
|
101
|
+
| `answerStream(params)` | `POST /v1/answer` (`stream: true`) | `AnswerParams` | `AsyncIterable<AnswerStreamEvent>` (`results`, `delta`…, `done`) |
|
|
102
|
+
| `extract(params)` | `POST /v1/extract` | `ExtractParams` (`urls` ≤ 20, `format`, `query`, `highlights`, `summary`, `output_schema`, `prompt`, …) | `ExtractResponse` (`results`, `failed`) |
|
|
99
103
|
| `map(params)` | `POST /v1/map` | `MapParams` (`url`, `search`, `limit`, `sitemap`, path filters) | `MapResponse` (`results[].url`) |
|
|
100
104
|
| `crawl(params)` | `POST /v1/crawl` | `CrawlParams` (`url`, `limit` ≤ 1000, `max_depth` ≤ 10, …) | `TaskCreated` (`202`) |
|
|
101
105
|
| `getCrawl(id)` / `cancelCrawl(id)` | `GET` / `DELETE /v1/crawl/{id}` | task id | `CrawlTask` / `TaskCancelResponse` |
|
|
102
106
|
| `waitForCrawl(idOrTask, options?)` | polls `GET /v1/crawl/{id}` | task id or task | `CrawlTask` (`completed`, `failed` or `canceled`) |
|
|
107
|
+
| `research(params)` | `POST /v1/research` | `ResearchParams` (`input`, `effort`: `lite`\|`standard`\|`deep`, `output_schema`, domains, `webhook_url`) | `TaskCreated` (`202`) |
|
|
108
|
+
| `getResearch(id)` / `cancelResearch(id)` | `GET` / `DELETE /v1/research/{id}` | task id | `ResearchTask` / `TaskCancelResponse` |
|
|
109
|
+
| `waitResearch(idOrTask, options?)` | polls `GET /v1/research/{id}` | task id or task | `ResearchTask` (`completed`, `failed` or `canceled`) |
|
|
103
110
|
| `monitors.create/list/get/update/delete/run` | `/v1/monitors…` | `MonitorCreateParams`, `MonitorUpdateParams` | `Monitor`, `MonitorList` |
|
|
104
111
|
| `monitors.runs(id, params?)` / `monitors.iterRuns(id)` | `GET /v1/monitors/{id}/runs` | `limit`, `before` | `MonitorRunList` / `AsyncIterable<MonitorRun>` |
|
|
105
112
|
| `account()` | `GET /v1/account` | none | `Account` (`balance`, `plan`, `rate_limit_rps`, `month`, …) |
|
|
@@ -111,7 +118,7 @@ Common `SearchParams`: `q` (required), `country` (default `us`), `language` (def
|
|
|
111
118
|
`location`, `uule`, `num` (10, or 100 for the depth bundle), `page` (1-10), `time`
|
|
112
119
|
(`hour`|`day`|`week`|`month`|`year`), `tbs`, `device` (`desktop`|`mobile`), `safe`, `autocorrect`,
|
|
113
120
|
`format` (`json`|`markdown`|`compact`), `fields`, `include_content` (0-5), `ads`, `max_age`,
|
|
114
|
-
`engine
|
|
121
|
+
`engine`, `include_answer` (`search` and `news`).
|
|
115
122
|
|
|
116
123
|
### Examples
|
|
117
124
|
|
|
@@ -192,7 +199,7 @@ await sk.news({ q: "espresso", engine: ["google", "brave"] });
|
|
|
192
199
|
```
|
|
193
200
|
|
|
194
201
|
- `engine` is `"google"` (default), `"auto"`, `"consensus"`, one `Provider` (`"brave"`, `"bing"`,
|
|
195
|
-
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a `Provider[]` list. `"auto"` and `"consensus"` can't
|
|
202
|
+
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`, `"yandex"`) or a `Provider[]` list. `"auto"` and `"consensus"` can't
|
|
196
203
|
be combined with other names; unknown names, or a provider that doesn't serve the endpoint, return
|
|
197
204
|
`400 invalid_request`.
|
|
198
205
|
- `engine: "consensus"` (`search` only) asks several independent indexes in parallel, merges the
|
|
@@ -203,6 +210,104 @@ await sk.news({ q: "espresso", engine: ["google", "brave"] });
|
|
|
203
210
|
and its `outcome` (absent on cache hits). `request.engine` echoes what you asked for.
|
|
204
211
|
- Credits (`meta.credits_used`, `X-Credits-Used`) follow the answering provider's price.
|
|
205
212
|
|
|
213
|
+
## AI answers
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
// A cited answer grounded on live Google results ("fast" answers from the result page;
|
|
217
|
+
// "standard", the default, also reads the top num_sources pages)
|
|
218
|
+
const res = await sk.answer({ q: "what is retrieval-augmented generation", depth: "fast" });
|
|
219
|
+
console.log(res.answer); // Markdown with [1] markers
|
|
220
|
+
for (const c of res.citations) console.log(c.index, c.title, c.link);
|
|
221
|
+
console.log(res.meta.credits, res.meta.llm); // { search, answer }, { provider, model, … }
|
|
222
|
+
|
|
223
|
+
// Stream it: results first, then text deltas, then done (final answer, citations, meta)
|
|
224
|
+
for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
|
|
225
|
+
if (ev.event === "delta") process.stdout.write(ev.data.text);
|
|
226
|
+
if (ev.event === "done") console.log("\n", ev.data.citations);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Structured output: the answer as JSON matching your schema (not with streaming)
|
|
230
|
+
const facts = await sk.answer({
|
|
231
|
+
q: "tallest building in the world",
|
|
232
|
+
output_schema: { type: "object", properties: { name: { type: "string" }, height_m: { type: "number" } } },
|
|
233
|
+
});
|
|
234
|
+
console.log(facts.output);
|
|
235
|
+
|
|
236
|
+
// A cited answer on top of a normal search or news response
|
|
237
|
+
const news = await sk.news({ q: "ECB rate decision", time: "day", include_answer: true });
|
|
238
|
+
console.log(news.answer?.text, news.meta.warnings); // no answer + a warning (refunded) if the model failed
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- A stream's `error` event (and a model failure on `answer`) is thrown as `SerpKiteError` with code
|
|
242
|
+
`ai_unavailable` (503); the model part is not charged. Breaking out of the `for await` loop
|
|
243
|
+
closes the connection and the model part is not charged either.
|
|
244
|
+
- `extract` takes `summary: true` (an AI summary per page) and `output_schema` (+ optional
|
|
245
|
+
`prompt`; each page's data as JSON in `output`), each at an extra per-page price. A page whose
|
|
246
|
+
model step failed carries `summary_error` / `output_error` instead and isn't charged for it. Its
|
|
247
|
+
default `timeout` becomes 75 seconds with either.
|
|
248
|
+
- AI prices (answers, `include_answer`, summaries, schema extraction, research) are listed at
|
|
249
|
+
[serpkite.com/pricing](https://serpkite.com/pricing); `meta.credits` on an answer splits the
|
|
250
|
+
search and the model part.
|
|
251
|
+
|
|
252
|
+
## AI assistants
|
|
253
|
+
|
|
254
|
+
Ask ChatGPT, Gemini, Copilot (Bing Copilot Search), Google AI Mode or Brave's AI answer a `prompt` as a
|
|
255
|
+
logged-out user and get the answer with the sources it cited, the searches it ran and the products
|
|
256
|
+
and places it showed. `track` lists brands and domains to look for: each comes back in `mentions`.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
const res = await sk.chatgpt({
|
|
260
|
+
prompt: "What are the best CRMs for a 10-person startup?",
|
|
261
|
+
country: "us", // ask from an exit in this country (prices and shops follow it)
|
|
262
|
+
track: ["hubspot.com", "Pipedrive"],
|
|
263
|
+
});
|
|
264
|
+
console.log(res.model, res.markdown); // citations as [n](link), matching sources[].position
|
|
265
|
+
for (const s of res.sources) console.log(s.position, s.cited ? "cited" : "listed", s.domain);
|
|
266
|
+
console.log(res.search_queries, res.products.map((p) => `${p.title} ${p.price}`));
|
|
267
|
+
for (const m of res.mentions ?? []) {
|
|
268
|
+
// mentioned: the text names it; cited: a cited source matches it; text_position 0-1 (-1: not named)
|
|
269
|
+
console.log(m.term, m.mentioned, m.cited, m.sources, m.text_position);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const md = await sk.aiMode({ prompt: "how to remove a red wine stain from wool", format: "markdown" }); // string
|
|
273
|
+
await sk.gemini({ prompt: "Wie funktionieren Wärmepumpen?", country: "de", language: "de" });
|
|
274
|
+
|
|
275
|
+
// Google's AI Overview on a normal search, when the page shows one
|
|
276
|
+
const serp = await sk.search({ q: "how do heat pumps work" });
|
|
277
|
+
console.log(serp.ai_overview?.text, serp.ai_overview?.sources);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
- Prices per answer: ChatGPT 5, Gemini 5, Copilot 3, AI Mode 3, Brave AI 2 credits (the same in
|
|
281
|
+
batches; `max_age` cache hits half). Failed, refused and empty answers are free.
|
|
282
|
+
- Answers take 10-90 s: these methods wait at least `ASSISTANT_TIMEOUT_MS` (120 s) per attempt
|
|
283
|
+
unless you pass `timeoutMs`, and are retried only on `429` (a lost response is still billed).
|
|
284
|
+
- `web_search` (default `true`) is ChatGPT only; `language` isn't taken by ChatGPT.
|
|
285
|
+
- Run many prompts through `batches.create({ endpoint: "chatgpt", requests: [{ prompt }, …] })`, or
|
|
286
|
+
track an answer over time with a monitor (`endpoint: "gemini"`, `prompt`, `track`; see Monitors).
|
|
287
|
+
|
|
288
|
+
## Research
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const task = await sk.research({
|
|
292
|
+
input: "Compare managed Postgres vector search options in 2026",
|
|
293
|
+
effort: "standard", // lite | standard (default) | deep
|
|
294
|
+
});
|
|
295
|
+
const done = await sk.waitResearch(task); // polls until completed/failed/canceled (15 min default)
|
|
296
|
+
if (done.status === "completed") {
|
|
297
|
+
console.log(done.result?.report); // Markdown report with [n] citations
|
|
298
|
+
for (const s of done.result?.sources ?? []) console.log(s.index, s.link, s.query);
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
- Each `effort` has a flat price ([serpkite.com/pricing](https://serpkite.com/pricing)), reserved
|
|
303
|
+
up front and charged only when the report is written; failed research is refunded. `getResearch(id)` shows `progress` (`round`, `queries`, `sources`, `step`) while it
|
|
304
|
+
runs. `cancelResearch(id)` refunds a queued task; a running one stops at its next round and is
|
|
305
|
+
charged for the searches already run.
|
|
306
|
+
- `output_schema` also returns the report as JSON in `result.output`; `include_domains` /
|
|
307
|
+
`exclude_domains`, `country` and `language` steer the searches.
|
|
308
|
+
- Pass `webhook_url` to get a signed `research.completed` delivery (same envelope as
|
|
309
|
+
`crawl.completed`) instead of polling.
|
|
310
|
+
|
|
206
311
|
## Map and extract
|
|
207
312
|
|
|
208
313
|
```ts
|
|
@@ -268,6 +373,21 @@ per check) and reports it as a `MonitorPageChange` (`url`, `title`, `change: "ne
|
|
|
268
373
|
stored on any monitor and echoed in its `monitor.results` webhooks; on `update`, an object replaces it
|
|
269
374
|
and `null` clears it.
|
|
270
375
|
|
|
376
|
+
An AI assistant monitor (`endpoint`: `chatgpt`, `gemini`, `copilot`, `ai_mode` or `brave_ai`) asks
|
|
377
|
+
`prompt` every run and reports every answer as one `MonitorAnswer` (`text`, `markdown`, `model`,
|
|
378
|
+
`sources`, `new_sources` (links not seen in earlier runs), `search_queries`, `products` and, with
|
|
379
|
+
`track`, `mentions`); its `monitor.results` webhook carries `prompt` instead of `q`. Each run costs
|
|
380
|
+
the endpoint's price.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
await sk.monitors.create({
|
|
384
|
+
endpoint: "chatgpt",
|
|
385
|
+
prompt: "What are the best CRMs for a 10-person startup?",
|
|
386
|
+
track: ["hubspot.com", "Pipedrive"],
|
|
387
|
+
interval: "daily",
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
271
391
|
```ts
|
|
272
392
|
const watch = await sk.monitors.create({
|
|
273
393
|
endpoint: "webpage",
|
|
@@ -286,7 +406,7 @@ resumes it. `webhook_url: ""` removes the webhook; a changed search reports ever
|
|
|
286
406
|
|
|
287
407
|
## Webhooks
|
|
288
408
|
|
|
289
|
-
Every delivery (`batch.completed`, `crawl.completed`, `monitor.results`) is signed with
|
|
409
|
+
Every delivery (`batch.completed`, `crawl.completed`, `research.completed`, `monitor.results`) is signed with
|
|
290
410
|
`X-SerpKite-Signature`. `parseWebhook` verifies it and returns the event typed by
|
|
291
411
|
`X-SerpKite-Event`, with `deliveryId` from `X-SerpKite-Delivery` (for `monitor.results`, the
|
|
292
412
|
`run_id`) to deduplicate retries:
|
|
@@ -310,7 +430,7 @@ import { isBatchError, SerpKite } from "serpkite";
|
|
|
310
430
|
|
|
311
431
|
const sk = new SerpKite();
|
|
312
432
|
const { batches } = await sk.batches.create({
|
|
313
|
-
endpoint: "search", // or images, news, maps, reviews, webpage, …
|
|
433
|
+
endpoint: "search", // or images, news, maps, reviews, webpage, chatgpt, gemini, …
|
|
314
434
|
requests: [{ q: "a" }, { q: "b" }],
|
|
315
435
|
webhook_url: "https://example.com/hooks/serpkite", // optional
|
|
316
436
|
});
|
|
@@ -362,6 +482,7 @@ try {
|
|
|
362
482
|
| `rate_limited` | 429 | Too many requests (retried automatically) |
|
|
363
483
|
| `upstream_error`, `upstream_blocked` | 503 | Google could not be fetched (not billed, retried) |
|
|
364
484
|
| `upstream_timeout` | 503 | The search took too long (not billed, retried) |
|
|
485
|
+
| `ai_unavailable` | 503 | The AI model failed or AI is off (model part refunded; honours `Retry-After`) |
|
|
365
486
|
| `unavailable` | 503 | Temporarily unavailable (retried) |
|
|
366
487
|
| `internal` | 500 | Server error (retried) |
|
|
367
488
|
| `connection_error`, `timeout` | 0 | No response received |
|
|
@@ -374,8 +495,10 @@ Failed, empty and blocked searches are refunded, so they never cost credits.
|
|
|
374
495
|
Requests are retried up to `maxRetries` times (default 2) on `429`, `5xx`, network errors and
|
|
375
496
|
timeouts, with exponential backoff (`retryDelayMs · 2^attempt`, capped at 8 s) and jitter. A
|
|
376
497
|
`Retry-After` header (seconds or HTTP date, capped at 60 s) takes precedence. Other `4xx` errors
|
|
377
|
-
are never retried. `batches.create`, `crawl`, `
|
|
378
|
-
`429`, so a lost response can never queue (and bill) the same
|
|
498
|
+
are never retried. `batches.create`, `crawl`, `research`, `extract`, `answer`, the AI assistant
|
|
499
|
+
methods, `monitors.create` and `monitors.run` retry only on `429`, so a lost response can never queue (and bill) the same
|
|
500
|
+
work twice. `answerStream` retries only until the stream starts. `answer` and `answerStream` wait
|
|
501
|
+
at least 100 s for the response headers (the search runs first) unless you pass `timeoutMs`.
|
|
379
502
|
|
|
380
503
|
## Types
|
|
381
504
|
|
|
@@ -387,7 +510,10 @@ Types are generated from the OpenAPI contract (`backend/api/serp-api.yaml`) with
|
|
|
387
510
|
`RankResponse`,
|
|
388
511
|
`Account`, `Batch`, `BatchCreateParams`, `Meta`, `Engine`, `Provider`, `RouteStep`,
|
|
389
512
|
`CompactResponse`, `MapResponse`, `ExtractResponse`, `CrawlParams`, `CrawlTask`, `CrawlResult`,
|
|
390
|
-
`TaskCreated`, `
|
|
513
|
+
`TaskCreated`, `AnswerParams`, `AnswerResponse`, `AnswerStreamEvent`, `Citation`, `SearchAnswer`,
|
|
514
|
+
`LLMUsage`, `AssistantParams`, `ChatGPTParams`, `AssistantResponse`, `AISource`, `AIProduct`,
|
|
515
|
+
`Mention`, `AIOverview`, `MonitorAnswer`, `ResearchParams`, `ResearchTask`, `ResearchResult`, `ResearchSource`,
|
|
516
|
+
`Monitor`, `MonitorRun`, `MonitorPageChange`, `TaskCompletedEvent`,
|
|
391
517
|
`MonitorResultsEvent`, `WebhookEvent` and more. The raw
|
|
392
518
|
`paths`, `components` and `operations` types are exported as well.
|
|
393
519
|
|
package/dist/index.cjs
CHANGED
|
@@ -50,8 +50,9 @@ function errorFromResponse(status, headers, body) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// src/client.ts
|
|
53
|
-
var VERSION = "0.
|
|
53
|
+
var VERSION = "0.8.0";
|
|
54
54
|
var DEFAULT_BASE_URL = "https://api.serpkite.com";
|
|
55
|
+
var ASSISTANT_TIMEOUT_MS = 12e4;
|
|
55
56
|
var TASK_DONE = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
|
|
56
57
|
var SerpKite = class {
|
|
57
58
|
baseUrl;
|
|
@@ -124,6 +125,24 @@ var SerpKite = class {
|
|
|
124
125
|
autocomplete = this.#vertical("/v1/autocomplete");
|
|
125
126
|
/** Fetch any public URL as clean Markdown plus metadata. */
|
|
126
127
|
webpage = this.#vertical("/v1/webpage");
|
|
128
|
+
// ── AI assistants ─────────────────────────────────────────────────────
|
|
129
|
+
// Billed even when the response is lost: retried only on 429 (failed, refused and empty
|
|
130
|
+
// answers are free), and each attempt waits at least 120 s (answers take 10-90 s).
|
|
131
|
+
/**
|
|
132
|
+
* Ask ChatGPT as a logged-out user: the answer (`text`, `markdown`), cited `sources`,
|
|
133
|
+
* `search_queries`, `products`, `places` and the guest `model`. `web_search` (default true)
|
|
134
|
+
* makes it search the web; `country` asks from an exit in that country. `track` returns
|
|
135
|
+
* `mentions` of your brands and domains. 5 credits.
|
|
136
|
+
*/
|
|
137
|
+
chatgpt = this.#assistant("/v1/chatgpt");
|
|
138
|
+
/** Ask Google Gemini as a logged-out user: the answer with the sources it grounded on. 5 credits. */
|
|
139
|
+
gemini = this.#assistant("/v1/gemini");
|
|
140
|
+
/** Microsoft Copilot Search on Bing: the answer, cited sources and products. 3 credits. */
|
|
141
|
+
copilot = this.#assistant("/v1/copilot");
|
|
142
|
+
/** Google AI Mode: the answer, cited sources and products. 3 credits. */
|
|
143
|
+
aiMode = this.#assistant("/v1/ai_mode");
|
|
144
|
+
/** Brave Search's AI answer: cited sources, the searches it ran and products. 2 credits. */
|
|
145
|
+
braveAi = this.#assistant("/v1/brave_ai");
|
|
127
146
|
/** Position of `domain` for keyword `q` in the top `num` results (default 100). */
|
|
128
147
|
rank(params, options) {
|
|
129
148
|
return this.request({ method: "POST", path: "/v1/rank", body: params, options });
|
|
@@ -132,9 +151,14 @@ var SerpKite = class {
|
|
|
132
151
|
* Read up to 20 URLs (HTML or PDF) as Markdown, text or HTML in one call, with optional
|
|
133
152
|
* query-ranked `highlights` (BM25), `include_links` and `include_images`. 1 credit per URL
|
|
134
153
|
* that comes back (0.5 from cache); URLs that fail are listed in `failed` and cost nothing.
|
|
154
|
+
* `summary: true` adds an AI summary of each page and `output_schema` (a JSON Schema, guided
|
|
155
|
+
* by an optional `prompt`) returns each page's data as JSON in `output`, each at an extra
|
|
156
|
+
* per-page price (https://serpkite.com/pricing); a page whose model step failed has
|
|
157
|
+
* `summary_error` / `output_error` instead and isn't charged for it.
|
|
135
158
|
*/
|
|
136
159
|
extract(params, options) {
|
|
137
|
-
const
|
|
160
|
+
const ai = params.summary === true || params.output_schema !== void 0;
|
|
161
|
+
const wait = ((params.timeout ?? (ai ? 75 : 50)) + 15) * 1e3;
|
|
138
162
|
return this.request({
|
|
139
163
|
method: "POST",
|
|
140
164
|
path: "/v1/extract",
|
|
@@ -159,6 +183,47 @@ var SerpKite = class {
|
|
|
159
183
|
account(options) {
|
|
160
184
|
return this.request({ method: "GET", path: "/v1/account", options });
|
|
161
185
|
}
|
|
186
|
+
// ── AI answers ────────────────────────────────────────────────────────
|
|
187
|
+
/**
|
|
188
|
+
* A cited answer grounded on live Google results: Markdown with `[n]` markers in `answer`,
|
|
189
|
+
* the cited sources in `citations`. `depth: "fast"` answers from the result page;
|
|
190
|
+
* `"standard"` (default) also reads the top `num_sources` pages and costs more
|
|
191
|
+
* (https://serpkite.com/pricing; `meta.credits` splits search and model). With
|
|
192
|
+
* `output_schema` the answer is a JSON value in `output`.
|
|
193
|
+
* When the model fails the call throws `ai_unavailable` (503) and the model part is refunded;
|
|
194
|
+
* a search with nothing to answer from returns `answer: null` and a `meta.warnings` entry.
|
|
195
|
+
*/
|
|
196
|
+
answer(params, options) {
|
|
197
|
+
return this.request({
|
|
198
|
+
method: "POST",
|
|
199
|
+
path: "/v1/answer",
|
|
200
|
+
body: params,
|
|
201
|
+
options: { ...options, timeoutMs: this.#answerTimeout(options) },
|
|
202
|
+
retry: "rate_limit"
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Streams an answer as Server-Sent Events: `results` first, then `delta` text pieces,
|
|
207
|
+
* then `done` (final answer, citations, meta). A server `error` event is thrown as a
|
|
208
|
+
* `SerpKiteError` (`ai_unavailable`). Breaking out of the loop closes the connection
|
|
209
|
+
* (the model part is then not charged). Can't be combined with `output_schema`.
|
|
210
|
+
*
|
|
211
|
+
* ```ts
|
|
212
|
+
* for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
|
|
213
|
+
* if (ev.event === "delta") process.stdout.write(ev.data.text);
|
|
214
|
+
* }
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
answerStream(params, options) {
|
|
218
|
+
return this.#stream(
|
|
219
|
+
{ ...params, stream: true },
|
|
220
|
+
{ ...options, timeoutMs: this.#answerTimeout(options) }
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
/** The search (pages read included) runs before the first byte: wait at least 100 s. */
|
|
224
|
+
#answerTimeout(options) {
|
|
225
|
+
return options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, 1e5) : 0);
|
|
226
|
+
}
|
|
162
227
|
// ── Async tasks: crawl ────────────────────────────────────────────────
|
|
163
228
|
/**
|
|
164
229
|
* Starts an async crawl of one site and returns the queued task (`202`). 1 credit per page
|
|
@@ -201,6 +266,58 @@ var SerpKite = class {
|
|
|
201
266
|
timeoutMs: options.timeoutMs ?? 21e5
|
|
202
267
|
});
|
|
203
268
|
}
|
|
269
|
+
// ── Async tasks: research ─────────────────────────────────────────────
|
|
270
|
+
/**
|
|
271
|
+
* Starts an async research task: the model plans searches, reads the sources and writes a
|
|
272
|
+
* cited Markdown report (`result.report`). `effort` (`lite`, `standard` (default), `deep`)
|
|
273
|
+
* sets how much is searched and read; its flat price (https://serpkite.com/pricing) is
|
|
274
|
+
* reserved up front and charged only when the report is written (a canceled task is charged
|
|
275
|
+
* for the searches already run). With
|
|
276
|
+
* `output_schema` the report is also returned as JSON in `result.output`. Poll with
|
|
277
|
+
* `getResearch` / `waitResearch` or receive the signed `research.completed` webhook.
|
|
278
|
+
* Only retried on 429, so a lost response never starts (and reserves) a second task.
|
|
279
|
+
*/
|
|
280
|
+
research(params, options) {
|
|
281
|
+
return this.request({
|
|
282
|
+
method: "POST",
|
|
283
|
+
path: "/v1/research",
|
|
284
|
+
body: params,
|
|
285
|
+
options,
|
|
286
|
+
retry: "rate_limit"
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Polls a research task (`progress` while it runs; results are kept for 24 hours). */
|
|
290
|
+
getResearch(id, options) {
|
|
291
|
+
return this.request({
|
|
292
|
+
method: "GET",
|
|
293
|
+
path: `/v1/research/${encodeURIComponent(id)}`,
|
|
294
|
+
options
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Cancels a research task: refunded if still queued, else stops at its next round and is
|
|
299
|
+
* charged for the searches run.
|
|
300
|
+
*/
|
|
301
|
+
cancelResearch(id, options) {
|
|
302
|
+
return this.request({
|
|
303
|
+
method: "DELETE",
|
|
304
|
+
path: `/v1/research/${encodeURIComponent(id)}`,
|
|
305
|
+
options
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Polls until the research task is `completed`, `failed` or `canceled` and returns it (a
|
|
310
|
+
* failed task is returned, not thrown; check `status` and `error`). The report is in
|
|
311
|
+
* `result.report`. Accepts an id or the task returned by `research`. Default timeout
|
|
312
|
+
* 15 minutes.
|
|
313
|
+
*/
|
|
314
|
+
async waitResearch(idOrTask, options = {}) {
|
|
315
|
+
return this.#waitTask(
|
|
316
|
+
taskId(idOrTask),
|
|
317
|
+
(id) => this.getResearch(id, { signal: options.signal }),
|
|
318
|
+
{ ...options, timeoutMs: options.timeoutMs ?? 9e5 }
|
|
319
|
+
);
|
|
320
|
+
}
|
|
204
321
|
// ── Monitors ──────────────────────────────────────────────────────────
|
|
205
322
|
monitors = {
|
|
206
323
|
/**
|
|
@@ -210,7 +327,9 @@ var SerpKite = class {
|
|
|
210
327
|
* and reports it (`MonitorPageChange`) when its content changes. `metadata` (your JSON
|
|
211
328
|
* object, at most 2 KB) is echoed on the monitor and in its webhooks. Without a
|
|
212
329
|
* `webhook_url`, read new results from `monitors.runs`. `active: false`
|
|
213
|
-
* creates it paused.
|
|
330
|
+
* creates it paused. With an AI assistant `endpoint` (`chatgpt`, `gemini`, `copilot`,
|
|
331
|
+
* `ai_mode`, `brave_ai`) and a `prompt` every run reports the answer (`MonitorAnswer`:
|
|
332
|
+
* sources, `new_sources`, `mentions` with `track`). A monitor pauses itself after 10 failed runs in a row
|
|
214
333
|
* (`last_status: "paused"`); `update(id, { active: true })` resumes it. Each run costs
|
|
215
334
|
* what its search costs. Only retried on 429.
|
|
216
335
|
*/
|
|
@@ -333,6 +452,18 @@ var SerpKite = class {
|
|
|
333
452
|
#vertical(path) {
|
|
334
453
|
return ((params, options) => this.request({ method: "POST", path, body: params, options }));
|
|
335
454
|
}
|
|
455
|
+
#assistant(path) {
|
|
456
|
+
return ((params, options) => this.request({
|
|
457
|
+
method: "POST",
|
|
458
|
+
path,
|
|
459
|
+
body: params,
|
|
460
|
+
options: {
|
|
461
|
+
...options,
|
|
462
|
+
timeoutMs: options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, ASSISTANT_TIMEOUT_MS) : 0)
|
|
463
|
+
},
|
|
464
|
+
retry: "rate_limit"
|
|
465
|
+
}));
|
|
466
|
+
}
|
|
336
467
|
async #wait(idOrEntry, o) {
|
|
337
468
|
let id;
|
|
338
469
|
if (typeof idOrEntry === "string") {
|
|
@@ -370,6 +501,149 @@ var SerpKite = class {
|
|
|
370
501
|
interval = Math.min(interval * 1.5, maxInterval);
|
|
371
502
|
}
|
|
372
503
|
}
|
|
504
|
+
/** Opens `POST /v1/answer` as an event stream (retrying the connect like `request`) and parses it. */
|
|
505
|
+
async *#stream(body, options = {}) {
|
|
506
|
+
const maxRetries = Math.max(0, options.maxRetries ?? this.maxRetries);
|
|
507
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
508
|
+
const headers = {
|
|
509
|
+
Accept: "text/event-stream, application/json;q=0.9",
|
|
510
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
511
|
+
...this.#headers,
|
|
512
|
+
...options.headers,
|
|
513
|
+
"Content-Type": "application/json"
|
|
514
|
+
};
|
|
515
|
+
if (!isBrowser()) headers["User-Agent"] = `serpkite-typescript/${VERSION}`;
|
|
516
|
+
let res;
|
|
517
|
+
let cleanup = () => {
|
|
518
|
+
};
|
|
519
|
+
for (let attempt = 0; !res; attempt++) {
|
|
520
|
+
const canRetry = attempt < maxRetries;
|
|
521
|
+
const t = withTimeout(options.signal, timeoutMs);
|
|
522
|
+
let r;
|
|
523
|
+
try {
|
|
524
|
+
r = await this.#fetch(`${this.baseUrl}/v1/answer`, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers,
|
|
527
|
+
body: JSON.stringify(body),
|
|
528
|
+
signal: t.signal
|
|
529
|
+
});
|
|
530
|
+
} catch (err) {
|
|
531
|
+
t.cleanup();
|
|
532
|
+
if (options.signal?.aborted) throw err;
|
|
533
|
+
if (canRetry) {
|
|
534
|
+
await sleep(this.#backoff(attempt), options.signal);
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const code = t.timedOut() ? "timeout" : "connection_error";
|
|
538
|
+
const msg = t.timedOut() ? `request timed out after ${timeoutMs} ms` : `request failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
539
|
+
throw new SerpKiteError(0, code, msg, void 0, void 0, { cause: err });
|
|
540
|
+
}
|
|
541
|
+
if (!r.ok) {
|
|
542
|
+
const text = await r.text().catch(() => "");
|
|
543
|
+
t.cleanup();
|
|
544
|
+
if (canRetry && (r.status === 429 || r.status >= 500)) {
|
|
545
|
+
await sleep(this.#retryDelay(attempt, r.headers), options.signal);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
throw errorFromResponse(r.status, r.headers, text);
|
|
549
|
+
}
|
|
550
|
+
t.stopTimer();
|
|
551
|
+
cleanup = t.cleanup;
|
|
552
|
+
res = r;
|
|
553
|
+
}
|
|
554
|
+
options.onResponse?.(responseInfo(res));
|
|
555
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
556
|
+
if (!res.body) {
|
|
557
|
+
cleanup();
|
|
558
|
+
throw new SerpKiteError(
|
|
559
|
+
res.status,
|
|
560
|
+
"invalid_response",
|
|
561
|
+
"response has no body",
|
|
562
|
+
requestId,
|
|
563
|
+
res.headers
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
if ((res.headers.get("content-type") ?? "").includes("json")) {
|
|
567
|
+
let answer;
|
|
568
|
+
try {
|
|
569
|
+
answer = JSON.parse(await res.text());
|
|
570
|
+
} catch (err) {
|
|
571
|
+
throw new SerpKiteError(
|
|
572
|
+
res.status,
|
|
573
|
+
"invalid_response",
|
|
574
|
+
"response is not valid JSON",
|
|
575
|
+
requestId,
|
|
576
|
+
res.headers,
|
|
577
|
+
{ cause: err }
|
|
578
|
+
);
|
|
579
|
+
} finally {
|
|
580
|
+
cleanup();
|
|
581
|
+
}
|
|
582
|
+
yield { event: "results", data: { request: answer.request, results: answer.results } };
|
|
583
|
+
yield {
|
|
584
|
+
event: "done",
|
|
585
|
+
data: { answer: answer.answer ?? "", citations: answer.citations, meta: answer.meta }
|
|
586
|
+
};
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const reader = res.body.getReader();
|
|
590
|
+
const decoder = new TextDecoder();
|
|
591
|
+
let buf = "";
|
|
592
|
+
let done = false;
|
|
593
|
+
try {
|
|
594
|
+
while (!done) {
|
|
595
|
+
let chunk;
|
|
596
|
+
try {
|
|
597
|
+
chunk = await reader.read();
|
|
598
|
+
} catch (err) {
|
|
599
|
+
if (options.signal?.aborted) throw err;
|
|
600
|
+
throw new SerpKiteError(
|
|
601
|
+
0,
|
|
602
|
+
"connection_error",
|
|
603
|
+
`stream read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
604
|
+
requestId,
|
|
605
|
+
res.headers,
|
|
606
|
+
{ cause: err }
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (chunk.done) {
|
|
610
|
+
buf += decoder.decode();
|
|
611
|
+
done = true;
|
|
612
|
+
} else {
|
|
613
|
+
buf += decoder.decode(chunk.value, { stream: true });
|
|
614
|
+
}
|
|
615
|
+
const held = !done && buf.endsWith("\r") ? "\r" : "";
|
|
616
|
+
if (held) buf = buf.slice(0, -1);
|
|
617
|
+
buf = buf.replace(/\r\n?/g, "\n");
|
|
618
|
+
let sep = buf.indexOf("\n\n");
|
|
619
|
+
while (sep >= 0 || done && buf.trim() !== "") {
|
|
620
|
+
const raw = sep >= 0 ? buf.slice(0, sep) : buf;
|
|
621
|
+
buf = sep >= 0 ? buf.slice(sep + 2) : "";
|
|
622
|
+
const ev = parseSSE(raw);
|
|
623
|
+
if (ev) {
|
|
624
|
+
if (ev.event === "error") throw sseError(ev.data, res.headers, requestId);
|
|
625
|
+
if (ev.event === "results" || ev.event === "delta" || ev.event === "done") {
|
|
626
|
+
yield { event: ev.event, data: ev.data };
|
|
627
|
+
if (ev.event === "done") return;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
sep = buf.indexOf("\n\n");
|
|
631
|
+
}
|
|
632
|
+
buf += held;
|
|
633
|
+
}
|
|
634
|
+
throw new SerpKiteError(
|
|
635
|
+
res.status,
|
|
636
|
+
"invalid_response",
|
|
637
|
+
"the stream ended before the done event",
|
|
638
|
+
requestId,
|
|
639
|
+
res.headers
|
|
640
|
+
);
|
|
641
|
+
} finally {
|
|
642
|
+
cleanup();
|
|
643
|
+
reader.cancel().catch(() => {
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
}
|
|
373
647
|
/** Low-level request: JSON body in, parsed JSON (or text for non-JSON responses) out. */
|
|
374
648
|
async request(call) {
|
|
375
649
|
const { method, path, body, options = {} } = call;
|
|
@@ -477,6 +751,36 @@ function taskId(idOrTask) {
|
|
|
477
751
|
if (!id) throw new SerpKiteError(0, "invalid_request", "a task id is required");
|
|
478
752
|
return id;
|
|
479
753
|
}
|
|
754
|
+
function parseSSE(block) {
|
|
755
|
+
let event = "message";
|
|
756
|
+
const data = [];
|
|
757
|
+
for (const line of block.split("\n")) {
|
|
758
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
759
|
+
const i = line.indexOf(":");
|
|
760
|
+
const field = i < 0 ? line : line.slice(0, i);
|
|
761
|
+
let value = i < 0 ? "" : line.slice(i + 1);
|
|
762
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
763
|
+
if (field === "event") event = value;
|
|
764
|
+
else if (field === "data") data.push(value);
|
|
765
|
+
}
|
|
766
|
+
if (data.length === 0) return void 0;
|
|
767
|
+
const text = data.join("\n");
|
|
768
|
+
try {
|
|
769
|
+
return { event, data: JSON.parse(text) };
|
|
770
|
+
} catch {
|
|
771
|
+
return { event, data: text };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function sseError(data, headers, requestId) {
|
|
775
|
+
const e = data?.error;
|
|
776
|
+
return new SerpKiteError(
|
|
777
|
+
503,
|
|
778
|
+
e?.code ?? "unavailable",
|
|
779
|
+
e?.message ?? "the answer stream failed",
|
|
780
|
+
e?.request_id ?? requestId,
|
|
781
|
+
headers
|
|
782
|
+
);
|
|
783
|
+
}
|
|
480
784
|
function parseRetryAfter(v) {
|
|
481
785
|
if (!v) return void 0;
|
|
482
786
|
const secs = Number(v);
|
|
@@ -518,6 +822,9 @@ function withTimeout(outer, ms) {
|
|
|
518
822
|
return {
|
|
519
823
|
signal: ctrl.signal,
|
|
520
824
|
timedOut: () => timedOut,
|
|
825
|
+
stopTimer: () => {
|
|
826
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
827
|
+
},
|
|
521
828
|
cleanup: () => {
|
|
522
829
|
if (timer !== void 0) clearTimeout(timer);
|
|
523
830
|
outer?.removeEventListener("abort", onAbort);
|
|
@@ -621,6 +928,7 @@ async function parseWebhook(secret, payload, headers, options = {}) {
|
|
|
621
928
|
return { type, deliveryId: header(headers, "x-serpkite-delivery"), data };
|
|
622
929
|
}
|
|
623
930
|
|
|
931
|
+
exports.ASSISTANT_TIMEOUT_MS = ASSISTANT_TIMEOUT_MS;
|
|
624
932
|
exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
|
|
625
933
|
exports.SerpKite = SerpKite;
|
|
626
934
|
exports.SerpKiteError = SerpKiteError;
|