@spendgraph/tools 0.3.2 → 0.3.3

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.
@@ -18,8 +18,18 @@ export interface WebSearchOptions {
18
18
  maxChars?: number;
19
19
  /** Longest source list handed back. Default 20. */
20
20
  maxResults?: number;
21
+ /**
22
+ * Tries for a rate limit or a bad minute on Perplexity's side. Default 3.
23
+ *
24
+ * A wave of sub-questions searches in parallel, so two calls landing together
25
+ * is ordinary and a 429 on the second is not a failure worth surfacing — it
26
+ * is a wait. `retry-after` is honoured where the server sends one.
27
+ */
28
+ attempts?: number;
21
29
  /** Injected for tests. */
22
30
  fetch?: typeof fetch;
31
+ /** Injected for tests. */
32
+ sleep?: (ms: number) => Promise<void>;
23
33
  }
24
34
  /**
25
35
  * The live web, through Perplexity's Sonar models.
@@ -12,6 +12,16 @@ const DEEP_TIMEOUT_MS = 300_000;
12
12
  const DEFAULT_MAX_CHARS = 20_000;
13
13
  const DEFAULT_MAX_RESULTS = 20;
14
14
  const ERROR_DETAIL_CHARS = 500;
15
+ const BACKOFF_BASE_MS = 500;
16
+ const MAX_BACKOFF_MS = 8_000;
17
+ function retryAfterMs(res) {
18
+ const raw = res.headers.get("retry-after");
19
+ if (!raw)
20
+ return null;
21
+ const seconds = Number(raw);
22
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : null;
23
+ }
24
+ const isWorthRetrying = (status) => status === 429 || status >= 500;
15
25
  function describe(maxDepth, domains) {
16
26
  return ("Searches the live web and returns an answer with the sources it rests on. Use it for " +
17
27
  "anything that turns on current facts — prices, releases, who holds a post, what " +
@@ -28,6 +38,8 @@ export function webSearch(opts) {
28
38
  }
29
39
  const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
30
40
  const maxDepth = opts.maxDepth ?? "pro";
41
+ const attempts = Math.max(1, opts.attempts ?? 3);
42
+ const pause = opts.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
31
43
  return tool({
32
44
  name: "web_search",
33
45
  description: describe(maxDepth, opts.domains),
@@ -61,25 +73,31 @@ export function webSearch(opts) {
61
73
  }
62
74
  const model = MODELS[depth];
63
75
  const fallback = depth === "deep" ? DEEP_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
64
- const res = await doFetch(ENDPOINT, {
65
- method: "POST",
66
- signal: AbortSignal.timeout(opts.timeoutMs ?? fallback),
67
- headers: {
68
- authorization: `Bearer ${opts.apiKey}`,
69
- "content-type": "application/json",
70
- },
71
- body: JSON.stringify({
72
- model,
73
- messages: [{ role: "user", content: query }],
74
- ...(recency ? { search_recency_filter: recency } : {}),
75
- ...(opts.domains?.length ? { search_domain_filter: opts.domains } : {}),
76
- }),
77
- });
78
- if (!res.ok) {
76
+ for (let attempt = 1;; attempt++) {
77
+ const res = await doFetch(ENDPOINT, {
78
+ method: "POST",
79
+ signal: AbortSignal.timeout(opts.timeoutMs ?? fallback),
80
+ headers: {
81
+ authorization: `Bearer ${opts.apiKey}`,
82
+ "content-type": "application/json",
83
+ },
84
+ body: JSON.stringify({
85
+ model,
86
+ messages: [{ role: "user", content: query }],
87
+ ...(recency ? { search_recency_filter: recency } : {}),
88
+ ...(opts.domains?.length ? { search_domain_filter: opts.domains } : {}),
89
+ }),
90
+ });
91
+ if (res.ok) {
92
+ return readReply(await res.json(), model, opts.maxChars ?? DEFAULT_MAX_CHARS, opts.maxResults ?? DEFAULT_MAX_RESULTS);
93
+ }
79
94
  const detail = (await res.text()).slice(0, ERROR_DETAIL_CHARS);
80
- throw new Error(`Perplexity returned ${res.status} for ${model}: ${detail}`);
95
+ if (!isWorthRetrying(res.status) || attempt === attempts) {
96
+ throw new Error(`Perplexity returned ${res.status} for ${model}: ${detail}`);
97
+ }
98
+ const wait = retryAfterMs(res) ?? BACKOFF_BASE_MS * 2 ** (attempt - 1);
99
+ await pause(Math.min(MAX_BACKOFF_MS, wait));
81
100
  }
82
- return readReply(await res.json(), model, opts.maxChars ?? DEFAULT_MAX_CHARS, opts.maxResults ?? DEFAULT_MAX_RESULTS);
83
101
  },
84
102
  });
85
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/tools",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Declare a tool once, offer the right few, and record what was called.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,10 +49,10 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@locusgraph/client": "^0.8.1",
52
- "@spendgraph/sdk": "^0.3.2"
52
+ "@spendgraph/sdk": "^0.3.3"
53
53
  },
54
54
  "devDependencies": {
55
- "@spendgraph/prompt": "^0.3.2",
55
+ "@spendgraph/prompt": "^0.3.3",
56
56
  "typescript": "^5"
57
57
  },
58
58
  "engines": {