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/README.md +79 -7
- package/dist/index.cjs +276 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +714 -23
- package/dist/index.d.ts +714 -23
- package/dist/index.js +276 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
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);
|