serpkite 0.6.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.6.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 {
@@ -130,9 +130,14 @@ var SerpKite = class {
130
130
  * Read up to 20 URLs (HTML or PDF) as Markdown, text or HTML in one call, with optional
131
131
  * query-ranked `highlights` (BM25), `include_links` and `include_images`. 1 credit per URL
132
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.
133
137
  */
134
138
  extract(params, options) {
135
- 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;
136
141
  return this.request({
137
142
  method: "POST",
138
143
  path: "/v1/extract",
@@ -157,6 +162,47 @@ var SerpKite = class {
157
162
  account(options) {
158
163
  return this.request({ method: "GET", path: "/v1/account", options });
159
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
+ }
160
206
  // ── Async tasks: crawl ────────────────────────────────────────────────
161
207
  /**
162
208
  * Starts an async crawl of one site and returns the queued task (`202`). 1 credit per page
@@ -199,6 +245,58 @@ var SerpKite = class {
199
245
  timeoutMs: options.timeoutMs ?? 21e5
200
246
  });
201
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
+ }
202
300
  // ── Monitors ──────────────────────────────────────────────────────────
203
301
  monitors = {
204
302
  /**
@@ -368,6 +466,149 @@ var SerpKite = class {
368
466
  interval = Math.min(interval * 1.5, maxInterval);
369
467
  }
370
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
+ }
371
612
  /** Low-level request: JSON body in, parsed JSON (or text for non-JSON responses) out. */
372
613
  async request(call) {
373
614
  const { method, path, body, options = {} } = call;
@@ -475,6 +716,36 @@ function taskId(idOrTask) {
475
716
  if (!id) throw new SerpKiteError(0, "invalid_request", "a task id is required");
476
717
  return id;
477
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
+ }
478
749
  function parseRetryAfter(v) {
479
750
  if (!v) return void 0;
480
751
  const secs = Number(v);
@@ -516,6 +787,9 @@ function withTimeout(outer, ms) {
516
787
  return {
517
788
  signal: ctrl.signal,
518
789
  timedOut: () => timedOut,
790
+ stopTimer: () => {
791
+ if (timer !== void 0) clearTimeout(timer);
792
+ },
519
793
  cleanup: () => {
520
794
  if (timer !== void 0) clearTimeout(timer);
521
795
  outer?.removeEventListener("abort", onAbort);