@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,151 @@
1
+ import { FrameworkEvents } from "@zerotal/core";
2
+ import { AiAgentLimitError, AiCancelledError } from "./errors.ts";
3
+ import { AiToolCalled } from "./events.ts";
4
+ import { runTool } from "./tool.ts";
5
+ import type { AgentOptions, AiDriver } from "./drivers/AiDriver.ts";
6
+ import { normalizeMessages } from "./drivers/AiDriver.ts";
7
+ import type { AiAgentResult, AiAgentStep, AiMessage, AiRequest, AiUsage } from "./types.ts";
8
+
9
+ /**
10
+ * The tool-calling loop, written once and shared by every driver.
11
+ *
12
+ * A driver may supply its own `agent()` when the provider runs tools server-side,
13
+ * but none of the built-in three do — they all run *this*, which is the point:
14
+ * an abstraction whose second implementation reuses none of the first is not an
15
+ * abstraction, it is two clients sharing a type. Everything a provider actually
16
+ * differs on lives in `text()`.
17
+ *
18
+ * Two ceilings bound the loop, because a model deciding when to stop is not a
19
+ * termination proof:
20
+ *
21
+ * - **`maxSteps`** caps tool-calling round trips.
22
+ * - **`maxResumes`** caps `pause_turn` restarts — see below, this one bites.
23
+ *
24
+ * @internal
25
+ */
26
+ export async function runAgentLoop(
27
+ driver: AiDriver,
28
+ request: AiRequest,
29
+ options: AgentOptions,
30
+ ): Promise<AiAgentResult> {
31
+ const tools = request.tools ?? [];
32
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
33
+
34
+ const messages: AiMessage[] = [...normalizeMessages(request)];
35
+ const steps: AiAgentStep[] = [];
36
+ const usage: AiUsage = {
37
+ inputTokens: 0,
38
+ outputTokens: 0,
39
+ cacheReadTokens: 0,
40
+ cacheWriteTokens: 0,
41
+ };
42
+
43
+ let step = 0;
44
+ let resumes = 0;
45
+ let model = driver.model;
46
+
47
+ // `prompt` was folded into `messages` above; leaving it set would make the
48
+ // first turn arrive twice on every iteration.
49
+ const { prompt: _prompt, ...base } = request;
50
+
51
+ for (;;) {
52
+ if (options.signal.aborted) throw new AiCancelledError();
53
+
54
+ const response = await driver.text({ ...base, messages, tools, signal: options.signal });
55
+
56
+ model = response.model;
57
+ accumulate(usage, response.usage);
58
+ messages.push(response.assistantTurn);
59
+
60
+ // A paused turn is *not* an error and *not* a completion — it is the
61
+ // provider saying "ask me again". Left unhandled it reads as a finished
62
+ // answer, so the user sees a silently truncated response with no warning
63
+ // anywhere. Push the turn back and re-request.
64
+ if (response.stopReason === "pause_turn") {
65
+ if (++resumes > options.maxResumes) {
66
+ throw new AiAgentLimitError(
67
+ `The provider paused the turn ${resumes} times, over the ceiling of ${options.maxResumes}. ` +
68
+ `Raise agent.maxResumes in config/ai.ts if the work genuinely needs it.`,
69
+ { resumes, maxResumes: options.maxResumes },
70
+ );
71
+ }
72
+ continue;
73
+ }
74
+
75
+ if (response.stopReason !== "tool_use" || response.toolCalls.length === 0) {
76
+ return { text: response.text, model, usage, steps, stopReason: response.stopReason };
77
+ }
78
+
79
+ if (step >= options.maxSteps) {
80
+ throw new AiAgentLimitError(
81
+ `The agent made ${step} tool-calling round trips without finishing, hitting the ceiling of ` +
82
+ `${options.maxSteps}. Raise agent.maxSteps in config/ai.ts, or narrow the task.`,
83
+ { steps: step, maxSteps: options.maxSteps },
84
+ );
85
+ }
86
+ step++;
87
+
88
+ // Every call in one assistant turn is answered in one user turn. Splitting
89
+ // them across turns is accepted by the API and quietly teaches the model to
90
+ // stop asking for parallel calls.
91
+ const results = await Promise.all(
92
+ response.toolCalls.map(async (call) => {
93
+ const startedAt = performance.now();
94
+ const tool = byName.get(call.name);
95
+
96
+ if (!tool) {
97
+ // Not a crash: naming the mistake back to the model is how it recovers.
98
+ const known = [...byName.keys()].join(", ") || "none";
99
+ return {
100
+ id: call.id,
101
+ content: `No tool named '${call.name}' is available. Available tools: ${known}.`,
102
+ isError: true,
103
+ step,
104
+ call,
105
+ durationMs: performance.now() - startedAt,
106
+ };
107
+ }
108
+
109
+ const outcome = await runTool(tool, call.input, { signal: options.signal, step });
110
+ const durationMs = performance.now() - startedAt;
111
+
112
+ FrameworkEvents.emit(
113
+ new AiToolCalled(
114
+ driver.name,
115
+ call.name,
116
+ step,
117
+ durationMs,
118
+ !outcome.isError,
119
+ outcome.isError ? outcome.content : undefined,
120
+ ),
121
+ );
122
+
123
+ return { id: call.id, ...outcome, step, call, durationMs };
124
+ }),
125
+ );
126
+
127
+ for (const result of results) {
128
+ steps.push({
129
+ step: result.step,
130
+ call: result.call,
131
+ result: result.content,
132
+ isError: result.isError,
133
+ durationMs: result.durationMs,
134
+ });
135
+ }
136
+
137
+ messages.push({
138
+ role: "user",
139
+ content: "",
140
+ toolResults: results.map((r) => ({ id: r.id, content: r.content, isError: r.isError })),
141
+ });
142
+ }
143
+ }
144
+
145
+ /** Sum a response's usage into the running total. */
146
+ function accumulate(total: AiUsage, next: AiUsage): void {
147
+ total.inputTokens += next.inputTokens;
148
+ total.outputTokens += next.outputTokens;
149
+ total.cacheReadTokens += next.cacheReadTokens;
150
+ total.cacheWriteTokens += next.cacheWriteTokens;
151
+ }
@@ -0,0 +1,58 @@
1
+ import type { Application } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import type { AiManager } from "../AiManager.ts";
4
+ import { modelStats } from "../stats.ts";
5
+ import { spentToday } from "../spend.ts";
6
+
7
+ /**
8
+ * `zt ai:spend` — what this process has spent today, by model.
9
+ *
10
+ * Deliberately process-scoped, and says so: the ledger is in-memory, so N
11
+ * workers hold N of these. It answers "what is *this* process doing" during
12
+ * development and incident response; the provider's dashboard remains the
13
+ * authority on the bill.
14
+ *
15
+ * @internal
16
+ */
17
+ export class AiSpendCommand extends Command {
18
+ static commandName = "ai:spend";
19
+ static description = "Show this process's AI token spend today, by model";
20
+ static needsApp = true;
21
+
22
+ async run(): Promise<void> {
23
+ const app = this.app as Application | undefined;
24
+ if (!app) {
25
+ this.error("Application not available.");
26
+ return;
27
+ }
28
+
29
+ const ai = app.container.makeSync("ai") as AiManager;
30
+ const models = modelStats();
31
+ const spent = spentToday();
32
+
33
+ this.line(`Spend today (this process): $${spent.toFixed(4)}`);
34
+ if (ai.config.limits.perDayUsd > 0) {
35
+ const share = ((spent / ai.config.limits.perDayUsd) * 100).toFixed(1);
36
+ this.line(`Ceiling: $${ai.config.limits.perDayUsd.toFixed(2)} (${share}% used)`);
37
+ }
38
+
39
+ if (models.length === 0) {
40
+ this.line("");
41
+ this.line("No generations recorded in this process yet.");
42
+ return;
43
+ }
44
+
45
+ this.line("");
46
+ for (const model of models) {
47
+ const cost = model.costUsd > 0 ? `$${model.costUsd.toFixed(4)}` : "unpriced";
48
+ this.line(
49
+ ` ${model.model.padEnd(24)} ${String(model.calls).padStart(5)} calls ` +
50
+ `${String(model.inputTokens).padStart(8)} in ` +
51
+ `${String(model.outputTokens).padStart(8)} out ${cost}`,
52
+ );
53
+ }
54
+
55
+ this.line("");
56
+ this.line("Estimated from public list prices — an account with negotiated rates pays less.");
57
+ }
58
+ }
@@ -0,0 +1,61 @@
1
+ import type { Application, ArgDef } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import type { AiManager } from "../AiManager.ts";
4
+
5
+ /**
6
+ * `zt ai:test [driver]` — reach the provider once and print what came back.
7
+ *
8
+ * AI configuration fails in ways unit tests cannot reach: a key with no access
9
+ * to the model, a model id that 404s because someone appended a date suffix, a
10
+ * gateway that rewrites the base URL. This exercises the whole path and prints
11
+ * the resolved model — which is the value people most often assume rather than
12
+ * check.
13
+ *
14
+ * @internal
15
+ */
16
+ export class AiTestCommand extends Command {
17
+ static commandName = "ai:test";
18
+ static description = "Verify AI credentials and print the resolved model";
19
+ static needsApp = true;
20
+
21
+ static args: ArgDef[] = [{ name: "driver", required: false }];
22
+
23
+ async run(): Promise<void> {
24
+ const app = this.app as Application | undefined;
25
+ if (!app) {
26
+ this.error("Application not available.");
27
+ return;
28
+ }
29
+
30
+ const ai = app.container.makeSync("ai") as AiManager;
31
+ const requested = this.args["driver"];
32
+ const names = requested ? [requested] : ai.drivers();
33
+
34
+ if (names.length === 0) {
35
+ this.error("No AI drivers are configured. Add one under drivers in config/ai.ts.");
36
+ return;
37
+ }
38
+
39
+ let failed = false;
40
+
41
+ for (const name of names) {
42
+ this.line(`${name}: contacting the provider…`);
43
+ const status = await ai.verify(name);
44
+
45
+ if (status.ok) {
46
+ this.info(`${name}: ok · model ${status.model} · ${status.detail}`);
47
+ } else {
48
+ failed = true;
49
+ this.error(`${name}: failed · model ${status.model} · ${status.detail}`);
50
+ }
51
+ }
52
+
53
+ if (!failed) {
54
+ this.line("");
55
+ this.line(`Default driver: ${ai.config.default}`);
56
+ if (ai.config.limits.perDayUsd > 0) {
57
+ this.line(`Daily spend ceiling: $${ai.config.limits.perDayUsd.toFixed(2)}`);
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,2 @@
1
+ export { AiTestCommand } from "./AiTestCommand.ts";
2
+ export { AiSpendCommand } from "./AiSpendCommand.ts";
package/src/config.ts ADDED
@@ -0,0 +1,293 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import { AiConfigError } from "./errors.ts";
3
+ import type {
4
+ AiConfigShape,
5
+ AnthropicConfigShape,
6
+ EmbeddingsConfigShape,
7
+ OllamaConfigShape,
8
+ OpenAiConfigShape,
9
+ } from "./types.ts";
10
+ import { modelRejectsSampling } from "./pricing.ts";
11
+
12
+ /**
13
+ * Recursively-optional view of the config, so callers override only what they
14
+ * care about.
15
+ *
16
+ * `NonNullable` before the `extends object` test is load-bearing: every driver
17
+ * block is already optional, so `AnthropicConfigShape | undefined` does not
18
+ * extend `object` and the recursion would stop one level too early — leaving
19
+ * `{ drivers: { anthropic: { apiKey } } }`, the single most common thing anyone
20
+ * writes in `config/ai.ts`, a type error.
21
+ */
22
+ type DeepPartial<T> = {
23
+ [K in keyof T]?: NonNullable<T[K]> extends object ? DeepPartial<NonNullable<T[K]>> : T[K];
24
+ };
25
+
26
+ /**
27
+ * What {@link AiConfig} accepts — every key optional, all the way down. The
28
+ * same type the factory's parameter is written as, exported under a name
29
+ * consumers can reach for.
30
+ *
31
+ * Exported because a real `config/ai.ts` often builds its driver map
32
+ * conditionally ("Anthropic when the key is set, Ollama otherwise"), and the
33
+ * intermediate variable needs a type that is not the fully-resolved shape.
34
+ *
35
+ * @example
36
+ * const drivers: NonNullable<AiConfigInput["drivers"]> = { ollama: { model } };
37
+ * if (key) drivers.anthropic = { apiKey: key };
38
+ * export default AiConfig({ default: key ? "anthropic" : "ollama", drivers });
39
+ */
40
+ export type AiConfigInput = DeepPartial<AiConfigShape>;
41
+
42
+ /**
43
+ * Everything that is not a driver block.
44
+ *
45
+ * No driver appears here on purpose: `deepMerge` would then materialise that
46
+ * driver for every app, and an app that only talks to Ollama would boot into
47
+ * "drivers.anthropic.apiKey is empty" for a provider it never asked for.
48
+ * Per-driver defaults are filled in by {@link applyDriverDefaults}, which runs
49
+ * only over the blocks the app actually declared.
50
+ */
51
+ const defaults: AiConfigShape = {
52
+ default: "anthropic",
53
+ drivers: {},
54
+ embeddings: {
55
+ default: "openai",
56
+ drivers: {},
57
+ },
58
+ limits: {
59
+ perRequestUsd: 0,
60
+ perDayUsd: 0,
61
+ },
62
+ redact: true,
63
+ agent: {
64
+ lock: true,
65
+ // Not "how long the agent might run" — that is unanswerable. It is how long
66
+ // after a crash before another run may take over, and the loop refreshes it.
67
+ lockTtl: 120,
68
+ maxSteps: 25,
69
+ maxResumes: 5,
70
+ },
71
+ };
72
+
73
+ /**
74
+ * Create a typed AI configuration object with defaults.
75
+ *
76
+ * @example
77
+ * import { AiConfig } from '@zerotal/ai';
78
+ *
79
+ * export default AiConfig({
80
+ * default: 'anthropic',
81
+ * drivers: {
82
+ * anthropic: { apiKey: Bun.env['ANTHROPIC_API_KEY'] ?? '' },
83
+ * ollama: { model: 'llama3.2', baseUrl: 'http://127.0.0.1:11434' },
84
+ * },
85
+ * embeddings: {
86
+ * default: 'openai',
87
+ * drivers: {
88
+ * openai: { apiKey: Bun.env['OPENAI_API_KEY'] ?? '', model: 'text-embedding-3-small' },
89
+ * },
90
+ * },
91
+ * limits: { perRequestUsd: 0.5, perDayUsd: 25 },
92
+ * });
93
+ */
94
+ export function AiConfig(options: DeepPartial<AiConfigShape> = {}): AiConfigShape {
95
+ const config = deepMerge(defaults, options as Partial<AiConfigShape>);
96
+ applyDriverDefaults(config);
97
+ validateAiConfig(config);
98
+ return config;
99
+ }
100
+
101
+ /**
102
+ * Fill in the per-driver defaults for drivers the app actually declared.
103
+ *
104
+ * They cannot live in `defaults` above: `deepMerge` would then materialise an
105
+ * `openai` block for every app, and a config where every driver looks present
106
+ * cannot tell "configured" from "left at its defaults".
107
+ *
108
+ * @internal
109
+ */
110
+ function applyDriverDefaults(config: AiConfigShape): void {
111
+ const { drivers, embeddings } = config;
112
+
113
+ if (drivers.anthropic) {
114
+ const given = drivers.anthropic as Partial<AnthropicConfigShape>;
115
+ drivers.anthropic = {
116
+ apiKey: given.apiKey ?? "",
117
+ // Exact model id, no date suffix. Never construct one.
118
+ model: given.model ?? "claude-opus-5",
119
+ // max_tokens caps thinking *plus* response text, and thinking is on by
120
+ // default on this model — a budget sized for the answer alone truncates.
121
+ maxTokens: given.maxTokens ?? 16000,
122
+ // Streaming has no HTTP-timeout ceiling to respect, so give it room.
123
+ streamMaxTokens: given.streamMaxTokens ?? 64000,
124
+ effort: given.effort ?? "high",
125
+ fallbacks: given.fallbacks ?? true,
126
+ cacheSystem: given.cacheSystem ?? true,
127
+ timeout: given.timeout ?? 600_000,
128
+ ...(given.baseUrl !== undefined ? { baseUrl: given.baseUrl } : {}),
129
+ ...(given.temperature !== undefined ? { temperature: given.temperature } : {}),
130
+ };
131
+ }
132
+
133
+ if (drivers.openai) {
134
+ const given = drivers.openai as Partial<OpenAiConfigShape>;
135
+ drivers.openai = {
136
+ apiKey: given.apiKey ?? "",
137
+ model: given.model ?? "gpt-4o-mini",
138
+ maxTokens: given.maxTokens ?? 16000,
139
+ baseUrl: given.baseUrl ?? "https://api.openai.com/v1",
140
+ timeout: given.timeout ?? 600_000,
141
+ };
142
+ }
143
+
144
+ if (drivers.ollama) {
145
+ const given = drivers.ollama as Partial<OllamaConfigShape>;
146
+ drivers.ollama = {
147
+ model: given.model ?? "llama3.2",
148
+ baseUrl: given.baseUrl ?? "http://127.0.0.1:11434",
149
+ timeout: given.timeout ?? 600_000,
150
+ };
151
+ }
152
+
153
+ if (embeddings.drivers.openai) {
154
+ const given = embeddings.drivers.openai as Partial<EmbeddingsConfigShape["drivers"]["openai"]>;
155
+ embeddings.drivers.openai = {
156
+ apiKey: given?.apiKey ?? "",
157
+ model: given?.model ?? "text-embedding-3-small",
158
+ baseUrl: given?.baseUrl ?? "https://api.openai.com/v1",
159
+ timeout: given?.timeout ?? 120_000,
160
+ };
161
+ }
162
+
163
+ if (embeddings.drivers.ollama) {
164
+ const given = embeddings.drivers.ollama as Partial<EmbeddingsConfigShape["drivers"]["ollama"]>;
165
+ embeddings.drivers.ollama = {
166
+ model: given?.model ?? "nomic-embed-text",
167
+ baseUrl: given?.baseUrl ?? "http://127.0.0.1:11434",
168
+ timeout: given?.timeout ?? 120_000,
169
+ };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The zero-config fallback: an Anthropic driver built from `ANTHROPIC_API_KEY`.
175
+ *
176
+ * Used when an app registers {@link AiProvider} without a `config/ai.ts`, which
177
+ * is the "installed it, exported the key, want to try it" path. With no key set
178
+ * it produces an empty driver list, so {@link validateAiConfig} raises the
179
+ * actionable error at boot rather than letting the first prompt fail.
180
+ *
181
+ * @example
182
+ * // bootstrap/providers.ts registers AiProvider; no config file needed:
183
+ * // ANTHROPIC_API_KEY=sk-ant-… bun zt serve
184
+ */
185
+ export function AiConfigFromEnv(): AiConfigShape {
186
+ const apiKey = Bun.env["ANTHROPIC_API_KEY"] ?? "";
187
+ return AiConfig(apiKey ? { drivers: { anthropic: { apiKey } } } : {});
188
+ }
189
+
190
+ /**
191
+ * Check the config for combinations that would only fail at generation time.
192
+ *
193
+ * A default driver with no block, or an API key left empty in production, is a
194
+ * deployment mistake — and the cheapest place to notice one is at boot, naming
195
+ * the key, rather than on the first user's first prompt.
196
+ *
197
+ * @throws {AiConfigError} on the first inconsistency found.
198
+ *
199
+ * @internal
200
+ */
201
+ export function validateAiConfig(config: AiConfigShape): void {
202
+ const { drivers, embeddings, limits, agent } = config;
203
+
204
+ const configured = Object.keys(drivers).filter(
205
+ (name) => drivers[name as keyof typeof drivers] !== undefined,
206
+ );
207
+
208
+ if (configured.length === 0) {
209
+ throw new AiConfigError(
210
+ "No AI drivers are configured. Add at least one under drivers in config/ai.ts.",
211
+ );
212
+ }
213
+
214
+ if (!configured.includes(config.default)) {
215
+ throw new AiConfigError(
216
+ `default is '${config.default}' but that driver has no block. Configured: ${configured.join(", ")}.`,
217
+ { default: config.default, configured },
218
+ );
219
+ }
220
+
221
+ if (drivers.anthropic) {
222
+ const a = drivers.anthropic;
223
+ if (!a.apiKey) {
224
+ throw new AiConfigError(
225
+ "drivers.anthropic.apiKey is empty. Set ANTHROPIC_API_KEY, or remove the anthropic block.",
226
+ );
227
+ }
228
+ if (/-\d{8}$/.test(a.model)) {
229
+ throw new AiConfigError(
230
+ `drivers.anthropic.model '${a.model}' carries a date suffix. Use the exact alias — e.g. 'claude-opus-5'.`,
231
+ { model: a.model },
232
+ );
233
+ }
234
+ if (a.streamMaxTokens < a.maxTokens) {
235
+ throw new AiConfigError(
236
+ "drivers.anthropic.streamMaxTokens is below maxTokens. Streaming exists to lift the ceiling, not lower it.",
237
+ { maxTokens: a.maxTokens, streamMaxTokens: a.streamMaxTokens },
238
+ );
239
+ }
240
+ if (a.temperature !== undefined && modelRejectsSampling(a.model)) {
241
+ // A warning rather than a throw: the driver drops it and the request still
242
+ // succeeds. Throwing would break an app whose config merely carries a
243
+ // leftover from a model that accepted it.
244
+ console.warn(
245
+ `[Zerotal/ai] drivers.anthropic.temperature is set, but ${a.model} rejects temperature/top_p/top_k ` +
246
+ `with a 400. The driver drops it. Use effort ('low' … 'max') to trade thoroughness for cost instead.`,
247
+ );
248
+ }
249
+ }
250
+
251
+ if (drivers.openai && !drivers.openai.apiKey) {
252
+ throw new AiConfigError(
253
+ "drivers.openai.apiKey is empty. Set OPENAI_API_KEY, or remove the openai block.",
254
+ );
255
+ }
256
+
257
+ const embedDrivers = Object.keys(embeddings.drivers).filter(
258
+ (name) => embeddings.drivers[name as keyof typeof embeddings.drivers] !== undefined,
259
+ );
260
+ if (embedDrivers.length > 0 && !embedDrivers.includes(embeddings.default)) {
261
+ throw new AiConfigError(
262
+ `embeddings.default is '${embeddings.default}' but that driver has no block. Configured: ${embedDrivers.join(", ")}.`,
263
+ { default: embeddings.default, configured: embedDrivers },
264
+ );
265
+ }
266
+ if (embeddings.drivers.openai && !embeddings.drivers.openai.apiKey) {
267
+ throw new AiConfigError("embeddings.drivers.openai.apiKey is empty. Set OPENAI_API_KEY.");
268
+ }
269
+
270
+ if (limits.perRequestUsd < 0 || limits.perDayUsd < 0) {
271
+ throw new AiConfigError("limits must not be negative. Use 0 to disable a ceiling.");
272
+ }
273
+ if (limits.perDayUsd > 0 && limits.perRequestUsd > limits.perDayUsd) {
274
+ throw new AiConfigError(
275
+ "limits.perRequestUsd exceeds limits.perDayUsd — the per-request ceiling could never be reached.",
276
+ { ...limits },
277
+ );
278
+ }
279
+
280
+ if (agent.maxSteps < 1) {
281
+ throw new AiConfigError("agent.maxSteps must be at least 1.");
282
+ }
283
+ if (agent.lockTtl < 1) {
284
+ throw new AiConfigError("agent.lockTtl must be at least 1 second.");
285
+ }
286
+ }
287
+
288
+ // Register this package's config namespace for typed config() dot-paths.
289
+ declare module "@zerotal/core" {
290
+ interface ConfigRegistry {
291
+ ai: AiConfigShape;
292
+ }
293
+ }
@@ -0,0 +1,104 @@
1
+ import type { SchemaInput } from "../schema.ts";
2
+ import type {
3
+ AiAgentResult,
4
+ AiMessage,
5
+ AiObjectResponse,
6
+ AiRequest,
7
+ AiResponse,
8
+ AiStreamChunk,
9
+ } from "../types.ts";
10
+
11
+ /** What the agent loop needs from the caller, beyond the request itself. */
12
+ export interface AgentOptions {
13
+ /** Hard ceiling on tool-calling round trips. */
14
+ maxSteps: number;
15
+ /** Hard ceiling on `pause_turn` resumes. */
16
+ maxResumes: number;
17
+ /**
18
+ * Aborted when the caller cancels *or* when the loop's lock is lost.
19
+ *
20
+ * Distinct from `request.signal`: a lost lock means another process may now be
21
+ * doing the same work, and continuing would double it.
22
+ */
23
+ signal: AbortSignal;
24
+ }
25
+
26
+ /**
27
+ * What every AI provider implements.
28
+ *
29
+ * Deliberately small. A driver translates this vocabulary to one provider's wire
30
+ * format and back; spend ceilings, redaction, telemetry, and the agent lock all
31
+ * live above it in the manager, so a second driver costs a translation layer and
32
+ * nothing else.
33
+ */
34
+ export interface AiDriver {
35
+ /** The driver's registered name — `anthropic`, `openai`, `ollama`, or custom. */
36
+ readonly name: string;
37
+ /** The configured default model. A request may override it. */
38
+ readonly model: string;
39
+
40
+ /** One non-streaming generation. */
41
+ text(request: AiRequest): Promise<AiResponse>;
42
+
43
+ /** One streaming generation. The final chunk is always `{ type: "done" }`. */
44
+ stream(request: AiRequest): AsyncIterable<AiStreamChunk>;
45
+
46
+ /** One generation constrained to a schema, parsed and re-checked. */
47
+ object<T>(request: AiRequest, schema: SchemaInput): Promise<AiObjectResponse<T>>;
48
+
49
+ /**
50
+ * Run the tool-calling loop to completion.
51
+ *
52
+ * Optional, and normally left unimplemented: the shared loop in `agentLoop.ts`
53
+ * drives any driver through {@link text}, so all three built-in drivers run
54
+ * the *same* loop — which is the only way "the abstraction is real" is a claim
55
+ * rather than a hope. Implement this only for a provider that executes tools
56
+ * server-side and so cannot be driven turn by turn.
57
+ */
58
+ agent?(request: AiRequest, options: AgentOptions): Promise<AiAgentResult>;
59
+
60
+ /**
61
+ * Count the tokens this request would consume, using the provider's own
62
+ * tokenizer. Never an estimate from another vendor's tokenizer — the spend
63
+ * panel is built on this number.
64
+ */
65
+ countTokens(request: AiRequest): Promise<number>;
66
+
67
+ /** Reach the provider once and report what came back. Backs `zt ai:test`. */
68
+ verify(): Promise<DriverStatus>;
69
+ }
70
+
71
+ /** What `zt ai:test` prints for one driver. */
72
+ export interface DriverStatus {
73
+ ok: boolean;
74
+ /** The model the driver resolved — the thing people most often get wrong. */
75
+ model: string;
76
+ /** One line: the provider's reply, or its complaint. */
77
+ detail: string;
78
+ }
79
+
80
+ /**
81
+ * Turn `prompt` / `messages` into the single list drivers work from.
82
+ *
83
+ * @internal
84
+ */
85
+ export function normalizeMessages(request: AiRequest): AiMessage[] {
86
+ if (request.messages?.length) return request.messages;
87
+ if (request.prompt !== undefined) return [{ role: "user", content: request.prompt }];
88
+ return [];
89
+ }
90
+
91
+ /**
92
+ * A short, redaction-safe label for telemetry: the last user turn, truncated by
93
+ * the caller's redaction setting.
94
+ *
95
+ * @internal
96
+ */
97
+ export function promptText(request: AiRequest): string {
98
+ const messages = normalizeMessages(request);
99
+ for (let i = messages.length - 1; i >= 0; i--) {
100
+ const message = messages[i]!;
101
+ if (message.role === "user" && message.content) return message.content;
102
+ }
103
+ return request.system ?? "";
104
+ }