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/README.md +80 -8
- package/dist/index.cjs +282 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +754 -33
- package/dist/index.d.ts +754 -33
- package/dist/index.js +282 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ vertical-specific extras and `meta` (`request_id`, `credits_used`, `cached`, `la
|
|
|
87
87
|
| `news(params)` | `POST /v1/news` | `SearchParams` | `NewsResponse` |
|
|
88
88
|
| `maps(params)` | `POST /v1/maps` | `SearchParams` (+ `ll`: `"@lat,lng,14z"`) | `PlacesResponse` |
|
|
89
89
|
| `places(params)` | `POST /v1/places` | `SearchParams` | `PlacesResponse` |
|
|
90
|
-
| `place(params)` | `POST /v1/place` | `PlaceParams` (`place_id` \| `cid` \| `fid
|
|
90
|
+
| `place(params)` | `POST /v1/place` | `PlaceParams` (`place_id` \| `cid` \| `fid` \| `oid` (Yandex Maps), `country`, `language`) | `PlacesResponse` (one place: `weekly_hours`, `photos`, `timezone`, …; empty when the id is unknown) |
|
|
91
91
|
| `reviews(params)` | `POST /v1/reviews` | `ReviewsParams` (`place_id` \| `cid` \| `fid`, `sort`, `page_token`, `num` ≤ 50) | `ReviewsResponse` (+ `next_page_token`) |
|
|
92
92
|
| `shopping(params)` | `POST /v1/shopping` | `SearchParams` | `ShoppingResponse` |
|
|
93
93
|
| `scholar(params)` | `POST /v1/scholar` | `SearchParams` | `ScholarResponse` |
|
|
@@ -95,11 +95,16 @@ vertical-specific extras and `meta` (`request_id`, `credits_used`, `cached`, `la
|
|
|
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
97
|
| `rank(params)` | `POST /v1/rank` | `RankParams` (`q`, `domain`, `num`: 10\|20\|30\|50\|100) | `RankResponse` (`position` or `null`, `matches`, `checked`) |
|
|
98
|
-
| `
|
|
98
|
+
| `answer(params)` | `POST /v1/answer` | `AnswerParams` (`q`, `depth`: `fast`\|`standard`, `num_sources` 1-5, `output_schema`, search options) | `AnswerResponse` (`answer`, `citations`, `results`, `output`) |
|
|
99
|
+
| `answerStream(params)` | `POST /v1/answer` (`stream: true`) | `AnswerParams` | `AsyncIterable<AnswerStreamEvent>` (`results`, `delta`…, `done`) |
|
|
100
|
+
| `extract(params)` | `POST /v1/extract` | `ExtractParams` (`urls` ≤ 20, `format`, `query`, `highlights`, `summary`, `output_schema`, `prompt`, …) | `ExtractResponse` (`results`, `failed`) |
|
|
99
101
|
| `map(params)` | `POST /v1/map` | `MapParams` (`url`, `search`, `limit`, `sitemap`, path filters) | `MapResponse` (`results[].url`) |
|
|
100
102
|
| `crawl(params)` | `POST /v1/crawl` | `CrawlParams` (`url`, `limit` ≤ 1000, `max_depth` ≤ 10, …) | `TaskCreated` (`202`) |
|
|
101
103
|
| `getCrawl(id)` / `cancelCrawl(id)` | `GET` / `DELETE /v1/crawl/{id}` | task id | `CrawlTask` / `TaskCancelResponse` |
|
|
102
104
|
| `waitForCrawl(idOrTask, options?)` | polls `GET /v1/crawl/{id}` | task id or task | `CrawlTask` (`completed`, `failed` or `canceled`) |
|
|
105
|
+
| `research(params)` | `POST /v1/research` | `ResearchParams` (`input`, `effort`: `lite`\|`standard`\|`deep`, `output_schema`, domains, `webhook_url`) | `TaskCreated` (`202`) |
|
|
106
|
+
| `getResearch(id)` / `cancelResearch(id)` | `GET` / `DELETE /v1/research/{id}` | task id | `ResearchTask` / `TaskCancelResponse` |
|
|
107
|
+
| `waitResearch(idOrTask, options?)` | polls `GET /v1/research/{id}` | task id or task | `ResearchTask` (`completed`, `failed` or `canceled`) |
|
|
103
108
|
| `monitors.create/list/get/update/delete/run` | `/v1/monitors…` | `MonitorCreateParams`, `MonitorUpdateParams` | `Monitor`, `MonitorList` |
|
|
104
109
|
| `monitors.runs(id, params?)` / `monitors.iterRuns(id)` | `GET /v1/monitors/{id}/runs` | `limit`, `before` | `MonitorRunList` / `AsyncIterable<MonitorRun>` |
|
|
105
110
|
| `account()` | `GET /v1/account` | none | `Account` (`balance`, `plan`, `rate_limit_rps`, `month`, …) |
|
|
@@ -111,7 +116,7 @@ Common `SearchParams`: `q` (required), `country` (default `us`), `language` (def
|
|
|
111
116
|
`location`, `uule`, `num` (10, or 100 for the depth bundle), `page` (1-10), `time`
|
|
112
117
|
(`hour`|`day`|`week`|`month`|`year`), `tbs`, `device` (`desktop`|`mobile`), `safe`, `autocorrect`,
|
|
113
118
|
`format` (`json`|`markdown`|`compact`), `fields`, `include_content` (0-5), `ads`, `max_age`,
|
|
114
|
-
`engine
|
|
119
|
+
`engine`, `include_answer` (`search` and `news`).
|
|
115
120
|
|
|
116
121
|
### Examples
|
|
117
122
|
|
|
@@ -192,7 +197,7 @@ await sk.news({ q: "espresso", engine: ["google", "brave"] });
|
|
|
192
197
|
```
|
|
193
198
|
|
|
194
199
|
- `engine` is `"google"` (default), `"auto"`, `"consensus"`, one `Provider` (`"brave"`, `"bing"`,
|
|
195
|
-
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`) or a `Provider[]` list. `"auto"` and `"consensus"` can't
|
|
200
|
+
`"yahoo"`, `"duckduckgo"`, `"mojeek"`, `"wikipedia"`, `"yandex"`) or a `Provider[]` list. `"auto"` and `"consensus"` can't
|
|
196
201
|
be combined with other names; unknown names, or a provider that doesn't serve the endpoint, return
|
|
197
202
|
`400 invalid_request`.
|
|
198
203
|
- `engine: "consensus"` (`search` only) asks several independent indexes in parallel, merges the
|
|
@@ -203,6 +208,68 @@ await sk.news({ q: "espresso", engine: ["google", "brave"] });
|
|
|
203
208
|
and its `outcome` (absent on cache hits). `request.engine` echoes what you asked for.
|
|
204
209
|
- Credits (`meta.credits_used`, `X-Credits-Used`) follow the answering provider's price.
|
|
205
210
|
|
|
211
|
+
## AI answers
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// A cited answer grounded on live Google results ("fast" answers from the result page;
|
|
215
|
+
// "standard", the default, also reads the top num_sources pages)
|
|
216
|
+
const res = await sk.answer({ q: "what is retrieval-augmented generation", depth: "fast" });
|
|
217
|
+
console.log(res.answer); // Markdown with [1] markers
|
|
218
|
+
for (const c of res.citations) console.log(c.index, c.title, c.link);
|
|
219
|
+
console.log(res.meta.credits, res.meta.llm); // { search, answer }, { provider, model, … }
|
|
220
|
+
|
|
221
|
+
// Stream it: results first, then text deltas, then done (final answer, citations, meta)
|
|
222
|
+
for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
|
|
223
|
+
if (ev.event === "delta") process.stdout.write(ev.data.text);
|
|
224
|
+
if (ev.event === "done") console.log("\n", ev.data.citations);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Structured output: the answer as JSON matching your schema (not with streaming)
|
|
228
|
+
const facts = await sk.answer({
|
|
229
|
+
q: "tallest building in the world",
|
|
230
|
+
output_schema: { type: "object", properties: { name: { type: "string" }, height_m: { type: "number" } } },
|
|
231
|
+
});
|
|
232
|
+
console.log(facts.output);
|
|
233
|
+
|
|
234
|
+
// A cited answer on top of a normal search or news response
|
|
235
|
+
const news = await sk.news({ q: "ECB rate decision", time: "day", include_answer: true });
|
|
236
|
+
console.log(news.answer?.text, news.meta.warnings); // no answer + a warning (refunded) if the model failed
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- A stream's `error` event (and a model failure on `answer`) is thrown as `SerpKiteError` with code
|
|
240
|
+
`ai_unavailable` (503); the model part is not charged. Breaking out of the `for await` loop
|
|
241
|
+
closes the connection and the model part is not charged either.
|
|
242
|
+
- `extract` takes `summary: true` (an AI summary per page) and `output_schema` (+ optional
|
|
243
|
+
`prompt`; each page's data as JSON in `output`), each at an extra per-page price. A page whose
|
|
244
|
+
model step failed carries `summary_error` / `output_error` instead and isn't charged for it. Its
|
|
245
|
+
default `timeout` becomes 75 seconds with either.
|
|
246
|
+
- AI prices (answers, `include_answer`, summaries, schema extraction, research) are listed at
|
|
247
|
+
[serpkite.com/pricing](https://serpkite.com/pricing); `meta.credits` on an answer splits the
|
|
248
|
+
search and the model part.
|
|
249
|
+
|
|
250
|
+
## Research
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
const task = await sk.research({
|
|
254
|
+
input: "Compare managed Postgres vector search options in 2026",
|
|
255
|
+
effort: "standard", // lite | standard (default) | deep
|
|
256
|
+
});
|
|
257
|
+
const done = await sk.waitResearch(task); // polls until completed/failed/canceled (15 min default)
|
|
258
|
+
if (done.status === "completed") {
|
|
259
|
+
console.log(done.result?.report); // Markdown report with [n] citations
|
|
260
|
+
for (const s of done.result?.sources ?? []) console.log(s.index, s.link, s.query);
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- Each `effort` has a flat price ([serpkite.com/pricing](https://serpkite.com/pricing)), reserved
|
|
265
|
+
up front and charged only when the report is written; failed research is refunded. `getResearch(id)` shows `progress` (`round`, `queries`, `sources`, `step`) while it
|
|
266
|
+
runs. `cancelResearch(id)` refunds a queued task; a running one stops at its next round and is
|
|
267
|
+
charged for the searches already run.
|
|
268
|
+
- `output_schema` also returns the report as JSON in `result.output`; `include_domains` /
|
|
269
|
+
`exclude_domains`, `country` and `language` steer the searches.
|
|
270
|
+
- Pass `webhook_url` to get a signed `research.completed` delivery (same envelope as
|
|
271
|
+
`crawl.completed`) instead of polling.
|
|
272
|
+
|
|
206
273
|
## Map and extract
|
|
207
274
|
|
|
208
275
|
```ts
|
|
@@ -286,7 +353,7 @@ resumes it. `webhook_url: ""` removes the webhook; a changed search reports ever
|
|
|
286
353
|
|
|
287
354
|
## Webhooks
|
|
288
355
|
|
|
289
|
-
Every delivery (`batch.completed`, `crawl.completed`, `monitor.results`) is signed with
|
|
356
|
+
Every delivery (`batch.completed`, `crawl.completed`, `research.completed`, `monitor.results`) is signed with
|
|
290
357
|
`X-SerpKite-Signature`. `parseWebhook` verifies it and returns the event typed by
|
|
291
358
|
`X-SerpKite-Event`, with `deliveryId` from `X-SerpKite-Delivery` (for `monitor.results`, the
|
|
292
359
|
`run_id`) to deduplicate retries:
|
|
@@ -362,6 +429,7 @@ try {
|
|
|
362
429
|
| `rate_limited` | 429 | Too many requests (retried automatically) |
|
|
363
430
|
| `upstream_error`, `upstream_blocked` | 503 | Google could not be fetched (not billed, retried) |
|
|
364
431
|
| `upstream_timeout` | 503 | The search took too long (not billed, retried) |
|
|
432
|
+
| `ai_unavailable` | 503 | The AI model failed or AI is off (model part refunded; honours `Retry-After`) |
|
|
365
433
|
| `unavailable` | 503 | Temporarily unavailable (retried) |
|
|
366
434
|
| `internal` | 500 | Server error (retried) |
|
|
367
435
|
| `connection_error`, `timeout` | 0 | No response received |
|
|
@@ -374,8 +442,10 @@ Failed, empty and blocked searches are refunded, so they never cost credits.
|
|
|
374
442
|
Requests are retried up to `maxRetries` times (default 2) on `429`, `5xx`, network errors and
|
|
375
443
|
timeouts, with exponential backoff (`retryDelayMs · 2^attempt`, capped at 8 s) and jitter. A
|
|
376
444
|
`Retry-After` header (seconds or HTTP date, capped at 60 s) takes precedence. Other `4xx` errors
|
|
377
|
-
are never retried. `batches.create`, `crawl`, `
|
|
378
|
-
`429`, so a lost response can never queue (and bill) the same
|
|
445
|
+
are never retried. `batches.create`, `crawl`, `research`, `extract`, `answer`, `monitors.create`
|
|
446
|
+
and `monitors.run` retry only on `429`, so a lost response can never queue (and bill) the same
|
|
447
|
+
work twice. `answerStream` retries only until the stream starts. `answer` and `answerStream` wait
|
|
448
|
+
at least 100 s for the response headers (the search runs first) unless you pass `timeoutMs`.
|
|
379
449
|
|
|
380
450
|
## Types
|
|
381
451
|
|
|
@@ -387,7 +457,9 @@ Types are generated from the OpenAPI contract (`backend/api/serp-api.yaml`) with
|
|
|
387
457
|
`RankResponse`,
|
|
388
458
|
`Account`, `Batch`, `BatchCreateParams`, `Meta`, `Engine`, `Provider`, `RouteStep`,
|
|
389
459
|
`CompactResponse`, `MapResponse`, `ExtractResponse`, `CrawlParams`, `CrawlTask`, `CrawlResult`,
|
|
390
|
-
`TaskCreated`, `
|
|
460
|
+
`TaskCreated`, `AnswerParams`, `AnswerResponse`, `AnswerStreamEvent`, `Citation`, `SearchAnswer`,
|
|
461
|
+
`LLMUsage`, `ResearchParams`, `ResearchTask`, `ResearchResult`, `ResearchSource`,
|
|
462
|
+
`Monitor`, `MonitorRun`, `MonitorPageChange`, `TaskCompletedEvent`,
|
|
391
463
|
`MonitorResultsEvent`, `WebhookEvent` and more. The raw
|
|
392
464
|
`paths`, `components` and `operations` types are exported as well.
|
|
393
465
|
|
package/dist/index.cjs
CHANGED
|
@@ -50,7 +50,7 @@ function errorFromResponse(status, headers, body) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
// src/client.ts
|
|
53
|
-
var VERSION = "0.
|
|
53
|
+
var VERSION = "0.7.0";
|
|
54
54
|
var DEFAULT_BASE_URL = "https://api.serpkite.com";
|
|
55
55
|
var TASK_DONE = /* @__PURE__ */ new Set(["completed", "failed", "canceled"]);
|
|
56
56
|
var SerpKite = class {
|
|
@@ -98,12 +98,16 @@ var SerpKite = class {
|
|
|
98
98
|
videos = this.#vertical("/v1/videos");
|
|
99
99
|
/** Google News. */
|
|
100
100
|
news = this.#vertical("/v1/news");
|
|
101
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Google Maps search (places with coordinates). Accepts `ll` ("@lat,lng,14z"). With
|
|
103
|
+
* `engine: "yandex"` (where enabled) the places come from Yandex Maps and carry an `oid`.
|
|
104
|
+
*/
|
|
102
105
|
maps = this.#vertical("/v1/maps");
|
|
103
106
|
/** Google local results. */
|
|
104
107
|
places = this.#vertical("/v1/places");
|
|
105
108
|
/**
|
|
106
|
-
* Details of one place by `place_id`, `cid` or `fid
|
|
109
|
+
* Details of one place by `place_id`, `cid` or `fid`, or by a Yandex Maps `oid` (implies
|
|
110
|
+
* `engine: "yandex"`): address, country, time zone, hours
|
|
107
111
|
* (`opening_hours` text and structured `weekly_hours`), phones, description and photos.
|
|
108
112
|
* `results` holds the place, or is empty (not charged) when no place has that id. 1 credit.
|
|
109
113
|
*/
|
|
@@ -128,9 +132,14 @@ var SerpKite = class {
|
|
|
128
132
|
* Read up to 20 URLs (HTML or PDF) as Markdown, text or HTML in one call, with optional
|
|
129
133
|
* query-ranked `highlights` (BM25), `include_links` and `include_images`. 1 credit per URL
|
|
130
134
|
* that comes back (0.5 from cache); URLs that fail are listed in `failed` and cost nothing.
|
|
135
|
+
* `summary: true` adds an AI summary of each page and `output_schema` (a JSON Schema, guided
|
|
136
|
+
* by an optional `prompt`) returns each page's data as JSON in `output`, each at an extra
|
|
137
|
+
* per-page price (https://serpkite.com/pricing); a page whose model step failed has
|
|
138
|
+
* `summary_error` / `output_error` instead and isn't charged for it.
|
|
131
139
|
*/
|
|
132
140
|
extract(params, options) {
|
|
133
|
-
const
|
|
141
|
+
const ai = params.summary === true || params.output_schema !== void 0;
|
|
142
|
+
const wait = ((params.timeout ?? (ai ? 75 : 50)) + 15) * 1e3;
|
|
134
143
|
return this.request({
|
|
135
144
|
method: "POST",
|
|
136
145
|
path: "/v1/extract",
|
|
@@ -155,6 +164,47 @@ var SerpKite = class {
|
|
|
155
164
|
account(options) {
|
|
156
165
|
return this.request({ method: "GET", path: "/v1/account", options });
|
|
157
166
|
}
|
|
167
|
+
// ── AI answers ────────────────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* A cited answer grounded on live Google results: Markdown with `[n]` markers in `answer`,
|
|
170
|
+
* the cited sources in `citations`. `depth: "fast"` answers from the result page;
|
|
171
|
+
* `"standard"` (default) also reads the top `num_sources` pages and costs more
|
|
172
|
+
* (https://serpkite.com/pricing; `meta.credits` splits search and model). With
|
|
173
|
+
* `output_schema` the answer is a JSON value in `output`.
|
|
174
|
+
* When the model fails the call throws `ai_unavailable` (503) and the model part is refunded;
|
|
175
|
+
* a search with nothing to answer from returns `answer: null` and a `meta.warnings` entry.
|
|
176
|
+
*/
|
|
177
|
+
answer(params, options) {
|
|
178
|
+
return this.request({
|
|
179
|
+
method: "POST",
|
|
180
|
+
path: "/v1/answer",
|
|
181
|
+
body: params,
|
|
182
|
+
options: { ...options, timeoutMs: this.#answerTimeout(options) },
|
|
183
|
+
retry: "rate_limit"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Streams an answer as Server-Sent Events: `results` first, then `delta` text pieces,
|
|
188
|
+
* then `done` (final answer, citations, meta). A server `error` event is thrown as a
|
|
189
|
+
* `SerpKiteError` (`ai_unavailable`). Breaking out of the loop closes the connection
|
|
190
|
+
* (the model part is then not charged). Can't be combined with `output_schema`.
|
|
191
|
+
*
|
|
192
|
+
* ```ts
|
|
193
|
+
* for await (const ev of sk.answerStream({ q: "what is retrieval-augmented generation" })) {
|
|
194
|
+
* if (ev.event === "delta") process.stdout.write(ev.data.text);
|
|
195
|
+
* }
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
answerStream(params, options) {
|
|
199
|
+
return this.#stream(
|
|
200
|
+
{ ...params, stream: true },
|
|
201
|
+
{ ...options, timeoutMs: this.#answerTimeout(options) }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
/** The search (pages read included) runs before the first byte: wait at least 100 s. */
|
|
205
|
+
#answerTimeout(options) {
|
|
206
|
+
return options?.timeoutMs ?? (this.timeoutMs > 0 ? Math.max(this.timeoutMs, 1e5) : 0);
|
|
207
|
+
}
|
|
158
208
|
// ── Async tasks: crawl ────────────────────────────────────────────────
|
|
159
209
|
/**
|
|
160
210
|
* Starts an async crawl of one site and returns the queued task (`202`). 1 credit per page
|
|
@@ -197,6 +247,58 @@ var SerpKite = class {
|
|
|
197
247
|
timeoutMs: options.timeoutMs ?? 21e5
|
|
198
248
|
});
|
|
199
249
|
}
|
|
250
|
+
// ── Async tasks: research ─────────────────────────────────────────────
|
|
251
|
+
/**
|
|
252
|
+
* Starts an async research task: the model plans searches, reads the sources and writes a
|
|
253
|
+
* cited Markdown report (`result.report`). `effort` (`lite`, `standard` (default), `deep`)
|
|
254
|
+
* sets how much is searched and read; its flat price (https://serpkite.com/pricing) is
|
|
255
|
+
* reserved up front and charged only when the report is written (a canceled task is charged
|
|
256
|
+
* for the searches already run). With
|
|
257
|
+
* `output_schema` the report is also returned as JSON in `result.output`. Poll with
|
|
258
|
+
* `getResearch` / `waitResearch` or receive the signed `research.completed` webhook.
|
|
259
|
+
* Only retried on 429, so a lost response never starts (and reserves) a second task.
|
|
260
|
+
*/
|
|
261
|
+
research(params, options) {
|
|
262
|
+
return this.request({
|
|
263
|
+
method: "POST",
|
|
264
|
+
path: "/v1/research",
|
|
265
|
+
body: params,
|
|
266
|
+
options,
|
|
267
|
+
retry: "rate_limit"
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** Polls a research task (`progress` while it runs; results are kept for 24 hours). */
|
|
271
|
+
getResearch(id, options) {
|
|
272
|
+
return this.request({
|
|
273
|
+
method: "GET",
|
|
274
|
+
path: `/v1/research/${encodeURIComponent(id)}`,
|
|
275
|
+
options
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Cancels a research task: refunded if still queued, else stops at its next round and is
|
|
280
|
+
* charged for the searches run.
|
|
281
|
+
*/
|
|
282
|
+
cancelResearch(id, options) {
|
|
283
|
+
return this.request({
|
|
284
|
+
method: "DELETE",
|
|
285
|
+
path: `/v1/research/${encodeURIComponent(id)}`,
|
|
286
|
+
options
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Polls until the research task is `completed`, `failed` or `canceled` and returns it (a
|
|
291
|
+
* failed task is returned, not thrown; check `status` and `error`). The report is in
|
|
292
|
+
* `result.report`. Accepts an id or the task returned by `research`. Default timeout
|
|
293
|
+
* 15 minutes.
|
|
294
|
+
*/
|
|
295
|
+
async waitResearch(idOrTask, options = {}) {
|
|
296
|
+
return this.#waitTask(
|
|
297
|
+
taskId(idOrTask),
|
|
298
|
+
(id) => this.getResearch(id, { signal: options.signal }),
|
|
299
|
+
{ ...options, timeoutMs: options.timeoutMs ?? 9e5 }
|
|
300
|
+
);
|
|
301
|
+
}
|
|
200
302
|
// ── Monitors ──────────────────────────────────────────────────────────
|
|
201
303
|
monitors = {
|
|
202
304
|
/**
|
|
@@ -366,6 +468,149 @@ var SerpKite = class {
|
|
|
366
468
|
interval = Math.min(interval * 1.5, maxInterval);
|
|
367
469
|
}
|
|
368
470
|
}
|
|
471
|
+
/** Opens `POST /v1/answer` as an event stream (retrying the connect like `request`) and parses it. */
|
|
472
|
+
async *#stream(body, options = {}) {
|
|
473
|
+
const maxRetries = Math.max(0, options.maxRetries ?? this.maxRetries);
|
|
474
|
+
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
475
|
+
const headers = {
|
|
476
|
+
Accept: "text/event-stream, application/json;q=0.9",
|
|
477
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
478
|
+
...this.#headers,
|
|
479
|
+
...options.headers,
|
|
480
|
+
"Content-Type": "application/json"
|
|
481
|
+
};
|
|
482
|
+
if (!isBrowser()) headers["User-Agent"] = `serpkite-typescript/${VERSION}`;
|
|
483
|
+
let res;
|
|
484
|
+
let cleanup = () => {
|
|
485
|
+
};
|
|
486
|
+
for (let attempt = 0; !res; attempt++) {
|
|
487
|
+
const canRetry = attempt < maxRetries;
|
|
488
|
+
const t = withTimeout(options.signal, timeoutMs);
|
|
489
|
+
let r;
|
|
490
|
+
try {
|
|
491
|
+
r = await this.#fetch(`${this.baseUrl}/v1/answer`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers,
|
|
494
|
+
body: JSON.stringify(body),
|
|
495
|
+
signal: t.signal
|
|
496
|
+
});
|
|
497
|
+
} catch (err) {
|
|
498
|
+
t.cleanup();
|
|
499
|
+
if (options.signal?.aborted) throw err;
|
|
500
|
+
if (canRetry) {
|
|
501
|
+
await sleep(this.#backoff(attempt), options.signal);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const code = t.timedOut() ? "timeout" : "connection_error";
|
|
505
|
+
const msg = t.timedOut() ? `request timed out after ${timeoutMs} ms` : `request failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
506
|
+
throw new SerpKiteError(0, code, msg, void 0, void 0, { cause: err });
|
|
507
|
+
}
|
|
508
|
+
if (!r.ok) {
|
|
509
|
+
const text = await r.text().catch(() => "");
|
|
510
|
+
t.cleanup();
|
|
511
|
+
if (canRetry && (r.status === 429 || r.status >= 500)) {
|
|
512
|
+
await sleep(this.#retryDelay(attempt, r.headers), options.signal);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
throw errorFromResponse(r.status, r.headers, text);
|
|
516
|
+
}
|
|
517
|
+
t.stopTimer();
|
|
518
|
+
cleanup = t.cleanup;
|
|
519
|
+
res = r;
|
|
520
|
+
}
|
|
521
|
+
options.onResponse?.(responseInfo(res));
|
|
522
|
+
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
523
|
+
if (!res.body) {
|
|
524
|
+
cleanup();
|
|
525
|
+
throw new SerpKiteError(
|
|
526
|
+
res.status,
|
|
527
|
+
"invalid_response",
|
|
528
|
+
"response has no body",
|
|
529
|
+
requestId,
|
|
530
|
+
res.headers
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if ((res.headers.get("content-type") ?? "").includes("json")) {
|
|
534
|
+
let answer;
|
|
535
|
+
try {
|
|
536
|
+
answer = JSON.parse(await res.text());
|
|
537
|
+
} catch (err) {
|
|
538
|
+
throw new SerpKiteError(
|
|
539
|
+
res.status,
|
|
540
|
+
"invalid_response",
|
|
541
|
+
"response is not valid JSON",
|
|
542
|
+
requestId,
|
|
543
|
+
res.headers,
|
|
544
|
+
{ cause: err }
|
|
545
|
+
);
|
|
546
|
+
} finally {
|
|
547
|
+
cleanup();
|
|
548
|
+
}
|
|
549
|
+
yield { event: "results", data: { request: answer.request, results: answer.results } };
|
|
550
|
+
yield {
|
|
551
|
+
event: "done",
|
|
552
|
+
data: { answer: answer.answer ?? "", citations: answer.citations, meta: answer.meta }
|
|
553
|
+
};
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const reader = res.body.getReader();
|
|
557
|
+
const decoder = new TextDecoder();
|
|
558
|
+
let buf = "";
|
|
559
|
+
let done = false;
|
|
560
|
+
try {
|
|
561
|
+
while (!done) {
|
|
562
|
+
let chunk;
|
|
563
|
+
try {
|
|
564
|
+
chunk = await reader.read();
|
|
565
|
+
} catch (err) {
|
|
566
|
+
if (options.signal?.aborted) throw err;
|
|
567
|
+
throw new SerpKiteError(
|
|
568
|
+
0,
|
|
569
|
+
"connection_error",
|
|
570
|
+
`stream read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
571
|
+
requestId,
|
|
572
|
+
res.headers,
|
|
573
|
+
{ cause: err }
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (chunk.done) {
|
|
577
|
+
buf += decoder.decode();
|
|
578
|
+
done = true;
|
|
579
|
+
} else {
|
|
580
|
+
buf += decoder.decode(chunk.value, { stream: true });
|
|
581
|
+
}
|
|
582
|
+
const held = !done && buf.endsWith("\r") ? "\r" : "";
|
|
583
|
+
if (held) buf = buf.slice(0, -1);
|
|
584
|
+
buf = buf.replace(/\r\n?/g, "\n");
|
|
585
|
+
let sep = buf.indexOf("\n\n");
|
|
586
|
+
while (sep >= 0 || done && buf.trim() !== "") {
|
|
587
|
+
const raw = sep >= 0 ? buf.slice(0, sep) : buf;
|
|
588
|
+
buf = sep >= 0 ? buf.slice(sep + 2) : "";
|
|
589
|
+
const ev = parseSSE(raw);
|
|
590
|
+
if (ev) {
|
|
591
|
+
if (ev.event === "error") throw sseError(ev.data, res.headers, requestId);
|
|
592
|
+
if (ev.event === "results" || ev.event === "delta" || ev.event === "done") {
|
|
593
|
+
yield { event: ev.event, data: ev.data };
|
|
594
|
+
if (ev.event === "done") return;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
sep = buf.indexOf("\n\n");
|
|
598
|
+
}
|
|
599
|
+
buf += held;
|
|
600
|
+
}
|
|
601
|
+
throw new SerpKiteError(
|
|
602
|
+
res.status,
|
|
603
|
+
"invalid_response",
|
|
604
|
+
"the stream ended before the done event",
|
|
605
|
+
requestId,
|
|
606
|
+
res.headers
|
|
607
|
+
);
|
|
608
|
+
} finally {
|
|
609
|
+
cleanup();
|
|
610
|
+
reader.cancel().catch(() => {
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
369
614
|
/** Low-level request: JSON body in, parsed JSON (or text for non-JSON responses) out. */
|
|
370
615
|
async request(call) {
|
|
371
616
|
const { method, path, body, options = {} } = call;
|
|
@@ -473,6 +718,36 @@ function taskId(idOrTask) {
|
|
|
473
718
|
if (!id) throw new SerpKiteError(0, "invalid_request", "a task id is required");
|
|
474
719
|
return id;
|
|
475
720
|
}
|
|
721
|
+
function parseSSE(block) {
|
|
722
|
+
let event = "message";
|
|
723
|
+
const data = [];
|
|
724
|
+
for (const line of block.split("\n")) {
|
|
725
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
726
|
+
const i = line.indexOf(":");
|
|
727
|
+
const field = i < 0 ? line : line.slice(0, i);
|
|
728
|
+
let value = i < 0 ? "" : line.slice(i + 1);
|
|
729
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
730
|
+
if (field === "event") event = value;
|
|
731
|
+
else if (field === "data") data.push(value);
|
|
732
|
+
}
|
|
733
|
+
if (data.length === 0) return void 0;
|
|
734
|
+
const text = data.join("\n");
|
|
735
|
+
try {
|
|
736
|
+
return { event, data: JSON.parse(text) };
|
|
737
|
+
} catch {
|
|
738
|
+
return { event, data: text };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function sseError(data, headers, requestId) {
|
|
742
|
+
const e = data?.error;
|
|
743
|
+
return new SerpKiteError(
|
|
744
|
+
503,
|
|
745
|
+
e?.code ?? "unavailable",
|
|
746
|
+
e?.message ?? "the answer stream failed",
|
|
747
|
+
e?.request_id ?? requestId,
|
|
748
|
+
headers
|
|
749
|
+
);
|
|
750
|
+
}
|
|
476
751
|
function parseRetryAfter(v) {
|
|
477
752
|
if (!v) return void 0;
|
|
478
753
|
const secs = Number(v);
|
|
@@ -514,6 +789,9 @@ function withTimeout(outer, ms) {
|
|
|
514
789
|
return {
|
|
515
790
|
signal: ctrl.signal,
|
|
516
791
|
timedOut: () => timedOut,
|
|
792
|
+
stopTimer: () => {
|
|
793
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
794
|
+
},
|
|
517
795
|
cleanup: () => {
|
|
518
796
|
if (timer !== void 0) clearTimeout(timer);
|
|
519
797
|
outer?.removeEventListener("abort", onAbort);
|