@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/schema.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@zerotal/validator` schema → JSON Schema, for structured output and tool inputs.
|
|
3
|
+
*
|
|
4
|
+
* The supported JSON Schema subset is narrow — narrower than most people expect,
|
|
5
|
+
* and the API rejects anything outside it at *request* time, which is the worst
|
|
6
|
+
* possible place to find out. So this module makes one decision and holds it:
|
|
7
|
+
*
|
|
8
|
+
* > **Strip what the API cannot express, then re-check it client-side.**
|
|
9
|
+
*
|
|
10
|
+
* `min(2)`, `max(140)`, `regex(...)` and friends are dropped from the emitted
|
|
11
|
+
* schema and re-applied by {@link recheckAgainstSchema} once the model answers.
|
|
12
|
+
* We already own a validator; running it over the parsed object costs nothing and
|
|
13
|
+
* keeps the constraint honest instead of decorative. The alternative — refusing
|
|
14
|
+
* the schema at definition time — would make `rule.string().min(2)` unusable for
|
|
15
|
+
* structured output, which is a worse deal for a constraint we can enforce
|
|
16
|
+
* ourselves.
|
|
17
|
+
*
|
|
18
|
+
* The exception is anything with no client-side rescue: a **recursive** schema
|
|
19
|
+
* cannot be expressed at all, and a **file** field has no JSON representation.
|
|
20
|
+
* Those throw here, at definition time, where the stack trace points at the
|
|
21
|
+
* schema rather than at a 400 from the provider.
|
|
22
|
+
*/
|
|
23
|
+
import type { FieldRule, FieldRuleDefinition, Schema } from "@zerotal/validator";
|
|
24
|
+
import { runValidation } from "@zerotal/validator";
|
|
25
|
+
import { AiSchemaError } from "./errors.ts";
|
|
26
|
+
import type { JsonSchema } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
/** Either shape callers have on hand: the builder map, or the raw definitions. */
|
|
29
|
+
export type SchemaInput = Record<string, FieldRule | FieldRuleDefinition>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Rule names that map onto a supported JSON Schema keyword. Everything else is
|
|
33
|
+
* stripped and re-checked. Kept as a map rather than a switch so the supported
|
|
34
|
+
* set is one readable list.
|
|
35
|
+
*/
|
|
36
|
+
const FORMAT_RULES: Record<string, string> = {
|
|
37
|
+
email: "email",
|
|
38
|
+
url: "uri",
|
|
39
|
+
ip: "ipv4",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Unwrap a builder rule to its definition. */
|
|
43
|
+
function defOf(value: FieldRule | FieldRuleDefinition): FieldRuleDefinition {
|
|
44
|
+
return "_def" in value ? value._def : value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalise either input shape to raw definitions. */
|
|
48
|
+
export function toSchema(input: SchemaInput): Schema {
|
|
49
|
+
const out: Schema = {};
|
|
50
|
+
for (const [key, value] of Object.entries(input)) out[key] = defOf(value);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Translate a validator schema into the JSON Schema the providers accept.
|
|
56
|
+
*
|
|
57
|
+
* Every object gets `additionalProperties: false` — required, not optional, and
|
|
58
|
+
* the single most common reason a hand-written schema is rejected.
|
|
59
|
+
*
|
|
60
|
+
* Optional and nullable fields are emitted the same way: listed in `required`,
|
|
61
|
+
* with the value type widened to `anyOf: [<type>, {type: "null"}]`. That is the
|
|
62
|
+
* form both Anthropic and OpenAI's strict mode accept, and
|
|
63
|
+
* {@link recheckAgainstSchema} drops a `null` that stood in for "absent" before
|
|
64
|
+
* re-validating, so the round trip preserves the original meaning.
|
|
65
|
+
*
|
|
66
|
+
* @throws {AiSchemaError} for a recursive schema or a `file` field.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* translateSchema({
|
|
70
|
+
* title: rule.string().min(3), // min stripped, re-checked after
|
|
71
|
+
* tags: rule.array(rule.string()),
|
|
72
|
+
* author: rule.string().optional(), // → anyOf [string, null]
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export function translateSchema(input: SchemaInput): JsonSchema {
|
|
76
|
+
const schema = toSchema(input);
|
|
77
|
+
return objectSchema(schema, new Set());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the `{type: "object", ...}` node for a map of field definitions. */
|
|
81
|
+
function objectSchema(schema: Schema, path: Set<FieldRuleDefinition>): JsonSchema {
|
|
82
|
+
const properties: Record<string, JsonSchema> = {};
|
|
83
|
+
const required: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
86
|
+
properties[name] = fieldSchema(def, name, path);
|
|
87
|
+
// Every property is listed. Optionality lives in the null branch of anyOf,
|
|
88
|
+
// because a subset `required` is rejected by strict tool use.
|
|
89
|
+
required.push(name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties,
|
|
95
|
+
required,
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Translate one field, widening to `anyOf` when it may be absent or null. */
|
|
101
|
+
function fieldSchema(
|
|
102
|
+
def: FieldRuleDefinition,
|
|
103
|
+
name: string,
|
|
104
|
+
path: Set<FieldRuleDefinition>,
|
|
105
|
+
): JsonSchema {
|
|
106
|
+
if (path.has(def)) {
|
|
107
|
+
throw new AiSchemaError(
|
|
108
|
+
`The schema for '${name}' is recursive. Structured output does not support recursive ` +
|
|
109
|
+
`schemas — flatten the shape, or bound the depth by declaring each level explicitly.`,
|
|
110
|
+
{ field: name },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const next = new Set(path).add(def);
|
|
115
|
+
const base = baseSchema(def, name, next);
|
|
116
|
+
|
|
117
|
+
const mayBeNull = def.nullable || !def.required || def.sometimes === true;
|
|
118
|
+
if (!mayBeNull) return base;
|
|
119
|
+
|
|
120
|
+
return { anyOf: [base, { type: "null" }] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The un-widened schema for a field's declared type. */
|
|
124
|
+
function baseSchema(
|
|
125
|
+
def: FieldRuleDefinition,
|
|
126
|
+
name: string,
|
|
127
|
+
path: Set<FieldRuleDefinition>,
|
|
128
|
+
): JsonSchema {
|
|
129
|
+
switch (def.type) {
|
|
130
|
+
case "string":
|
|
131
|
+
return withStringKeywords({ type: "string" }, def);
|
|
132
|
+
|
|
133
|
+
case "number":
|
|
134
|
+
return { type: def.rules.some((r) => r.name === "integer") ? "integer" : "number" };
|
|
135
|
+
|
|
136
|
+
case "boolean":
|
|
137
|
+
return { type: "boolean" };
|
|
138
|
+
|
|
139
|
+
case "date":
|
|
140
|
+
// No native date type in JSON Schema; `date-time` is in the supported
|
|
141
|
+
// format list, and the model returns an ISO-8601 string.
|
|
142
|
+
return { type: "string", format: "date-time" };
|
|
143
|
+
|
|
144
|
+
case "array": {
|
|
145
|
+
if (!def.children) {
|
|
146
|
+
throw new AiSchemaError(
|
|
147
|
+
`The array field '${name}' has no item type. Declare one: rule.array(rule.string()).`,
|
|
148
|
+
{ field: name },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return { type: "array", items: fieldSchema(def.children, `${name}[]`, path) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "object": {
|
|
155
|
+
if (!def.shape) {
|
|
156
|
+
throw new AiSchemaError(
|
|
157
|
+
`The object field '${name}' has no shape. Declare one: rule.object({ … }).`,
|
|
158
|
+
{ field: name },
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const properties: Record<string, JsonSchema> = {};
|
|
162
|
+
const required: string[] = [];
|
|
163
|
+
for (const [key, child] of Object.entries(def.shape)) {
|
|
164
|
+
properties[key] = fieldSchema(child, `${name}.${key}`, path);
|
|
165
|
+
required.push(key);
|
|
166
|
+
}
|
|
167
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
case "file":
|
|
171
|
+
throw new AiSchemaError(
|
|
172
|
+
`The field '${name}' is a file. A model cannot return a file through structured output — ` +
|
|
173
|
+
`ask for a filename or an identifier instead.`,
|
|
174
|
+
{ field: name },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
default:
|
|
178
|
+
throw new AiSchemaError(
|
|
179
|
+
`The field '${name}' has an unsupported type '${String(def.type)}'.`,
|
|
180
|
+
{
|
|
181
|
+
field: name,
|
|
182
|
+
type: def.type,
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Apply the string rules that survive translation: `enum` and `format`. */
|
|
189
|
+
function withStringKeywords(base: JsonSchema, def: FieldRuleDefinition): JsonSchema {
|
|
190
|
+
const out: JsonSchema = { ...base };
|
|
191
|
+
|
|
192
|
+
for (const rule of def.rules) {
|
|
193
|
+
if (rule.name === "in" && Array.isArray(rule.args[0])) {
|
|
194
|
+
// `enum` narrows harder than any format, so it wins outright.
|
|
195
|
+
return { enum: (rule.args[0] as string[]).slice() };
|
|
196
|
+
}
|
|
197
|
+
const format = FORMAT_RULES[rule.name];
|
|
198
|
+
if (format) out.format = format;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Which of a schema's constraints this translation could not express.
|
|
206
|
+
*
|
|
207
|
+
* Useful in a test or a boot-time audit: it names exactly what
|
|
208
|
+
* {@link recheckAgainstSchema} will be carrying, so a schema whose important
|
|
209
|
+
* constraint is invisible to the model is a thing you can notice rather than
|
|
210
|
+
* discover.
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* strippedConstraints({ title: rule.string().min(3).max(80) });
|
|
214
|
+
* // → ["title: min", "title: max"]
|
|
215
|
+
*/
|
|
216
|
+
export function strippedConstraints(input: SchemaInput): string[] {
|
|
217
|
+
const out: string[] = [];
|
|
218
|
+
walk(toSchema(input), "", out, new Set());
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function walk(schema: Schema, prefix: string, out: string[], path: Set<FieldRuleDefinition>): void {
|
|
223
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
224
|
+
if (path.has(def)) continue;
|
|
225
|
+
const next = new Set(path).add(def);
|
|
226
|
+
const label = prefix ? `${prefix}.${name}` : name;
|
|
227
|
+
|
|
228
|
+
for (const rule of def.rules) {
|
|
229
|
+
if (rule.name === "in" || FORMAT_RULES[rule.name] || rule.name === "integer") continue;
|
|
230
|
+
out.push(`${label}: ${rule.name}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (def.shape) walk(def.shape, label, out, next);
|
|
234
|
+
if (def.children) walk({ [`${name}[]`]: def.children }, prefix, out, next);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Re-apply the constraints translation had to drop.
|
|
240
|
+
*
|
|
241
|
+
* Called on the model's parsed answer. A `null` standing in for an absent
|
|
242
|
+
* optional field is removed first — the wire form widened it to null, and the
|
|
243
|
+
* validator would otherwise reject a value the caller never asked for.
|
|
244
|
+
*
|
|
245
|
+
* @returns The validated object.
|
|
246
|
+
* @throws {AiSchemaError} listing every field that failed.
|
|
247
|
+
*
|
|
248
|
+
* @internal
|
|
249
|
+
*/
|
|
250
|
+
export function recheckAgainstSchema<T>(input: SchemaInput, value: unknown): T {
|
|
251
|
+
const schema = toSchema(input);
|
|
252
|
+
|
|
253
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
254
|
+
throw new AiSchemaError(
|
|
255
|
+
`The model returned ${Array.isArray(value) ? "an array" : typeof value}, not an object.`,
|
|
256
|
+
{ received: value },
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const cleaned = dropAbsentNulls(schema, value as Record<string, unknown>);
|
|
261
|
+
const result = runValidation(schema, cleaned);
|
|
262
|
+
|
|
263
|
+
if (!result.success) {
|
|
264
|
+
const detail = Object.entries(result.errors)
|
|
265
|
+
.map(([field, message]) => `${field}: ${message}`)
|
|
266
|
+
.join("; ");
|
|
267
|
+
throw new AiSchemaError(
|
|
268
|
+
`The model's answer does not satisfy the schema — ${detail}. These constraints are enforced ` +
|
|
269
|
+
`here rather than by the provider, because structured output cannot express them.`,
|
|
270
|
+
{ errors: result.errors },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return result.data as T;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Remove `null`s that stood in for "absent" on optional, non-nullable fields. */
|
|
278
|
+
function dropAbsentNulls(schema: Schema, value: Record<string, unknown>): Record<string, unknown> {
|
|
279
|
+
const out: Record<string, unknown> = { ...value };
|
|
280
|
+
|
|
281
|
+
for (const [name, def] of Object.entries(schema)) {
|
|
282
|
+
if (out[name] === null && !def.nullable) {
|
|
283
|
+
delete out[name];
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (def.shape && out[name] && typeof out[name] === "object") {
|
|
287
|
+
out[name] = dropAbsentNulls(def.shape, out[name] as Record<string, unknown>);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return out;
|
|
292
|
+
}
|
package/src/spend.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend ceilings and the running spend ledger.
|
|
3
|
+
*
|
|
4
|
+
* Two guards, both cheap, both approximate on purpose:
|
|
5
|
+
*
|
|
6
|
+
* - **Per-request** is checked *before* the call, from a token estimate, because
|
|
7
|
+
* after the call the money is already spent. It bounds the blast radius of one
|
|
8
|
+
* runaway prompt.
|
|
9
|
+
* - **Per-day** is checked from actual reported usage, accumulated in-process.
|
|
10
|
+
* In-process is a real limitation — N workers get N ceilings — and it is
|
|
11
|
+
* stated rather than hidden, because the alternative (a shared counter behind
|
|
12
|
+
* the cache) buys precision for an availability dependency on the hot path.
|
|
13
|
+
*
|
|
14
|
+
* A model with no price contributes 0 and is never blocked. See `pricing.ts`.
|
|
15
|
+
*/
|
|
16
|
+
import { AiSpendLimitError } from "./errors.ts";
|
|
17
|
+
import { estimateCost, modelPrice } from "./pricing.ts";
|
|
18
|
+
import type { AiLimitsConfigShape, AiUsage } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Today's spend, keyed by UTC date so the window rolls without a timer. */
|
|
21
|
+
let _day = "";
|
|
22
|
+
let _spentUsd = 0;
|
|
23
|
+
|
|
24
|
+
/** UTC `YYYY-MM-DD`. UTC, not local, so a deploy across zones agrees with itself. */
|
|
25
|
+
function today(): string {
|
|
26
|
+
return new Date().toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function roll(): void {
|
|
30
|
+
const now = today();
|
|
31
|
+
if (now !== _day) {
|
|
32
|
+
_day = now;
|
|
33
|
+
_spentUsd = 0;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** USD recorded so far today, in this process. */
|
|
38
|
+
export function spentToday(): number {
|
|
39
|
+
roll();
|
|
40
|
+
return _spentUsd;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Add a completed request's cost to today's total. */
|
|
44
|
+
export function recordSpend(model: string, usage: AiUsage): number {
|
|
45
|
+
roll();
|
|
46
|
+
const cost = estimateCost(model, usage);
|
|
47
|
+
_spentUsd += cost;
|
|
48
|
+
return cost;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reset the ledger. Tests, and the `ai:spend --reset` path. */
|
|
52
|
+
export function resetSpend(): void {
|
|
53
|
+
_day = today();
|
|
54
|
+
_spentUsd = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Refuse a request that would breach a ceiling.
|
|
59
|
+
*
|
|
60
|
+
* @param model - The model about to be called.
|
|
61
|
+
* @param estimatedInputTokens - Counted, not guessed, where the driver can.
|
|
62
|
+
* @param maxOutputTokens - The request's own ceiling; the worst case we can bound.
|
|
63
|
+
*
|
|
64
|
+
* @throws {AiSpendLimitError} when either ceiling would be breached.
|
|
65
|
+
*/
|
|
66
|
+
export function assertWithinLimits(
|
|
67
|
+
limits: AiLimitsConfigShape,
|
|
68
|
+
model: string,
|
|
69
|
+
estimatedInputTokens: number,
|
|
70
|
+
maxOutputTokens: number,
|
|
71
|
+
): void {
|
|
72
|
+
if (limits.perDayUsd > 0 && spentToday() >= limits.perDayUsd) {
|
|
73
|
+
throw new AiSpendLimitError(
|
|
74
|
+
`Daily AI spend ceiling reached: $${spentToday().toFixed(2)} of $${limits.perDayUsd.toFixed(2)}. ` +
|
|
75
|
+
`Raise limits.perDayUsd in config/ai.ts, or wait for the UTC day to roll.`,
|
|
76
|
+
{ model, spentUsd: spentToday(), limitUsd: limits.perDayUsd },
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (limits.perRequestUsd <= 0) return;
|
|
81
|
+
// Unpriced model: the ceiling has nothing to compare against, so it stands
|
|
82
|
+
// aside rather than blocking every request to a model we simply don't know.
|
|
83
|
+
if (!modelPrice(model)) return;
|
|
84
|
+
|
|
85
|
+
const worstCase = estimateCost(model, {
|
|
86
|
+
inputTokens: estimatedInputTokens,
|
|
87
|
+
outputTokens: maxOutputTokens,
|
|
88
|
+
cacheReadTokens: 0,
|
|
89
|
+
cacheWriteTokens: 0,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (worstCase > limits.perRequestUsd) {
|
|
93
|
+
throw new AiSpendLimitError(
|
|
94
|
+
`This request could cost up to $${worstCase.toFixed(4)}, over the per-request ceiling of ` +
|
|
95
|
+
`$${limits.perRequestUsd.toFixed(4)}. Shorten the prompt, lower maxTokens, or raise ` +
|
|
96
|
+
`limits.perRequestUsd in config/ai.ts.`,
|
|
97
|
+
{ model, estimatedInputTokens, maxOutputTokens, worstCaseUsd: worstCase },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process counters behind the monitor's AI section.
|
|
3
|
+
*
|
|
4
|
+
* A ring buffer, not a table: the panel answers "what is this app doing with the
|
|
5
|
+
* model right now", and the durable record of that is the provider's own
|
|
6
|
+
* dashboard plus whatever the app writes down deliberately. Keeping prompts in
|
|
7
|
+
* a framework-owned store would make every app that installs this package a
|
|
8
|
+
* place user prompts accumulate, which is not a decision this package gets to
|
|
9
|
+
* make for them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One recorded generation. */
|
|
13
|
+
export interface AiDelivery {
|
|
14
|
+
at: number;
|
|
15
|
+
driver: string;
|
|
16
|
+
model: string;
|
|
17
|
+
operation: string;
|
|
18
|
+
inputTokens: number;
|
|
19
|
+
outputTokens: number;
|
|
20
|
+
cacheReadTokens: number;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
costUsd: number;
|
|
23
|
+
ok: boolean;
|
|
24
|
+
refused: boolean;
|
|
25
|
+
/** Already redacted by the manager — never the raw prompt. */
|
|
26
|
+
preview: string;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rolled-up figures for one model. */
|
|
31
|
+
export interface ModelStat {
|
|
32
|
+
model: string;
|
|
33
|
+
calls: number;
|
|
34
|
+
inputTokens: number;
|
|
35
|
+
outputTokens: number;
|
|
36
|
+
cacheReadTokens: number;
|
|
37
|
+
costUsd: number;
|
|
38
|
+
failures: number;
|
|
39
|
+
refusals: number;
|
|
40
|
+
/** Median latency, in milliseconds. */
|
|
41
|
+
p50: number;
|
|
42
|
+
/** 95th-percentile latency, in milliseconds. */
|
|
43
|
+
p95: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How many generations are kept. Roughly a working session's worth. */
|
|
47
|
+
const CAPACITY = 200;
|
|
48
|
+
|
|
49
|
+
const _deliveries: AiDelivery[] = [];
|
|
50
|
+
|
|
51
|
+
/** Record one generation. Called from the observability bridge. @internal */
|
|
52
|
+
export function recordDelivery(delivery: AiDelivery): void {
|
|
53
|
+
_deliveries.push(delivery);
|
|
54
|
+
if (_deliveries.length > CAPACITY) _deliveries.splice(0, _deliveries.length - CAPACITY);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The most recent generations, newest first. */
|
|
58
|
+
export function recentGenerations(limit = 50): AiDelivery[] {
|
|
59
|
+
return _deliveries.slice(-limit).reverse();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per-model roll-up over everything still in the buffer. */
|
|
63
|
+
export function modelStats(): ModelStat[] {
|
|
64
|
+
const byModel = new Map<string, { rows: AiDelivery[]; latencies: number[] }>();
|
|
65
|
+
|
|
66
|
+
for (const delivery of _deliveries) {
|
|
67
|
+
const entry = byModel.get(delivery.model) ?? { rows: [], latencies: [] };
|
|
68
|
+
entry.rows.push(delivery);
|
|
69
|
+
if (delivery.ok) entry.latencies.push(delivery.durationMs);
|
|
70
|
+
byModel.set(delivery.model, entry);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const out: ModelStat[] = [];
|
|
74
|
+
for (const [model, { rows, latencies }] of byModel) {
|
|
75
|
+
latencies.sort((a, b) => a - b);
|
|
76
|
+
out.push({
|
|
77
|
+
model,
|
|
78
|
+
calls: rows.length,
|
|
79
|
+
inputTokens: sum(rows, (r) => r.inputTokens),
|
|
80
|
+
outputTokens: sum(rows, (r) => r.outputTokens),
|
|
81
|
+
cacheReadTokens: sum(rows, (r) => r.cacheReadTokens),
|
|
82
|
+
costUsd: sum(rows, (r) => r.costUsd),
|
|
83
|
+
failures: rows.filter((r) => !r.ok).length,
|
|
84
|
+
refusals: rows.filter((r) => r.refused).length,
|
|
85
|
+
p50: percentile(latencies, 0.5),
|
|
86
|
+
p95: percentile(latencies, 0.95),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return out.sort((a, b) => b.costUsd - a.costUsd || b.calls - a.calls);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Share of recorded calls that the provider declined, 0–1. */
|
|
94
|
+
export function refusalRate(): number {
|
|
95
|
+
if (_deliveries.length === 0) return 0;
|
|
96
|
+
return _deliveries.filter((d) => d.refused).length / _deliveries.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reset the buffer. Tests. */
|
|
100
|
+
export function resetStats(): void {
|
|
101
|
+
_deliveries.length = 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sum(rows: AiDelivery[], pick: (row: AiDelivery) => number): number {
|
|
105
|
+
let total = 0;
|
|
106
|
+
for (const row of rows) total += pick(row);
|
|
107
|
+
return total;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Nearest-rank percentile over a sorted list.
|
|
112
|
+
*
|
|
113
|
+
* Nearest-rank rather than interpolated: with a couple of dozen samples the
|
|
114
|
+
* interpolation invents a latency nothing actually took, and the point of a p95
|
|
115
|
+
* here is to name a request that really happened.
|
|
116
|
+
*/
|
|
117
|
+
function percentile(sorted: number[], fraction: number): number {
|
|
118
|
+
if (sorted.length === 0) return 0;
|
|
119
|
+
const rank = Math.ceil(fraction * sorted.length);
|
|
120
|
+
return Math.round(sorted[Math.min(rank, sorted.length) - 1] ?? 0);
|
|
121
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { RuleBuilder } from "@zerotal/validator";
|
|
2
|
+
import type { FieldRule } from "@zerotal/validator";
|
|
3
|
+
import { recheckAgainstSchema, translateSchema, type SchemaInput } from "./schema.ts";
|
|
4
|
+
import type { AiTool, AiToolContext } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Define a tool the model can call, with its input described by the validator
|
|
8
|
+
* you already use for forms.
|
|
9
|
+
*
|
|
10
|
+
* The schema is translated to JSON Schema for the provider and re-checked on the
|
|
11
|
+
* way back in, so the handler receives input that has actually been validated —
|
|
12
|
+
* not merely input the provider promised to shape. Constraints the provider
|
|
13
|
+
* cannot express (`min`, `regex`, …) are enforced by that second pass; see
|
|
14
|
+
* `schema.ts` for why that is the deal.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const lookupOrder = tool({
|
|
18
|
+
* name: "lookup_order",
|
|
19
|
+
* description:
|
|
20
|
+
* "Fetch one order by its id. Call this whenever the user refers to an order " +
|
|
21
|
+
* "number — do not answer from the conversation alone.",
|
|
22
|
+
* input: (rule) => ({ id: rule.string().uuid() }),
|
|
23
|
+
* async handle({ id }) {
|
|
24
|
+
* return await Order.find(id);
|
|
25
|
+
* },
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* await Ai.agent({ prompt: "Where is order 2f1c…?", tools: [lookupOrder] });
|
|
29
|
+
*/
|
|
30
|
+
export function tool<I extends Record<string, unknown> = Record<string, unknown>>(options: {
|
|
31
|
+
/** Snake_case, specific: `lookup_order` beats `orders`. */
|
|
32
|
+
name: string;
|
|
33
|
+
/**
|
|
34
|
+
* What it does *and when to call it*. The trigger condition is the half that
|
|
35
|
+
* moves the call rate — a description that only states what the tool does
|
|
36
|
+
* leaves the model guessing about when it applies.
|
|
37
|
+
*/
|
|
38
|
+
description: string;
|
|
39
|
+
/** The input shape, as a validator schema. */
|
|
40
|
+
input: ((rule: RuleBuilder) => Record<string, FieldRule>) | SchemaInput;
|
|
41
|
+
/** Runs when the model calls the tool. Return anything JSON-serializable. */
|
|
42
|
+
handle: (input: I, ctx: AiToolContext) => Promise<unknown> | unknown;
|
|
43
|
+
}): AiTool {
|
|
44
|
+
const schema: SchemaInput =
|
|
45
|
+
typeof options.input === "function" ? options.input(new RuleBuilder()) : options.input;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
name: options.name,
|
|
49
|
+
description: options.description,
|
|
50
|
+
inputSchema: translateSchema(schema),
|
|
51
|
+
handler: (raw, ctx) => options.handle(recheckAgainstSchema<I>(schema, raw), ctx),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// The return is `AiTool`, not `AiTool<I>`, on purpose. `I` appears in a
|
|
55
|
+
// parameter position, so `AiTool<{a, b}>` is *not* assignable to
|
|
56
|
+
// `AiTool<Record<string, unknown>>` — and `tools: [add]` would be a type error
|
|
57
|
+
// at the one call site every user writes. The narrowing still happens: `handle`
|
|
58
|
+
// receives `I`, validated by `recheckAgainstSchema`, and only the stored
|
|
59
|
+
// signature is widened.
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run a tool's handler and turn whatever it returns — or throws — into the
|
|
63
|
+
* string the provider expects back.
|
|
64
|
+
*
|
|
65
|
+
* A throwing handler must not end the run: the model is perfectly capable of
|
|
66
|
+
* trying something else once it is told the call failed, and killing the turn
|
|
67
|
+
* denies it that. So the error becomes an error-flagged result instead.
|
|
68
|
+
*
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
export async function runTool(
|
|
72
|
+
t: AiTool,
|
|
73
|
+
input: Record<string, unknown>,
|
|
74
|
+
ctx: AiToolContext,
|
|
75
|
+
): Promise<{ content: string; isError: boolean }> {
|
|
76
|
+
try {
|
|
77
|
+
const value = await t.handler(input, ctx);
|
|
78
|
+
return { content: stringifyToolResult(value), isError: false };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
content: error instanceof Error ? error.message : String(error),
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Strings pass through; everything else is JSON. Undefined becomes a stated no-op. */
|
|
88
|
+
function stringifyToolResult(value: unknown): string {
|
|
89
|
+
if (typeof value === "string") return value;
|
|
90
|
+
if (value === undefined) return "(no output)";
|
|
91
|
+
try {
|
|
92
|
+
return JSON.stringify(value) ?? String(value);
|
|
93
|
+
} catch {
|
|
94
|
+
// A cycle or a BigInt. Better a named failure the model can react to than a
|
|
95
|
+
// thrown error that ends the turn.
|
|
96
|
+
return "(tool output could not be serialized to JSON)";
|
|
97
|
+
}
|
|
98
|
+
}
|