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/dist/index.js CHANGED
@@ -48,8 +48,9 @@ function errorFromResponse(status, headers, body) {
48
48
  }
49
49
 
50
50
  // src/client.ts
51
- var VERSION = "0.6.0";
51
+ var VERSION = "0.8.0";
52
52
  var DEFAULT_BASE_URL = "https://api.serpkite.com";
53
+ var ASSISTANT_TIMEOUT_MS = 12e4;
53
54
  var TASK_DONE = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
54
55
  var SerpKite = class {
55
56
  baseUrl;
@@ -122,6 +123,24 @@ var SerpKite = class {
122
123
  autocomplete = this.#vertical("/v1/autocomplete");
123
124
  /** Fetch any public URL as clean Markdown plus metadata. */
124
125
  webpage = this.#vertical("/v1/webpage");
126
+ // ── AI assistants ─────────────────────────────────────────────────────
127
+ // Billed even when the response is lost: retried only on 429 (failed, refused and empty
128
+ // answers are free), and each attempt waits at least 120 s (answers take 10-90 s).
129
+ /**
130
+ * Ask ChatGPT as a logged-out user: the answer (`text`, `markdown`), cited `sources`,
131
+ * `search_queries`, `products`, `places` and the guest `model`. `web_search` (default true)
132
+ * makes it search the web; `country` asks from an exit in that country. `track` returns
133
+ * `mentions` of your brands and domains. 5 credits.
134
+ */
135
+ chatgpt = this.#assistant("/v1/chatgpt");
136
+ /** Ask Google Gemini as a logged-out user: the answer with the sources it grounded on. 5 credits. */
137
+ gemini = this.#assistant("/v1/gemini");
138
+ /** Microsoft Copilot Search on Bing: the answer, cited sources and products. 3 credits. */
139
+ copilot = this.#assistant("/v1/copilot");
140
+ /** Google AI Mode: the answer, cited sources and products. 3 credits. */
141
+ aiMode = this.#assistant("/v1/ai_mode");
142
+ /** Brave Search's AI answer: cited sources, the searches it ran and products. 2 credits. */
143
+ braveAi = this.#assistant("/v1/brave_ai");
125
144
  /** Position of `domain` for keyword `q` in the top `num` results (default 100). */
126
145
  rank(params, options) {
127
146
  return this.request({ method: "POST", path: "/v1/rank", body: params, options });
@@ -130,9 +149,14 @@ var SerpKite = class {
130
149
  * Read up to 20 URLs (HTML or PDF) as Markdown, text or HTML in one call, with optional
131
150
  * query-ranked `highlights` (BM25), `include_links` and `include_images`. 1 credit per URL
132
151
  * that comes back (0.5 from cache); URLs that fail are listed in `failed` and cost nothing.
152
+ * `summary: true` adds an AI summary of each page and `output_schema` (a JSON Schema, guided
153
+ * by an optional `prompt`) returns each page's data as JSON in `output`, each at an extra
154
+ * per-page price (https://serpkite.com/pricing); a page whose model step failed has
155
+ * `summary_error` / `output_error` instead and isn't charged for it.
133
156
  */
134
157
  extract(params, options) {
135
- const wait = ((params.timeout ?? 50) + 15) * 1e3;
158
+ const ai = params.summary === true || params.output_schema !== void 0;
159
+ const wait = ((params.timeout ?? (ai ? 75 : 50)) + 15) * 1e3;
136
160
  return this.request({
137
161
  method: "POST",
138
162
  path: "/v1/extract",
@@ -157,6 +181,47 @@ var SerpKite = class {
157
181
  account(options) {
158
182
  return this.request({ method: "GET", path: "/v1/account", options });
159
183
  }
184
+ // ── AI answers ────────────────────────────────────────────────────────
185
+ /**
186
+ * A cited answer grounded on live Google results: Markdown with `[n]` markers in `answer`,
187
+ * the cited sources in `citations`. `depth: "fast"` answers from the result page;
188
+ * `"standard"` (default) also reads the top `num_sources` pages and costs more
189
+ * (https://serpkite.com/pricing; `meta.credits` splits search and model). With
190
+ * `output_schema` the answer is a JSON value in `output`.
191
+ * When the model fails the call throws `ai_unavailable` (503) and the model part is refunded;
192
+ * a search with nothing to answer from returns `answer: null` and a `meta.warnings` entry.
193
+ */
194
+ answer(params, options) {
195
+ return this.request({
196
+ method: "POST",
197
+ path: "/v1/answer",
198
+ body: params,
199
+ options: { ...options, timeoutMs: this.#answerTimeout(options) },
200
+ retry: "rate_limit"
201
+ });
202
+ }
203
+ /**
204
+ * Streams an answer as Server-Sent Events: `results` first, then `delta` text pieces,
205
+ * then `done` (final answer, citations, meta). A server `error` event is thrown as a
206
+ * `SerpKiteError` (`ai_unavailable`). Breaking out of the loop closes the connection
207
+ * (the model part is then not charged). Can't be combined with `output_schema`.
208
+ *
209
+ * ```ts
210
+ * for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
211
+ * if (ev.event === "delta") process.stdout.write(ev.data.text);
212
+ * }
213
+ * ```
214
+ */
215
+ answerStream(params, options) {
216
+ return this.#stream(
217
+ { ...params, stream: true },
218
+ { ...options, timeoutMs: this.#answerTimeout(options) }
219
+ );
220
+ }
221
+ /** The search (pages read included) runs before the first byte: wait at least 100 s. */
222
+ #answerTimeout(options) {
223
+ return options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, 1e5) : 0);
224
+ }
160
225
  // ── Async tasks: crawl ────────────────────────────────────────────────
161
226
  /**
162
227
  * Starts an async crawl of one site and returns the queued task (`202`). 1 credit per page
@@ -199,6 +264,58 @@ var SerpKite = class {
199
264
  timeoutMs: options.timeoutMs ?? 21e5
200
265
  });
201
266
  }
267
+ // ── Async tasks: research ─────────────────────────────────────────────
268
+ /**
269
+ * Starts an async research task: the model plans searches, reads the sources and writes a
270
+ * cited Markdown report (`result.report`). `effort` (`lite`, `standard` (default), `deep`)
271
+ * sets how much is searched and read; its flat price (https://serpkite.com/pricing) is
272
+ * reserved up front and charged only when the report is written (a canceled task is charged
273
+ * for the searches already run). With
274
+ * `output_schema` the report is also returned as JSON in `result.output`. Poll with
275
+ * `getResearch` / `waitResearch` or receive the signed `research.completed` webhook.
276
+ * Only retried on 429, so a lost response never starts (and reserves) a second task.
277
+ */
278
+ research(params, options) {
279
+ return this.request({
280
+ method: "POST",
281
+ path: "/v1/research",
282
+ body: params,
283
+ options,
284
+ retry: "rate_limit"
285
+ });
286
+ }
287
+ /** Polls a research task (`progress` while it runs; results are kept for 24 hours). */
288
+ getResearch(id, options) {
289
+ return this.request({
290
+ method: "GET",
291
+ path: `/v1/research/${encodeURIComponent(id)}`,
292
+ options
293
+ });
294
+ }
295
+ /**
296
+ * Cancels a research task: refunded if still queued, else stops at its next round and is
297
+ * charged for the searches run.
298
+ */
299
+ cancelResearch(id, options) {
300
+ return this.request({
301
+ method: "DELETE",
302
+ path: `/v1/research/${encodeURIComponent(id)}`,
303
+ options
304
+ });
305
+ }
306
+ /**
307
+ * Polls until the research task is `completed`, `failed` or `canceled` and returns it (a
308
+ * failed task is returned, not thrown; check `status` and `error`). The report is in
309
+ * `result.report`. Accepts an id or the task returned by `research`. Default timeout
310
+ * 15 minutes.
311
+ */
312
+ async waitResearch(idOrTask, options = {}) {
313
+ return this.#waitTask(
314
+ taskId(idOrTask),
315
+ (id) => this.getResearch(id, { signal: options.signal }),
316
+ { ...options, timeoutMs: options.timeoutMs ?? 9e5 }
317
+ );
318
+ }
202
319
  // ── Monitors ──────────────────────────────────────────────────────────
203
320
  monitors = {
204
321
  /**
@@ -208,7 +325,9 @@ var SerpKite = class {
208
325
  * and reports it (`MonitorPageChange`) when its content changes. `metadata` (your JSON
209
326
  * object, at most 2 KB) is echoed on the monitor and in its webhooks. Without a
210
327
  * `webhook_url`, read new results from `monitors.runs`. `active: false`
211
- * creates it paused. A monitor pauses itself after 10 failed runs in a row
328
+ * creates it paused. With an AI assistant `endpoint` (`chatgpt`, `gemini`, `copilot`,
329
+ * `ai_mode`, `brave_ai`) and a `prompt` every run reports the answer (`MonitorAnswer`:
330
+ * sources, `new_sources`, `mentions` with `track`). A monitor pauses itself after 10 failed runs in a row
212
331
  * (`last_status: "paused"`); `update(id, { active: true })` resumes it. Each run costs
213
332
  * what its search costs. Only retried on 429.
214
333
  */
@@ -331,6 +450,18 @@ var SerpKite = class {
331
450
  #vertical(path) {
332
451
  return ((params, options) => this.request({ method: "POST", path, body: params, options }));
333
452
  }
453
+ #assistant(path) {
454
+ return ((params, options) => this.request({
455
+ method: "POST",
456
+ path,
457
+ body: params,
458
+ options: {
459
+ ...options,
460
+ timeoutMs: options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, ASSISTANT_TIMEOUT_MS) : 0)
461
+ },
462
+ retry: "rate_limit"
463
+ }));
464
+ }
334
465
  async #wait(idOrEntry, o) {
335
466
  let id;
336
467
  if (typeof idOrEntry === "string") {
@@ -368,6 +499,149 @@ var SerpKite = class {
368
499
  interval = Math.min(interval * 1.5, maxInterval);
369
500
  }
370
501
  }
502
+ /** Opens `POST /v1/answer` as an event stream (retrying the connect like `request`) and parses it. */
503
+ async *#stream(body, options = {}) {
504
+ const maxRetries = Math.max(0, options.maxRetries ?? this.maxRetries);
505
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
506
+ const headers = {
507
+ Accept: "text/event-stream, application/json;q=0.9",
508
+ Authorization: `Bearer ${this.#apiKey}`,
509
+ ...this.#headers,
510
+ ...options.headers,
511
+ "Content-Type": "application/json"
512
+ };
513
+ if (!isBrowser()) headers["User-Agent"] = `serpkite-typescript/${VERSION}`;
514
+ let res;
515
+ let cleanup = () => {
516
+ };
517
+ for (let attempt = 0; !res; attempt++) {
518
+ const canRetry = attempt < maxRetries;
519
+ const t = withTimeout(options.signal, timeoutMs);
520
+ let r;
521
+ try {
522
+ r = await this.#fetch(`${this.baseUrl}/v1/answer`, {
523
+ method: "POST",
524
+ headers,
525
+ body: JSON.stringify(body),
526
+ signal: t.signal
527
+ });
528
+ } catch (err) {
529
+ t.cleanup();
530
+ if (options.signal?.aborted) throw err;
531
+ if (canRetry) {
532
+ await sleep(this.#backoff(attempt), options.signal);
533
+ continue;
534
+ }
535
+ const code = t.timedOut() ? "timeout" : "connection_error";
536
+ const msg = t.timedOut() ? `request timed out after ${timeoutMs} ms` : `request failed: ${err instanceof Error ? err.message : String(err)}`;
537
+ throw new SerpKiteError(0, code, msg, void 0, void 0, { cause: err });
538
+ }
539
+ if (!r.ok) {
540
+ const text = await r.text().catch(() => "");
541
+ t.cleanup();
542
+ if (canRetry && (r.status === 429 || r.status >= 500)) {
543
+ await sleep(this.#retryDelay(attempt, r.headers), options.signal);
544
+ continue;
545
+ }
546
+ throw errorFromResponse(r.status, r.headers, text);
547
+ }
548
+ t.stopTimer();
549
+ cleanup = t.cleanup;
550
+ res = r;
551
+ }
552
+ options.onResponse?.(responseInfo(res));
553
+ const requestId = res.headers.get("x-request-id") ?? void 0;
554
+ if (!res.body) {
555
+ cleanup();
556
+ throw new SerpKiteError(
557
+ res.status,
558
+ "invalid_response",
559
+ "response has no body",
560
+ requestId,
561
+ res.headers
562
+ );
563
+ }
564
+ if ((res.headers.get("content-type") ?? "").includes("json")) {
565
+ let answer;
566
+ try {
567
+ answer = JSON.parse(await res.text());
568
+ } catch (err) {
569
+ throw new SerpKiteError(
570
+ res.status,
571
+ "invalid_response",
572
+ "response is not valid JSON",
573
+ requestId,
574
+ res.headers,
575
+ { cause: err }
576
+ );
577
+ } finally {
578
+ cleanup();
579
+ }
580
+ yield { event: "results", data: { request: answer.request, results: answer.results } };
581
+ yield {
582
+ event: "done",
583
+ data: { answer: answer.answer ?? "", citations: answer.citations, meta: answer.meta }
584
+ };
585
+ return;
586
+ }
587
+ const reader = res.body.getReader();
588
+ const decoder = new TextDecoder();
589
+ let buf = "";
590
+ let done = false;
591
+ try {
592
+ while (!done) {
593
+ let chunk;
594
+ try {
595
+ chunk = await reader.read();
596
+ } catch (err) {
597
+ if (options.signal?.aborted) throw err;
598
+ throw new SerpKiteError(
599
+ 0,
600
+ "connection_error",
601
+ `stream read failed: ${err instanceof Error ? err.message : String(err)}`,
602
+ requestId,
603
+ res.headers,
604
+ { cause: err }
605
+ );
606
+ }
607
+ if (chunk.done) {
608
+ buf += decoder.decode();
609
+ done = true;
610
+ } else {
611
+ buf += decoder.decode(chunk.value, { stream: true });
612
+ }
613
+ const held = !done && buf.endsWith("\r") ? "\r" : "";
614
+ if (held) buf = buf.slice(0, -1);
615
+ buf = buf.replace(/\r\n?/g, "\n");
616
+ let sep = buf.indexOf("\n\n");
617
+ while (sep >= 0 || done && buf.trim() !== "") {
618
+ const raw = sep >= 0 ? buf.slice(0, sep) : buf;
619
+ buf = sep >= 0 ? buf.slice(sep + 2) : "";
620
+ const ev = parseSSE(raw);
621
+ if (ev) {
622
+ if (ev.event === "error") throw sseError(ev.data, res.headers, requestId);
623
+ if (ev.event === "results" || ev.event === "delta" || ev.event === "done") {
624
+ yield { event: ev.event, data: ev.data };
625
+ if (ev.event === "done") return;
626
+ }
627
+ }
628
+ sep = buf.indexOf("\n\n");
629
+ }
630
+ buf += held;
631
+ }
632
+ throw new SerpKiteError(
633
+ res.status,
634
+ "invalid_response",
635
+ "the stream ended before the done event",
636
+ requestId,
637
+ res.headers
638
+ );
639
+ } finally {
640
+ cleanup();
641
+ reader.cancel().catch(() => {
642
+ });
643
+ }
644
+ }
371
645
  /** Low-level request: JSON body in, parsed JSON (or text for non-JSON responses) out. */
372
646
  async request(call) {
373
647
  const { method, path, body, options = {} } = call;
@@ -475,6 +749,36 @@ function taskId(idOrTask) {
475
749
  if (!id) throw new SerpKiteError(0, "invalid_request", "a task id is required");
476
750
  return id;
477
751
  }
752
+ function parseSSE(block) {
753
+ let event = "message";
754
+ const data = [];
755
+ for (const line of block.split("\n")) {
756
+ if (line === "" || line.startsWith(":")) continue;
757
+ const i = line.indexOf(":");
758
+ const field = i < 0 ? line : line.slice(0, i);
759
+ let value = i < 0 ? "" : line.slice(i + 1);
760
+ if (value.startsWith(" ")) value = value.slice(1);
761
+ if (field === "event") event = value;
762
+ else if (field === "data") data.push(value);
763
+ }
764
+ if (data.length === 0) return void 0;
765
+ const text = data.join("\n");
766
+ try {
767
+ return { event, data: JSON.parse(text) };
768
+ } catch {
769
+ return { event, data: text };
770
+ }
771
+ }
772
+ function sseError(data, headers, requestId) {
773
+ const e = data?.error;
774
+ return new SerpKiteError(
775
+ 503,
776
+ e?.code ?? "unavailable",
777
+ e?.message ?? "the answer stream failed",
778
+ e?.request_id ?? requestId,
779
+ headers
780
+ );
781
+ }
478
782
  function parseRetryAfter(v) {
479
783
  if (!v) return void 0;
480
784
  const secs = Number(v);
@@ -516,6 +820,9 @@ function withTimeout(outer, ms) {
516
820
  return {
517
821
  signal: ctrl.signal,
518
822
  timedOut: () => timedOut,
823
+ stopTimer: () => {
824
+ if (timer !== void 0) clearTimeout(timer);
825
+ },
519
826
  cleanup: () => {
520
827
  if (timer !== void 0) clearTimeout(timer);
521
828
  outer?.removeEventListener("abort", onAbort);
@@ -619,6 +926,6 @@ async function parseWebhook(secret, payload, headers, options = {}) {
619
926
  return { type, deliveryId: header(headers, "x-serpkite-delivery"), data };
620
927
  }
621
928
 
622
- export { DEFAULT_BASE_URL, SerpKite, SerpKiteError, VERSION, isBatchError, parseRetryAfter, parseWebhook, verifyWebhook };
929
+ export { ASSISTANT_TIMEOUT_MS, DEFAULT_BASE_URL, SerpKite, SerpKiteError, VERSION, isBatchError, parseRetryAfter, parseWebhook, verifyWebhook };
623
930
  //# sourceMappingURL=index.js.map
624
931
  //# sourceMappingURL=index.js.map