@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 +77 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +67 -0
- package/src/AiFake.ts +351 -0
- package/src/AiGenerationJob.ts +76 -0
- package/src/AiManager.ts +608 -0
- package/src/agentLoop.ts +151 -0
- package/src/commands/AiSpendCommand.ts +58 -0
- package/src/commands/AiTestCommand.ts +61 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +293 -0
- package/src/drivers/AiDriver.ts +104 -0
- package/src/drivers/AnthropicDriver.ts +483 -0
- package/src/drivers/OllamaDriver.ts +286 -0
- package/src/drivers/OpenAiDriver.ts +344 -0
- package/src/drivers/anthropic-sdk.ts +126 -0
- package/src/drivers/embeddings/EmbeddingsDriver.ts +20 -0
- package/src/drivers/embeddings/OllamaEmbeddingsDriver.ts +60 -0
- package/src/drivers/embeddings/OpenAiEmbeddingsDriver.ts +69 -0
- package/src/drivers/sse.ts +107 -0
- package/src/errors.ts +129 -0
- package/src/events.ts +51 -0
- package/src/facades/Ai.ts +3 -0
- package/src/global.d.ts +8 -0
- package/src/index.ts +106 -0
- package/src/monitor.ts +179 -0
- package/src/observability.ts +130 -0
- package/src/pricing.ts +90 -0
- package/src/provider/AiProvider.ts +65 -0
- package/src/redact.ts +32 -0
- package/src/schema.ts +292 -0
- package/src/spend.ts +100 -0
- package/src/stats.ts +121 -0
- package/src/tool.ts +98 -0
- package/src/types.ts +317 -0
package/src/agentLoop.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|