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 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
- | `extract(params)` | `POST /v1/extract` | `ExtractParams` (`urls` ≤ 20, `format`, `query`, `highlights`, …) | `ExtractResponse` (`results`, `failed`) |
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`, `monitors.create` and `monitors.run` retry only on
378
- `429`, so a lost response can never queue (and bill) the same work twice.
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`, `Monitor`, `MonitorRun`, `MonitorPageChange`, `TaskCompletedEvent`,
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.6.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 wait = ((params.timeout ?? 50) + 15) * 1e3;
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. A monitor pauses itself after 10 failed runs in a row
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;