serpkite 0.5.0 → 0.7.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,7 +48,7 @@ function errorFromResponse(status, headers, body) {
48
48
  }
49
49
 
50
50
  // src/client.ts
51
- var VERSION = "0.5.0";
51
+ var VERSION = "0.7.0";
52
52
  var DEFAULT_BASE_URL = "https://api.serpkite.com";
53
53
  var TASK_DONE = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
54
54
  var SerpKite = class {
@@ -96,12 +96,16 @@ var SerpKite = class {
96
96
  videos = this.#vertical("/v1/videos");
97
97
  /** Google News. */
98
98
  news = this.#vertical("/v1/news");
99
- /** Google Maps search (places with coordinates). Accepts `ll` ("@lat,lng,14z"). */
99
+ /**
100
+ * Google Maps search (places with coordinates). Accepts `ll` ("@lat,lng,14z"). With
101
+ * `engine: "yandex"` (where enabled) the places come from Yandex Maps and carry an `oid`.
102
+ */
100
103
  maps = this.#vertical("/v1/maps");
101
104
  /** Google local results. */
102
105
  places = this.#vertical("/v1/places");
103
106
  /**
104
- * Details of one place by `place_id`, `cid` or `fid`: address, country, time zone, hours
107
+ * Details of one place by `place_id`, `cid` or `fid`, or by a Yandex Maps `oid` (implies
108
+ * `engine: "yandex"`): address, country, time zone, hours
105
109
  * (`opening_hours` text and structured `weekly_hours`), phones, description and photos.
106
110
  * `results` holds the place, or is empty (not charged) when no place has that id. 1 credit.
107
111
  */
@@ -126,9 +130,14 @@ var SerpKite = class {
126
130
  * Read up to 20 URLs (HTML or PDF) as Markdown, text or HTML in one call, with optional
127
131
  * query-ranked `highlights` (BM25), `include_links` and `include_images`. 1 credit per URL
128
132
  * that comes back (0.5 from cache); URLs that fail are listed in `failed` and cost nothing.
133
+ * `summary: true` adds an AI summary of each page and `output_schema` (a JSON Schema, guided
134
+ * by an optional `prompt`) returns each page's data as JSON in `output`, each at an extra
135
+ * per-page price (https://serpkite.com/pricing); a page whose model step failed has
136
+ * `summary_error` / `output_error` instead and isn't charged for it.
129
137
  */
130
138
  extract(params, options) {
131
- const wait = ((params.timeout ?? 50) + 15) * 1e3;
139
+ const ai = params.summary === true || params.output_schema !== void 0;
140
+ const wait = ((params.timeout ?? (ai ? 75 : 50)) + 15) * 1e3;
132
141
  return this.request({
133
142
  method: "POST",
134
143
  path: "/v1/extract",
@@ -153,6 +162,47 @@ var SerpKite = class {
153
162
  account(options) {
154
163
  return this.request({ method: "GET", path: "/v1/account", options });
155
164
  }
165
+ // ── AI answers ────────────────────────────────────────────────────────
166
+ /**
167
+ * A cited answer grounded on live Google results: Markdown with `[n]` markers in `answer`,
168
+ * the cited sources in `citations`. `depth: "fast"` answers from the result page;
169
+ * `"standard"` (default) also reads the top `num_sources` pages and costs more
170
+ * (https://serpkite.com/pricing; `meta.credits` splits search and model). With
171
+ * `output_schema` the answer is a JSON value in `output`.
172
+ * When the model fails the call throws `ai_unavailable` (503) and the model part is refunded;
173
+ * a search with nothing to answer from returns `answer: null` and a `meta.warnings` entry.
174
+ */
175
+ answer(params, options) {
176
+ return this.request({
177
+ method: "POST",
178
+ path: "/v1/answer",
179
+ body: params,
180
+ options: { ...options, timeoutMs: this.#answerTimeout(options) },
181
+ retry: "rate_limit"
182
+ });
183
+ }
184
+ /**
185
+ * Streams an answer as Server-Sent Events: `results` first, then `delta` text pieces,
186
+ * then `done` (final answer, citations, meta). A server `error` event is thrown as a
187
+ * `SerpKiteError` (`ai_unavailable`). Breaking out of the loop closes the connection
188
+ * (the model part is then not charged). Can't be combined with `output_schema`.
189
+ *
190
+ * ```ts
191
+ * for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
192
+ * if (ev.event === "delta") process.stdout.write(ev.data.text);
193
+ * }
194
+ * ```
195
+ */
196
+ answerStream(params, options) {
197
+ return this.#stream(
198
+ { ...params, stream: true },
199
+ { ...options, timeoutMs: this.#answerTimeout(options) }
200
+ );
201
+ }
202
+ /** The search (pages read included) runs before the first byte: wait at least 100 s. */
203
+ #answerTimeout(options) {
204
+ return options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, 1e5) : 0);
205
+ }
156
206
  // ── Async tasks: crawl ────────────────────────────────────────────────
157
207
  /**
158
208
  * Starts an async crawl of one site and returns the queued task (`202`). 1 credit per page
@@ -195,6 +245,58 @@ var SerpKite = class {
195
245
  timeoutMs: options.timeoutMs ?? 21e5
196
246
  });
197
247
  }
248
+ // ── Async tasks: research ─────────────────────────────────────────────
249
+ /**
250
+ * Starts an async research task: the model plans searches, reads the sources and writes a
251
+ * cited Markdown report (`result.report`). `effort` (`lite`, `standard` (default), `deep`)
252
+ * sets how much is searched and read; its flat price (https://serpkite.com/pricing) is
253
+ * reserved up front and charged only when the report is written (a canceled task is charged
254
+ * for the searches already run). With
255
+ * `output_schema` the report is also returned as JSON in `result.output`. Poll with
256
+ * `getResearch` / `waitResearch` or receive the signed `research.completed` webhook.
257
+ * Only retried on 429, so a lost response never starts (and reserves) a second task.
258
+ */
259
+ research(params, options) {
260
+ return this.request({
261
+ method: "POST",
262
+ path: "/v1/research",
263
+ body: params,
264
+ options,
265
+ retry: "rate_limit"
266
+ });
267
+ }
268
+ /** Polls a research task (`progress` while it runs; results are kept for 24 hours). */
269
+ getResearch(id, options) {
270
+ return this.request({
271
+ method: "GET",
272
+ path: `/v1/research/${encodeURIComponent(id)}`,
273
+ options
274
+ });
275
+ }
276
+ /**
277
+ * Cancels a research task: refunded if still queued, else stops at its next round and is
278
+ * charged for the searches run.
279
+ */
280
+ cancelResearch(id, options) {
281
+ return this.request({
282
+ method: "DELETE",
283
+ path: `/v1/research/${encodeURIComponent(id)}`,
284
+ options
285
+ });
286
+ }
287
+ /**
288
+ * Polls until the research task is `completed`, `failed` or `canceled` and returns it (a
289
+ * failed task is returned, not thrown; check `status` and `error`). The report is in
290
+ * `result.report`. Accepts an id or the task returned by `research`. Default timeout
291
+ * 15 minutes.
292
+ */
293
+ async waitResearch(idOrTask, options = {}) {
294
+ return this.#waitTask(
295
+ taskId(idOrTask),
296
+ (id) => this.getResearch(id, { signal: options.signal }),
297
+ { ...options, timeoutMs: options.timeoutMs ?? 9e5 }
298
+ );
299
+ }
198
300
  // ── Monitors ──────────────────────────────────────────────────────────
199
301
  monitors = {
200
302
  /**
@@ -364,6 +466,149 @@ var SerpKite = class {
364
466
  interval = Math.min(interval * 1.5, maxInterval);
365
467
  }
366
468
  }
469
+ /** Opens `POST /v1/answer` as an event stream (retrying the connect like `request`) and parses it. */
470
+ async *#stream(body, options = {}) {
471
+ const maxRetries = Math.max(0, options.maxRetries ?? this.maxRetries);
472
+ const timeoutMs = options.timeoutMs ?? this.timeoutMs;
473
+ const headers = {
474
+ Accept: "text/event-stream, application/json;q=0.9",
475
+ Authorization: `Bearer ${this.#apiKey}`,
476
+ ...this.#headers,
477
+ ...options.headers,
478
+ "Content-Type": "application/json"
479
+ };
480
+ if (!isBrowser()) headers["User-Agent"] = `serpkite-typescript/${VERSION}`;
481
+ let res;
482
+ let cleanup = () => {
483
+ };
484
+ for (let attempt = 0; !res; attempt++) {
485
+ const canRetry = attempt < maxRetries;
486
+ const t = withTimeout(options.signal, timeoutMs);
487
+ let r;
488
+ try {
489
+ r = await this.#fetch(`${this.baseUrl}/v1/answer`, {
490
+ method: "POST",
491
+ headers,
492
+ body: JSON.stringify(body),
493
+ signal: t.signal
494
+ });
495
+ } catch (err) {
496
+ t.cleanup();
497
+ if (options.signal?.aborted) throw err;
498
+ if (canRetry) {
499
+ await sleep(this.#backoff(attempt), options.signal);
500
+ continue;
501
+ }
502
+ const code = t.timedOut() ? "timeout" : "connection_error";
503
+ const msg = t.timedOut() ? `request timed out after ${timeoutMs} ms` : `request failed: ${err instanceof Error ? err.message : String(err)}`;
504
+ throw new SerpKiteError(0, code, msg, void 0, void 0, { cause: err });
505
+ }
506
+ if (!r.ok) {
507
+ const text = await r.text().catch(() => "");
508
+ t.cleanup();
509
+ if (canRetry && (r.status === 429 || r.status >= 500)) {
510
+ await sleep(this.#retryDelay(attempt, r.headers), options.signal);
511
+ continue;
512
+ }
513
+ throw errorFromResponse(r.status, r.headers, text);
514
+ }
515
+ t.stopTimer();
516
+ cleanup = t.cleanup;
517
+ res = r;
518
+ }
519
+ options.onResponse?.(responseInfo(res));
520
+ const requestId = res.headers.get("x-request-id") ?? void 0;
521
+ if (!res.body) {
522
+ cleanup();
523
+ throw new SerpKiteError(
524
+ res.status,
525
+ "invalid_response",
526
+ "response has no body",
527
+ requestId,
528
+ res.headers
529
+ );
530
+ }
531
+ if ((res.headers.get("content-type") ?? "").includes("json")) {
532
+ let answer;
533
+ try {
534
+ answer = JSON.parse(await res.text());
535
+ } catch (err) {
536
+ throw new SerpKiteError(
537
+ res.status,
538
+ "invalid_response",
539
+ "response is not valid JSON",
540
+ requestId,
541
+ res.headers,
542
+ { cause: err }
543
+ );
544
+ } finally {
545
+ cleanup();
546
+ }
547
+ yield { event: "results", data: { request: answer.request, results: answer.results } };
548
+ yield {
549
+ event: "done",
550
+ data: { answer: answer.answer ?? "", citations: answer.citations, meta: answer.meta }
551
+ };
552
+ return;
553
+ }
554
+ const reader = res.body.getReader();
555
+ const decoder = new TextDecoder();
556
+ let buf = "";
557
+ let done = false;
558
+ try {
559
+ while (!done) {
560
+ let chunk;
561
+ try {
562
+ chunk = await reader.read();
563
+ } catch (err) {
564
+ if (options.signal?.aborted) throw err;
565
+ throw new SerpKiteError(
566
+ 0,
567
+ "connection_error",
568
+ `stream read failed: ${err instanceof Error ? err.message : String(err)}`,
569
+ requestId,
570
+ res.headers,
571
+ { cause: err }
572
+ );
573
+ }
574
+ if (chunk.done) {
575
+ buf += decoder.decode();
576
+ done = true;
577
+ } else {
578
+ buf += decoder.decode(chunk.value, { stream: true });
579
+ }
580
+ const held = !done && buf.endsWith("\r") ? "\r" : "";
581
+ if (held) buf = buf.slice(0, -1);
582
+ buf = buf.replace(/\r\n?/g, "\n");
583
+ let sep = buf.indexOf("\n\n");
584
+ while (sep >= 0 || done && buf.trim() !== "") {
585
+ const raw = sep >= 0 ? buf.slice(0, sep) : buf;
586
+ buf = sep >= 0 ? buf.slice(sep + 2) : "";
587
+ const ev = parseSSE(raw);
588
+ if (ev) {
589
+ if (ev.event === "error") throw sseError(ev.data, res.headers, requestId);
590
+ if (ev.event === "results" || ev.event === "delta" || ev.event === "done") {
591
+ yield { event: ev.event, data: ev.data };
592
+ if (ev.event === "done") return;
593
+ }
594
+ }
595
+ sep = buf.indexOf("\n\n");
596
+ }
597
+ buf += held;
598
+ }
599
+ throw new SerpKiteError(
600
+ res.status,
601
+ "invalid_response",
602
+ "the stream ended before the done event",
603
+ requestId,
604
+ res.headers
605
+ );
606
+ } finally {
607
+ cleanup();
608
+ reader.cancel().catch(() => {
609
+ });
610
+ }
611
+ }
367
612
  /** Low-level request: JSON body in, parsed JSON (or text for non-JSON responses) out. */
368
613
  async request(call) {
369
614
  const { method, path, body, options = {} } = call;
@@ -471,6 +716,36 @@ function taskId(idOrTask) {
471
716
  if (!id) throw new SerpKiteError(0, "invalid_request", "a task id is required");
472
717
  return id;
473
718
  }
719
+ function parseSSE(block) {
720
+ let event = "message";
721
+ const data = [];
722
+ for (const line of block.split("\n")) {
723
+ if (line === "" || line.startsWith(":")) continue;
724
+ const i = line.indexOf(":");
725
+ const field = i < 0 ? line : line.slice(0, i);
726
+ let value = i < 0 ? "" : line.slice(i + 1);
727
+ if (value.startsWith(" ")) value = value.slice(1);
728
+ if (field === "event") event = value;
729
+ else if (field === "data") data.push(value);
730
+ }
731
+ if (data.length === 0) return void 0;
732
+ const text = data.join("\n");
733
+ try {
734
+ return { event, data: JSON.parse(text) };
735
+ } catch {
736
+ return { event, data: text };
737
+ }
738
+ }
739
+ function sseError(data, headers, requestId) {
740
+ const e = data?.error;
741
+ return new SerpKiteError(
742
+ 503,
743
+ e?.code ?? "unavailable",
744
+ e?.message ?? "the answer stream failed",
745
+ e?.request_id ?? requestId,
746
+ headers
747
+ );
748
+ }
474
749
  function parseRetryAfter(v) {
475
750
  if (!v) return void 0;
476
751
  const secs = Number(v);
@@ -512,6 +787,9 @@ function withTimeout(outer, ms) {
512
787
  return {
513
788
  signal: ctrl.signal,
514
789
  timedOut: () => timedOut,
790
+ stopTimer: () => {
791
+ if (timer !== void 0) clearTimeout(timer);
792
+ },
515
793
  cleanup: () => {
516
794
  if (timer !== void 0) clearTimeout(timer);
517
795
  outer?.removeEventListener("abort", onAbort);