@spendgraph/tools 0.3.1 → 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.
@@ -6,7 +6,7 @@ export function httpRequest(opts) {
6
6
  if (!opts.allow?.length) {
7
7
  throw new Error("httpRequest needs an allow list of hosts; there is no safe default.");
8
8
  }
9
- const doFetch = opts.fetch ?? globalThis.fetch;
9
+ const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
10
10
  const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
11
11
  return tool({
12
12
  name: "http_request",
@@ -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 " +
@@ -26,8 +36,10 @@ export function webSearch(opts) {
26
36
  if (!opts.apiKey?.trim()) {
27
37
  throw new Error("webSearch needs a Perplexity apiKey; it will not read one from the process.");
28
38
  }
29
- const doFetch = opts.fetch ?? globalThis.fetch;
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
  }
@@ -36,7 +36,7 @@ function deadlineSignal(timeoutMs, caller) {
36
36
  };
37
37
  }
38
38
  export function createRequest(config = {}) {
39
- const doFetch = config.fetch ?? globalThis.fetch;
39
+ const doFetch = config.fetch ?? globalThis.fetch.bind(globalThis);
40
40
  const base = (config.baseUrl ?? MOA_BASE_URL).replace(/\/+$/, "");
41
41
  return async function request(path, opts = {}) {
42
42
  const timeoutMs = opts.timeoutMs ?? config.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/tools",
3
- "version": "0.3.1",
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.1"
52
+ "@spendgraph/sdk": "^0.3.3"
53
53
  },
54
54
  "devDependencies": {
55
- "@spendgraph/prompt": "^0.3.1",
55
+ "@spendgraph/prompt": "^0.3.3",
56
56
  "typescript": "^5"
57
57
  },
58
58
  "engines": {