@zerotal/ai 1.5.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/CHANGELOG.md ADDED
@@ -0,0 +1,77 @@
1
+ # Changelog — @zerotal/ai
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `experimental`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.5.0] — 2026-08-15
12
+
13
+ ### Added
14
+
15
+ - **The package.** `Ai.text()`, `Ai.stream()`, `Ai.object()`, `Ai.agent()`, and
16
+ `Ai.embed()` behind one facade, with the provider chosen in `config/ai.ts`
17
+ rather than at each call site. Three drivers ship: Anthropic (via the optional
18
+ `@anthropic-ai/sdk` peer), OpenAI, and Ollama (both over plain `fetch`).
19
+
20
+ - **One agent loop, shared by every driver.** `AiDriver.agent()` is optional and
21
+ none of the three implement it — they all run the same loop over `text()`. An
22
+ abstraction whose second implementation reuses none of the first is not an
23
+ abstraction, and the shared suite in `drivers.test.ts` runs the same
24
+ assertions against all three so that stays true.
25
+
26
+ - **`pause_turn` is resumed, not mistaken for an answer.** A provider running a
27
+ long server-side tool can end a turn with `stop_reason: "pause_turn"`, meaning
28
+ "ask me again". It is neither an error nor a completion, so an unhandled one
29
+ reads as a finished answer and the user sees a silently truncated response
30
+ with no warning anywhere. The loop pushes the paused turn back and re-requests,
31
+ capped by `agent.maxResumes`.
32
+
33
+ - **Refusals are typed, and checked before the content is read.** A declined
34
+ request arrives as a **successful HTTP 200** with empty or partial content, so
35
+ code that reads `content[0]` first crashes on a response the API considers
36
+ fine. `AiRefusedError` carries the provider's category and, for a mid-stream
37
+ decline, the partial text — so a caller can discard a truncated answer
38
+ knowingly. Anthropic's server-side `fallbacks: "default"` is on by default.
39
+
40
+ - **Schema translation that decides, rather than hoping.** Structured output
41
+ accepts a narrow JSON Schema subset — `additionalProperties: false` required on
42
+ every object, no length or numeric bounds, no recursion — and rejects anything
43
+ else at _request_ time. So `translateSchema()` strips what it cannot express
44
+ and `recheckAgainstSchema()` re-applies it with the validator we already own;
45
+ a recursive schema, which has no client-side rescue, is refused at definition
46
+ time instead. `schema.test.ts` pins the exact output for every supported rule.
47
+
48
+ - **A refreshable lock on named agent runs.** `Ai.agent({ lock: "refund:4821" })`
49
+ is exclusive for that name, refreshes for as long as the loop runs, and aborts
50
+ the loop's signal if the lock is ever lost. Opt-in and named on purpose: a
51
+ shared key would serialize every agent run in the app.
52
+
53
+ - **Spend ceilings, prompt redaction, and a monitor section.**
54
+ `limits.perRequestUsd` is checked before the request is sent, from a real token
55
+ count via the provider's own tokenizer — never `tiktoken`, which undercounts
56
+ Claude by 15–20%. Prompts are redacted in logs and the monitor by default,
57
+ because a prompt is user data and observability is where it would otherwise be
58
+ durably kept. The monitor's **AI** section shows spend against the ceiling,
59
+ tokens, latency percentiles per model, and the refusal rate.
60
+
61
+ - **`AiFake`** — `respondWith()`, `respondWithObject()`, `refuse()`, and
62
+ `assertPrompted()` / `assertPromptCount()` / `assertSystemPrompted()`. The
63
+ assertions are about what the application asked for, which is the part that can
64
+ be wrong. The suite passes with no API key set.
65
+
66
+ - **Commands.** `zt ai:test [driver]` reaches the provider once and prints the
67
+ resolved model; `zt ai:spend` reports this process's token spend by model.
68
+
69
+ ### Notes
70
+
71
+ - `temperature` is accepted on the request surface but **dropped** by the
72
+ Anthropic driver, because current Claude models reject it with a 400 and a
73
+ parameter forwarded blindly would fail every request. `validateAiConfig()`
74
+ warns when a configured model rejects it. Use `effort` instead.
75
+ - The daily spend ceiling is in-process: N workers hold N ceilings, and the
76
+ figures are estimated from public list prices. It is a guard against a runaway
77
+ loop, not a billing system.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @zerotal/ai
2
+
3
+ > Text, streaming, structured output, typed tools, and an agent loop — behind one provider-agnostic facade.
4
+
5
+ One way to talk to a language model: `Ai.text()` for a completion, `Ai.stream()` for tokens as they arrive, `Ai.object()` for a value that satisfies a validator schema, and `Ai.agent()` for a loop that calls your tools until the model is done. The provider is chosen in `config/ai.ts`, not at each call site.
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ **Maturity: `experimental`** — the API may change in a minor release.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ bun add @zerotal/ai
15
+ ```
16
+
17
+ Provider SDKs are optional peers, imported lazily. Install only the one you use:
18
+
19
+ ```bash
20
+ bun add @anthropic-ai/sdk # only for the anthropic driver
21
+ ```
22
+
23
+ The OpenAI and Ollama drivers use `fetch` directly and need nothing extra.
24
+
25
+ ## Setup
26
+
27
+ Register the provider in `bootstrap/providers.ts`:
28
+
29
+ ```ts
30
+ import { AiProvider } from "@zerotal/ai";
31
+ ```
32
+
33
+ Then configure it:
34
+
35
+ ```ts
36
+ // config/ai.ts
37
+ import { AiConfig } from "@zerotal/ai";
38
+
39
+ export default AiConfig({
40
+ default: "anthropic",
41
+ drivers: {
42
+ anthropic: { apiKey: Bun.env["ANTHROPIC_API_KEY"] ?? "" },
43
+ },
44
+ limits: { perRequestUsd: 0.5, perDayUsd: 25 },
45
+ });
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```ts
51
+ import { Ai, tool } from "@zerotal/ai";
52
+
53
+ // A completion.
54
+ const summary = await Ai.text(`Summarize in one sentence:\n\n${article}`);
55
+
56
+ // Tokens as they arrive, cancellable.
57
+ for await (const chunk of Ai.stream({ prompt, signal })) {
58
+ if (chunk.type === "text") process.stdout.write(chunk.text);
59
+ }
60
+
61
+ // A value that satisfies a validator schema — the same schema you use for forms.
62
+ const review = await Ai.object({ prompt }, (rule) => ({
63
+ sentiment: rule.string().in(["positive", "neutral", "negative"]),
64
+ score: rule.number().min(1).max(5),
65
+ }));
66
+
67
+ // A tool-calling loop, exclusive for the work it names.
68
+ const lookupOrder = tool({
69
+ name: "lookup_order",
70
+ description: "Fetch one order by id. Call this whenever the user mentions an order number.",
71
+ input: (rule) => ({ id: rule.string() }),
72
+ handle: async ({ id }) => await Order.find(id),
73
+ });
74
+
75
+ const result = await Ai.agent({
76
+ prompt: "Where is order 4821?",
77
+ tools: [lookupOrder],
78
+ lock: "order:4821",
79
+ });
80
+ ```
81
+
82
+ ## Testing
83
+
84
+ ```ts
85
+ import { AiFake } from "@zerotal/ai";
86
+
87
+ const ai = AiFake.install();
88
+ ai.respondWith("A one-sentence summary.");
89
+
90
+ await service.summarize(article);
91
+
92
+ ai.assertPrompted(/Summarize/);
93
+ ai.restore();
94
+ ```
95
+
96
+ No API key, no network. The suite for this package passes with neither.
97
+
98
+ ## Commands
99
+
100
+ | Command | What it does |
101
+ | --------------------- | -------------------------------------------------------- |
102
+ | `zt ai:test [driver]` | Reach the provider once and print the **resolved model** |
103
+ | `zt ai:spend` | This process's token spend today, by model |
104
+
105
+ ## Documentation
106
+
107
+ Full documentation: [zerotal.dev/docs/ai](https://zerotal.dev/docs/ai).
108
+
109
+ ## License
110
+
111
+ MIT
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@zerotal/ai",
3
+ "version": "1.5.0",
4
+ "license": "MIT",
5
+ "maturity": "experimental",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "files": [
14
+ "CHANGELOG.md",
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.test.tsx",
18
+ "!src/**/*.spec.ts",
19
+ "!src/**/__fixtures__/**"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.3.14"
26
+ },
27
+ "scripts": {
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@zerotal/core": "1.5.0",
33
+ "@zerotal/validator": "1.5.0",
34
+ "@zerotal/queue": "1.5.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@anthropic-ai/sdk": ">=0.70.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@anthropic-ai/sdk": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.8.0"
46
+ },
47
+ "description": "Provider-agnostic AI generation for Zerotal — text, streaming, structured output, typed tools, and an agent loop.",
48
+ "keywords": [
49
+ "zerotal",
50
+ "bun",
51
+ "typescript",
52
+ "framework",
53
+ "ai",
54
+ "llm",
55
+ "anthropic",
56
+ "claude",
57
+ "openai",
58
+ "ollama"
59
+ ],
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
63
+ "directory": "packages/ai"
64
+ },
65
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/ai#readme",
66
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
67
+ }
package/src/AiFake.ts ADDED
@@ -0,0 +1,351 @@
1
+ import { Application, currentApp } from "@zerotal/core";
2
+ import { AiRefusedError } from "./errors.ts";
3
+ import { normalizeMessages, promptText, type DriverStatus } from "./drivers/AiDriver.ts";
4
+ import type { AiAgentRequest, AiQueueHandler, AiQueueOptions } from "./AiManager.ts";
5
+ import type {
6
+ AiAgentResult,
7
+ AiEmbedRequest,
8
+ AiEmbedResponse,
9
+ AiRequest,
10
+ AiResponse,
11
+ AiStreamChunk,
12
+ AiUsage,
13
+ } from "./types.ts";
14
+
15
+ type Binding = unknown;
16
+
17
+ /** One recorded call. */
18
+ export interface CapturedGeneration {
19
+ /** `"text" | "stream" | "object" | "agent" | "embed" | "queue"`. */
20
+ operation: string;
21
+ /** The last user turn, unredacted — this is a test, and it is in memory. */
22
+ prompt: string;
23
+ system: string | undefined;
24
+ request: AiRequest;
25
+ }
26
+
27
+ /** What the fake returns when nothing more specific was queued. */
28
+ const DEFAULT_TEXT = "This is a fake AI response.";
29
+
30
+ const NO_USAGE: AiUsage = {
31
+ inputTokens: 0,
32
+ outputTokens: 0,
33
+ cacheReadTokens: 0,
34
+ cacheWriteTokens: 0,
35
+ };
36
+
37
+ /**
38
+ * Drop-in replacement for {@link AiManager} that records prompts and answers
39
+ * from a script, instead of calling a provider.
40
+ *
41
+ * This is the piece that makes an AI feature testable at all: the assertions are
42
+ * about *what your application asked for*, which is the part you wrote and the
43
+ * part that can be wrong. Whether the model's prose is good is not a unit test.
44
+ *
45
+ * @example
46
+ * const ai = AiFake.install();
47
+ * ai.respondWith("A one-sentence summary.");
48
+ *
49
+ * await service.summarize(article);
50
+ *
51
+ * ai.assertPrompted(/Summarize/);
52
+ * ai.assertPromptCount(1);
53
+ * ai.restore(); // in afterEach
54
+ */
55
+ export class AiFake {
56
+ private readonly _calls: CapturedGeneration[] = [];
57
+ private readonly _texts: string[] = [];
58
+ private readonly _objects: unknown[] = [];
59
+ private readonly _handlers = new Map<string, AiQueueHandler>();
60
+ private _refuseWith: { category: string | null; explanation: string | null } | undefined;
61
+
62
+ private constructor(
63
+ private readonly _app: Application,
64
+ private readonly _original: Binding,
65
+ ) {}
66
+
67
+ /** Replace the `ai` container binding with this fake. */
68
+ static install(): AiFake {
69
+ const app = currentApp();
70
+ const original = app.container.registry.get("ai");
71
+ const fake = new AiFake(app, original);
72
+ app.container.value("ai", fake);
73
+ return fake;
74
+ }
75
+
76
+ /** Restore the original `ai` binding. Call in `afterEach`. */
77
+ restore(): void {
78
+ if (this._original !== undefined) {
79
+ this._app.container.registry.set("ai", this._original as never);
80
+ } else {
81
+ this._app.container.registry.delete("ai");
82
+ }
83
+ }
84
+
85
+ // ── Scripting ────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Queue one or more text answers, returned in order. The last one repeats
89
+ * once the queue is exhausted, so a test that does not care how many calls
90
+ * happen does not have to count them.
91
+ */
92
+ respondWith(...texts: string[]): this {
93
+ this._texts.push(...texts);
94
+ return this;
95
+ }
96
+
97
+ /** Queue one or more answers for `object()`, returned in order. */
98
+ respondWithObject(...objects: unknown[]): this {
99
+ this._objects.push(...objects);
100
+ return this;
101
+ }
102
+
103
+ /**
104
+ * Make the next call refuse, as the provider's safety classifiers would.
105
+ *
106
+ * Worth testing deliberately: a refusal is an HTTP 200 with no content, so the
107
+ * handling path is the one most likely to have never run.
108
+ */
109
+ refuse(category: string | null = "cyber", explanation: string | null = null): this {
110
+ this._refuseWith = { category, explanation };
111
+ return this;
112
+ }
113
+
114
+ // ── The AiManager surface ────────────────────────────────────────────────
115
+
116
+ async text(request: AiRequest | string): Promise<string> {
117
+ return (await this.generate(request)).text;
118
+ }
119
+
120
+ async generate(request: AiRequest | string): Promise<AiResponse> {
121
+ const normalized = capture(request);
122
+ this._record("text", normalized);
123
+ this._maybeRefuse();
124
+
125
+ const text = this._nextText();
126
+ return {
127
+ text,
128
+ model: "fake",
129
+ usage: NO_USAGE,
130
+ stopReason: "end_turn",
131
+ toolCalls: [],
132
+ assistantTurn: { role: "assistant", content: text },
133
+ };
134
+ }
135
+
136
+ async *stream(request: AiRequest | string): AsyncIterable<AiStreamChunk> {
137
+ const normalized = capture(request);
138
+ this._record("stream", normalized);
139
+ this._maybeRefuse();
140
+
141
+ const text = this._nextText();
142
+ // Word by word, because a caller that only ever sees one chunk is not
143
+ // actually exercising its accumulation.
144
+ for (const word of text.split(/(\s+)/)) {
145
+ if (word) yield { type: "text", text: word };
146
+ }
147
+
148
+ yield {
149
+ type: "done",
150
+ response: {
151
+ text,
152
+ model: "fake",
153
+ usage: NO_USAGE,
154
+ stopReason: "end_turn",
155
+ toolCalls: [],
156
+ assistantTurn: { role: "assistant", content: text },
157
+ },
158
+ };
159
+ }
160
+
161
+ async object<T = Record<string, unknown>>(request: AiRequest | string): Promise<T> {
162
+ const normalized = capture(request);
163
+ this._record("object", normalized);
164
+ this._maybeRefuse();
165
+
166
+ if (this._objects.length === 0) {
167
+ throw new Error(
168
+ "[Zerotal/ai] AiFake had no scripted object to return. Call " +
169
+ "ai.respondWithObject({ … }) before the code under test runs.",
170
+ );
171
+ }
172
+ return (this._objects.length > 1 ? this._objects.shift() : this._objects[0]) as T;
173
+ }
174
+
175
+ async agent(request: AiAgentRequest): Promise<AiAgentResult> {
176
+ this._record("agent", request);
177
+ this._maybeRefuse();
178
+
179
+ return {
180
+ text: this._nextText(),
181
+ model: "fake",
182
+ usage: NO_USAGE,
183
+ steps: [],
184
+ stopReason: "end_turn",
185
+ };
186
+ }
187
+
188
+ async embed(
189
+ input: string | string[],
190
+ _options: Omit<AiEmbedRequest, "input"> = {},
191
+ ): Promise<AiEmbedResponse> {
192
+ const inputs = Array.isArray(input) ? input : [input];
193
+ this._record("embed", { prompt: inputs.join("\n") });
194
+
195
+ // A deterministic, non-zero vector: a test asserting "these two differ"
196
+ // should pass, and one asserting a specific value should not.
197
+ return {
198
+ embeddings: inputs.map((value) => [value.length, value.charCodeAt(0) || 0, 0]),
199
+ model: "fake",
200
+ usage: { inputTokens: 0 },
201
+ };
202
+ }
203
+
204
+ async countTokens(request: AiRequest | string): Promise<number> {
205
+ const normalized = capture(request);
206
+ return Math.ceil(promptText(normalized).length / 4);
207
+ }
208
+
209
+ async verify(): Promise<DriverStatus> {
210
+ return { ok: true, model: "fake", detail: "AiFake is installed — no provider was contacted." };
211
+ }
212
+
213
+ onGenerated(name: string, handler: AiQueueHandler): this {
214
+ this._handlers.set(name, handler);
215
+ return this;
216
+ }
217
+
218
+ handlerFor(name: string): AiQueueHandler | undefined {
219
+ return this._handlers.get(name);
220
+ }
221
+
222
+ /** Records the call and runs the handler inline — no worker, no queue driver. */
223
+ async queue(request: AiRequest, options: AiQueueOptions): Promise<void> {
224
+ this._record("queue", request);
225
+ const handler = this._handlers.get(options.handler);
226
+ if (handler) await handler(await this.generate(request), options.meta ?? {});
227
+ }
228
+
229
+ drivers(): string[] {
230
+ return ["fake"];
231
+ }
232
+
233
+ // ── Assertions ───────────────────────────────────────────────────────────
234
+
235
+ /** Every recorded call, in order. */
236
+ get calls(): CapturedGeneration[] {
237
+ return [...this._calls];
238
+ }
239
+
240
+ /** Just the prompts, in order. */
241
+ get prompts(): string[] {
242
+ return this._calls.map((call) => call.prompt);
243
+ }
244
+
245
+ /**
246
+ * Assert some prompt matched.
247
+ *
248
+ * @param expected - A substring, a regular expression, or a predicate.
249
+ */
250
+ assertPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
251
+ const matches = this._calls.some((call) => matchPrompt(call.prompt, expected));
252
+ if (matches) return;
253
+
254
+ throw new Error(
255
+ `[Zerotal/ai] Expected a prompt matching ${describe(expected)}, but ` +
256
+ (this._calls.length === 0
257
+ ? "nothing was prompted."
258
+ : `the ${this._calls.length} prompt(s) were:\n` +
259
+ this.prompts.map((p) => ` - ${truncate(p)}`).join("\n")),
260
+ );
261
+ }
262
+
263
+ /** Assert no prompt matched. */
264
+ assertNotPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
265
+ const match = this._calls.find((call) => matchPrompt(call.prompt, expected));
266
+ if (!match) return;
267
+ throw new Error(
268
+ `[Zerotal/ai] Expected no prompt matching ${describe(expected)}, but found: ${truncate(match.prompt)}`,
269
+ );
270
+ }
271
+
272
+ /** Assert the system prompt of some call matched. */
273
+ assertSystemPrompted(expected: string | RegExp | ((prompt: string) => boolean)): void {
274
+ const matches = this._calls.some(
275
+ (call) => call.system !== undefined && matchPrompt(call.system, expected),
276
+ );
277
+ if (matches) return;
278
+ throw new Error(
279
+ `[Zerotal/ai] Expected a system prompt matching ${describe(expected)}, but ` +
280
+ `none of the ${this._calls.length} call(s) had one that did.`,
281
+ );
282
+ }
283
+
284
+ /** Assert exactly `count` generations happened. */
285
+ assertPromptCount(count: number): void {
286
+ if (this._calls.length === count) return;
287
+ throw new Error(
288
+ `[Zerotal/ai] Expected ${count} generation(s), got ${this._calls.length}.` +
289
+ (this._calls.length > 0
290
+ ? `\n${this._calls.map((c) => ` - ${c.operation}: ${truncate(c.prompt)}`).join("\n")}`
291
+ : ""),
292
+ );
293
+ }
294
+
295
+ /** Assert nothing was generated. */
296
+ assertNothingPrompted(): void {
297
+ this.assertPromptCount(0);
298
+ }
299
+
300
+ // ── Internals ────────────────────────────────────────────────────────────
301
+
302
+ private _record(operation: string, request: Partial<AiRequest> & { prompt?: string }): void {
303
+ const full = request as AiRequest;
304
+ this._calls.push({
305
+ operation,
306
+ prompt: promptText(full) || (request.prompt ?? ""),
307
+ system: full.system,
308
+ request: full,
309
+ });
310
+ }
311
+
312
+ private _nextText(): string {
313
+ if (this._texts.length === 0) return DEFAULT_TEXT;
314
+ return this._texts.length > 1 ? this._texts.shift()! : this._texts[0]!;
315
+ }
316
+
317
+ private _maybeRefuse(): void {
318
+ if (!this._refuseWith) return;
319
+ const { category, explanation } = this._refuseWith;
320
+ this._refuseWith = undefined;
321
+ throw new AiRefusedError(category, explanation);
322
+ }
323
+ }
324
+
325
+ // ── Helpers ─────────────────────────────────────────────────────────────────
326
+
327
+ function capture(request: AiRequest | string): AiRequest {
328
+ return typeof request === "string" ? { prompt: request } : request;
329
+ }
330
+
331
+ function matchPrompt(
332
+ prompt: string,
333
+ expected: string | RegExp | ((prompt: string) => boolean),
334
+ ): boolean {
335
+ if (typeof expected === "function") return expected(prompt);
336
+ if (expected instanceof RegExp) return expected.test(prompt);
337
+ return prompt.includes(expected);
338
+ }
339
+
340
+ function describe(expected: string | RegExp | ((prompt: string) => boolean)): string {
341
+ if (typeof expected === "function") return "the given predicate";
342
+ if (expected instanceof RegExp) return String(expected);
343
+ return `"${expected}"`;
344
+ }
345
+
346
+ function truncate(text: string, limit = 120): string {
347
+ return text.length > limit ? `${text.slice(0, limit)}…` : text;
348
+ }
349
+
350
+ /** Re-exported so a fake's caller can normalise messages the same way. @internal */
351
+ export { normalizeMessages };