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 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`, `country`, `language`) | `PlacesResponse` (one place: `weekly_hours`, `photos`, `timezone`, …; empty when the id is unknown) |
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
- | `extract(params)` | `POST /v1/extract` | `ExtractParams` (`urls` ≤ 20, `format`, `query`, `highlights`, …) | `ExtractResponse` (`results`, `failed`) |
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`, `monitors.create` and `monitors.run` retry only on
378
- `429`, so a lost response can never queue (and bill) the same work twice.
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`, `Monitor`, `MonitorRun`, `MonitorPageChange`, `TaskCompletedEvent`,
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.5.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
- /** Google Maps search (places with coordinates). Accepts `ll` ("@lat,lng,14z"). */
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`: address, country, time zone, hours
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 wait = ((params.timeout ?? 50) + 15) * 1e3;
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);