@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.
@@ -0,0 +1,76 @@
1
+ import { Job, JobRegistry } from "@zerotal/queue";
2
+ import type { AiManager, AiQueueOptions } from "./AiManager.ts";
3
+ import type { AiRequest } from "./types.ts";
4
+
5
+ /** The wire form. Tools and signals cannot cross a queue, so they are dropped. */
6
+ interface QueuedGeneration {
7
+ request: AiRequest;
8
+ handler: string;
9
+ meta: Record<string, unknown>;
10
+ }
11
+
12
+ /**
13
+ * Background job behind `Ai.queue()`.
14
+ *
15
+ * Only the serializable half of a request survives the trip — `tools` carry
16
+ * function handlers and `signal` is a live object, so both are stripped at
17
+ * dispatch rather than silently arriving as `undefined` on the worker. A queued
18
+ * generation is a one-shot completion, not an agent run; `Ai.agent()` stays
19
+ * in-process where its tools are.
20
+ *
21
+ * @internal
22
+ */
23
+ export class AiGenerationJob extends Job {
24
+ override readonly queue: string;
25
+
26
+ private readonly _data: QueuedGeneration;
27
+
28
+ constructor(request: AiRequest, options: AiQueueOptions) {
29
+ super();
30
+ this.queue = options.queue ?? "ai";
31
+ this._data = {
32
+ request: serializableRequest(request),
33
+ handler: options.handler,
34
+ meta: options.meta ?? {},
35
+ };
36
+ }
37
+
38
+ override payload(): Record<string, unknown> {
39
+ return { ...this._data };
40
+ }
41
+
42
+ static fromPayload(data: Record<string, unknown>): AiGenerationJob {
43
+ // Field by field rather than one cast over the whole record: this is the
44
+ // boundary where a stored payload becomes typed, and naming each key makes
45
+ // it obvious what an older or hand-edited row is allowed to be missing.
46
+ return new AiGenerationJob((data["request"] ?? {}) as AiRequest, {
47
+ handler: String(data["handler"] ?? ""),
48
+ meta: (data["meta"] as Record<string, unknown> | undefined) ?? {},
49
+ });
50
+ }
51
+
52
+ async handle(): Promise<void> {
53
+ const { currentApp } = await import("@zerotal/core");
54
+ const ai = currentApp().container.makeSync("ai") as AiManager;
55
+
56
+ const handler = ai.handlerFor(this._data.handler);
57
+ if (!handler) {
58
+ throw new Error(
59
+ `[Zerotal/ai] This worker has no queued-generation handler named ` +
60
+ `'${this._data.handler}'. Register it in a service provider so the worker process ` +
61
+ `sees it too — registering it in a route only reaches the web process.`,
62
+ );
63
+ }
64
+
65
+ const response = await ai.generate(this._data.request);
66
+ await handler(response, this._data.meta);
67
+ }
68
+ }
69
+
70
+ /** Strip everything that cannot survive JSON. */
71
+ function serializableRequest(request: AiRequest): AiRequest {
72
+ const { tools: _tools, signal: _signal, ...rest } = request;
73
+ return rest;
74
+ }
75
+
76
+ JobRegistry.register(AiGenerationJob);
@@ -0,0 +1,608 @@
1
+ import { FrameworkEvents } from "@zerotal/core";
2
+ import { runAgentLoop } from "./agentLoop.ts";
3
+ import { AnthropicDriver } from "./drivers/AnthropicDriver.ts";
4
+ import { OllamaDriver } from "./drivers/OllamaDriver.ts";
5
+ import { OpenAiDriver } from "./drivers/OpenAiDriver.ts";
6
+ import { OllamaEmbeddingsDriver } from "./drivers/embeddings/OllamaEmbeddingsDriver.ts";
7
+ import { OpenAiEmbeddingsDriver } from "./drivers/embeddings/OpenAiEmbeddingsDriver.ts";
8
+ import type { EmbeddingsDriver } from "./drivers/embeddings/EmbeddingsDriver.ts";
9
+ import type { AiDriver, DriverStatus } from "./drivers/AiDriver.ts";
10
+ import { normalizeMessages, promptText } from "./drivers/AiDriver.ts";
11
+ import { AiCancelledError, AiConfigError, AiRefusedError, UnknownAiDriverError } from "./errors.ts";
12
+ import { AiGenerated, AiRefused } from "./events.ts";
13
+ import { estimateCost } from "./pricing.ts";
14
+ import { redactPrompt } from "./redact.ts";
15
+ import type { SchemaInput } from "./schema.ts";
16
+ import { assertWithinLimits, recordSpend } from "./spend.ts";
17
+ import type {
18
+ AiAgentResult,
19
+ AiConfigShape,
20
+ AiEmbedRequest,
21
+ AiEmbedResponse,
22
+ AiRequest,
23
+ AiResponse,
24
+ AiStreamChunk,
25
+ AiUsage,
26
+ } from "./types.ts";
27
+
28
+ /** A generation driver factory, resolved once on first use. */
29
+ type DriverResolver = () => AiDriver;
30
+ /** An embeddings driver factory, resolved once on first use. */
31
+ type EmbeddingsResolver = () => EmbeddingsDriver;
32
+
33
+ /** An agent run, plus the two things only the caller can decide. */
34
+ export interface AiAgentRequest extends AiRequest {
35
+ /**
36
+ * Name this run to hold a lock for its duration.
37
+ *
38
+ * Opt-in and *named*, because a lock is only meaningful when it identifies the
39
+ * work: `lock: "triage:invoice-4821"` stops two workers triaging the same
40
+ * invoice, while a shared key would serialize every agent run in the app.
41
+ *
42
+ * The lock refreshes while the loop runs — a multi-minute turn is exactly the
43
+ * process a fixed TTL cannot size for — and the loop's signal is aborted if it
44
+ * is ever lost, because at that point another holder may be doing the same work.
45
+ */
46
+ lock?: string;
47
+ /** Override `agent.maxSteps` for this run. */
48
+ maxSteps?: number;
49
+ /** Override `agent.maxResumes` for this run. */
50
+ maxResumes?: number;
51
+ }
52
+
53
+ /** What `Ai.queue()` needs beyond the request. */
54
+ export interface AiQueueOptions {
55
+ /** The handler name registered with {@link AiManager.onGenerated}. */
56
+ handler: string;
57
+ /** Anything the handler needs to know — an id, a tenant. Must be JSON-safe. */
58
+ meta?: Record<string, unknown>;
59
+ /** Queue name. Defaults to `ai`. */
60
+ queue?: string;
61
+ }
62
+
63
+ /** What a queued generation's handler receives. */
64
+ export type AiQueueHandler = (
65
+ response: AiResponse,
66
+ meta: Record<string, unknown>,
67
+ ) => Promise<void> | void;
68
+
69
+ /** Handlers for queued generations, by name. Module-level so the worker finds them. */
70
+ const _handlers = new Map<string, AiQueueHandler>();
71
+
72
+ /**
73
+ * The AI manager: one surface over every configured provider.
74
+ *
75
+ * Everything that is *not* provider-specific lives here rather than in the
76
+ * drivers — spend ceilings, prompt redaction, telemetry, the agent lock — so a
77
+ * new driver is a translation layer and nothing more. That is the difference
78
+ * between an abstraction and a pile of clients.
79
+ */
80
+ export class AiManager {
81
+ private readonly _resolvers = new Map<string, DriverResolver>();
82
+ private readonly _drivers = new Map<string, AiDriver>();
83
+ private readonly _embeddingResolvers = new Map<string, EmbeddingsResolver>();
84
+ private readonly _embeddings = new Map<string, EmbeddingsDriver>();
85
+
86
+ constructor(readonly config: AiConfigShape) {
87
+ const { drivers, embeddings } = config;
88
+
89
+ if (drivers.anthropic) {
90
+ const cfg = drivers.anthropic;
91
+ this._resolvers.set("anthropic", () => new AnthropicDriver(cfg));
92
+ }
93
+ if (drivers.openai) {
94
+ const cfg = drivers.openai;
95
+ this._resolvers.set("openai", () => new OpenAiDriver(cfg));
96
+ }
97
+ if (drivers.ollama) {
98
+ const cfg = drivers.ollama;
99
+ this._resolvers.set("ollama", () => new OllamaDriver(cfg));
100
+ }
101
+
102
+ if (embeddings.drivers.openai) {
103
+ const cfg = embeddings.drivers.openai;
104
+ this._embeddingResolvers.set("openai", () => new OpenAiEmbeddingsDriver(cfg));
105
+ }
106
+ if (embeddings.drivers.ollama) {
107
+ const cfg = embeddings.drivers.ollama;
108
+ this._embeddingResolvers.set("ollama", () => new OllamaEmbeddingsDriver(cfg));
109
+ }
110
+ }
111
+
112
+ // ── Drivers ──────────────────────────────────────────────────────────────
113
+
114
+ /** Every configured generation driver name. */
115
+ drivers(): string[] {
116
+ return [...this._resolvers.keys()];
117
+ }
118
+
119
+ /**
120
+ * Register a custom provider, or replace a built-in one.
121
+ *
122
+ * @example
123
+ * // in a service provider's onBooted()
124
+ * const ai = app.container.makeSync("ai");
125
+ * ai.extend("bedrock", () => new BedrockDriver(config));
126
+ */
127
+ extend(name: string, factory: DriverResolver): this {
128
+ this._resolvers.set(name, factory);
129
+ this._drivers.delete(name);
130
+ return this;
131
+ }
132
+
133
+ /** Register a custom embeddings provider. */
134
+ extendEmbeddings(name: string, factory: EmbeddingsResolver): this {
135
+ this._embeddingResolvers.set(name, factory);
136
+ this._embeddings.delete(name);
137
+ return this;
138
+ }
139
+
140
+ /** Resolve a generation driver, constructing it on first use. */
141
+ driver(name?: string): AiDriver {
142
+ const key = name ?? this.config.default;
143
+ const existing = this._drivers.get(key);
144
+ if (existing) return existing;
145
+
146
+ const resolver = this._resolvers.get(key);
147
+ if (!resolver) throw new UnknownAiDriverError(key, [...this._resolvers.keys()]);
148
+
149
+ const instance = resolver();
150
+ this._drivers.set(key, instance);
151
+ return instance;
152
+ }
153
+
154
+ /** Resolve an embeddings driver, constructing it on first use. */
155
+ embeddingsDriver(name?: string): EmbeddingsDriver {
156
+ const key = name ?? this.config.embeddings.default;
157
+ const existing = this._embeddings.get(key);
158
+ if (existing) return existing;
159
+
160
+ const resolver = this._embeddingResolvers.get(key);
161
+ if (!resolver) {
162
+ throw new AiConfigError(
163
+ `No embeddings driver '${key}' is configured. Anthropic has no embeddings endpoint, so ` +
164
+ `embeddings need their own block: embeddings: { default: 'openai', drivers: { openai: … } }.`,
165
+ { driver: key, configured: [...this._embeddingResolvers.keys()] },
166
+ );
167
+ }
168
+
169
+ const instance = resolver();
170
+ this._embeddings.set(key, instance);
171
+ return instance;
172
+ }
173
+
174
+ // ── Generation ───────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Generate text and return just the text.
178
+ *
179
+ * @example
180
+ * const summary = await Ai.text(`Summarize in one sentence:\n\n${article}`);
181
+ */
182
+ async text(request: AiRequest | string): Promise<string> {
183
+ return (await this.generate(request)).text;
184
+ }
185
+
186
+ /**
187
+ * Generate text and return the full response — usage, stop reason, tool calls.
188
+ *
189
+ * @example
190
+ * const response = await Ai.generate({ prompt, effort: "low" });
191
+ * metrics.increment("ai.tokens", response.usage.outputTokens);
192
+ */
193
+ async generate(request: AiRequest | string): Promise<AiResponse> {
194
+ const normalized = normalize(request);
195
+ const driver = this.driver(normalized.driver);
196
+
197
+ return this._observe("text", driver, normalized, async () => {
198
+ await this._guardSpend(driver, normalized);
199
+ return driver.text(normalized);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Stream a generation, token by token.
205
+ *
206
+ * The final chunk is always `{ type: "done" }`, carrying the assembled
207
+ * response — so a caller that only wants the tokens can ignore it, and one
208
+ * that needs usage does not have to add up the pieces itself.
209
+ *
210
+ * @example
211
+ * for await (const chunk of Ai.stream({ prompt, signal: this.signal })) {
212
+ * if (chunk.type === "text") this.answer += chunk.text;
213
+ * }
214
+ */
215
+ async *stream(request: AiRequest | string): AsyncIterable<AiStreamChunk> {
216
+ const normalized = normalize(request);
217
+ const driver = this.driver(normalized.driver);
218
+ const startedAt = performance.now();
219
+ let recorded = false;
220
+
221
+ try {
222
+ await this._guardSpend(driver, normalized);
223
+
224
+ for await (const chunk of driver.stream(normalized)) {
225
+ if (chunk.type === "done") {
226
+ recorded = true;
227
+ this._record("stream", driver, normalized, chunk.response, startedAt);
228
+ }
229
+ yield chunk;
230
+ }
231
+ } catch (error) {
232
+ recorded = true;
233
+ this._recordFailure("stream", driver, normalized, error, startedAt);
234
+ throw error;
235
+ } finally {
236
+ // A consumer that `break`s out — a cancelled Flow task, a closed tab —
237
+ // never sees the done chunk, and the tokens generated so far were still
238
+ // paid for. Recording it here is the difference between "cancelled
239
+ // streams are cheap" being true and merely being invisible.
240
+ if (!recorded) {
241
+ this._recordFailure("stream", driver, normalized, new AiCancelledError(), startedAt);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Generate a value that satisfies a validator schema.
248
+ *
249
+ * Constraints structured output cannot express (`min`, `regex`, …) are
250
+ * stripped from the schema sent to the provider and re-checked here, against
251
+ * the same schema — see `schema.ts` for why that is the deal.
252
+ *
253
+ * @example
254
+ * const review = await Ai.object(
255
+ * { prompt: `Classify this review:\n\n${text}` },
256
+ * (rule) => ({
257
+ * sentiment: rule.string().in(["positive", "neutral", "negative"]),
258
+ * summary: rule.string().max(140),
259
+ * score: rule.number().min(1).max(5),
260
+ * }),
261
+ * );
262
+ */
263
+ async object<T = Record<string, unknown>>(
264
+ request: AiRequest | string,
265
+ schema: SchemaInput | ((rule: import("@zerotal/validator").RuleBuilder) => SchemaInput),
266
+ ): Promise<T> {
267
+ const normalized = normalize(request);
268
+ const driver = this.driver(normalized.driver);
269
+ const resolved = await resolveSchema(schema);
270
+ const startedAt = performance.now();
271
+
272
+ try {
273
+ await this._guardSpend(driver, normalized);
274
+ const result = await driver.object<T>(normalized, resolved);
275
+ this._record(
276
+ "object",
277
+ driver,
278
+ normalized,
279
+ { model: result.model, usage: result.usage },
280
+ startedAt,
281
+ );
282
+ return result.object;
283
+ } catch (error) {
284
+ this._recordFailure("object", driver, normalized, error, startedAt);
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Run the tool-calling loop until the model stops asking for tools.
291
+ *
292
+ * Pass `lock` to make the run exclusive for its own name; the lock refreshes
293
+ * for as long as the loop runs, and the loop stops if it is ever lost.
294
+ *
295
+ * @example
296
+ * const result = await Ai.agent({
297
+ * prompt: "Refund order 4821 if it shipped over 30 days ago.",
298
+ * tools: [lookupOrder, issueRefund],
299
+ * lock: "refund:4821",
300
+ * });
301
+ */
302
+ async agent(request: AiAgentRequest): Promise<AiAgentResult> {
303
+ const driver = this.driver(request.driver);
304
+ const startedAt = performance.now();
305
+
306
+ const options = {
307
+ maxSteps: request.maxSteps ?? this.config.agent.maxSteps,
308
+ maxResumes: request.maxResumes ?? this.config.agent.maxResumes,
309
+ signal: request.signal ?? new AbortController().signal,
310
+ };
311
+
312
+ const run = async (signal: AbortSignal): Promise<AiAgentResult> => {
313
+ await this._guardSpend(driver, request);
314
+ const loop = driver.agent ?? ((r, o) => runAgentLoop(driver, r, o));
315
+ return loop(request, { ...options, signal });
316
+ };
317
+
318
+ try {
319
+ const result = await this._withLock(request, options.signal, run);
320
+ this._record("agent", driver, request, result, startedAt);
321
+ return result;
322
+ } catch (error) {
323
+ this._recordFailure("agent", driver, request, error, startedAt);
324
+ throw error;
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Embed text into vectors.
330
+ *
331
+ * @example
332
+ * const { embeddings } = await Ai.embed(["first chunk", "second chunk"]);
333
+ */
334
+ async embed(
335
+ input: string | string[],
336
+ options: Omit<AiEmbedRequest, "input"> = {},
337
+ ): Promise<AiEmbedResponse> {
338
+ const driver = this.embeddingsDriver(options.driver);
339
+ const startedAt = performance.now();
340
+
341
+ const result = await driver.embed({ input, ...options });
342
+
343
+ FrameworkEvents.emit(
344
+ new AiGenerated(
345
+ driver.name,
346
+ result.model,
347
+ "embed",
348
+ result.usage.inputTokens,
349
+ 0,
350
+ 0,
351
+ performance.now() - startedAt,
352
+ 0,
353
+ true,
354
+ `${Array.isArray(input) ? input.length : 1} input(s)`,
355
+ ),
356
+ );
357
+
358
+ return result;
359
+ }
360
+
361
+ /** Count the prompt's tokens with the provider's own tokenizer. */
362
+ async countTokens(request: AiRequest | string): Promise<number> {
363
+ const normalized = normalize(request);
364
+ return this.driver(normalized.driver).countTokens(normalized);
365
+ }
366
+
367
+ /** Reach a provider once and report what came back. Backs `zt ai:test`. */
368
+ async verify(name?: string): Promise<DriverStatus> {
369
+ return this.driver(name).verify();
370
+ }
371
+
372
+ // ── Queued generations ───────────────────────────────────────────────────
373
+
374
+ /**
375
+ * Register a handler for queued generations.
376
+ *
377
+ * Named rather than passed inline because a queued job is serialized: a
378
+ * closure cannot survive the trip to a worker process, but a name can.
379
+ *
380
+ * @example
381
+ * Ai.onGenerated("summarize-ticket", async (response, meta) => {
382
+ * await Ticket.query().where("id", meta.ticketId).update({ summary: response.text });
383
+ * });
384
+ */
385
+ onGenerated(name: string, handler: AiQueueHandler): this {
386
+ _handlers.set(name, handler);
387
+ return this;
388
+ }
389
+
390
+ /** The handler registered under `name`, if any. @internal */
391
+ handlerFor(name: string): AiQueueHandler | undefined {
392
+ return _handlers.get(name);
393
+ }
394
+
395
+ /**
396
+ * Run a generation in the background, then call a registered handler.
397
+ *
398
+ * @example
399
+ * await Ai.queue({ prompt }, { handler: "summarize-ticket", meta: { ticketId } });
400
+ */
401
+ async queue(request: AiRequest, options: AiQueueOptions): Promise<void> {
402
+ if (!_handlers.has(options.handler)) {
403
+ throw new AiConfigError(
404
+ `No queued-generation handler named '${options.handler}'. Register one with ` +
405
+ `Ai.onGenerated("${options.handler}", …) — in a service provider, so the worker ` +
406
+ `process registers it too.`,
407
+ { handler: options.handler, registered: [..._handlers.keys()] },
408
+ );
409
+ }
410
+
411
+ const { AiGenerationJob } = await import("./AiGenerationJob.ts");
412
+ const { Queue } = await import("@zerotal/queue");
413
+ await Queue.dispatch(new AiGenerationJob(request, options));
414
+ }
415
+
416
+ // ── Internals ────────────────────────────────────────────────────────────
417
+
418
+ /**
419
+ * Hold the run's lock, if it named one, refreshing it throughout.
420
+ *
421
+ * Locking is skipped silently when `LockProvider` is not registered — an app
422
+ * that never configured locks should not have its agent calls fail on a
423
+ * dependency it did not ask for.
424
+ */
425
+ private async _withLock<T>(
426
+ request: AiAgentRequest,
427
+ signal: AbortSignal,
428
+ run: (signal: AbortSignal) => Promise<T>,
429
+ ): Promise<T> {
430
+ if (!request.lock || !this.config.agent.lock) return run(signal);
431
+
432
+ const manager = await lockManager();
433
+ if (!manager) return run(signal);
434
+
435
+ return manager.try(
436
+ `ai:agent:${request.lock}`,
437
+ this.config.agent.lockTtl,
438
+ // Two signals, one abort: the caller's cancellation and the lost-lock
439
+ // signal both have to stop the loop, and only one can be passed down.
440
+ (_lock, lostSignal) => run(anySignal([signal, lostSignal])),
441
+ { refresh: true },
442
+ );
443
+ }
444
+
445
+ /**
446
+ * Refuse a request that would breach a ceiling, before it is sent.
447
+ *
448
+ * The token count costs a round trip, so it is only taken when a per-request
449
+ * ceiling actually exists; with only a daily ceiling configured the check is
450
+ * free. A driver with no counting endpoint reports 0 and gets a labelled
451
+ * character approximation — good enough to bound spend, never used for billing.
452
+ */
453
+ private async _guardSpend(driver: AiDriver, request: AiRequest): Promise<void> {
454
+ const { limits } = this.config;
455
+ if (limits.perRequestUsd <= 0 && limits.perDayUsd <= 0) return;
456
+
457
+ const model = request.model ?? driver.model;
458
+ let inputTokens = 0;
459
+
460
+ if (limits.perRequestUsd > 0) {
461
+ inputTokens = await driver.countTokens(request).catch(() => 0);
462
+ if (inputTokens === 0) inputTokens = approximateTokens(request);
463
+ }
464
+
465
+ const maxTokens = request.maxTokens ?? this.config.drivers.anthropic?.maxTokens ?? 16000;
466
+ assertWithinLimits(limits, model, inputTokens, maxTokens);
467
+ }
468
+
469
+ /** Run one operation, recording success or failure exactly once. */
470
+ private async _observe<T extends { model: string; usage: AiUsage }>(
471
+ operation: string,
472
+ driver: AiDriver,
473
+ request: AiRequest,
474
+ fn: () => Promise<T>,
475
+ ): Promise<T> {
476
+ const startedAt = performance.now();
477
+ try {
478
+ const result = await fn();
479
+ this._record(operation, driver, request, result, startedAt);
480
+ return result;
481
+ } catch (error) {
482
+ this._recordFailure(operation, driver, request, error, startedAt);
483
+ throw error;
484
+ }
485
+ }
486
+
487
+ private _record(
488
+ operation: string,
489
+ driver: AiDriver,
490
+ request: AiRequest,
491
+ result: { model: string; usage: AiUsage },
492
+ startedAt: number,
493
+ ): void {
494
+ const cost = recordSpend(result.model, result.usage);
495
+ FrameworkEvents.emit(
496
+ new AiGenerated(
497
+ driver.name,
498
+ result.model,
499
+ operation,
500
+ result.usage.inputTokens,
501
+ result.usage.outputTokens,
502
+ result.usage.cacheReadTokens,
503
+ performance.now() - startedAt,
504
+ cost,
505
+ true,
506
+ this._preview(request),
507
+ ),
508
+ );
509
+ }
510
+
511
+ private _recordFailure(
512
+ operation: string,
513
+ driver: AiDriver,
514
+ request: AiRequest,
515
+ error: unknown,
516
+ startedAt: number,
517
+ ): void {
518
+ const model = request.model ?? driver.model;
519
+ const preview = this._preview(request);
520
+
521
+ if (error instanceof AiRefusedError) {
522
+ FrameworkEvents.emit(new AiRefused(driver.name, model, error.category, preview));
523
+ }
524
+
525
+ FrameworkEvents.emit(
526
+ new AiGenerated(
527
+ driver.name,
528
+ model,
529
+ operation,
530
+ 0,
531
+ 0,
532
+ 0,
533
+ performance.now() - startedAt,
534
+ 0,
535
+ false,
536
+ preview,
537
+ error instanceof Error ? error.message : String(error),
538
+ ),
539
+ );
540
+ }
541
+
542
+ /** The telemetry label for a request — redacted unless the app opted out. */
543
+ private _preview(request: AiRequest): string {
544
+ return redactPrompt(promptText(request), this.config.redact);
545
+ }
546
+ }
547
+
548
+ // ── Helpers ─────────────────────────────────────────────────────────────────
549
+
550
+ /** A bare string is the common case; keep it a one-liner at every call site. */
551
+ function normalize(request: AiRequest | string): AiRequest {
552
+ return typeof request === "string" ? { prompt: request } : request;
553
+ }
554
+
555
+ /** Accept either a schema map or a `(rule) => schema` factory. */
556
+ async function resolveSchema(
557
+ schema: SchemaInput | ((rule: import("@zerotal/validator").RuleBuilder) => SchemaInput),
558
+ ): Promise<SchemaInput> {
559
+ if (typeof schema !== "function") return schema;
560
+ const { RuleBuilder } = await import("@zerotal/validator");
561
+ return schema(new RuleBuilder());
562
+ }
563
+
564
+ /** The `lock` binding, or `undefined` when the app has no LockProvider. */
565
+ async function lockManager(): Promise<import("@zerotal/core/lock").LockManager | undefined> {
566
+ try {
567
+ const { currentApp } = await import("@zerotal/core");
568
+ return currentApp().container.tryMake("lock");
569
+ } catch {
570
+ return undefined;
571
+ }
572
+ }
573
+
574
+ /**
575
+ * One signal that aborts when any of its inputs does.
576
+ *
577
+ * `AbortSignal.any` covers this in current runtimes; the manual fallback keeps
578
+ * a lost lock able to stop the loop on anything older.
579
+ */
580
+ function anySignal(signals: AbortSignal[]): AbortSignal {
581
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
582
+
583
+ const controller = new AbortController();
584
+ for (const signal of signals) {
585
+ if (signal.aborted) {
586
+ controller.abort(signal.reason);
587
+ break;
588
+ }
589
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
590
+ }
591
+ return controller.signal;
592
+ }
593
+
594
+ /**
595
+ * A rough token count for drivers with no counting endpoint.
596
+ *
597
+ * Four characters per token is the usual English approximation. It exists only
598
+ * to give the per-request ceiling something to compare against — never for
599
+ * billing, and never for a Claude model, where `countTokens` is exact.
600
+ */
601
+ function approximateTokens(request: AiRequest): number {
602
+ let characters = request.system?.length ?? 0;
603
+ for (const message of normalizeMessages(request)) characters += message.content.length;
604
+ return Math.ceil(characters / 4);
605
+ }
606
+
607
+ /** Re-exported so the monitor section can price a row without importing pricing. */
608
+ export { estimateCost };