@spendgraph/prompt 0.2.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.
Files changed (72) hide show
  1. package/README.md +240 -0
  2. package/dist/budget/budget.d.ts +31 -0
  3. package/dist/budget/budget.js +53 -0
  4. package/dist/budget/errors.d.ts +12 -0
  5. package/dist/budget/errors.js +18 -0
  6. package/dist/budget/index.d.ts +2 -0
  7. package/dist/budget/index.js +2 -0
  8. package/dist/build/build.d.ts +19 -0
  9. package/dist/build/build.js +73 -0
  10. package/dist/build/index.d.ts +2 -0
  11. package/dist/build/index.js +1 -0
  12. package/dist/cache/cache.d.ts +118 -0
  13. package/dist/cache/cache.js +198 -0
  14. package/dist/cache/index.d.ts +1 -0
  15. package/dist/cache/index.js +1 -0
  16. package/dist/client.d.ts +147 -0
  17. package/dist/client.js +230 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.js +5 -0
  20. package/dist/internals.d.ts +17 -0
  21. package/dist/internals.js +13 -0
  22. package/dist/prompt/bind.d.ts +60 -0
  23. package/dist/prompt/bind.js +21 -0
  24. package/dist/prompt/factory.d.ts +49 -0
  25. package/dist/prompt/factory.js +118 -0
  26. package/dist/prompt/index.d.ts +4 -0
  27. package/dist/prompt/index.js +2 -0
  28. package/dist/pull/index.d.ts +1 -0
  29. package/dist/pull/index.js +1 -0
  30. package/dist/pull/pull.d.ts +12 -0
  31. package/dist/pull/pull.js +31 -0
  32. package/dist/render/index.d.ts +4 -0
  33. package/dist/render/index.js +3 -0
  34. package/dist/render/messages.d.ts +12 -0
  35. package/dist/render/messages.js +24 -0
  36. package/dist/render/system.d.ts +8 -0
  37. package/dist/render/system.js +19 -0
  38. package/dist/render/types.d.ts +15 -0
  39. package/dist/render/types.js +1 -0
  40. package/dist/render/variables.d.ts +11 -0
  41. package/dist/render/variables.js +29 -0
  42. package/dist/run/across-models.d.ts +7 -0
  43. package/dist/run/across-models.js +6 -0
  44. package/dist/run/concurrency.d.ts +7 -0
  45. package/dist/run/concurrency.js +18 -0
  46. package/dist/run/id.d.ts +2 -0
  47. package/dist/run/id.js +4 -0
  48. package/dist/run/index.d.ts +5 -0
  49. package/dist/run/index.js +5 -0
  50. package/dist/run/once.d.ts +4 -0
  51. package/dist/run/once.js +22 -0
  52. package/dist/run/sample.d.ts +17 -0
  53. package/dist/run/sample.js +39 -0
  54. package/dist/types/dataset.d.ts +22 -0
  55. package/dist/types/dataset.js +1 -0
  56. package/dist/types/index.d.ts +8 -0
  57. package/dist/types/index.js +1 -0
  58. package/dist/types/payload.d.ts +16 -0
  59. package/dist/types/payload.js +1 -0
  60. package/dist/types/prompt.d.ts +73 -0
  61. package/dist/types/prompt.js +1 -0
  62. package/dist/types/result.d.ts +13 -0
  63. package/dist/types/result.js +1 -0
  64. package/dist/types/rollout.d.ts +8 -0
  65. package/dist/types/rollout.js +1 -0
  66. package/dist/types/run.d.ts +18 -0
  67. package/dist/types/run.js +1 -0
  68. package/dist/types/trace.d.ts +71 -0
  69. package/dist/types/trace.js +1 -0
  70. package/dist/types/version.d.ts +21 -0
  71. package/dist/types/version.js +1 -0
  72. package/package.json +63 -0
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # @spendgraph/prompt
2
+
3
+ Pull a stored prompt or write one in code, render it, and record what it cost.
4
+ Talks to spendgraph through `@spendgraph/sdk` and to nothing else.
5
+
6
+ ```sh
7
+ npm install @spendgraph/prompt
8
+ ```
9
+
10
+ ## Two ways in, one prompt
11
+
12
+ `pullPrompt` fetches the wording from the server. `buildCustomPrompt` declares
13
+ it in code. Both hand back the same `Prompt`, so `render`, `serialize` and
14
+ `call` behave identically and nothing downstream has to know which it was
15
+ given.
16
+
17
+ ```ts
18
+ import { PromptClient, buildCustomPrompt } from "@spendgraph/prompt";
19
+
20
+ const prompts = new PromptClient({
21
+ apiKey: process.env.SPENDGRAPH_API_KEY,
22
+ baseUrl: "https://your-spendgraph.example.com",
23
+ });
24
+
25
+ const stored = await prompts.pull("billing-explainer");
26
+ const local = buildCustomPrompt({
27
+ name: "billing-explainer",
28
+ blocks: [{ title: "Role", body: "You explain invoices to {audience}." }],
29
+ question: "Explain this line item in {max_words} words.",
30
+ });
31
+ ```
32
+
33
+ `pull` takes an id or a slug — the server resolves either. `pullPrompt(handle,
34
+ opts)` is the same thing without keeping a client around; it builds one per
35
+ call, so a service pulling repeatedly wants the class, which caches.
36
+
37
+ ## Running one
38
+
39
+ Bind a model and the prompt runs itself:
40
+
41
+ ```ts
42
+ import { Llm } from "@spendgraph/llms";
43
+
44
+ const llm = new Llm({ client: anthropic, model: "claude-opus-5" });
45
+ const chain = stored.bind(llm);
46
+
47
+ const answer = await chain.invoke({ audience: "students", max_words: 50 });
48
+ answer.output;
49
+ answer.rolloutId;
50
+
51
+ await chain.stream(values, { onText: (delta) => res.write(delta) });
52
+ const answers = await chain.batch(cases, { concurrency: 4 });
53
+ ```
54
+
55
+ `bind` asks for a shape, not an import — anything with `call(messages)` returning
56
+ an outcome satisfies `Model`, and `Llm`'s reply already is one, so neither
57
+ package depends on the other.
58
+
59
+ ### Or keep the call yourself
60
+
61
+ `call` renders, runs the model call you give it, and records what happened
62
+ around it. This package never sees the provider response, which is why the call
63
+ is handed to it rather than made by it.
64
+
65
+ ```ts
66
+ const reply = await stored.call({ audience: "students", max_words: 50 }, async ({ messages }) => {
67
+ const res = await anthropic.messages.create({ model, max_tokens: 400, messages });
68
+ return { output: res.content[0].text, model, inputTokens: 400, outputTokens: 12 };
69
+ });
70
+ ```
71
+
72
+ It fills in the version, fields, messages, latency and status; you return the
73
+ text, model and tokens. Return the tokens or every cost on the prompt reads as
74
+ zero — the first call that forgets warns once.
75
+
76
+ Recording is on for every prompt and is fire-and-forget. There is no
77
+ client-level switch: pass `{ report: false }` on the call that should not be
78
+ recorded, which is where you know whether it should.
79
+
80
+ What was sent is built once and shared by the success path and the failure path,
81
+ so the two cannot disagree about it. That is the thing hand-written reporting
82
+ gets wrong. A callback that throws is recorded as failed and then rethrown: a
83
+ provider call that throws is otherwise recorded nowhere at all, which leaves the
84
+ run most in need of explaining with no evidence.
85
+
86
+ ### What each kind records
87
+
88
+ A **server** prompt has a row and a version behind it, so a call writes a
89
+ rollout. A **custom** prompt has neither, and `POST /api/v1/prompts/:id/rollouts`
90
+ needs both — so it records usage instead. The tokens still land on the
91
+ dashboard, attributed to the model and tagged with the prompt's name; only the
92
+ wording goes unversioned. Read `prompt.source` to tell them apart.
93
+
94
+ A custom prompt is keyed on its name rather than a generated id, because that
95
+ name is what tags the usage event — a random one would file every run of the
96
+ same prompt under a different heading.
97
+
98
+ Leave `sdk` off `buildCustomPrompt` and the prompt touches the network never.
99
+ `{ report: false }` on any call runs the model and records nothing.
100
+
101
+ ## Conversation history
102
+
103
+ Pass prior turns and they render between the system turn and the question,
104
+ oldest first.
105
+
106
+ ```ts
107
+ const history = [
108
+ { role: "user", content: "What is a proration?" },
109
+ { role: "assistant", content: "A mid-cycle adjustment." },
110
+ ];
111
+
112
+ prompt.format(values, { history });
113
+ await prompt.call(values, ({ messages }) => call(messages), { history });
114
+ ```
115
+
116
+ History is passed through **verbatim** — it is a record of what was already
117
+ said, not a template, so `{placeholders}` inside it are left alone. Only the
118
+ prompt's own blocks and question are substituted. A `system` turn in the history
119
+ is dropped: the prompt owns that turn, and two of them is a different request
120
+ from the one the prompt describes.
121
+
122
+ Tools are still selected on the question, not on an older turn — the shortlist
123
+ has to serve what is being asked now.
124
+
125
+ A rollout records at most 50 messages, which is what the route accepts. A longer
126
+ conversation still runs; only its rollout is refused, and that refusal reaches
127
+ `onReportError` rather than your call — so the first time it happens this warns,
128
+ once, while somebody is still watching. `MAX_RECORDED_MESSAGES` is exported if
129
+ you want to trim before it bites.
130
+
131
+ ## Fields
132
+
133
+ A `{placeholder}` is declared as a `FieldSpec` — a name, a type, whether it is
134
+ required, and how it renders. A missing required field throws before anything is
135
+ sent. Types come from `@spendgraph/sdk`, because a tool's arguments and a graph
136
+ node's inputs are the same problem.
137
+
138
+ ```ts
139
+ prompt.format(values); // Message[], validated and substituted
140
+ prompt.serialize(values); // the field map that render used
141
+ ```
142
+
143
+ ## Caching, and invalidating it
144
+
145
+ `pull` serves a stale copy immediately and refreshes behind the caller, so an
146
+ edit takes effect within a TTL without any request paying for the fetch.
147
+
148
+ `promote` invalidates for you. Doing it by hand needs to drop **both** handles,
149
+ not just the one passed: `pull` keys on whatever string it was given, so the
150
+ same prompt sits under its uuid and its slug at once, and `promote` is only
151
+ reachable with a uuid while the dashboard teaches pulling by slug. Dropping one
152
+ left the other warm for a full TTL, still rendering the wording the promotion
153
+ replaced.
154
+
155
+ The delete runs even when nothing is stored under the handle: a cold isolate has
156
+ a fetch in flight and no entry yet, and the delete is what stops that in-flight
157
+ result being stored after the promotion.
158
+
159
+ ```ts
160
+ await prompts.promote(promptId, versionId); // invalidates
161
+ prompts.invalidate(promptId); // both handles
162
+ prompts.invalidate(); // everything
163
+ ```
164
+
165
+ `onPullError` is worth setting. Without it a background refresh that has stopped
166
+ resolving is silent, and a rename on the dashboard freezes a running service
167
+ with nothing anywhere saying so.
168
+
169
+ ## Versions and datasets
170
+
171
+ ```ts
172
+ await prompts.versions(promptId, { origin: "user", limit: 20 });
173
+ await prompts.cases(promptId);
174
+ await prompts.setCases(promptId, cases);
175
+ ```
176
+
177
+ `setCases` replaces the whole dataset. A dataset is a set — "which cases am I
178
+ evaluating against" has one answer at a time, and a merge would leave no way to
179
+ remove a case.
180
+
181
+ ## Running server-side
182
+
183
+ `run`, `sample` and `runAll` execute the prompt on the server, where the spec,
184
+ rendering and pricing already live. This is the batch path. A production request
185
+ should pull, render and report instead: the server-side call adds a hop and makes
186
+ spendgraph a dependency of your uptime, which is the right trade for evaluation
187
+ and the wrong one for a user waiting on a response.
188
+
189
+ ```ts
190
+ await prompts.run(promptId, values);
191
+ await prompts.sample(promptId, values, { k: 5 });
192
+ await prompts.runAll(promptId, values); // one rollout per saved model
193
+ ```
194
+
195
+ `maxCostMicros` caps what those spend. Omit it and the client warns once that it
196
+ is unbounded; pass `null` to say you meant it.
197
+
198
+ ## Tools
199
+
200
+ `call` and `invoke` accept anything that hands out a turn — `toolbus()` from
201
+ `@spendgraph/tools`, or your own. The callback gets the turn fully typed; the
202
+ rollout gets the tools offered and the steps taken.
203
+
204
+ ```ts
205
+ await prompt.call(values, ({ messages, turn }) => call(messages, turn), { tools: bus });
206
+ ```
207
+
208
+ Which tools were offered is recorded as well as which were called: without it a
209
+ rollout cannot tell "chose not to" from "was never offered", which are opposite
210
+ bugs.
211
+
212
+ ## What is exported
213
+
214
+ The main entry is six values, because everything else is already wired into
215
+ `PromptClient`:
216
+
217
+ ```ts
218
+ import {
219
+ PromptClient, // pull, run, report, versions, datasets
220
+ pullPrompt, // one prompt, without keeping a client
221
+ buildCustomPrompt, // a prompt written in code
222
+ BudgetExceededError,
223
+ FieldValidationError,
224
+ MAX_RECORDED_MESSAGES,
225
+ } from "@spendgraph/prompt";
226
+ ```
227
+
228
+ Everything a prompt can do hangs off the object itself — `format`, `serialize`,
229
+ `call`, `bind` — so there is nothing else to import to use one.
230
+
231
+ Two subpaths hold the rest:
232
+
233
+ | | |
234
+ | --- | --- |
235
+ | `@spendgraph/prompt/render` | `renderMessages` · `compileSystemPrompt` · `applyVariables` · `findVariables` |
236
+ | `@spendgraph/prompt/internals` | `PullCache` · `Budget` · `makePrompt` · `bind` · `mapLimit` · … |
237
+
238
+ Reach for `internals` only to build your own client; `PromptClient` already
239
+ wires all of it.
240
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What this client has spent, and whether it may spend more.
3
+ *
4
+ * A `sample` loop over a dataset is the shape of an expensive accident, and the
5
+ * guard is a counter and a comparison.
6
+ *
7
+ * Governs what this client initiates — `run`, `sample`, `runAll`. Not `report`,
8
+ * which records money already spent elsewhere.
9
+ */
10
+ export declare class Budget {
11
+ private used;
12
+ private readonly limit;
13
+ /**
14
+ * `undefined` means no ceiling and warns once — an unbounded client in a
15
+ * scheduled script is how a surprise bill happens. `null` means the same
16
+ * thing deliberately, and says nothing.
17
+ */
18
+ constructor(maxCostMicros?: number | null);
19
+ spent(): number;
20
+ remaining(): number;
21
+ add(costMicros: number): void;
22
+ /**
23
+ * Throws if the ceiling is already reached.
24
+ *
25
+ * Checked before a request rather than after: stopping once the bill has been
26
+ * incurred is a report, not a limit.
27
+ */
28
+ assertAffordable(): void;
29
+ }
30
+ /** Test seam: lets the once-only warning be exercised more than once. */
31
+ export declare function resetBudgetWarning(): void;
@@ -0,0 +1,53 @@
1
+ import { BudgetExceededError } from "./errors.js";
2
+ /** Warned once per process, not per client — a library that nags gets muted. */
3
+ let warned = false;
4
+ /**
5
+ * What this client has spent, and whether it may spend more.
6
+ *
7
+ * A `sample` loop over a dataset is the shape of an expensive accident, and the
8
+ * guard is a counter and a comparison.
9
+ *
10
+ * Governs what this client initiates — `run`, `sample`, `runAll`. Not `report`,
11
+ * which records money already spent elsewhere.
12
+ */
13
+ export class Budget {
14
+ used = 0;
15
+ limit;
16
+ /**
17
+ * `undefined` means no ceiling and warns once — an unbounded client in a
18
+ * scheduled script is how a surprise bill happens. `null` means the same
19
+ * thing deliberately, and says nothing.
20
+ */
21
+ constructor(maxCostMicros) {
22
+ this.limit = maxCostMicros ?? Infinity;
23
+ if (maxCostMicros === undefined && !warned) {
24
+ warned = true;
25
+ console.warn("[prompt] No maxCostMicros set — this client can spend without limit. " +
26
+ "Pass maxCostMicros, or null to say you meant it.");
27
+ }
28
+ }
29
+ spent() {
30
+ return this.used;
31
+ }
32
+ remaining() {
33
+ return this.limit === Infinity ? Infinity : Math.max(0, this.limit - this.used);
34
+ }
35
+ add(costMicros) {
36
+ if (Number.isFinite(costMicros) && costMicros > 0)
37
+ this.used += costMicros;
38
+ }
39
+ /**
40
+ * Throws if the ceiling is already reached.
41
+ *
42
+ * Checked before a request rather than after: stopping once the bill has been
43
+ * incurred is a report, not a limit.
44
+ */
45
+ assertAffordable() {
46
+ if (this.used >= this.limit)
47
+ throw new BudgetExceededError(this.used, this.limit);
48
+ }
49
+ }
50
+ /** Test seam: lets the once-only warning be exercised more than once. */
51
+ export function resetBudgetWarning() {
52
+ warned = false;
53
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Thrown when a client has spent its ceiling.
3
+ *
4
+ * Deliberately not a `SpendgraphError`: nothing was refused by the server, so a
5
+ * caller inspecting `status` or `retryable` would be told a story about HTTP
6
+ * that never happened.
7
+ */
8
+ export declare class BudgetExceededError extends Error {
9
+ readonly spentMicros: number;
10
+ readonly limitMicros: number;
11
+ constructor(spentMicros: number, limitMicros: number);
12
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Thrown when a client has spent its ceiling.
3
+ *
4
+ * Deliberately not a `SpendgraphError`: nothing was refused by the server, so a
5
+ * caller inspecting `status` or `retryable` would be told a story about HTTP
6
+ * that never happened.
7
+ */
8
+ export class BudgetExceededError extends Error {
9
+ spentMicros;
10
+ limitMicros;
11
+ constructor(spentMicros, limitMicros) {
12
+ super(`Cost ceiling reached: spent $${(spentMicros / 1e6).toFixed(4)} of ` +
13
+ `$${(limitMicros / 1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`);
14
+ this.name = "BudgetExceededError";
15
+ this.spentMicros = spentMicros;
16
+ this.limitMicros = limitMicros;
17
+ }
18
+ }
@@ -0,0 +1,2 @@
1
+ export { Budget, resetBudgetWarning } from "./budget.js";
2
+ export { BudgetExceededError } from "./errors.js";
@@ -0,0 +1,2 @@
1
+ export { Budget, resetBudgetWarning } from "./budget.js";
2
+ export { BudgetExceededError } from "./errors.js";
@@ -0,0 +1,19 @@
1
+ import type { Spendgraph } from "@spendgraph/sdk";
2
+ import type { Prompt, PromptSpec, ReportInput } from "../types/index.js";
3
+ export interface BuildOptions {
4
+ /** Where usage goes. Omit and the prompt is entirely offline. */
5
+ sdk?: Spendgraph;
6
+ /** Attached to every usage event this prompt records. */
7
+ metadata?: Record<string, string | number | boolean>;
8
+ /** Replaces the once-only warning about a callback that returned no tokens. */
9
+ onEmptyTokens?: (entry: ReportInput) => void;
10
+ }
11
+ /**
12
+ * A prompt written in code rather than pulled from the server.
13
+ *
14
+ * The same `Prompt` `pullPrompt` returns — same `render`, same `serialize`,
15
+ * same `trace` — so nothing downstream has to know which one it was handed.
16
+ * Give it an `sdk` and it records what the call cost; leave it off and it
17
+ * touches the network never.
18
+ */
19
+ export declare function buildCustomPrompt(spec: PromptSpec, opts?: BuildOptions): Prompt;
@@ -0,0 +1,73 @@
1
+ import { makePrompt, noRecorder } from "../prompt/index.js";
2
+ import { applyVariables } from "../render/index.js";
3
+ function neverBreakTheCallItMeasured() { }
4
+ let warnedAboutTokens = false;
5
+ function warnOnEmptyTokens(entry) {
6
+ if (warnedAboutTokens)
7
+ return;
8
+ if ((entry.inputTokens ?? 0) > 0 || (entry.outputTokens ?? 0) > 0)
9
+ return;
10
+ warnedAboutTokens = true;
11
+ console.warn("[prompt] trace() recorded a call with no tokens. This package never sees " +
12
+ "the provider response, so return inputTokens and outputTokens from your callback " +
13
+ "or every cost on this prompt reads as zero.");
14
+ }
15
+ /**
16
+ * Usage, not a rollout.
17
+ *
18
+ * A custom prompt has no row on the server and so no version to reference, and
19
+ * `POST /api/v1/prompts/:id/rollouts` needs both. The tokens are still real, so
20
+ * they go to ingest — the spend lands on the dashboard, attributed to the model
21
+ * and tagged with the prompt's name; only the wording goes unversioned.
22
+ */
23
+ function usageRecorder(sdk, name, opts) {
24
+ const onEmpty = opts.onEmptyTokens ?? warnOnEmptyTokens;
25
+ return {
26
+ enabled: true,
27
+ write(_promptId, entry) {
28
+ if (entry.status === "failed")
29
+ return;
30
+ onEmpty(entry);
31
+ void sdk.ingest
32
+ .send([
33
+ {
34
+ eventId: entry.rolloutId,
35
+ model: entry.model,
36
+ inputTokens: entry.inputTokens ?? 0,
37
+ outputTokens: entry.outputTokens ?? 0,
38
+ cacheReadTokens: entry.cacheReadTokens,
39
+ cacheWriteTokens: entry.cacheWriteTokens,
40
+ citationTokens: entry.citationTokens,
41
+ reasoningTokens: entry.reasoningTokens,
42
+ metadata: { ...opts.metadata, prompt: name },
43
+ },
44
+ ])
45
+ .catch(neverBreakTheCallItMeasured);
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * A prompt written in code rather than pulled from the server.
51
+ *
52
+ * The same `Prompt` `pullPrompt` returns — same `render`, same `serialize`,
53
+ * same `trace` — so nothing downstream has to know which one it was handed.
54
+ * Give it an `sdk` and it records what the call cost; leave it off and it
55
+ * touches the network never.
56
+ */
57
+ export function buildCustomPrompt(spec, opts = {}) {
58
+ const variables = spec.variables ?? {};
59
+ const blocks = (spec.blocks ?? []).map((b) => ({
60
+ title: applyVariables(b.title, variables),
61
+ body: applyVariables(b.body, variables),
62
+ }));
63
+ return makePrompt({
64
+ id: spec.name,
65
+ name: spec.name,
66
+ slug: null,
67
+ blocks,
68
+ question: applyVariables(spec.question ?? "", variables),
69
+ fields: spec.fields ?? [],
70
+ versionId: null,
71
+ models: spec.models ?? [],
72
+ }, "custom", opts.sdk ? usageRecorder(opts.sdk, spec.name, opts) : noRecorder);
73
+ }
@@ -0,0 +1,2 @@
1
+ export type { BuildOptions } from "./build.js";
2
+ export { buildCustomPrompt } from "./build.js";
@@ -0,0 +1 @@
1
+ export { buildCustomPrompt } from "./build.js";
@@ -0,0 +1,118 @@
1
+ /**
2
+ * LRU with a TTL, and staleness reported rather than enforced.
3
+ *
4
+ * Returning a stale entry instead of dropping it is what lets `pull` serve
5
+ * immediately and refresh behind the request. Evicting on expiry puts a round
6
+ * trip on whichever request arrives a millisecond after the TTL lapses.
7
+ */
8
+ export interface CacheOptions<T = unknown> {
9
+ /** Entries older than this are stale, not gone. Default 300 (5 minutes). */
10
+ ttlSeconds?: number;
11
+ /** Default 100. */
12
+ maxSize?: number;
13
+ /** Injectable clock, so tests do not sleep. */
14
+ now?: () => number;
15
+ /**
16
+ * How long to leave a key alone after its refresh failed. Default 60.
17
+ *
18
+ * A handle that has stopped resolving is served from cache and retried on a
19
+ * schedule, rather than on every call.
20
+ */
21
+ retryAfterSeconds?: number;
22
+ /**
23
+ * Called when a background refresh throws.
24
+ *
25
+ * `report` has `onReportError` because silence there loses spend data; the
26
+ * same argument applies here, where silence means serving a wording that can
27
+ * never update. Without it a rename on the dashboard freezes a running
28
+ * service with nothing anywhere saying so.
29
+ */
30
+ onRefreshError?: (err: unknown, key: string) => void;
31
+ /**
32
+ * Every name a cached value answers to — for a prompt, its id and its slug.
33
+ *
34
+ * The cache is keyed on whatever handle the caller passed, which is why
35
+ * invalidation kept missing: a promotion knows the uuid, production pulled by
36
+ * the slug, and a fetch still in flight carries no payload to connect them.
37
+ * With this, an invalidation is recorded against the prompt rather than
38
+ * against one string, and a fetch is dropped when the thing it fetched was
39
+ * invalidated — not when some unrelated key was.
40
+ */
41
+ identify?: (value: T) => string[];
42
+ }
43
+ export interface CacheHit<T> {
44
+ value: T;
45
+ stale: boolean;
46
+ ageMs: number;
47
+ }
48
+ export declare class PullCache<T> {
49
+ private readonly store;
50
+ private readonly ttlMs;
51
+ private readonly maxSize;
52
+ private readonly now;
53
+ /** Keys with a refresh in flight, so N concurrent readers cause one fetch. */
54
+ private readonly refreshing;
55
+ /** When a key's refresh last failed, so a dead handle is not retried per call. */
56
+ private readonly failures;
57
+ private readonly retryAfterMs;
58
+ private readonly onRefreshError?;
59
+ private readonly identify?;
60
+ /**
61
+ * A monotonic counter and the tick at which each name was last invalidated.
62
+ *
63
+ * A fetch records the counter when it starts and, when it lands, asks whether
64
+ * anything it turned out to be — its key, its id, its slug — has been
65
+ * invalidated since. That is the whole protocol, and it holds for the cold
66
+ * path, the background refresh, and a handle nobody has seen before, none of
67
+ * which the previous per-key epoch could cover.
68
+ */
69
+ private tick;
70
+ private readonly invalidatedAt;
71
+ /**
72
+ * The tick of the last `clear()`.
73
+ *
74
+ * `clear` cannot name a fetch it has never seen — a cold one is in neither
75
+ * `store` nor `refreshing` — so it raises a floor instead: anything that
76
+ * started before it is out of date whatever it turns out to be.
77
+ */
78
+ private clearedAt;
79
+ /** Fetches outstanding, so the log above can be dropped when none remain. */
80
+ private outstanding;
81
+ constructor(opts?: CacheOptions<T>);
82
+ get(key: string): CacheHit<T> | undefined;
83
+ set(key: string, value: T): void;
84
+ /**
85
+ * Runs `refresh` unless one is already in flight for this key.
86
+ *
87
+ * Without the guard, a burst of requests arriving just after the TTL lapses
88
+ * each start their own refresh — the stampede the cache exists to prevent.
89
+ * Failures are swallowed on purpose: a background refresh that throws must not
90
+ * surface in a request that was already answered from cache.
91
+ */
92
+ refreshInBackground(key: string, refresh: () => Promise<T>): void;
93
+ /** Records a fetch starting, and the tick it must be judged against. */
94
+ beginFetch(): number;
95
+ /** Stores a landed fetch unless what it fetched was invalidated meanwhile. */
96
+ settle(key: string, value: T, startedAt: number): void;
97
+ /** Ends a fetch that threw, and backs the handle off. */
98
+ abandon(key: string): void;
99
+ /** Records that everything under this name is out of date. */
100
+ invalidate(name: string): void;
101
+ /** The log only matters while a fetch could still be judged against it. */
102
+ private forgetLogIfIdle;
103
+ /** Keeps `failures` inside the same bound as the entries themselves. */
104
+ private boundFailures;
105
+ /** Every key whose entry satisfies `match`. */
106
+ keysWhere(match: (value: T) => boolean): string[];
107
+ delete(key: string): void;
108
+ clear(): void;
109
+ get size(): number;
110
+ /**
111
+ * How many keys the cache holds bookkeeping for, entries aside.
112
+ *
113
+ * Exposed for the test that this stays bounded: `epochs` and `failures` are
114
+ * private and grow on paths `size` cannot see, so a test written against
115
+ * `size` passes whether or not they leak — which is what the first one did.
116
+ */
117
+ get trackedKeys(): number;
118
+ }