@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.
- package/README.md +240 -0
- package/dist/budget/budget.d.ts +31 -0
- package/dist/budget/budget.js +53 -0
- package/dist/budget/errors.d.ts +12 -0
- package/dist/budget/errors.js +18 -0
- package/dist/budget/index.d.ts +2 -0
- package/dist/budget/index.js +2 -0
- package/dist/build/build.d.ts +19 -0
- package/dist/build/build.js +73 -0
- package/dist/build/index.d.ts +2 -0
- package/dist/build/index.js +1 -0
- package/dist/cache/cache.d.ts +118 -0
- package/dist/cache/cache.js +198 -0
- package/dist/cache/index.d.ts +1 -0
- package/dist/cache/index.js +1 -0
- package/dist/client.d.ts +147 -0
- package/dist/client.js +230 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/dist/internals.d.ts +17 -0
- package/dist/internals.js +13 -0
- package/dist/prompt/bind.d.ts +60 -0
- package/dist/prompt/bind.js +21 -0
- package/dist/prompt/factory.d.ts +49 -0
- package/dist/prompt/factory.js +118 -0
- package/dist/prompt/index.d.ts +4 -0
- package/dist/prompt/index.js +2 -0
- package/dist/pull/index.d.ts +1 -0
- package/dist/pull/index.js +1 -0
- package/dist/pull/pull.d.ts +12 -0
- package/dist/pull/pull.js +31 -0
- package/dist/render/index.d.ts +4 -0
- package/dist/render/index.js +3 -0
- package/dist/render/messages.d.ts +12 -0
- package/dist/render/messages.js +24 -0
- package/dist/render/system.d.ts +8 -0
- package/dist/render/system.js +19 -0
- package/dist/render/types.d.ts +15 -0
- package/dist/render/types.js +1 -0
- package/dist/render/variables.d.ts +11 -0
- package/dist/render/variables.js +29 -0
- package/dist/run/across-models.d.ts +7 -0
- package/dist/run/across-models.js +6 -0
- package/dist/run/concurrency.d.ts +7 -0
- package/dist/run/concurrency.js +18 -0
- package/dist/run/id.d.ts +2 -0
- package/dist/run/id.js +4 -0
- package/dist/run/index.d.ts +5 -0
- package/dist/run/index.js +5 -0
- package/dist/run/once.d.ts +4 -0
- package/dist/run/once.js +22 -0
- package/dist/run/sample.d.ts +17 -0
- package/dist/run/sample.js +39 -0
- package/dist/types/dataset.d.ts +22 -0
- package/dist/types/dataset.js +1 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.js +1 -0
- package/dist/types/payload.d.ts +16 -0
- package/dist/types/payload.js +1 -0
- package/dist/types/prompt.d.ts +73 -0
- package/dist/types/prompt.js +1 -0
- package/dist/types/result.d.ts +13 -0
- package/dist/types/result.js +1 -0
- package/dist/types/rollout.d.ts +8 -0
- package/dist/types/rollout.js +1 -0
- package/dist/types/run.d.ts +18 -0
- package/dist/types/run.js +1 -0
- package/dist/types/trace.d.ts +71 -0
- package/dist/types/trace.js +1 -0
- package/dist/types/version.d.ts +21 -0
- package/dist/types/version.js +1 -0
- 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,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 @@
|
|
|
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
|
+
}
|