@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/src/monitor.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The AI section contributed to `@zerotal/monitor`.
3
+ *
4
+ * The panel is *described*, not rendered — the monitor owns the shell and this
5
+ * package owns the knowledge of what is worth watching about a model call. The
6
+ * host interface is declared locally so this package has no dependency on the
7
+ * monitor at all: an app without it simply never resolves the binding.
8
+ */
9
+ import type { Application } from "@zerotal/core";
10
+ import { modelStats, recentGenerations, refusalRate } from "./stats.ts";
11
+ import { spentToday } from "./spend.ts";
12
+ import type { AiConfigShape } from "./types.ts";
13
+
14
+ /** The monitor's write surface, redeclared to avoid a dependency. */
15
+ interface MonitorHost {
16
+ enabled(id: string): boolean;
17
+ section(section: {
18
+ id: string;
19
+ label: string;
20
+ group?: string;
21
+ icon?: string;
22
+ sort?: number;
23
+ resolve(range: string): unknown;
24
+ }): void;
25
+ }
26
+
27
+ /** A chat bubble with a spark inside — generation, not messaging. */
28
+ const ICON =
29
+ `<svg class="w-5 h-5 shrink-0" viewBox="0 0 20 20" fill="none" stroke="currentColor" ` +
30
+ `stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
31
+ `<path d="M17 11.5a4 4 0 0 1-4 4H8l-4 3v-3H4a4 4 0 0 1-4-4v-5a4 4 0 0 1 4-4h9a4 4 0 0 1 4 4z" ` +
32
+ `transform="translate(1.5 0.5)"/>` +
33
+ `<path d="M10 5.5l1 2.5 2.5 1-2.5 1-1 2.5-1-2.5L6.5 9 9 8z"/></svg>`;
34
+
35
+ /**
36
+ * Register the AI section, if the monitor is installed and the app left it on.
37
+ *
38
+ * @internal
39
+ */
40
+ export function installAiMonitor(app: Application, config: AiConfigShape): void {
41
+ const monitor = app.container.tryMake("monitor.panel" as never) as MonitorHost | undefined;
42
+ if (!monitor?.enabled("ai")) return;
43
+
44
+ monitor.section({
45
+ id: "ai",
46
+ label: "AI",
47
+ group: "Integrations",
48
+ icon: ICON,
49
+ resolve: () => resolveSection(config),
50
+ });
51
+ }
52
+
53
+ /** The section's content. Reads only what the package already recorded. */
54
+ function resolveSection(config: AiConfigShape): unknown {
55
+ const models = modelStats();
56
+ const recent = recentGenerations(30);
57
+
58
+ const calls = models.reduce((total, m) => total + m.calls, 0);
59
+ const outputTokens = models.reduce((total, m) => total + m.outputTokens, 0);
60
+ const inputTokens = models.reduce((total, m) => total + m.inputTokens, 0);
61
+ const cacheReadTokens = models.reduce((total, m) => total + m.cacheReadTokens, 0);
62
+ const refusals = refusalRate();
63
+ const spent = spentToday();
64
+
65
+ const stats = [
66
+ { label: "Generations", value: calls, detail: `${models.length} model(s) in the buffer` },
67
+ {
68
+ label: "Spend today",
69
+ value: `$${spent.toFixed(4)}`,
70
+ detail:
71
+ config.limits.perDayUsd > 0
72
+ ? `of $${config.limits.perDayUsd.toFixed(2)} ceiling`
73
+ : "estimated at list prices · no ceiling set",
74
+ ...(config.limits.perDayUsd > 0
75
+ ? { percent: Math.min(100, (spent / config.limits.perDayUsd) * 100) }
76
+ : {}),
77
+ tone: ceilingTone(spent, config.limits.perDayUsd),
78
+ },
79
+ {
80
+ label: "Tokens",
81
+ value: `${format(inputTokens)} in / ${format(outputTokens)} out`,
82
+ detail:
83
+ cacheReadTokens > 0
84
+ ? `${format(cacheReadTokens)} served from cache`
85
+ : "no cache reads recorded",
86
+ },
87
+ {
88
+ label: "Refusal rate",
89
+ value: `${(refusals * 100).toFixed(1)}%`,
90
+ detail: "provider declined the request",
91
+ tone: refusals > 0.05 ? "warn" : "default",
92
+ },
93
+ ];
94
+
95
+ return {
96
+ stats,
97
+ tables: [
98
+ {
99
+ title: "By model",
100
+ columns: [
101
+ { key: "model", label: "Model", mono: true },
102
+ { key: "calls", label: "Calls", align: "end" },
103
+ {
104
+ key: "inputTokens",
105
+ label: "In",
106
+ align: "end",
107
+ format: (v: unknown) => format(Number(v)),
108
+ },
109
+ {
110
+ key: "outputTokens",
111
+ label: "Out",
112
+ align: "end",
113
+ format: (v: unknown) => format(Number(v)),
114
+ },
115
+ {
116
+ key: "costUsd",
117
+ label: "Cost",
118
+ align: "end",
119
+ format: (v: unknown) => (Number(v) > 0 ? `$${Number(v).toFixed(4)}` : "—"),
120
+ },
121
+ { key: "p50", label: "p50", align: "end", format: (v: unknown) => `${String(v)} ms` },
122
+ { key: "p95", label: "p95", align: "end", format: (v: unknown) => `${String(v)} ms` },
123
+ {
124
+ key: "failures",
125
+ label: "Failed",
126
+ align: "end",
127
+ tone: (v: unknown) => (Number(v) > 0 ? "bad" : null),
128
+ },
129
+ {
130
+ key: "refusals",
131
+ label: "Refused",
132
+ align: "end",
133
+ tone: (v: unknown) => (Number(v) > 0 ? "warn" : null),
134
+ },
135
+ ],
136
+ rows: models,
137
+ empty: "No generations recorded yet.",
138
+ },
139
+ {
140
+ title: "Recent generations",
141
+ columns: [
142
+ { key: "operation", label: "Op" },
143
+ { key: "model", label: "Model", mono: true },
144
+ { key: "preview", label: "Prompt" },
145
+ {
146
+ key: "durationMs",
147
+ label: "Time",
148
+ align: "end",
149
+ format: (v: unknown) => `${Math.round(Number(v))} ms`,
150
+ },
151
+ {
152
+ key: "ok",
153
+ label: "Result",
154
+ format: (_v: unknown, row: Record<string, unknown>) =>
155
+ row["refused"] ? "refused" : row["ok"] ? "ok" : "failed",
156
+ tone: (_v: unknown, row: Record<string, unknown>) =>
157
+ row["refused"] ? "warn" : row["ok"] ? "good" : "bad",
158
+ },
159
+ ],
160
+ rows: recent,
161
+ empty: "No generations recorded yet.",
162
+ },
163
+ ],
164
+ };
165
+ }
166
+
167
+ function ceilingTone(spent: number, ceiling: number): "default" | "warn" | "bad" {
168
+ if (ceiling <= 0) return "default";
169
+ const share = spent / ceiling;
170
+ if (share >= 1) return "bad";
171
+ return share >= 0.8 ? "warn" : "default";
172
+ }
173
+
174
+ /** Thousands separators, but short: 12.3k rather than 12,345. */
175
+ function format(tokens: number): string {
176
+ if (tokens < 1000) return String(tokens);
177
+ if (tokens < 1_000_000) return `${(tokens / 1000).toFixed(1)}k`;
178
+ return `${(tokens / 1_000_000).toFixed(2)}M`;
179
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * AI → observer bridges. This package emits `AiGenerated` / `AiRefused` /
3
+ * `AiToolCalled` on core's `FrameworkEvents` bus; this module forwards them to
4
+ * whichever observer packages happen to be installed.
5
+ *
6
+ * Each observer's write surface is resolved from the container by binding key
7
+ * and typed through a local structural interface, so this package depends on
8
+ * none of them — installing or removing an observer requires no change here.
9
+ */
10
+ import { FrameworkEvents } from "@zerotal/core";
11
+ import type { Application } from "@zerotal/core";
12
+ import { AiGenerated, AiRefused, AiToolCalled } from "./events.ts";
13
+ import { recordDelivery } from "./stats.ts";
14
+
15
+ /** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
16
+ interface MonitorSink {
17
+ recordEvent(e: {
18
+ kind: string;
19
+ label: string;
20
+ status?: "ok" | "warn" | "bad" | "info";
21
+ route?: string | null;
22
+ data?: Record<string, unknown>;
23
+ }): void;
24
+ }
25
+
26
+ /** The subset of the logger this bridge calls (bound as `log`). */
27
+ interface LogSink {
28
+ warn(message: string, context?: Record<string, unknown>): void;
29
+ error(message: string, context?: Record<string, unknown>, error?: unknown): void;
30
+ }
31
+
32
+ /**
33
+ * Subscribe the AI events to every installed observer, plus this package's own
34
+ * counters. Returns a disposer; call it from the provider's `onStopping()`.
35
+ */
36
+ export function installAiObservability(app: Application): () => void {
37
+ const unsubs: Array<() => void> = [];
38
+ const refused = new Set<string>();
39
+
40
+ // A refusal is reported as a *failed* generation as well, so the two events
41
+ // arrive for the same call. Remembering the preview lets the delivery row be
42
+ // labelled a refusal rather than a generic failure.
43
+ unsubs.push(
44
+ FrameworkEvents.on(AiRefused, (e) => {
45
+ refused.add(e.preview);
46
+ }),
47
+ );
48
+
49
+ unsubs.push(
50
+ FrameworkEvents.on(AiGenerated, (e) => {
51
+ const wasRefusal = !e.ok && refused.delete(e.preview);
52
+ recordDelivery({
53
+ at: Date.now(),
54
+ driver: e.driver,
55
+ model: e.model,
56
+ operation: e.operation,
57
+ inputTokens: e.inputTokens,
58
+ outputTokens: e.outputTokens,
59
+ cacheReadTokens: e.cacheReadTokens,
60
+ durationMs: e.durationMs,
61
+ costUsd: e.costUsd,
62
+ ok: e.ok,
63
+ refused: wasRefusal,
64
+ preview: e.preview,
65
+ ...(e.error ? { error: e.error } : {}),
66
+ });
67
+ }),
68
+ );
69
+
70
+ const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
71
+ if (store) {
72
+ unsubs.push(
73
+ FrameworkEvents.on(AiGenerated, (e) =>
74
+ store.recordEvent({
75
+ kind: "ai",
76
+ label: `${e.operation} · ${e.model}`,
77
+ status: e.ok ? "ok" : "bad",
78
+ route: e.driver,
79
+ data: {
80
+ driver: e.driver,
81
+ model: e.model,
82
+ operation: e.operation,
83
+ inputTokens: e.inputTokens,
84
+ outputTokens: e.outputTokens,
85
+ costUsd: Number(e.costUsd.toFixed(6)),
86
+ ms: Math.round(e.durationMs),
87
+ detail: e.error ?? e.preview,
88
+ },
89
+ }),
90
+ ),
91
+ FrameworkEvents.on(AiToolCalled, (e) =>
92
+ store.recordEvent({
93
+ kind: "ai.tool",
94
+ label: e.tool,
95
+ status: e.ok ? "ok" : "bad",
96
+ route: e.driver,
97
+ data: { step: e.step, ms: Math.round(e.durationMs), detail: e.error ?? "" },
98
+ }),
99
+ ),
100
+ );
101
+ }
102
+
103
+ const log = app.container.tryMake("log" as never) as LogSink | undefined;
104
+ if (log) {
105
+ unsubs.push(
106
+ // A refusal is not a bug, so it is a warning rather than an error — but it
107
+ // is also invisible otherwise, since the HTTP call succeeded.
108
+ FrameworkEvents.on(AiRefused, (e) =>
109
+ log.warn("AI request refused by the provider", {
110
+ driver: e.driver,
111
+ model: e.model,
112
+ category: e.category,
113
+ prompt: e.preview,
114
+ }),
115
+ ),
116
+ FrameworkEvents.on(AiGenerated, (e) => {
117
+ if (e.ok) return;
118
+ log.error(
119
+ "AI generation failed",
120
+ { driver: e.driver, model: e.model, operation: e.operation, prompt: e.preview },
121
+ new Error(e.error ?? "unknown error"),
122
+ );
123
+ }),
124
+ );
125
+ }
126
+
127
+ return () => {
128
+ for (const unsub of unsubs) unsub();
129
+ };
130
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Token prices, and what to do about models we have no price for.
3
+ *
4
+ * The spend panel and the ceilings both need a number per request, and the only
5
+ * honest source is a table someone maintains. So: models we know are priced,
6
+ * models we don't return 0 — and 0 is documented as *unpriced*, never as free.
7
+ * A ceiling therefore never blocks a request it cannot price, which is the safe
8
+ * direction to fail for a limit whose job is to catch runaway spend, not to be
9
+ * the billing system.
10
+ */
11
+ import type { AiUsage } from "./types.ts";
12
+
13
+ /** USD per million tokens. */
14
+ export interface ModelPrice {
15
+ input: number;
16
+ output: number;
17
+ }
18
+
19
+ /**
20
+ * Published list prices, USD per million tokens. Keys are exact model ids.
21
+ *
22
+ * Extend it with {@link registerModelPrice} rather than editing this — a fork
23
+ * of the table drifts the moment a price changes.
24
+ */
25
+ const PRICES: Record<string, ModelPrice> = {
26
+ // Anthropic
27
+ "claude-fable-5": { input: 10, output: 50 },
28
+ "claude-mythos-5": { input: 10, output: 50 },
29
+ "claude-opus-5": { input: 5, output: 25 },
30
+ "claude-opus-4-8": { input: 5, output: 25 },
31
+ "claude-opus-4-7": { input: 5, output: 25 },
32
+ "claude-opus-4-6": { input: 5, output: 25 },
33
+ "claude-sonnet-5": { input: 3, output: 15 },
34
+ "claude-sonnet-4-6": { input: 3, output: 15 },
35
+ "claude-haiku-4-5": { input: 1, output: 5 },
36
+ };
37
+
38
+ /** Cache reads bill at roughly a tenth of input; writes at a 25% premium. */
39
+ const CACHE_READ_MULTIPLIER = 0.1;
40
+ const CACHE_WRITE_MULTIPLIER = 1.25;
41
+
42
+ /**
43
+ * Teach the cost estimator about a model it does not know.
44
+ *
45
+ * @example
46
+ * registerModelPrice("gpt-4o-mini", { input: 0.15, output: 0.6 });
47
+ */
48
+ export function registerModelPrice(model: string, price: ModelPrice): void {
49
+ PRICES[model] = price;
50
+ }
51
+
52
+ /** The price for a model, or `undefined` when we have none. */
53
+ export function modelPrice(model: string): ModelPrice | undefined {
54
+ return PRICES[model];
55
+ }
56
+
57
+ /**
58
+ * Estimated USD for one request's usage. Returns 0 for an unpriced model.
59
+ *
60
+ * "Estimated" is load-bearing: these are public list prices, and an account
61
+ * with negotiated rates pays something else.
62
+ */
63
+ export function estimateCost(model: string, usage: AiUsage): number {
64
+ const price = PRICES[model];
65
+ if (!price) return 0;
66
+
67
+ const perInputToken = price.input / 1_000_000;
68
+ const perOutputToken = price.output / 1_000_000;
69
+
70
+ return (
71
+ usage.inputTokens * perInputToken +
72
+ usage.outputTokens * perOutputToken +
73
+ usage.cacheReadTokens * perInputToken * CACHE_READ_MULTIPLIER +
74
+ usage.cacheWriteTokens * perInputToken * CACHE_WRITE_MULTIPLIER
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Whether a Claude model rejects `temperature` / `top_p` / `top_k` with a 400.
80
+ *
81
+ * Unknown models answer `true`. The removal has only ever gone one way, and the
82
+ * two failure modes are not symmetric: guessing "rejects" costs a dropped
83
+ * parameter the API would have ignored anyway, while guessing "accepts" fails
84
+ * every single request against a model released after this line was written.
85
+ */
86
+ export function modelRejectsSampling(model: string): boolean {
87
+ // Opus 4.6 and Sonnet 4.6 were the last Claude models to accept sampling
88
+ // parameters; everything before them predates the models this package targets.
89
+ return !/^claude-(opus|sonnet)-4-6\b/.test(model) && model.startsWith("claude-");
90
+ }
@@ -0,0 +1,65 @@
1
+ import { ServiceProvider } from "@zerotal/core";
2
+ import type { AppEnvironment } from "@zerotal/core";
3
+ import type { ConfigManager } from "@zerotal/core/config";
4
+ import { AiManager } from "../AiManager.ts";
5
+ import { AiConfigFromEnv } from "../config.ts";
6
+ import type { AiConfigShape } from "../types.ts";
7
+ import { installAiObservability } from "../observability.ts";
8
+ import { installAiMonitor } from "../monitor.ts";
9
+ import { modelStats, recentGenerations } from "../stats.ts";
10
+ import { spentToday } from "../spend.ts";
11
+
12
+ declare module "@zerotal/core" {
13
+ interface ContainerBindings {
14
+ ai: AiManager;
15
+ }
16
+ }
17
+
18
+ export class AiProvider extends ServiceProvider {
19
+ static override provides = ["ai"] as const;
20
+ static override environments: AppEnvironment[] = ["web", "console", "worker", "test"];
21
+
22
+ private _disposeObservability: (() => void) | undefined = undefined;
23
+
24
+ override onRegister(): void {
25
+ this.app.container.singleton("ai", () => {
26
+ const config = this.app.container.makeSync("config") as ConfigManager;
27
+ // No eager default: `AiConfig()` validates, and an app *with* a config
28
+ // file must not pay for — or fail on — a fallback it never uses.
29
+ const declared = config.get<AiConfigShape | undefined>("ai");
30
+ return new AiManager(declared ?? AiConfigFromEnv());
31
+ });
32
+ }
33
+
34
+ override async onBooted(): Promise<void> {
35
+ const ai = (await this.app.container.make("ai")) as AiManager;
36
+
37
+ this._disposeObservability = installAiObservability(this.app);
38
+ installAiMonitor(this.app, ai.config);
39
+
40
+ const runner = this.app.container.tryMake("commands");
41
+ if (runner) {
42
+ runner.registerLazy("ai:test", () =>
43
+ import("../commands/AiTestCommand.ts").then((m) => m.AiTestCommand),
44
+ );
45
+ runner.registerLazy("ai:spend", () =>
46
+ import("../commands/AiSpendCommand.ts").then((m) => m.AiSpendCommand),
47
+ );
48
+ }
49
+ }
50
+
51
+ override async onStopping(): Promise<void> {
52
+ this._disposeObservability?.();
53
+ this._disposeObservability = undefined;
54
+ }
55
+
56
+ /** What `zt repl` puts on the global scope. */
57
+ override replContext(): Record<string, unknown> {
58
+ return {
59
+ Ai: this.app.container.makeSync("ai"),
60
+ aiSpentToday: spentToday,
61
+ aiModelStats: modelStats,
62
+ aiRecentGenerations: recentGenerations,
63
+ };
64
+ }
65
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Prompt redaction for the observability path.
3
+ *
4
+ * A prompt is user data. It is also the single most useful thing to put on a
5
+ * monitor row, which is exactly the tension: the debugging value is real, and
6
+ * so is the fact that logs outlive the request and get shipped somewhere else.
7
+ *
8
+ * The compromise is a *preview*, not the prompt: length and shape survive, the
9
+ * content does not. With redaction off you get a truncated prompt instead —
10
+ * still truncated, because a 40 KB system prompt on every monitor row is its
11
+ * own problem.
12
+ */
13
+
14
+ /** Characters of prompt kept when redaction is off. */
15
+ const PREVIEW_LIMIT = 200;
16
+
17
+ /**
18
+ * Turn a prompt into something safe to record.
19
+ *
20
+ * @param prompt - The raw prompt text.
21
+ * @param redact - When true (the default from config), emit shape only.
22
+ *
23
+ * @example
24
+ * redactPrompt("Reset the password for ada@example.com", true);
25
+ * // → "[redacted 38 chars]"
26
+ * redactPrompt("Reset the password for ada@example.com", false);
27
+ * // → "Reset the password for ada@example.com"
28
+ */
29
+ export function redactPrompt(prompt: string, redact: boolean): string {
30
+ if (redact) return `[redacted ${prompt.length} chars]`;
31
+ return prompt.length > PREVIEW_LIMIT ? `${prompt.slice(0, PREVIEW_LIMIT)}…` : prompt;
32
+ }